From 65a11b6f457ea730cc07a38bfb176c000a29ce46 Mon Sep 17 00:00:00 2001 From: "JUST.in DO IT" Date: Thu, 1 Sep 2022 13:39:34 -0700 Subject: [PATCH] refactor: introduce react-query on api resource hook (#21240) --- superset-frontend/package-lock.json | 96 ++++++++ superset-frontend/package.json | 1 + .../spec/helpers/testing-library.tsx | 7 + superset-frontend/src/SqlLab/App.jsx | 15 +- .../components/SqlEditor/SqlEditor.test.jsx | 9 +- .../TabbedSqlEditors.test.jsx | 9 +- .../Datasource/DatasourceModal.test.jsx | 9 +- .../src/components/TableSelector/index.tsx | 127 ++++------ .../src/hooks/apiResources/index.ts | 1 + .../src/hooks/apiResources/tables.test.ts | 221 ++++++++++++++++++ .../src/hooks/apiResources/tables.ts | 97 ++++++++ superset-frontend/src/views/App.tsx | 43 ++-- superset-frontend/src/views/QueryProvider.tsx | 43 ++++ 13 files changed, 561 insertions(+), 117 deletions(-) create mode 100644 superset-frontend/src/hooks/apiResources/tables.test.ts create mode 100644 superset-frontend/src/hooks/apiResources/tables.ts create mode 100644 superset-frontend/src/views/QueryProvider.tsx diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index f1bdeead0..ea8933f5d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -111,6 +111,7 @@ "react-lines-ellipsis": "^0.15.0", "react-loadable": "^5.5.0", "react-markdown": "^4.3.1", + "react-query": "^3.39.2", "react-redux": "^7.2.0", "react-resize-detector": "^6.7.6", "react-reverse-portal": "^2.0.1", @@ -37290,6 +37291,11 @@ "node": ">=0.10.0" } }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "node_modules/js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -45584,6 +45590,55 @@ "react-dom": "^16.6.0 || ^17.0.0" } }, + "node_modules/react-query": { + "version": "3.39.2", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz", + "integrity": "sha512-F6hYDKyNgDQfQOuR1Rsp3VRzJnWHx6aRnnIZHMNGGgbL3SBgpZTDg8MQwmxOgpCAoqZJA+JSNCydF1xGJqKOCA==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-query/node_modules/broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, + "node_modules/react-query/node_modules/unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "dependencies": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + }, "node_modules/react-redux": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz", @@ -86719,6 +86774,11 @@ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==" }, + "js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -93219,6 +93279,42 @@ "react-popper": "^2.2.4" } }, + "react-query": { + "version": "3.39.2", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz", + "integrity": "sha512-F6hYDKyNgDQfQOuR1Rsp3VRzJnWHx6aRnnIZHMNGGgbL3SBgpZTDg8MQwmxOgpCAoqZJA+JSNCydF1xGJqKOCA==", + "requires": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "dependencies": { + "broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "requires": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, + "unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "requires": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" + } + } + } + }, "react-redux": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index a40aaa1ed..cf442fd6b 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -175,6 +175,7 @@ "react-lines-ellipsis": "^0.15.0", "react-loadable": "^5.5.0", "react-markdown": "^4.3.1", + "react-query": "^3.39.2", "react-redux": "^7.2.0", "react-resize-detector": "^6.7.6", "react-reverse-portal": "^2.0.1", diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx index b0e04902c..d489ec2de 100644 --- a/superset-frontend/spec/helpers/testing-library.tsx +++ b/superset-frontend/spec/helpers/testing-library.tsx @@ -34,12 +34,14 @@ import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import reducerIndex from 'spec/helpers/reducerIndex'; import { QueryParamProvider } from 'use-query-params'; +import QueryProvider from 'src/views/QueryProvider'; type Options = Omit & { useRedux?: boolean; useDnd?: boolean; useQueryParams?: boolean; useRouter?: boolean; + useQuery?: boolean; initialState?: {}; reducers?: {}; store?: Store; @@ -50,6 +52,7 @@ function createWrapper(options?: Options) { useDnd, useRedux, useQueryParams, + useQuery = true, useRouter, initialState, reducers, @@ -85,6 +88,10 @@ function createWrapper(options?: Options) { result = {result}; } + if (useQuery) { + result = {result}; + } + return result; }; } diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx index 39f784a5c..ce768fee8 100644 --- a/superset-frontend/src/SqlLab/App.jsx +++ b/superset-frontend/src/SqlLab/App.jsx @@ -23,6 +23,7 @@ import thunkMiddleware from 'redux-thunk'; import { hot } from 'react-hot-loader/root'; import { ThemeProvider } from '@superset-ui/core'; import { GlobalStyles } from 'src/GlobalStyles'; +import QueryProvider from 'src/views/QueryProvider'; import { initFeatureFlags, isFeatureEnabled, @@ -134,12 +135,14 @@ if (sqlLabMenu) { } const Application = () => ( - - - - - - + + + + + + + + ); export default hot(Application); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx index 735264b95..f1b959c0f 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx @@ -31,6 +31,7 @@ import { import AceEditorWrapper from 'src/SqlLab/components/AceEditorWrapper'; import ConnectedSouthPane from 'src/SqlLab/components/SouthPane/state'; import SqlEditor from 'src/SqlLab/components/SqlEditor'; +import QueryProvider from 'src/views/QueryProvider'; import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar'; import { AntdDropdown } from 'src/components'; import { @@ -101,9 +102,11 @@ describe('SqlEditor', () => { const buildWrapper = (props = {}) => mount( - - - , + + + + + , { wrappingComponent: ThemeProvider, wrappingComponentProps: { theme: supersetTheme }, diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx index bc290673b..c00745825 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx @@ -31,6 +31,7 @@ import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors'; import SqlEditor from 'src/SqlLab/components/SqlEditor'; import { table, initialState } from 'src/SqlLab/fixtures'; import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; +import QueryProvider from 'src/views/QueryProvider'; fetchMock.get('glob:*/api/v1/database/*', {}); fetchMock.get('glob:*/savedqueryviewapi/api/get/*', {}); @@ -89,9 +90,11 @@ describe('TabbedSqlEditors', () => { const mountWithAct = async () => act(async () => { mount( - - - , + + + + + , { wrappingComponent: ThemeProvider, wrappingComponentProps: { theme: supersetTheme }, diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx b/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx index 9743e3a32..12be35052 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal.test.jsx @@ -32,6 +32,7 @@ import { DatasourceModal } from 'src/components/Datasource'; import DatasourceEditor from 'src/components/Datasource/DatasourceEditor'; import * as featureFlags from 'src/featureFlags'; import mockDatasource from 'spec/fixtures/mockDatasource'; +import QueryProvider from 'src/views/QueryProvider'; const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -53,9 +54,11 @@ const mockedProps = { async function mountAndWait(props = mockedProps) { const mounted = mount( - - - , + + + + + , { wrappingComponent: ThemeProvider, wrappingComponentProps: { theme: supersetTheme }, diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index a41c4aa64..a4291690c 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -25,7 +25,7 @@ import React, { } from 'react'; import { SelectValue } from 'antd/lib/select'; -import { styled, SupersetClient, t } from '@superset-ui/core'; +import { styled, t } from '@superset-ui/core'; import { Select } from 'src/components'; import { FormLabel } from 'src/components/Form'; import Icons from 'src/components/Icons'; @@ -37,6 +37,7 @@ import CertifiedBadge from 'src/components/CertifiedBadge'; import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { SchemaOption } from 'src/SqlLab/types'; +import { useTables, Table } from 'src/hooks/apiResources'; const TableSelectorWrapper = styled.div` ${({ theme }) => ` @@ -101,19 +102,6 @@ interface TableSelectorProps { tableSelectMode?: 'single' | 'multiple'; } -interface Table { - label: string; - value: string; - type: string; - extra?: { - certification?: { - certified_by: string; - details: string; - }; - warning_markdown?: string; - }; -} - interface TableOption { label: JSX.Element; text: string; @@ -147,6 +135,15 @@ const TableOption = ({ table }: { table: Table }) => { ); }; +function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { + return ( +
+ {select} + {refreshBtn} +
+ ); +} + const TableSelector: FunctionComponent = ({ database, emptyState, @@ -166,34 +163,50 @@ const TableSelector: FunctionComponent = ({ tableValue = undefined, onTableSelectChange, }) => { - const [currentDatabase, setCurrentDatabase] = useState< - DatabaseObject | null | undefined - >(database); + const { addSuccessToast } = useToasts(); const [currentSchema, setCurrentSchema] = useState( schema, ); - const [tableOptions, setTableOptions] = useState([]); const [tableSelectValue, setTableSelectValue] = useState< SelectValue | undefined >(undefined); - const [refresh, setRefresh] = useState(0); - const [previousRefresh, setPreviousRefresh] = useState(0); - const [loadingTables, setLoadingTables] = useState(false); - const { addSuccessToast } = useToasts(); + const { + data, + isFetching: loadingTables, + isFetched, + refetch, + } = useTables({ + dbId: database?.id, + schema: currentSchema, + onSuccess: (data: { options: Table[] }) => { + onTablesLoad?.(data.options); + if (isFetched) { + addSuccessToast('List updated'); + } + }, + onError: () => handleError(t('There was an error loading the tables')), + }); + + const tableOptions = useMemo( + () => + data + ? data.options.map(table => ({ + value: table.value, + label: , + text: table.label, + })) + : [], + [data], + ); useEffect(() => { // reset selections if (database === undefined) { - setCurrentDatabase(undefined); setCurrentSchema(undefined); setTableSelectValue(undefined); } }, [database, tableSelectMode]); - useEffect(() => { - setCurrentDatabase(database); - }, [database]); - useEffect(() => { if (tableSelectMode === 'single') { setTableSelectValue( @@ -208,56 +221,6 @@ const TableSelector: FunctionComponent = ({ } }, [tableOptions, tableValue, tableSelectMode]); - useEffect(() => { - if (currentDatabase && currentSchema) { - setLoadingTables(true); - const encodedSchema = encodeURIComponent(currentSchema); - const forceRefresh = refresh !== previousRefresh; - // TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. - const endpoint = encodeURI( - `/superset/tables/${currentDatabase.id}/${encodedSchema}/undefined/${forceRefresh}/`, - ); - - if (previousRefresh !== refresh) { - setPreviousRefresh(refresh); - } - - SupersetClient.get({ endpoint }) - .then(({ json }) => { - const options: TableOption[] = json.options.map((table: Table) => { - const option: TableOption = { - value: table.value, - label: , - text: table.label, - }; - - return option; - }); - - onTablesLoad?.(json.options); - setTableOptions(options); - setLoadingTables(false); - if (forceRefresh) addSuccessToast('List updated'); - }) - .catch(() => { - setLoadingTables(false); - handleError(t('There was an error loading the tables')); - }); - } - // We are using the refresh state to re-trigger the query - // previousRefresh should be out of dependencies array - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentDatabase, currentSchema, onTablesLoad, setTableOptions, refresh]); - - function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { - return ( -
- {select} - {refreshBtn} -
- ); - } - const internalTableChange = ( selectedOptions: TableOption | TableOption[] | undefined, ) => { @@ -274,7 +237,6 @@ const TableSelector: FunctionComponent = ({ }; const internalDbChange = (db: DatabaseObject) => { - setCurrentDatabase(db); if (onDbChange) { onDbChange(db); } @@ -286,14 +248,15 @@ const TableSelector: FunctionComponent = ({ onSchemaChange(schema); } - internalTableChange(undefined); + const value = tableSelectMode === 'single' ? undefined : []; + internalTableChange(value); }; function renderDatabaseSelector() { return ( = ({ const refreshLabel = !formMode && !readOnly && ( setRefresh(refresh + 1)} + onClick={() => refetch()} tooltipContent={t('Force refresh table list')} /> ); diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts index 5e6392073..32a6418dc 100644 --- a/superset-frontend/src/hooks/apiResources/index.ts +++ b/superset-frontend/src/hooks/apiResources/index.ts @@ -28,3 +28,4 @@ export { // different files for different resource types. export * from './charts'; export * from './dashboards'; +export * from './tables'; diff --git a/superset-frontend/src/hooks/apiResources/tables.test.ts b/superset-frontend/src/hooks/apiResources/tables.test.ts new file mode 100644 index 000000000..11afef6b1 --- /dev/null +++ b/superset-frontend/src/hooks/apiResources/tables.test.ts @@ -0,0 +1,221 @@ +/** + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { SupersetClient } from '@superset-ui/core'; +import QueryProvider, { queryClient } from 'src/views/QueryProvider'; +import { useTables } from './tables'; + +const fakeApiResult = { + json: { + options: [ + { + id: 1, + name: 'fake api result1', + label: 'fake api label1', + }, + { + id: 2, + name: 'fake api result2', + label: 'fake api label2', + }, + ], + tableLength: 2, + }, +}; + +const fakeHasMoreApiResult = { + json: { + options: [ + { + id: 1, + name: 'fake api result1', + label: 'fake api label1', + }, + { + id: 2, + name: 'fake api result2', + label: 'fake api label2', + }, + ], + tableLength: 4, + }, +}; + +const expectedData = { + ...fakeApiResult.json, + hasMore: false, +}; + +const expectedHasMoreData = { + ...fakeHasMoreApiResult.json, + hasMore: true, +}; + +jest.mock('@superset-ui/core', () => ({ + SupersetClient: { + get: jest.fn().mockResolvedValue(fakeApiResult), + }, +})); + +describe('useTables hook', () => { + beforeEach(() => { + (SupersetClient.get as jest.Mock).mockClear(); + queryClient.clear(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns api response mapping json options', async () => { + const expectDbId = 'db1'; + const expectedSchema = 'schemaA'; + const forceRefresh = false; + const { result } = renderHook( + () => + useTables({ + dbId: expectDbId, + schema: expectedSchema, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(SupersetClient.get).toHaveBeenCalledWith({ + endpoint: `/superset/tables/${expectDbId}/${expectedSchema}/undefined/${forceRefresh}/`, + }); + expect(result.current.data).toEqual(expectedData); + await act(async () => { + result.current.refetch(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(2); + expect(SupersetClient.get).toHaveBeenCalledWith({ + endpoint: `/superset/tables/${expectDbId}/${expectedSchema}/undefined/true/`, + }); + expect(result.current.data).toEqual(expectedData); + }); + + it('returns api response for search keyword', async () => { + const expectDbId = 'db1'; + const expectedSchema = 'schemaA'; + const expectedKeyword = 'my work'; + const forceRefresh = false; + renderHook( + () => + useTables({ + dbId: expectDbId, + schema: expectedSchema, + keyword: expectedKeyword, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(SupersetClient.get).toHaveBeenCalledWith({ + endpoint: `/superset/tables/${expectDbId}/${expectedSchema}/${encodeURIComponent( + expectedKeyword, + )}/${forceRefresh}/`, + }); + }); + + it('returns hasMore when total is larger than result size', async () => { + (SupersetClient.get as jest.Mock).mockResolvedValueOnce( + fakeHasMoreApiResult, + ); + const expectDbId = 'db1'; + const expectedSchema = 'schemaA'; + const { result } = renderHook( + () => + useTables({ + dbId: expectDbId, + schema: expectedSchema, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(result.current.data).toEqual(expectedHasMoreData); + }); + + it('returns cached data without api request', async () => { + const expectDbId = 'db1'; + const expectedSchema = 'schemaA'; + const { result, rerender } = renderHook( + () => + useTables({ + dbId: expectDbId, + schema: expectedSchema, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + rerender(); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(result.current.data).toEqual(expectedData); + }); + + it('returns refreshed data after expires', async () => { + const expectDbId = 'db1'; + const expectedSchema = 'schemaA'; + const { result, rerender } = renderHook( + () => + useTables({ + dbId: expectDbId, + schema: expectedSchema, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + rerender(); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(1); + queryClient.clear(); + rerender(); + await act(async () => { + jest.runAllTimers(); + }); + expect(SupersetClient.get).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(expectedData); + }); +}); diff --git a/superset-frontend/src/hooks/apiResources/tables.ts b/superset-frontend/src/hooks/apiResources/tables.ts new file mode 100644 index 000000000..80dd5001a --- /dev/null +++ b/superset-frontend/src/hooks/apiResources/tables.ts @@ -0,0 +1,97 @@ +/** + * 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 { useRef } from 'react'; +import { useQuery, UseQueryOptions } from 'react-query'; +import { SupersetClient } from '@superset-ui/core'; + +export type FetchTablesQueryParams = { + dbId?: string | number; + schema?: string; + forceRefresh?: boolean; + keyword?: string; +}; +export interface Table { + label: string; + value: string; + type: string; + extra?: { + certification?: { + certified_by: string; + details: string; + }; + warning_markdown?: string; + }; +} + +type QueryData = { + json: { options: Table[]; tableLength: number }; + response: Response; +}; + +export type Data = QueryData['json'] & { + hasMore: boolean; +}; + +export function fetchTables({ + dbId, + schema, + forceRefresh, + keyword, +}: FetchTablesQueryParams) { + const encodedSchema = schema ? encodeURIComponent(schema) : ''; + const encodedKeyword = keyword ? encodeURIComponent(keyword) : 'undefined'; + // TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. + const endpoint = `/superset/tables/${ + dbId ?? 'undefined' + }/${encodedSchema}/${encodedKeyword}/${forceRefresh}/`; + return SupersetClient.get({ endpoint }) as Promise; +} + +type Params = FetchTablesQueryParams & + Pick; + +export function useTables(options: Params) { + const { dbId, schema, keyword, onSuccess, onError } = options || {}; + const forceRefreshRef = useRef(false); + const params = { dbId, schema, keyword }; + const result = useQuery( + ['tables', { dbId, schema, keyword }], + () => fetchTables({ ...params, forceRefresh: forceRefreshRef.current }), + { + select: ({ json }) => ({ + ...json, + hasMore: json.tableLength > json.options.length, + }), + enabled: Boolean(dbId && schema), + onSuccess, + onError, + onSettled: () => { + forceRefreshRef.current = false; + }, + }, + ); + + return { + ...result, + refetch: () => { + forceRefreshRef.current = true; + return result.refetch(); + }, + }; +} diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 87563d381..f13b9c550 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -36,6 +36,7 @@ import { routes, isFrontendRoute } from 'src/views/routes'; import { Logger } from 'src/logger/LogUtils'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; +import QueryProvider from './QueryProvider'; setupApp(); setupPlugins(); @@ -60,26 +61,28 @@ const LocationPathnameLogger = () => { }; const App = () => ( - - - - - - - - {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( - - }> - - - - - - ))} - - - - + + + + + + + + + {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( + + }> + + + + + + ))} + + + + + ); export default hot(App); diff --git a/superset-frontend/src/views/QueryProvider.tsx b/superset-frontend/src/views/QueryProvider.tsx new file mode 100644 index 000000000..9fef2022d --- /dev/null +++ b/superset-frontend/src/views/QueryProvider.tsx @@ -0,0 +1,43 @@ +/** + * 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 { QueryClient, QueryClientProvider } from 'react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + retry: false, + retryOnMount: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }, + }, +}); + +type Props = { + children: React.ReactNode; +}; + +const Queryprovider: React.FC = ({ children }) => ( + {children} +); + +export default Queryprovider;