diff --git a/superset-frontend/src/components/MetadataBar/ContentConfig.tsx b/superset-frontend/src/components/MetadataBar/ContentConfig.tsx index 23d1dec3d18d..55dbf82166b9 100644 --- a/superset-frontend/src/components/MetadataBar/ContentConfig.tsx +++ b/superset-frontend/src/components/MetadataBar/ContentConfig.tsx @@ -20,7 +20,7 @@ import React from 'react'; import moment from 'moment'; import { ensureIsArray, styled, t } from '@superset-ui/core'; import Icons from 'src/components/Icons'; -import { ContentType, MetadataType } from './ContentType'; +import { ContentType, MetadataType } from '.'; const Header = styled.div` font-weight: ${({ theme }) => theme.typography.weights.bold}; diff --git a/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx index 5ad9d9785173..6501a1b64c54 100644 --- a/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx +++ b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { css } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; -import MetadataBar, { MetadataBarProps } from './index'; +import MetadataBar, { MetadataBarProps, MetadataType } from '.'; export default { title: 'MetadataBar', @@ -72,26 +72,26 @@ Component.story = { Component.args = { items: [ { - type: 'sql', + type: MetadataType.SQL, title: 'Click to view query', }, { - type: 'owner', + type: MetadataType.OWNER, createdBy: 'Jane Smith', owners: ['John Doe', 'Mary Wilson'], createdOn: A_WEEK_AGO, }, { - type: 'lastModified', + type: MetadataType.LAST_MODIFIED, value: A_WEEK_AGO, modifiedBy: 'Jane Smith', }, { - type: 'tags', + type: MetadataType.TAGS, values: ['management', 'research', 'poc'], }, { - type: 'dashboards', + type: MetadataType.DASHBOARDS, title: 'Added to 452 dashboards', description: 'To preview the list of dashboards go to "More" settings on the right.', diff --git a/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx b/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx index c8a3f9e54603..d045709482c0 100644 --- a/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx +++ b/superset-frontend/src/components/MetadataBar/MetadataBar.test.tsx @@ -23,8 +23,12 @@ import * as resizeDetector from 'react-resize-detector'; import moment from 'moment'; import { supersetTheme } from '@superset-ui/core'; import { hexToRgb } from 'src/utils/colorUtils'; -import MetadataBar, { MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS } from '.'; -import { ContentType, MetadataType } from './ContentType'; +import MetadataBar, { + MIN_NUMBER_ITEMS, + MAX_NUMBER_ITEMS, + ContentType, + MetadataType, +} from '.'; const DASHBOARD_TITLE = 'Added to 452 dashboards'; const DASHBOARD_DESCRIPTION = @@ -166,8 +170,8 @@ test('renders the items sorted', () => { const { container } = render(); const nodes = container.firstChild?.childNodes as NodeListOf; expect(within(nodes[0]).getByText(DASHBOARD_TITLE)).toBeInTheDocument(); - expect(within(nodes[1]).getByText(ROWS_TITLE)).toBeInTheDocument(); - expect(within(nodes[2]).getByText(SQL_TITLE)).toBeInTheDocument(); + expect(within(nodes[1]).getByText(SQL_TITLE)).toBeInTheDocument(); + expect(within(nodes[2]).getByText(ROWS_TITLE)).toBeInTheDocument(); expect(within(nodes[3]).getByText(DESCRIPTION_VALUE)).toBeInTheDocument(); expect(within(nodes[4]).getByText(CREATED_BY)).toBeInTheDocument(); }); @@ -217,7 +221,7 @@ test('correctly renders the owner tooltip', async () => { test('correctly renders the rows tooltip', async () => { await runWithBarCollapsed(async () => { render(); - userEvent.hover(screen.getAllByRole('img')[0]); + userEvent.hover(screen.getAllByRole('img')[2]); const tooltip = await screen.findByRole('tooltip'); expect(tooltip).toBeInTheDocument(); expect(within(tooltip).getByText(ROWS_TITLE)).toBeInTheDocument(); @@ -237,7 +241,7 @@ test('correctly renders the sql tooltip', async () => { test('correctly renders the table tooltip', async () => { await runWithBarCollapsed(async () => { render(); - userEvent.hover(screen.getAllByRole('img')[2]); + userEvent.hover(screen.getAllByRole('img')[0]); const tooltip = await screen.findByRole('tooltip'); expect(tooltip).toBeInTheDocument(); expect(within(tooltip).getByText(TABLE_TITLE)).toBeInTheDocument(); diff --git a/superset-frontend/src/components/MetadataBar/MetadataBar.tsx b/superset-frontend/src/components/MetadataBar/MetadataBar.tsx new file mode 100644 index 000000000000..5217cbcc9921 --- /dev/null +++ b/superset-frontend/src/components/MetadataBar/MetadataBar.tsx @@ -0,0 +1,190 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useEffect, useRef, useState } from 'react'; +import { useResizeDetector } from 'react-resize-detector'; +import { uniqWith } from 'lodash'; +import { styled } from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; +import { ContentType } from './ContentType'; +import { config } from './ContentConfig'; + +export const MIN_NUMBER_ITEMS = 2; +export const MAX_NUMBER_ITEMS = 6; + +const HORIZONTAL_PADDING = 12; +const VERTICAL_PADDING = 8; +const ICON_PADDING = 8; +const SPACE_BETWEEN_ITEMS = 16; +const ICON_WIDTH = 16; +const TEXT_MIN_WIDTH = 70; +const TEXT_MAX_WIDTH = 150; +const ORDER = { + dashboards: 0, + table: 1, + sql: 2, + rows: 3, + tags: 4, + description: 5, + owner: 6, + lastModified: 7, +}; + +const Bar = styled.div<{ count: number }>` + ${({ theme, count }) => ` + display: flex; + align-items: center; + padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px; + background-color: ${theme.colors.grayscale.light4}; + color: ${theme.colors.grayscale.base}; + font-size: ${theme.typography.sizes.s}px; + min-width: ${ + HORIZONTAL_PADDING * 2 + + (ICON_WIDTH + SPACE_BETWEEN_ITEMS) * count - + SPACE_BETWEEN_ITEMS + }px; + `} +`; + +const StyledItem = styled.div<{ + collapsed: boolean; + last: boolean; + onClick?: () => void; +}>` + ${({ theme, collapsed, last, onClick }) => ` + max-width: ${ + ICON_WIDTH + + ICON_PADDING + + TEXT_MAX_WIDTH + + (last ? 0 : SPACE_BETWEEN_ITEMS) + }px; + min-width: ${ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)}px; + overflow: hidden; + text-overflow: ${collapsed ? 'unset' : 'ellipsis'}; + white-space: nowrap; + padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px; + text-decoration: ${onClick ? 'underline' : 'none'}; + cursor: ${onClick ? 'pointer' : 'default'}; + & > span { + color: ${onClick && collapsed ? theme.colors.primary.base : 'undefined'}; + padding-right: ${collapsed ? 0 : ICON_PADDING}px; + } + `} +`; + +// Make sure big tootips are truncated +const TootipContent = styled.div` + display: -webkit-box; + -webkit-line-clamp: 20; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +`; + +const Item = ({ + barWidth, + contentType, + collapsed, + last = false, +}: { + barWidth: number | undefined; + contentType: ContentType; + collapsed: boolean; + last?: boolean; +}) => { + const { icon, title, tooltip = title } = config(contentType); + const [isTruncated, setIsTruncated] = useState(false); + const ref = useRef(null); + const Icon = icon; + const { type, onClick } = contentType; + + useEffect(() => { + setIsTruncated( + ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false, + ); + }, [barWidth, setIsTruncated, contentType]); + + const content = ( + onClick(type) : undefined} + ref={ref} + > + + {!collapsed && title} + + ); + return isTruncated || collapsed || (tooltip && tooltip !== title) ? ( + {tooltip}}> + {content} + + ) : ( + content + ); +}; + +export interface MetadataBarProps { + /** + * Array of content type configurations. To see the available properties + * for each content type, check {@link ContentType} + */ + items: ContentType[]; +} + +/** + * The metadata bar component is used to display additional information about an entity. + * Content types are predefined and consistent across the whole app. This means that + * they will be displayed and behave in a consistent manner, keeping the same ordering, + * information formatting, and interactions. + * To extend the list of content types, a developer needs to request the inclusion of the new type in the design system. + * This process is important to make sure the new type is reviewed by the design team, improving Superset consistency. + */ +const MetadataBar = ({ items }: MetadataBarProps) => { + const { width, ref } = useResizeDetector(); + const uniqueItems = uniqWith(items, (a, b) => a.type === b.type); + const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]); + const count = sortedItems.length; + if (count < MIN_NUMBER_ITEMS) { + throw Error('The minimum number of items for the metadata bar is 2.'); + } + if (count > MAX_NUMBER_ITEMS) { + throw Error('The maximum number of items for the metadata bar is 6.'); + } + // Calculates the breakpoint width to collapse the bar. + // The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total. + const breakpoint = + (ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) * count - + SPACE_BETWEEN_ITEMS; + const collapsed = Boolean(width && width < breakpoint); + return ( + + {sortedItems.map((item, index) => ( + + ))} + + ); +}; + +export default MetadataBar; diff --git a/superset-frontend/src/components/MetadataBar/index.tsx b/superset-frontend/src/components/MetadataBar/index.tsx index c9f57395e72a..e4398a3063b2 100644 --- a/superset-frontend/src/components/MetadataBar/index.tsx +++ b/superset-frontend/src/components/MetadataBar/index.tsx @@ -16,175 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useRef, useState } from 'react'; -import { useResizeDetector } from 'react-resize-detector'; -import { uniqWith } from 'lodash'; -import { styled } from '@superset-ui/core'; -import { Tooltip } from 'src/components/Tooltip'; -import { ContentType } from './ContentType'; -import { config } from './ContentConfig'; +import MetadataBar, { + MetadataBarProps, + MIN_NUMBER_ITEMS, + MAX_NUMBER_ITEMS, +} from './MetadataBar'; -export const MIN_NUMBER_ITEMS = 2; -export const MAX_NUMBER_ITEMS = 6; - -const HORIZONTAL_PADDING = 12; -const VERTICAL_PADDING = 8; -const ICON_PADDING = 8; -const SPACE_BETWEEN_ITEMS = 16; -const ICON_WIDTH = 16; -const TEXT_MIN_WIDTH = 70; -const TEXT_MAX_WIDTH = 150; -const ORDER = { - dashboards: 0, - rows: 1, - sql: 2, - table: 3, - tags: 4, - description: 5, - owner: 6, - lastModified: 7, -}; - -const Bar = styled.div<{ count: number }>` - ${({ theme, count }) => ` - display: flex; - align-items: center; - padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px; - background-color: ${theme.colors.grayscale.light4}; - color: ${theme.colors.grayscale.base}; - font-size: ${theme.typography.sizes.s}px; - min-width: ${ - HORIZONTAL_PADDING * 2 + - (ICON_WIDTH + SPACE_BETWEEN_ITEMS) * count - - SPACE_BETWEEN_ITEMS - }px; - `} -`; - -const StyledItem = styled.div<{ - collapsed: boolean; - last: boolean; - onClick?: () => void; -}>` - ${({ theme, collapsed, last, onClick }) => ` - max-width: ${ - ICON_WIDTH + - ICON_PADDING + - TEXT_MAX_WIDTH + - (last ? 0 : SPACE_BETWEEN_ITEMS) - }px; - min-width: ${ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)}px; - overflow: hidden; - text-overflow: ${collapsed ? 'unset' : 'ellipsis'}; - white-space: nowrap; - padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px; - text-decoration: ${onClick ? 'underline' : 'none'}; - cursor: ${onClick ? 'pointer' : 'default'}; - & > span { - color: ${onClick && collapsed ? theme.colors.primary.base : 'undefined'}; - padding-right: ${collapsed ? 0 : ICON_PADDING}px; - } - `} -`; - -// Make sure big tootips are truncated -const TootipContent = styled.div` - display: -webkit-box; - -webkit-line-clamp: 20; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; -`; - -const Item = ({ - barWidth, - contentType, - collapsed, - last = false, -}: { - barWidth: number | undefined; - contentType: ContentType; - collapsed: boolean; - last?: boolean; -}) => { - const { icon, title, tooltip = title } = config(contentType); - const [isTruncated, setIsTruncated] = useState(false); - const ref = useRef(null); - const Icon = icon; - const { type, onClick } = contentType; - - useEffect(() => { - setIsTruncated( - ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false, - ); - }, [barWidth, setIsTruncated, contentType]); - - const content = ( - onClick(type) : undefined} - ref={ref} - > - - {!collapsed && title} - - ); - return isTruncated || collapsed || (tooltip && tooltip !== title) ? ( - {tooltip}}> - {content} - - ) : ( - content - ); -}; - -export interface MetadataBarProps { - /** - * Array of content type configurations. To see the available properties - * for each content type, check {@link ContentType} - */ - items: ContentType[]; -} +export default MetadataBar; -/** - * The metadata bar component is used to display additional information about an entity. - * Content types are predefined and consistent across the whole app. This means that - * they will be displayed and behave in a consistent manner, keeping the same ordering, - * information formatting, and interactions. - * To extend the list of content types, a developer needs to request the inclusion of the new type in the design system. - * This process is important to make sure the new type is reviewed by the design team, improving Superset consistency. - */ -const MetadataBar = ({ items }: MetadataBarProps) => { - const { width, ref } = useResizeDetector(); - const uniqueItems = uniqWith(items, (a, b) => a.type === b.type); - const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]); - const count = sortedItems.length; - if (count < MIN_NUMBER_ITEMS) { - throw Error('The minimum number of items for the metadata bar is 2.'); - } - if (count > MAX_NUMBER_ITEMS) { - throw Error('The maximum number of items for the metadata bar is 6.'); - } - // Calculates the breakpoint width to collapse the bar. - // The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total. - const breakpoint = - (ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) * count - - SPACE_BETWEEN_ITEMS; - const collapsed = Boolean(width && width < breakpoint); - return ( - - {sortedItems.map((item, index) => ( - - ))} - - ); -}; +export { MetadataBarProps, MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS }; -export default MetadataBar; +export * from './ContentType'; diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx index a65d469fb02d..70e67c2639e5 100644 --- a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.test.tsx @@ -22,6 +22,7 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore'; import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries'; import { QueryFormData, SupersetClient } from '@superset-ui/core'; import fetchMock from 'fetch-mock'; +import moment from 'moment'; import DrillDetailPane from './DrillDetailPane'; const chart = chartQueries[sliceId]; @@ -37,12 +38,46 @@ const setup = (overrides: Record = {}) => { store, }); }; + const waitForRender = (overrides: Record = {}) => waitFor(() => setup(overrides)); -const samplesEndpoint = + +const SAMPLES_ENDPOINT = 'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=1'; -const fetchWithNoData = () => - fetchMock.post(samplesEndpoint, { + +const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/*'; + +const MOCKED_DATASET = { + changed_on: new Date(Date.parse('2022-01-01')), + created_on: new Date(Date.parse('2022-01-01')), + description: 'Simple description', + table_name: 'test_table', + changed_by: { + first_name: 'John', + last_name: 'Doe', + }, + created_by: { + first_name: 'John', + last_name: 'Doe', + }, + owners: [ + { + first_name: 'John', + last_name: 'Doe', + }, + ], +}; + +const setupDatasetEndpoint = () => { + fetchMock.get(DATASET_ENDPOINT, { + status: 'complete', + result: MOCKED_DATASET, + }); +}; + +const fetchWithNoData = () => { + setupDatasetEndpoint(); + fetchMock.post(SAMPLES_ENDPOINT, { result: { total_count: 0, data: [], @@ -50,8 +85,11 @@ const fetchWithNoData = () => coltypes: [], }, }); -const fetchWithData = () => - fetchMock.post(samplesEndpoint, { +}; + +const fetchWithData = () => { + setupDatasetEndpoint(); + fetchMock.post(SAMPLES_ENDPOINT, { result: { total_count: 3, data: [ @@ -75,7 +113,7 @@ const fetchWithData = () => coltypes: [0, 0, 0], }, }); -const SupersetClientPost = jest.spyOn(SupersetClient, 'post'); +}; afterEach(fetchMock.restore); @@ -85,12 +123,12 @@ test('should render', async () => { expect(container).toBeInTheDocument(); }); -test('should render the loading component', async () => { +test('should render metadata and table loading indicators', async () => { fetchWithData(); setup(); - await waitFor(() => { - expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); - }); + await waitFor(() => + expect(screen.getAllByLabelText('Loading').length).toBe(2), + ); }); test('should render the table with results', async () => { @@ -117,8 +155,42 @@ test('should render the "No results" components', async () => { ).toBeInTheDocument(); }); +test('should render the metadata bar', async () => { + fetchWithNoData(); + setup(); + expect( + await screen.findByText(MOCKED_DATASET.table_name), + ).toBeInTheDocument(); + expect( + await screen.findByText(MOCKED_DATASET.description), + ).toBeInTheDocument(); + expect( + await screen.findByText( + `${MOCKED_DATASET.created_by.first_name} ${MOCKED_DATASET.created_by.last_name}`, + ), + ).toBeInTheDocument(); + expect( + await screen.findByText(moment.utc(MOCKED_DATASET.changed_on).fromNow()), + ).toBeInTheDocument(); +}); + +test('should render an error message when fails to load the metadata', async () => { + fetchWithNoData(); + fetchMock.get( + DATASET_ENDPOINT, + { status: 'error', error: 'Some error' }, + { overwriteRoutes: true }, + ); + setup(); + expect( + await screen.findByText('There was an error loading the dataset metadata'), + ).toBeInTheDocument(); +}); + test('should render the error', async () => { - SupersetClientPost.mockRejectedValue(new Error('Something went wrong')); + jest + .spyOn(SupersetClient, 'post') + .mockRejectedValue(new Error('Something went wrong')); await waitForRender(); expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx index bf3f1985b7f1..6c2088a5a8d4 100644 --- a/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx +++ b/superset-frontend/src/dashboard/components/DrillDetailPane/DrillDetailPane.tsx @@ -28,7 +28,6 @@ import { BinaryQueryObjectFilterClause, css, ensureIsArray, - GenericDataType, t, useTheme, QueryFormData, @@ -39,15 +38,15 @@ import { EmptyStateMedium } from 'src/components/EmptyState'; import TableView, { EmptyWrapperType } from 'src/components/TableView'; import { useTableColumns } from 'src/explore/components/DataTableControl'; import { getDatasourceSamples } from 'src/components/Chart/chartAction'; +import MetadataBar, { + ContentType, + MetadataType, +} from 'src/components/MetadataBar'; +import Alert from 'src/components/Alert'; +import { useApiV1Resource } from 'src/hooks/apiResources'; import TableControls from './TableControls'; import { getDrillPayload } from './utils'; - -type ResultsPage = { - total: number; - data: Record[]; - colNames: string[]; - colTypes: GenericDataType[]; -}; +import { Dataset, ResultsPage } from './types'; const PAGE_SIZE = 50; @@ -242,8 +241,78 @@ export default function DrillDetailPane({ ); } + // Get datasource metadata + const response = useApiV1Resource(`/api/v1/dataset/${datasourceId}`); + + const metadata = useMemo(() => { + const { status, result } = response; + const items: ContentType[] = []; + if (result) { + const { + changed_on, + created_on, + description, + table_name, + changed_by, + created_by, + owners, + } = result; + const notAvailable = t('Not available'); + const createdBy = + `${created_by?.first_name ?? ''} ${ + created_by?.last_name ?? '' + }`.trim() || notAvailable; + const modifiedBy = changed_by + ? `${changed_by.first_name} ${changed_by.last_name}` + : notAvailable; + const formattedOwners = + owners.length > 0 + ? owners.map(owner => `${owner.first_name} ${owner.last_name}`) + : [notAvailable]; + items.push({ + type: MetadataType.TABLE, + title: table_name, + }); + items.push({ + type: MetadataType.LAST_MODIFIED, + value: changed_on, + modifiedBy, + }); + items.push({ + type: MetadataType.OWNER, + createdBy, + owners: formattedOwners, + createdOn: created_on, + }); + if (description) { + items.push({ + type: MetadataType.DESCRIPTION, + value: description, + }); + } + } + return ( +
+ {status === 'loading' && } + {status === 'complete' && } + {status === 'error' && ( + + )} +
+ ); + }, [response, theme.gridUnit]); + return ( <> + {metadata} []; + colNames: string[]; + colTypes: GenericDataType[]; +}; + +export type Dataset = { + changed_by?: { + first_name: string; + last_name: string; + }; + created_by?: { + first_name: string; + last_name: string; + }; + changed_on: Date; + created_on: Date; + description: string; + table_name: string; + owners: { + first_name: string; + last_name: string; + }[]; +}; diff --git a/superset/datasets/api.py b/superset/datasets/api.py index bcc7d7a43ac7..86faa8bb9df1 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -178,6 +178,12 @@ class DatasetRestApi(BaseSupersetModelRestApi): "url", "extra", "kind", + "created_on", + "created_by.first_name", + "created_by.last_name", + "changed_on", + "changed_by.first_name", + "changed_by.last_name", ] show_columns = show_select_columns + [ "columns.type_generic",