mirror of https://github.com/apache/superset.git
refactor: introduce react-query on api resource hook (#21240)
This commit is contained in:
parent
1aeb8fd6b7
commit
65a11b6f45
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<RenderOptions, 'queries'> & {
|
||||
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 = <BrowserRouter>{result}</BrowserRouter>;
|
||||
}
|
||||
|
||||
if (useQuery) {
|
||||
result = <QueryProvider>{result}</QueryProvider>;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 = () => (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
<QueryProvider>
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<GlobalStyles />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
export default hot(Application);
|
||||
|
|
|
@ -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(
|
||||
<Provider store={store}>
|
||||
<SqlEditor {...mockedProps} {...props} />
|
||||
</Provider>,
|
||||
<QueryProvider>
|
||||
<Provider store={store}>
|
||||
<SqlEditor {...mockedProps} {...props} />
|
||||
</Provider>
|
||||
</QueryProvider>,
|
||||
{
|
||||
wrappingComponent: ThemeProvider,
|
||||
wrappingComponentProps: { theme: supersetTheme },
|
||||
|
|
|
@ -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(
|
||||
<Provider store={store}>
|
||||
<TabbedSqlEditors {...mockedProps} />
|
||||
</Provider>,
|
||||
<QueryProvider>
|
||||
<Provider store={store}>
|
||||
<TabbedSqlEditors {...mockedProps} />
|
||||
</Provider>
|
||||
</QueryProvider>,
|
||||
{
|
||||
wrappingComponent: ThemeProvider,
|
||||
wrappingComponentProps: { theme: supersetTheme },
|
||||
|
|
|
@ -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(
|
||||
<Provider store={store}>
|
||||
<DatasourceModal {...props} />
|
||||
</Provider>,
|
||||
<QueryProvider>
|
||||
<Provider store={store}>
|
||||
<DatasourceModal {...props} />
|
||||
</Provider>
|
||||
</QueryProvider>,
|
||||
{
|
||||
wrappingComponent: ThemeProvider,
|
||||
wrappingComponentProps: { theme: supersetTheme },
|
||||
|
|
|
@ -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 (
|
||||
<div className="section">
|
||||
<span className="select">{select}</span>
|
||||
<span className="refresh">{refreshBtn}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
database,
|
||||
emptyState,
|
||||
|
@ -166,34 +163,50 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||
tableValue = undefined,
|
||||
onTableSelectChange,
|
||||
}) => {
|
||||
const [currentDatabase, setCurrentDatabase] = useState<
|
||||
DatabaseObject | null | undefined
|
||||
>(database);
|
||||
const { addSuccessToast } = useToasts();
|
||||
const [currentSchema, setCurrentSchema] = useState<string | undefined>(
|
||||
schema,
|
||||
);
|
||||
const [tableOptions, setTableOptions] = useState<TableOption[]>([]);
|
||||
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<TableOption[]>(
|
||||
() =>
|
||||
data
|
||||
? data.options.map(table => ({
|
||||
value: table.value,
|
||||
label: <TableOption table={table} />,
|
||||
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<TableSelectorProps> = ({
|
|||
}
|
||||
}, [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: <TableOption table={table} />,
|
||||
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 (
|
||||
<div className="section">
|
||||
<span className="select">{select}</span>
|
||||
<span className="refresh">{refreshBtn}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const internalTableChange = (
|
||||
selectedOptions: TableOption | TableOption[] | undefined,
|
||||
) => {
|
||||
|
@ -274,7 +237,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||
};
|
||||
|
||||
const internalDbChange = (db: DatabaseObject) => {
|
||||
setCurrentDatabase(db);
|
||||
if (onDbChange) {
|
||||
onDbChange(db);
|
||||
}
|
||||
|
@ -286,14 +248,15 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||
onSchemaChange(schema);
|
||||
}
|
||||
|
||||
internalTableChange(undefined);
|
||||
const value = tableSelectMode === 'single' ? undefined : [];
|
||||
internalTableChange(value);
|
||||
};
|
||||
|
||||
function renderDatabaseSelector() {
|
||||
return (
|
||||
<DatabaseSelector
|
||||
key={currentDatabase?.id}
|
||||
db={currentDatabase}
|
||||
key={database?.id}
|
||||
db={database}
|
||||
emptyState={emptyState}
|
||||
formMode={formMode}
|
||||
getDbList={getDbList}
|
||||
|
@ -353,7 +316,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||
|
||||
const refreshLabel = !formMode && !readOnly && (
|
||||
<RefreshLabel
|
||||
onClick={() => setRefresh(refresh + 1)}
|
||||
onClick={() => refetch()}
|
||||
tooltipContent={t('Force refresh table list')}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -28,3 +28,4 @@ export {
|
|||
// different files for different resource types.
|
||||
export * from './charts';
|
||||
export * from './dashboards';
|
||||
export * from './tables';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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<QueryData>;
|
||||
}
|
||||
|
||||
type Params = FetchTablesQueryParams &
|
||||
Pick<UseQueryOptions, 'onSuccess' | 'onError'>;
|
||||
|
||||
export function useTables(options: Params) {
|
||||
const { dbId, schema, keyword, onSuccess, onError } = options || {};
|
||||
const forceRefreshRef = useRef(false);
|
||||
const params = { dbId, schema, keyword };
|
||||
const result = useQuery<QueryData, Error, Data>(
|
||||
['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();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 = () => (
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<LocationPathnameLogger />
|
||||
<RootContextProviders>
|
||||
<GlobalStyles />
|
||||
<Menu data={menu} isFrontendRoute={isFrontendRoute} />
|
||||
<Switch>
|
||||
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ErrorBoundary>
|
||||
<Component user={user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
</Router>
|
||||
<QueryProvider>
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<LocationPathnameLogger />
|
||||
<RootContextProviders>
|
||||
<GlobalStyles />
|
||||
<Menu data={menu} isFrontendRoute={isFrontendRoute} />
|
||||
<Switch>
|
||||
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
|
||||
<Route path={path} key={path}>
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<ErrorBoundary>
|
||||
<Component user={user} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
</Router>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
export default hot(App);
|
||||
|
|
|
@ -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<Props> = ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default Queryprovider;
|
Loading…
Reference in New Issue