diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js index 35783dd85d286..6da02f4dae98e 100644 --- a/superset-frontend/.storybook/main.js +++ b/superset-frontend/.storybook/main.js @@ -24,8 +24,8 @@ module.exports = { builder: 'webpack5', }, stories: [ - '../src/@(components|common|filters|explore|views|dashboard)/**/*.stories.@(tsx|jsx)', - '../src/@(components|common|filters|explore|views|dashboard)/**/*.*.@(mdx)', + '../src/@(components|common|filters|explore|views|dashboard|features)/**/*.stories.@(tsx|jsx)', + '../src/@(components|common|filters|explore|views|dashboard|features)/**/*.*.@(mdx)', ], addons: [ '@storybook/addon-essentials', @@ -33,6 +33,7 @@ module.exports = { 'storybook-addon-jsx', '@storybook/addon-knobs', 'storybook-addon-paddings', + 'storybook-addon-mock', ], staticDirs: ['../src/assets/images'], webpackFinal: config => ({ diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 5b250ec47d847..5cccef02e7f1c 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -268,6 +268,7 @@ "source-map-support": "^0.5.16", "speed-measure-webpack-plugin": "^1.5.0", "storybook-addon-jsx": "^7.3.14", + "storybook-addon-mock": "^3.2.0", "storybook-addon-paddings": "^4.3.0", "style-loader": "^3.2.1", "thread-loader": "^3.0.4", @@ -44664,6 +44665,15 @@ "node": ">= 8" } }, + "node_modules/mock-xmlhttprequest": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-7.0.4.tgz", + "integrity": "sha512-hA0fIHy/74p5DE0rdmrpU0sV1U+gnWTcgShWequGRLy0L1eT+zY0ozFukawpLaxMwIA+orRcqFRElYwT+5p81A==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mocked-env": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mocked-env/-/mocked-env-1.3.2.tgz", @@ -53864,6 +53874,51 @@ "react-dom": "^16.2.0 || ^17.0.0" } }, + "node_modules/storybook-addon-mock": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/storybook-addon-mock/-/storybook-addon-mock-3.2.0.tgz", + "integrity": "sha512-LaggsF/6Lt0AyHiotIEVQpwKfIiZ3KsNqtdXKVnIdOetjaD7GaOQeX0jIZiZUFX/i6QLmMuNoXFngqqkdVtfSg==", + "dev": true, + "dependencies": { + "mock-xmlhttprequest": "^7.0.3", + "path-to-regexp": "^6.2.0", + "polished": "^4.2.2" + }, + "peerDependencies": { + "@storybook/addons": "^6.4.0", + "@storybook/api": "^6.4.0", + "@storybook/components": "^6.4.0", + "@storybook/theming": "^6.4.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/storybook-addon-mock/node_modules/path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "node_modules/storybook-addon-mock/node_modules/polished": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", + "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/storybook-addon-paddings": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/storybook-addon-paddings/-/storybook-addon-paddings-4.3.0.tgz", @@ -96739,6 +96794,12 @@ "url-parse": "^1.4.4" } }, + "mock-xmlhttprequest": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/mock-xmlhttprequest/-/mock-xmlhttprequest-7.0.4.tgz", + "integrity": "sha512-hA0fIHy/74p5DE0rdmrpU0sV1U+gnWTcgShWequGRLy0L1eT+zY0ozFukawpLaxMwIA+orRcqFRElYwT+5p81A==", + "dev": true + }, "mocked-env": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/mocked-env/-/mocked-env-1.3.2.tgz", @@ -103874,6 +103935,34 @@ "storybook-pretty-props": "^1.0.3" } }, + "storybook-addon-mock": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/storybook-addon-mock/-/storybook-addon-mock-3.2.0.tgz", + "integrity": "sha512-LaggsF/6Lt0AyHiotIEVQpwKfIiZ3KsNqtdXKVnIdOetjaD7GaOQeX0jIZiZUFX/i6QLmMuNoXFngqqkdVtfSg==", + "dev": true, + "requires": { + "mock-xmlhttprequest": "^7.0.3", + "path-to-regexp": "^6.2.0", + "polished": "^4.2.2" + }, + "dependencies": { + "path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dev": true + }, + "polished": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.2.2.tgz", + "integrity": "sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.17.8" + } + } + } + }, "storybook-addon-paddings": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/storybook-addon-paddings/-/storybook-addon-paddings-4.3.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 65d10722f9615..1ebf6842e0c08 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -332,6 +332,7 @@ "source-map-support": "^0.5.16", "speed-measure-webpack-plugin": "^1.5.0", "storybook-addon-jsx": "^7.3.14", + "storybook-addon-mock": "^3.2.0", "storybook-addon-paddings": "^4.3.0", "style-loader": "^3.2.1", "thread-loader": "^3.0.4", diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 0a46ee5549922..f03a8c8b9fab9 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -17,23 +17,23 @@ * under the License. */ import React, { - useState, + ReactElement, + useCallback, useEffect, useMemo, - useCallback, useRef, - ReactElement, + useState, } from 'react'; import { useSelector } from 'react-redux'; import { BinaryQueryObjectFilterClause, css, ensureIsArray, + GenericDataType, + JsonObject, + QueryFormData, t, useTheme, - QueryFormData, - JsonObject, - GenericDataType, } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import Loading from 'src/components/Loading'; @@ -43,16 +43,12 @@ import TimeCell from 'src/components/Table/cell-renderers/TimeCell'; import { EmptyStateMedium } from 'src/components/EmptyState'; import { getDatasourceSamples } from 'src/components/Chart/chartAction'; import Table, { ColumnsType, TableSize } from 'src/components/Table'; -import MetadataBar, { - ContentType, - MetadataType, -} from 'src/components/MetadataBar'; -import Alert from 'src/components/Alert'; -import { useApiV1Resource } from 'src/hooks/apiResources'; import HeaderWithRadioGroup from 'src/components/Table/header-renderers/HeaderWithRadioGroup'; +import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; +import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar'; import TableControls from './DrillDetailTableControls'; import { getDrillPayload } from './utils'; -import { Dataset, ResultsPage } from './types'; +import { ResultsPage } from './types'; const PAGE_SIZE = 50; @@ -106,6 +102,8 @@ export default function DrillDetailPane({ [formData.datasource], ); + const { metadataBar, status: metadataBarStatus } = + useDatasetMetadataBar(datasourceId); // Get page of results const resultsPage = useMemo(() => { const nextResultsPage = resultsPages.get(pageIndex); @@ -261,11 +259,9 @@ export default function DrillDetailPane({ resultsPages, ]); - // Get datasource metadata - const response = useApiV1Resource(`/api/v1/dataset/${datasourceId}`); - const bootstrapping = - (!responseError && !resultsPages.size) || response.status === 'loading'; + (!responseError && !resultsPages.size) || + metadataBarStatus === ResourceStatus.LOADING; let tableContent = null; if (responseError) { @@ -308,76 +304,9 @@ export default function DrillDetailPane({ ); } - const metadata = useMemo(() => { - const { status, result } = response; - const items: ContentType[] = []; - if (result) { - const { - changed_on_humanized, - created_on_humanized, - 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_humanized, - modifiedBy, - }); - items.push({ - type: MetadataType.OWNER, - createdBy, - owners: formattedOwners, - createdOn: created_on_humanized, - }); - if (description) { - items.push({ - type: MetadataType.DESCRIPTION, - value: description, - }); - } - } - return ( -
- {status === 'complete' && ( - - )} - {status === 'error' && ( - - )} -
- ); - }, [response, theme.gridUnit]); - return ( <> - {!bootstrapping && metadata} + {!bootstrapping && metadataBar} {!bootstrapping && ( { + SupersetClient.reset(); + SupersetClient.configure({ csrfToken: '1234' }).init(); + const { metadataBar } = useDatasetMetadataBar(1); + const { width, height, ref } = useResizeDetector(); + // eslint-disable-next-line no-param-reassign + return ( +
+ {metadataBar} + {`${width}x${height}`} +
+ ); +}; + +DatasetSpecific.story = { + parameters: { + knobs: { + disable: true, + }, + }, +}; diff --git a/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.test.tsx b/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.test.tsx new file mode 100644 index 0000000000000..fdca8fb7f554c --- /dev/null +++ b/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.test.tsx @@ -0,0 +1,93 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import { renderHook } from '@testing-library/react-hooks'; +import { createWrapper, render } from 'spec/helpers/testing-library'; +import { useDatasetMetadataBar } from './useDatasetMetadataBar'; + +test('renders dataset metadata bar', async () => { + fetchMock.get('glob:*/api/v1/dataset/1', { + result: { + changed_on: '2023-01-26T12:06:58.733316', + changed_on_humanized: 'a month ago', + changed_by: { first_name: 'Han', last_name: 'Solo' }, + created_by: { first_name: 'Luke', last_name: 'Skywalker' }, + created_on: '2023-01-26T12:06:54.965034', + created_on_humanized: 'a month ago', + table_name: `This is dataset's name`, + owners: [ + { first_name: 'John', last_name: 'Doe' }, + { first_name: 'Luke', last_name: 'Skywalker' }, + ], + description: 'This is a dataset description', + }, + }); + + const { result, waitForValueToChange } = renderHook( + () => useDatasetMetadataBar(1), + { + wrapper: createWrapper(), + }, + ); + expect(result.current.status).toEqual('loading'); + await waitForValueToChange(() => result.current.status); + expect(result.current.status).toEqual('complete'); + + const { findByText, findAllByRole } = render(result.current.metadataBar); + expect(await findByText(`This is dataset's name`)).toBeVisible(); + expect(await findByText('This is a dataset description')).toBeVisible(); + expect(await findByText('Luke Skywalker')).toBeVisible(); + expect(await findByText('a month ago')).toBeVisible(); + expect(await findAllByRole('img')).toHaveLength(4); + fetchMock.restore(); +}); + +test('renders dataset metadata bar without description and owners', async () => { + fetchMock.get('glob:*/api/v1/dataset/1', { + result: { + changed_on: '2023-01-26T12:06:58.733316', + changed_on_humanized: 'a month ago', + created_on: '2023-01-26T12:06:54.965034', + created_on_humanized: 'a month ago', + table_name: `This is dataset's name`, + }, + }); + + const { result, waitForValueToChange } = renderHook( + () => useDatasetMetadataBar(1), + { + wrapper: createWrapper(), + }, + ); + expect(result.current.status).toEqual('loading'); + await waitForValueToChange(() => result.current.status); + expect(result.current.status).toEqual('complete'); + + const { findByText, queryByText, findAllByRole } = render( + result.current.metadataBar, + ); + expect(await findByText(`This is dataset's name`)).toBeVisible(); + expect(queryByText('This is a dataset description')).not.toBeInTheDocument(); + expect(await findByText('Not available')).toBeVisible(); + expect(await findByText('a month ago')).toBeVisible(); + expect(await findAllByRole('img')).toHaveLength(3); + + fetchMock.restore(); +}); diff --git a/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.tsx b/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.tsx new file mode 100644 index 0000000000000..280b7a398960d --- /dev/null +++ b/superset-frontend/src/features/datasets/metadataBar/useDatasetMetadataBar.tsx @@ -0,0 +1,107 @@ +/** + * 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, { useMemo } from 'react'; +import { css, t, useTheme } from '@superset-ui/core'; +import Alert from 'src/components/Alert'; +import { useApiV1Resource } from 'src/hooks/apiResources'; +import { Dataset } from 'src/components/Chart/DrillDetail/types'; +import MetadataBar from 'src/components/MetadataBar'; +import { + ContentType, + MetadataType, +} from 'src/components/MetadataBar/ContentType'; +import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; + +export const useDatasetMetadataBar = (datasetId: number | string) => { + const theme = useTheme(); + const response = useApiV1Resource(`/api/v1/dataset/${datasetId}`); + + const { status, result } = response; + + const metadataBar = useMemo(() => { + const items: ContentType[] = []; + if (result) { + const { + changed_on_humanized, + created_on_humanized, + 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_humanized, + modifiedBy, + }); + items.push({ + type: MetadataType.OWNER, + createdBy, + owners: formattedOwners, + createdOn: created_on_humanized, + }); + if (description) { + items.push({ + type: MetadataType.DESCRIPTION, + value: description, + }); + } + } + return ( +
+ {status === ResourceStatus.COMPLETE && ( + + )} + {status === ResourceStatus.ERROR && ( + + )} +
+ ); + }, [result, status, theme.gridUnit]); + + return { + metadataBar, + status, + }; +};