From 32fac77b5ffd39f693634ee09bcdb205860cb788 Mon Sep 17 00:00:00 2001 From: Cody Leff Date: Tue, 7 Feb 2023 10:34:30 -0800 Subject: [PATCH] feat(datasets): Populate Usage tab in Edit Dataset view (#22670) --- superset-frontend/spec/fixtures/mockCharts.ts | 55 +++ .../src/components/TruncatedList/index.tsx | 160 +++++++ .../src/pages/ChartList/index.tsx | 7 +- superset-frontend/src/types/Chart.ts | 14 + .../EditDataset/UsageTab/UsageTab.test.tsx | 405 ++++++++++++++++++ .../AddDataset/EditDataset/UsageTab/index.tsx | 261 +++++++++++ .../dataset/AddDataset/EditDataset/index.tsx | 6 +- 7 files changed, 901 insertions(+), 7 deletions(-) create mode 100644 superset-frontend/spec/fixtures/mockCharts.ts create mode 100644 superset-frontend/src/components/TruncatedList/index.tsx create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/UsageTab.test.tsx create mode 100644 superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/index.tsx diff --git a/superset-frontend/spec/fixtures/mockCharts.ts b/superset-frontend/spec/fixtures/mockCharts.ts new file mode 100644 index 0000000000000..570d52cdc0612 --- /dev/null +++ b/superset-frontend/spec/fixtures/mockCharts.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +export interface ChartListChart { + id: number; + slice_name: string; + url: string; + last_saved_at: null | string; + last_saved_by: null | { id: number; first_name: string; last_name: string }; + owners: { + id: number; + first_name: string; + last_name: string; + username: string; + }[]; + dashboards: { id: number; dashboard_title: string }[]; +} + +const CHART_ID = 1; +const MOCK_CHART: ChartListChart = { + id: CHART_ID, + slice_name: 'Sample chart', + url: `/explore/?slice_id=${CHART_ID}`, + last_saved_at: null, + dashboards: [], + last_saved_by: null, + owners: [], +}; + +/** + * Get mock charts as would be returned by the /api/v1/chart list endpoint. + */ +export const getMockChart = ( + overrides: Partial = {}, +): ChartListChart => ({ + ...MOCK_CHART, + ...(overrides.id ? { url: `/explore/?slice_id=${overrides.id}` } : null), + ...overrides, +}); diff --git a/superset-frontend/src/components/TruncatedList/index.tsx b/superset-frontend/src/components/TruncatedList/index.tsx new file mode 100644 index 0000000000000..37d4fe0436564 --- /dev/null +++ b/superset-frontend/src/components/TruncatedList/index.tsx @@ -0,0 +1,160 @@ +/** + * 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, { ReactNode, useMemo, useRef } from 'react'; +import { styled, t } from '@superset-ui/core'; +import { useTruncation } from 'src/hooks/useTruncation'; +import { Tooltip } from '../Tooltip'; + +export type TruncatedListProps = { + /** + * Array of input items of type `ListItemType`. + */ + items: ListItemType[]; + + /** + * Renderer for items not overflowed into the tooltip. + * Required if `ListItemType` is not renderable by React. + */ + renderVisibleItem?: (item: ListItemType) => ReactNode; + + /** + * Renderer for items that are overflowed into the tooltip. + * Required if `ListItemType` is not renderable by React. + */ + renderTooltipItem?: (item: ListItemType) => ReactNode; + + /** + * Returns the React key for an item. + */ + getKey?: (item: ListItemType) => React.Key; + + /** + * The max number of links that should appear in the tooltip. + */ + maxLinks?: number; +}; + +const StyledTruncatedList = styled.div` + & > span { + width: 100%; + display: flex; + + .ant-tooltip-open { + display: inline; + } + } +`; + +const StyledVisibleItems = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + width: 100%; + vertical-align: bottom; +`; + +const StyledVisibleItem = styled.span` + &:not(:last-child)::after { + content: ', '; + } +`; + +const StyledTooltipItem = styled.div` + .link { + color: ${({ theme }) => theme.colors.grayscale.light5}; + display: block; + text-decoration: underline; + } +`; + +const StyledPlus = styled.span` + ${({ theme }) => ` + cursor: pointer; + color: ${theme.colors.primary.dark1}; + font-weight: ${theme.typography.weights.normal}; + `} +`; + +export default function TruncatedList({ + items, + renderVisibleItem = item => item, + renderTooltipItem = item => item, + getKey = item => item as unknown as React.Key, + maxLinks = 20, +}: TruncatedListProps) { + const itemsNotInTooltipRef = useRef(null); + const plusRef = useRef(null); + const [elementsTruncated, hasHiddenElements] = useTruncation( + itemsNotInTooltipRef, + plusRef, + ) as [number, boolean]; + + const nMoreItems = useMemo( + () => (items.length > maxLinks ? items.length - maxLinks : undefined), + [items, maxLinks], + ); + + const itemsNotInTooltip = useMemo( + () => ( + + {items.map(item => ( + + {renderVisibleItem(item)} + + ))} + + ), + [getKey, items, renderVisibleItem], + ); + + const itemsInTooltip = useMemo( + () => + items + .slice(0, maxLinks) + .map(item => ( + + {renderTooltipItem(item)} + + )), + [getKey, items, maxLinks, renderTooltipItem], + ); + + return ( + + + {itemsInTooltip} + {nMoreItems && {t('+ %s more', nMoreItems)}} + + ) : null + } + > + {itemsNotInTooltip} + {hasHiddenElements && ( + +{elementsTruncated} + )} + + + ); +} diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index d449321686bcc..38271e470db40 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -57,7 +57,7 @@ import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers'; import withToasts from 'src/components/MessageToasts/withToasts'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import ImportModelsModal from 'src/components/ImportModal/index'; -import Chart from 'src/types/Chart'; +import Chart, { ChartLinkedDashboard } from 'src/types/Chart'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; @@ -148,11 +148,6 @@ interface ChartListProps { }; } -type ChartLinkedDashboard = { - id: number; - dashboard_title: string; -}; - const Actions = styled.div` color: ${({ theme }) => theme.colors.grayscale.base}; `; diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts index df6460080b34c..76ec04f60b7a9 100644 --- a/superset-frontend/src/types/Chart.ts +++ b/superset-frontend/src/types/Chart.ts @@ -24,6 +24,11 @@ import { QueryFormData } from '@superset-ui/core'; import Owner from './Owner'; +export type ChartLinkedDashboard = { + id: number; + dashboard_title: string; +}; + export interface Chart { id: number; url: string; @@ -39,11 +44,20 @@ export interface Chart { cache_timeout: number | null; thumbnail_url?: string; owners?: Owner[]; + last_saved_at?: string; + last_saved_by?: { + id: number; + first_name: string; + last_name: string; + }; datasource_name_text?: string; form_data: { viz_type: string; }; is_managed_externally: boolean; + + // TODO: Update API spec to describe `dashboards` key + dashboards: ChartLinkedDashboard[]; } export type Slice = { diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/UsageTab.test.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/UsageTab.test.tsx new file mode 100644 index 0000000000000..7bbdbbfd52cfa --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/UsageTab.test.tsx @@ -0,0 +1,405 @@ +/** + * 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 from 'react'; +import fetchMock from 'fetch-mock'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import { ChartListChart, getMockChart } from 'spec/fixtures/mockCharts'; +import ToastContainer from 'src/components/MessageToasts/ToastContainer'; +import DatasetUsage from '.'; + +const DEFAULT_DATASET_ID = '1'; +const DEFAULT_ORDER_COLUMN = 'last_saved_at'; +const DEFAULT_ORDER_DIRECTION = 'desc'; +const DEFAULT_PAGE = 0; +const DEFAULT_PAGE_SIZE = 25; + +const getChartResponse = (result: ChartListChart[]) => ({ + count: result.length, + result, +}); + +const CHARTS_ENDPOINT = 'glob:*/api/v1/chart/?*'; +const mockChartsFetch = (response: fetchMock.MockResponse) => { + fetchMock.reset(); + fetchMock.get('glob:*/api/v1/chart/_info?*', { + permissions: ['can_export', 'can_read', 'can_write'], + }); + + fetchMock.get(CHARTS_ENDPOINT, response); +}; + +const renderDatasetUsage = () => + render( + <> + + + , + { useRedux: true, useRouter: true }, + ); + +const expectLastChartRequest = (params?: { + datasetId?: string; + orderColumn?: string; + orderDirection?: 'desc' | 'asc'; + page?: number; + pageSize?: number; +}) => { + const { datasetId, orderColumn, orderDirection, page, pageSize } = { + datasetId: DEFAULT_DATASET_ID, + orderColumn: DEFAULT_ORDER_COLUMN, + orderDirection: DEFAULT_ORDER_DIRECTION, + page: DEFAULT_PAGE, + pageSize: DEFAULT_PAGE_SIZE, + ...(params || {}), + }; + + const calls = fetchMock.calls(CHARTS_ENDPOINT); + expect(calls.length).toBeGreaterThan(0); + const lastChartRequestUrl = calls[calls.length - 1][0]; + expect(lastChartRequestUrl).toMatch( + new RegExp(`col:datasource_id,opr:eq,value:%27${datasetId}%27`), + ); + + expect(lastChartRequestUrl).toMatch( + new RegExp(`order_column:${orderColumn}`), + ); + + expect(lastChartRequestUrl).toMatch( + new RegExp(`order_direction:${orderDirection}`), + ); + + expect(lastChartRequestUrl).toMatch(new RegExp(`page:${page}`)); + expect(lastChartRequestUrl).toMatch(new RegExp(`page_size:${pageSize}`)); +}; + +test('shows loading state', async () => { + mockChartsFetch( + new Promise(resolve => + setTimeout(() => resolve(getChartResponse([])), 250), + ), + ); + + renderDatasetUsage(); + + const loadingIndicator = await screen.findByRole('status', { + name: /loading/i, + }); + + expect(loadingIndicator).toBeVisible(); +}); + +test('shows error state', async () => { + mockChartsFetch(500); + renderDatasetUsage(); + + const errorMessage = await screen.findByText( + /an error occurred while fetching charts/i, + ); + + expect(errorMessage).toBeInTheDocument(); +}); + +test('shows empty state', async () => { + mockChartsFetch(getChartResponse([])); + renderDatasetUsage(); + + const noChartsTitle = await screen.findByText(/no charts/i); + const noChartsDescription = screen.getByText( + /this dataset is not used to power any charts\./i, + ); + + expect(noChartsTitle).toBeVisible(); + expect(noChartsDescription).toBeVisible(); + expect(fetchMock.calls(CHARTS_ENDPOINT)).toHaveLength(1); + expectLastChartRequest(); +}); + +test('show and sort by chart title', async () => { + mockChartsFetch( + getChartResponse([ + getMockChart({ id: 1, slice_name: 'Sample A' }), + getMockChart({ id: 2, slice_name: 'Sample C' }), + getMockChart({ id: 3, slice_name: 'Sample B' }), + ]), + ); + + renderDatasetUsage(); + + const chartNameColumnHeader = screen.getByText('Chart'); + const chartNameLinks = await screen.findAllByRole('link', { + name: /sample/i, + }); + + // Default sort + expect(chartNameLinks).toHaveLength(3); + expect(chartNameLinks[0]).toHaveTextContent('Sample A'); + expect(chartNameLinks[0]).toHaveAttribute('href', '/explore/?slice_id=1'); + expect(chartNameLinks[1]).toHaveTextContent('Sample C'); + expect(chartNameLinks[1]).toHaveAttribute('href', '/explore/?slice_id=2'); + expect(chartNameLinks[2]).toHaveTextContent('Sample B'); + expect(chartNameLinks[2]).toHaveAttribute('href', '/explore/?slice_id=3'); + expectLastChartRequest(); + + // Sort by name ascending + userEvent.click(chartNameColumnHeader); + waitFor(() => { + expectLastChartRequest({ + orderColumn: 'slice_name', + orderDirection: 'asc', + }); + }); + + // Sort by name descending + userEvent.click(chartNameColumnHeader); + waitFor(() => { + expectLastChartRequest({ + orderColumn: 'slice_name', + orderDirection: 'desc', + }); + }); +}); + +test('show chart owners', async () => { + mockChartsFetch( + getChartResponse([ + getMockChart({ + id: 1, + owners: [ + { id: 1, first_name: 'John', last_name: 'Doe', username: 'j1' }, + { id: 2, first_name: 'Jane', last_name: 'Doe', username: 'j2' }, + ], + }), + getMockChart({ id: 2 }), + getMockChart({ + id: 3, + owners: [ + { id: 3, first_name: 'John', last_name: 'Doe', username: 'j1' }, + ], + }), + ]), + ); + + renderDatasetUsage(); + + const chartOwners = await screen.findAllByText(/doe/i); + + expect(chartOwners).toHaveLength(3); + expect(chartOwners[0]).toHaveTextContent('John Doe'); + expect(chartOwners[1]).toHaveTextContent('Jane Doe'); + expect(chartOwners[0].parentNode).toBe(chartOwners[1].parentNode); + expect(chartOwners[2]).toHaveTextContent('John Doe'); + expect(chartOwners[2].parentNode).not.toBe(chartOwners[0].parentNode); + expect(chartOwners[2].parentNode).not.toBe(chartOwners[1].parentNode); + expectLastChartRequest(); +}); + +const getDate = (msAgo: number) => { + const date = new Date(); + date.setMilliseconds(date.getMilliseconds() - msAgo); + return date; +}; + +test('show and sort by chart last modified', async () => { + mockChartsFetch( + getChartResponse([ + getMockChart({ id: 2, last_saved_at: getDate(10000).toISOString() }), + getMockChart({ id: 1, last_saved_at: getDate(1000000).toISOString() }), + getMockChart({ id: 3, last_saved_at: getDate(100000000).toISOString() }), + ]), + ); + + renderDatasetUsage(); + + const chartLastModifiedColumnHeader = screen.getByText('Chart last modified'); + const chartLastModifiedValues = await screen.findAllByText( + /a few seconds ago|17 minutes ago|a day ago/i, + ); + + // Default sort + expect(chartLastModifiedValues).toHaveLength(3); + expect(chartLastModifiedValues[0]).toHaveTextContent('a few seconds ago'); + expect(chartLastModifiedValues[1]).toHaveTextContent('17 minutes ago'); + expect(chartLastModifiedValues[2]).toHaveTextContent('a day ago'); + expectLastChartRequest(); + + // Sort by last modified ascending + userEvent.click(chartLastModifiedColumnHeader); + waitFor(() => { + expectLastChartRequest({ orderDirection: 'asc' }); + }); + + // Sort by last modified descending + userEvent.click(chartLastModifiedColumnHeader); + waitFor(() => { + expectLastChartRequest({ orderDirection: 'desc' }); + }); +}); + +test('show and sort by chart last modified by', async () => { + mockChartsFetch( + getChartResponse([ + getMockChart({ + id: 2, + last_saved_by: { id: 1, first_name: 'John', last_name: 'Doe' }, + }), + getMockChart({ + id: 1, + last_saved_by: null, + }), + getMockChart({ + id: 3, + last_saved_by: { id: 2, first_name: 'Jane', last_name: 'Doe' }, + }), + ]), + ); + + renderDatasetUsage(); + + const chartLastModifiedByColumnHeader = screen.getByText( + 'Chart last modified by', + ); + + const chartLastModifiedByValues = await screen.findAllByText(/doe/i); + + // Default sort + expect(chartLastModifiedByValues).toHaveLength(2); + expect(chartLastModifiedByValues[0]).toHaveTextContent('John Doe'); + expect(chartLastModifiedByValues[1]).toHaveTextContent('Jane Doe'); + expectLastChartRequest(); + + // Sort by last modified ascending + userEvent.click(chartLastModifiedByColumnHeader); + waitFor(() => { + expectLastChartRequest({ orderDirection: 'asc' }); + }); + + // Sort by last modified descending + userEvent.click(chartLastModifiedByColumnHeader); + waitFor(() => { + expectLastChartRequest({ orderDirection: 'desc' }); + }); +}); + +test('show chart dashboards', async () => { + mockChartsFetch( + getChartResponse([ + getMockChart({ + id: 1, + dashboards: [ + { id: 1, dashboard_title: 'Sample dashboard A' }, + { id: 2, dashboard_title: 'Sample dashboard B' }, + ], + }), + getMockChart({ id: 2 }), + getMockChart({ + id: 3, + dashboards: [{ id: 3, dashboard_title: 'Sample dashboard C' }], + }), + ]), + ); + + renderDatasetUsage(); + + const chartDashboards = await screen.findAllByRole('link', { + name: /sample dashboard/i, + }); + + expect(chartDashboards).toHaveLength(3); + expect(chartDashboards[0]).toHaveTextContent('Sample dashboard A'); + expect(chartDashboards[0]).toHaveAttribute('href', '/superset/dashboard/1'); + expect(chartDashboards[1]).toHaveTextContent('Sample dashboard B'); + expect(chartDashboards[1]).toHaveAttribute('href', '/superset/dashboard/2'); + expect(chartDashboards[0].closest('.ant-table-cell')).toBe( + chartDashboards[1].closest('.ant-table-cell'), + ); + + expect(chartDashboards[2]).toHaveTextContent('Sample dashboard C'); + expect(chartDashboards[2]).toHaveAttribute('href', '/superset/dashboard/3'); + expect(chartDashboards[2].closest('.ant-table-cell')).not.toBe( + chartDashboards[0].closest('.ant-table-cell'), + ); + + expect(chartDashboards[2].closest('.ant-table-cell')).not.toBe( + chartDashboards[1].closest('.ant-table-cell'), + ); + + expectLastChartRequest(); + + expect( + screen.queryByRole('button', { + name: /right/i, + }), + ).not.toBeInTheDocument(); +}); + +test('paginates', async () => { + const charts = []; + for (let i = 0; i < 65; i += 1) { + charts.push( + getMockChart({ + id: i + 1, + slice_name: `Sample chart ${i + 1}`, + }), + ); + } + + mockChartsFetch(getChartResponse(charts)); + renderDatasetUsage(); + + // First page + let chartNameValues = await screen.findAllByRole('cell', { + name: /sample chart/i, + }); + + expect(chartNameValues).toHaveLength(25); + expect(chartNameValues[0]).toHaveTextContent('Sample chart 1'); + expect(chartNameValues[24]).toHaveTextContent('Sample chart 25'); + + // Second page + userEvent.click( + screen.getByRole('button', { + name: /right/i, + }), + ); + + chartNameValues = await screen.findAllByRole('cell', { + name: /sample chart/i, + }); + + expect(chartNameValues).toHaveLength(25); + expect(chartNameValues[0]).toHaveTextContent('Sample chart 26'); + expect(chartNameValues[24]).toHaveTextContent('Sample chart 50'); + + // Third page + userEvent.click( + screen.getByRole('button', { + name: /right/i, + }), + ); + + chartNameValues = await screen.findAllByRole('cell', { + name: /sample chart/i, + }); + + expect(chartNameValues).toHaveLength(15); + expect(chartNameValues[0]).toHaveTextContent('Sample chart 51'); + expect(chartNameValues[14]).toHaveTextContent('Sample chart 65'); +}); diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/index.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/index.tsx new file mode 100644 index 0000000000000..99663d91e19dc --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/UsageTab/index.tsx @@ -0,0 +1,261 @@ +/** + * 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, { useCallback, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { + css, + ensureIsArray, + styled, + SupersetTheme, + t, +} from '@superset-ui/core'; +import Chart, { ChartLinkedDashboard } from 'src/types/Chart'; +import Table, { + ColumnsType, + TableSize, + OnChangeFunction, +} from 'src/components/Table'; +import { EmptyStateBig } from 'src/components/EmptyState'; +import ChartImage from 'src/assets/images/chart.svg'; +import Icons from 'src/components/Icons'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import { FilterOperator } from 'src/components/ListView'; +import moment from 'moment'; +import TruncatedList from 'src/components/TruncatedList'; + +interface DatasetUsageProps { + datasetId: string; +} + +const DEFAULT_PAGE_SIZE = 25; + +const getLinkProps = (dashboard: ChartLinkedDashboard) => ({ + key: dashboard.id, + to: `/superset/dashboard/${dashboard.id}`, + target: '_blank', + rel: 'noreferer noopener', + children: dashboard.dashboard_title, +}); + +const tooltipItemCSS = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.light5}; + text-decoration: underline; + &:hover { + color: inherit; + } +`; + +const columns: ColumnsType = [ + { + key: 'slice_name', + title: t('Chart'), + width: '320px', + sorter: true, + render: (value, record) => {record.slice_name}, + }, + { + key: 'owners', + title: t('Chart owners'), + width: '242px', + render: (value, record) => ( + `${owner.first_name} ${owner.last_name}`, + ) ?? [] + } + /> + ), + }, + { + key: 'last_saved_at', + title: t('Chart last modified'), + width: '209px', + sorter: true, + defaultSortOrder: 'descend', + render: (value, record) => + record.last_saved_at ? moment.utc(record.last_saved_at).fromNow() : null, + }, + { + key: 'last_saved_by.first_name', + title: t('Chart last modified by'), + width: '216px', + sorter: true, + render: (value, record) => + record.last_saved_by + ? `${record.last_saved_by.first_name} ${record.last_saved_by.last_name}` + : null, + }, + { + key: 'dashboards', + title: t('Dashboard usage'), + width: '420px', + render: (value, record) => ( + + items={record.dashboards} + renderVisibleItem={dashboard => } + renderTooltipItem={dashboard => ( + + )} + getKey={dashboard => dashboard.id} + /> + ), + }, +]; + +const emptyStateTableCSS = (theme: SupersetTheme) => css` + && th.ant-table-cell { + color: ${theme.colors.grayscale.light1}; + } + + .ant-table-placeholder { + display: none; + } +`; + +const emptyStateButtonText = ( + <> + .anticon { + line-height: 0; + } + `} + /> + {t('Create chart with dataset')} + +); + +const StyledEmptyStateBig = styled(EmptyStateBig)` + margin: ${({ theme }) => 13 * theme.gridUnit}px 0; +`; + +/** + * Hook that uses the useListViewResource hook to retrieve records + * based on pagination state. + */ +const useDatasetChartRecords = (datasetId: string) => { + const { addDangerToast } = useToasts(); + + // Always filters charts by dataset + const baseFilters = useMemo( + () => [ + { + id: 'datasource_id', + operator: FilterOperator.equals, + value: datasetId, + }, + ], + [datasetId], + ); + + // Returns request status/results and function for re-fetching + const { + state: { loading, resourceCount, resourceCollection }, + fetchData, + } = useListViewResource( + 'chart', + t('chart'), + addDangerToast, + true, + [], + baseFilters, + ); + + // Adds `key` field + const resourceCollectionWithKey = useMemo( + () => resourceCollection.map(o => ({ ...o, key: o.id })), + [resourceCollection], + ); + + // Called by table with updated table state to fetch new data + const onChange: OnChangeFunction = useCallback( + (tablePagination, tableFilters, tableSorter) => { + const pageIndex = (tablePagination.current ?? 1) - 1; + const pageSize = tablePagination.pageSize ?? 0; + const sortBy = ensureIsArray(tableSorter) + .filter(({ columnKey }) => typeof columnKey === 'string') + .map(({ columnKey, order }) => ({ + id: columnKey as string, + desc: order === 'descend', + })); + fetchData({ pageIndex, pageSize, sortBy, filters: [] }); + }, + [fetchData], + ); + + // Initial data request + useEffect(() => { + fetchData({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE, + sortBy: [{ id: 'last_saved_at', desc: true }], + filters: [], + }); + }, [fetchData]); + + return { + loading, + recordCount: resourceCount, + data: resourceCollectionWithKey, + onChange, + }; +}; + +const DatasetUsage = ({ datasetId }: DatasetUsageProps) => { + const { loading, recordCount, data, onChange } = + useDatasetChartRecords(datasetId); + + const emptyStateButtonAction = useCallback( + () => + window.open( + `/explore/?dataset_type=table&dataset_id=${datasetId}`, + '_blank', + ), + [datasetId], + ); + + return ( +
+ + {!data.length && !loading ? ( + } + title={t('No charts')} + description={t('This dataset is not used to power any charts.')} + buttonText={emptyStateButtonText} + buttonAction={emptyStateButtonAction} + /> + ) : null} + + ); +}; + +export default DatasetUsage; diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/index.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/index.tsx index 7abc676fcab5b..e8853cf043b19 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/index.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/EditDataset/index.tsx @@ -21,11 +21,13 @@ import React from 'react'; import { useGetDatasetRelatedCounts } from 'src/views/CRUD/data/hooks'; import Badge from 'src/components/Badge'; import Tabs from 'src/components/Tabs'; +import UsageTab from './UsageTab'; const StyledTabs = styled(Tabs)` ${({ theme }) => ` margin-top: ${theme.gridUnit * 8.5}px; padding-left: ${theme.gridUnit * 4}px; + padding-right: ${theme.gridUnit * 4}px; .ant-tabs-top > .ant-tabs-nav::before { width: ${theme.gridUnit * 50}px; @@ -66,7 +68,9 @@ const EditPage = ({ id }: EditPageProps) => { - + + + ); };