mirror of
https://github.com/apache/superset.git
synced 2024-09-07 06:17:45 -04:00
fix(sqllab): Invalid schema fetch by deprecated value (#22968)
This commit is contained in:
parent
a7bb14e433
commit
d3d59ee0ae
@ -41,15 +41,25 @@ const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
const store = mockStore(initialState);
|
||||
|
||||
fetchMock.get('glob:*/api/v1/database/*/schemas/?*', { result: [] });
|
||||
fetchMock.get('glob:*/api/v1/database/*/tables/*', {
|
||||
count: 1,
|
||||
result: [
|
||||
{
|
||||
label: 'ab_user',
|
||||
value: 'ab_user',
|
||||
},
|
||||
],
|
||||
beforeEach(() => {
|
||||
fetchMock.get('glob:*/api/v1/database/?*', { result: [] });
|
||||
fetchMock.get('glob:*/api/v1/database/*/schemas/?*', {
|
||||
count: 2,
|
||||
result: ['main', 'new_schema'],
|
||||
});
|
||||
fetchMock.get('glob:*/api/v1/database/*/tables/*', {
|
||||
count: 1,
|
||||
result: [
|
||||
{
|
||||
label: 'ab_user',
|
||||
value: 'ab_user',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
const renderAndWait = (props, store) =>
|
||||
@ -110,8 +120,9 @@ test('should toggle the table when the header is clicked', async () => {
|
||||
userEvent.click(header);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getActions()).toHaveLength(4);
|
||||
expect(store.getActions()[3].type).toEqual('COLLAPSE_TABLE');
|
||||
expect(store.getActions()[store.getActions().length - 1].type).toEqual(
|
||||
'COLLAPSE_TABLE',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -129,14 +140,55 @@ test('When changing database the table list must be updated', async () => {
|
||||
database_name: 'new_db',
|
||||
backend: 'postgresql',
|
||||
}}
|
||||
queryEditor={{ ...mockedProps.queryEditor, schema: 'new_schema' }}
|
||||
queryEditorId={defaultQueryEditor.id}
|
||||
tables={[{ ...mockedProps.tables[0], dbId: 2, name: 'new_table' }]}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState,
|
||||
store: mockStore({
|
||||
...initialState,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
unsavedQueryEditor: {
|
||||
id: defaultQueryEditor.id,
|
||||
schema: 'new_schema',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(await screen.findByText(/new_db/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/new_table/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('ignore schema api when current schema is deprecated', async () => {
|
||||
const invalidSchemaName = 'None';
|
||||
const { rerender } = await renderAndWait(
|
||||
mockedProps,
|
||||
mockStore({
|
||||
...initialState,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
unsavedQueryEditor: {
|
||||
id: defaultQueryEditor.id,
|
||||
schema: invalidSchemaName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/Database/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/None/i)).toBeInTheDocument();
|
||||
expect(fetchMock.calls()).not.toContainEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
`/tables/${mockedProps.database.id}/${invalidSchemaName}/`,
|
||||
),
|
||||
]),
|
||||
);
|
||||
rerender();
|
||||
// Deselect the deprecated schema selection
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/None/i)).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
@ -18,14 +18,13 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { queryClient } from 'src/views/QueryProvider';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DatabaseSelector, { DatabaseSelectorProps } from '.';
|
||||
import { EmptyStateSmall } from '../EmptyState';
|
||||
|
||||
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
|
||||
|
||||
const createProps = (): DatabaseSelectorProps => ({
|
||||
db: {
|
||||
id: 1,
|
||||
@ -35,7 +34,7 @@ const createProps = (): DatabaseSelectorProps => ({
|
||||
formMode: false,
|
||||
isDatabaseSelectEnabled: true,
|
||||
readOnly: false,
|
||||
schema: undefined,
|
||||
schema: 'public',
|
||||
sqlLabMode: true,
|
||||
getDbList: jest.fn(),
|
||||
handleError: jest.fn(),
|
||||
@ -44,124 +43,133 @@ const createProps = (): DatabaseSelectorProps => ({
|
||||
onSchemasLoad: jest.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
SupersetClientGet.mockImplementation(
|
||||
async ({ endpoint }: { endpoint: string }) => {
|
||||
if (endpoint.includes('schemas')) {
|
||||
return {
|
||||
json: { result: ['information_schema', 'public'] },
|
||||
} as any;
|
||||
}
|
||||
if (endpoint.includes('/function_names')) {
|
||||
return {
|
||||
json: { function_names: [] },
|
||||
} as any;
|
||||
}
|
||||
return {
|
||||
json: {
|
||||
count: 2,
|
||||
description_columns: {},
|
||||
ids: [1, 2],
|
||||
label_columns: {
|
||||
allow_file_upload: 'Allow Csv Upload',
|
||||
allow_ctas: 'Allow Ctas',
|
||||
allow_cvas: 'Allow Cvas',
|
||||
allow_dml: 'Allow Dml',
|
||||
allow_run_async: 'Allow Run Async',
|
||||
allows_cost_estimate: 'Allows Cost Estimate',
|
||||
allows_subquery: 'Allows Subquery',
|
||||
allows_virtual_table_explore: 'Allows Virtual Table Explore',
|
||||
disable_data_preview: 'Disables SQL Lab Data Preview',
|
||||
backend: 'Backend',
|
||||
changed_on: 'Changed On',
|
||||
changed_on_delta_humanized: 'Changed On Delta Humanized',
|
||||
'created_by.first_name': 'Created By First Name',
|
||||
'created_by.last_name': 'Created By Last Name',
|
||||
database_name: 'Database Name',
|
||||
explore_database_id: 'Explore Database Id',
|
||||
expose_in_sqllab: 'Expose In Sqllab',
|
||||
force_ctas_schema: 'Force Ctas Schema',
|
||||
id: 'Id',
|
||||
},
|
||||
list_columns: [
|
||||
'allow_file_upload',
|
||||
'allow_ctas',
|
||||
'allow_cvas',
|
||||
'allow_dml',
|
||||
'allow_run_async',
|
||||
'allows_cost_estimate',
|
||||
'allows_subquery',
|
||||
'allows_virtual_table_explore',
|
||||
'disable_data_preview',
|
||||
'backend',
|
||||
'changed_on',
|
||||
'changed_on_delta_humanized',
|
||||
'created_by.first_name',
|
||||
'created_by.last_name',
|
||||
'database_name',
|
||||
'explore_database_id',
|
||||
'expose_in_sqllab',
|
||||
'force_ctas_schema',
|
||||
'id',
|
||||
],
|
||||
list_title: 'List Database',
|
||||
order_columns: [
|
||||
'allow_file_upload',
|
||||
'allow_dml',
|
||||
'allow_run_async',
|
||||
'changed_on',
|
||||
'changed_on_delta_humanized',
|
||||
'created_by.first_name',
|
||||
'database_name',
|
||||
'expose_in_sqllab',
|
||||
],
|
||||
result: [
|
||||
{
|
||||
allow_file_upload: false,
|
||||
allow_ctas: false,
|
||||
allow_cvas: false,
|
||||
allow_dml: false,
|
||||
allow_run_async: false,
|
||||
allows_cost_estimate: null,
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
disable_data_preview: false,
|
||||
backend: 'postgresql',
|
||||
changed_on: '2021-03-09T19:02:07.141095',
|
||||
changed_on_delta_humanized: 'a day ago',
|
||||
created_by: null,
|
||||
database_name: 'test-postgres',
|
||||
explore_database_id: 1,
|
||||
expose_in_sqllab: true,
|
||||
force_ctas_schema: null,
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
allow_csv_upload: false,
|
||||
allow_ctas: false,
|
||||
allow_cvas: false,
|
||||
allow_dml: false,
|
||||
allow_run_async: false,
|
||||
allows_cost_estimate: null,
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
disable_data_preview: false,
|
||||
backend: 'mysql',
|
||||
changed_on: '2021-03-09T19:02:07.141095',
|
||||
changed_on_delta_humanized: 'a day ago',
|
||||
created_by: null,
|
||||
database_name: 'test-mysql',
|
||||
explore_database_id: 1,
|
||||
expose_in_sqllab: true,
|
||||
force_ctas_schema: null,
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
const fakeDatabaseApiResult = {
|
||||
count: 2,
|
||||
description_columns: {},
|
||||
ids: [1, 2],
|
||||
label_columns: {
|
||||
allow_file_upload: 'Allow Csv Upload',
|
||||
allow_ctas: 'Allow Ctas',
|
||||
allow_cvas: 'Allow Cvas',
|
||||
allow_dml: 'Allow Dml',
|
||||
allow_run_async: 'Allow Run Async',
|
||||
allows_cost_estimate: 'Allows Cost Estimate',
|
||||
allows_subquery: 'Allows Subquery',
|
||||
allows_virtual_table_explore: 'Allows Virtual Table Explore',
|
||||
disable_data_preview: 'Disables SQL Lab Data Preview',
|
||||
backend: 'Backend',
|
||||
changed_on: 'Changed On',
|
||||
changed_on_delta_humanized: 'Changed On Delta Humanized',
|
||||
'created_by.first_name': 'Created By First Name',
|
||||
'created_by.last_name': 'Created By Last Name',
|
||||
database_name: 'Database Name',
|
||||
explore_database_id: 'Explore Database Id',
|
||||
expose_in_sqllab: 'Expose In Sqllab',
|
||||
force_ctas_schema: 'Force Ctas Schema',
|
||||
id: 'Id',
|
||||
},
|
||||
list_columns: [
|
||||
'allow_file_upload',
|
||||
'allow_ctas',
|
||||
'allow_cvas',
|
||||
'allow_dml',
|
||||
'allow_run_async',
|
||||
'allows_cost_estimate',
|
||||
'allows_subquery',
|
||||
'allows_virtual_table_explore',
|
||||
'disable_data_preview',
|
||||
'backend',
|
||||
'changed_on',
|
||||
'changed_on_delta_humanized',
|
||||
'created_by.first_name',
|
||||
'created_by.last_name',
|
||||
'database_name',
|
||||
'explore_database_id',
|
||||
'expose_in_sqllab',
|
||||
'force_ctas_schema',
|
||||
'id',
|
||||
],
|
||||
list_title: 'List Database',
|
||||
order_columns: [
|
||||
'allow_file_upload',
|
||||
'allow_dml',
|
||||
'allow_run_async',
|
||||
'changed_on',
|
||||
'changed_on_delta_humanized',
|
||||
'created_by.first_name',
|
||||
'database_name',
|
||||
'expose_in_sqllab',
|
||||
],
|
||||
result: [
|
||||
{
|
||||
allow_file_upload: false,
|
||||
allow_ctas: false,
|
||||
allow_cvas: false,
|
||||
allow_dml: false,
|
||||
allow_run_async: false,
|
||||
allows_cost_estimate: null,
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
disable_data_preview: false,
|
||||
backend: 'postgresql',
|
||||
changed_on: '2021-03-09T19:02:07.141095',
|
||||
changed_on_delta_humanized: 'a day ago',
|
||||
created_by: null,
|
||||
database_name: 'test-postgres',
|
||||
explore_database_id: 1,
|
||||
expose_in_sqllab: true,
|
||||
force_ctas_schema: null,
|
||||
id: 1,
|
||||
},
|
||||
);
|
||||
{
|
||||
allow_csv_upload: false,
|
||||
allow_ctas: false,
|
||||
allow_cvas: false,
|
||||
allow_dml: false,
|
||||
allow_run_async: false,
|
||||
allows_cost_estimate: null,
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
disable_data_preview: false,
|
||||
backend: 'mysql',
|
||||
changed_on: '2021-03-09T19:02:07.141095',
|
||||
changed_on_delta_humanized: 'a day ago',
|
||||
created_by: null,
|
||||
database_name: 'test-mysql',
|
||||
explore_database_id: 1,
|
||||
expose_in_sqllab: true,
|
||||
force_ctas_schema: null,
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeSchemaApiResult = {
|
||||
count: 2,
|
||||
result: ['information_schema', 'public'],
|
||||
};
|
||||
|
||||
const fakeFunctionNamesApiResult = {
|
||||
function_names: [],
|
||||
};
|
||||
|
||||
const databaseApiRoute = 'glob:*/api/v1/database/?*';
|
||||
const schemaApiRoute = 'glob:*/api/v1/database/*/schemas/?*';
|
||||
const tablesApiRoute = 'glob:*/api/v1/database/*/tables/*';
|
||||
|
||||
function setupFetchMock() {
|
||||
fetchMock.get(databaseApiRoute, fakeDatabaseApiResult);
|
||||
fetchMock.get(schemaApiRoute, fakeSchemaApiResult);
|
||||
fetchMock.get(tablesApiRoute, fakeFunctionNamesApiResult);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
setupFetchMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
test('Should render', async () => {
|
||||
@ -175,6 +183,8 @@ test('Refresh should work', async () => {
|
||||
|
||||
render(<DatabaseSelector {...props} />, { useRedux: true });
|
||||
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(0);
|
||||
|
||||
const select = screen.getByRole('combobox', {
|
||||
name: 'Select schema or type schema name',
|
||||
});
|
||||
@ -182,23 +192,22 @@ test('Refresh should work', async () => {
|
||||
userEvent.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SupersetClientGet).toBeCalledTimes(2);
|
||||
expect(props.getDbList).toBeCalledTimes(0);
|
||||
expect(fetchMock.calls(databaseApiRoute).length).toBe(1);
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(props.handleError).toBeCalledTimes(0);
|
||||
expect(props.onDbChange).toBeCalledTimes(0);
|
||||
expect(props.onSchemaChange).toBeCalledTimes(0);
|
||||
expect(props.onSchemasLoad).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
// click schema reload
|
||||
userEvent.click(screen.getByRole('button', { name: 'refresh' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(SupersetClientGet).toBeCalledTimes(3);
|
||||
expect(props.getDbList).toBeCalledTimes(1);
|
||||
expect(fetchMock.calls(databaseApiRoute).length).toBe(1);
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
|
||||
expect(props.handleError).toBeCalledTimes(0);
|
||||
expect(props.onDbChange).toBeCalledTimes(0);
|
||||
expect(props.onSchemaChange).toBeCalledTimes(0);
|
||||
expect(props.onSchemasLoad).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -214,9 +223,10 @@ test('Should database select display options', async () => {
|
||||
});
|
||||
|
||||
test('should show empty state if there are no options', async () => {
|
||||
SupersetClientGet.mockImplementation(
|
||||
async () => ({ json: { result: [] } } as any),
|
||||
);
|
||||
fetchMock.reset();
|
||||
fetchMock.get(databaseApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
fetchMock.get(tablesApiRoute, { result: [] });
|
||||
const props = createProps();
|
||||
render(
|
||||
<DatabaseSelector
|
||||
|
@ -24,10 +24,7 @@ import Label from 'src/components/Label';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import RefreshLabel from 'src/components/RefreshLabel';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
getClientErrorMessage,
|
||||
getClientErrorObject,
|
||||
} from 'src/utils/getClientErrorObject';
|
||||
import { useSchemas, SchemaOption } from 'src/hooks/apiResources';
|
||||
|
||||
const DatabaseSelectorWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
@ -86,8 +83,6 @@ export type DatabaseObject = {
|
||||
backend: string;
|
||||
};
|
||||
|
||||
type SchemaValue = { label: string; value: string };
|
||||
|
||||
export interface DatabaseSelectorProps {
|
||||
db?: DatabaseObject | null;
|
||||
emptyState?: ReactNode;
|
||||
@ -119,6 +114,8 @@ const SelectLabel = ({
|
||||
</LabelStyle>
|
||||
);
|
||||
|
||||
const EMPTY_SCHEMA_OPTIONS: SchemaOption[] = [];
|
||||
|
||||
export default function DatabaseSelector({
|
||||
db,
|
||||
formMode = false,
|
||||
@ -134,13 +131,10 @@ export default function DatabaseSelector({
|
||||
schema,
|
||||
sqlLabMode = false,
|
||||
}: DatabaseSelectorProps) {
|
||||
const [loadingSchemas, setLoadingSchemas] = useState(false);
|
||||
const [schemaOptions, setSchemaOptions] = useState<SchemaValue[]>([]);
|
||||
const [currentDb, setCurrentDb] = useState<DatabaseValue | undefined>();
|
||||
const [currentSchema, setCurrentSchema] = useState<SchemaValue | undefined>(
|
||||
schema ? { label: schema, value: schema } : undefined,
|
||||
const [currentSchema, setCurrentSchema] = useState<SchemaOption | undefined>(
|
||||
schema ? { label: schema, value: schema, title: schema } : undefined,
|
||||
);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const { addSuccessToast } = useToasts();
|
||||
|
||||
const loadDatabases = useMemo(
|
||||
@ -221,48 +215,37 @@ export default function DatabaseSelector({
|
||||
);
|
||||
}, [db]);
|
||||
|
||||
function changeSchema(schema: SchemaValue) {
|
||||
function changeSchema(schema: SchemaOption | undefined) {
|
||||
setCurrentSchema(schema);
|
||||
if (onSchemaChange) {
|
||||
onSchemaChange(schema.value);
|
||||
onSchemaChange(schema?.value);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDb) {
|
||||
setLoadingSchemas(true);
|
||||
const queryParams = rison.encode({ force: refresh > 0 });
|
||||
const endpoint = `/api/v1/database/${currentDb.value}/schemas/?q=${queryParams}`;
|
||||
const {
|
||||
data,
|
||||
isFetching: loadingSchemas,
|
||||
isFetched,
|
||||
refetch,
|
||||
} = useSchemas({
|
||||
dbId: currentDb?.value,
|
||||
onSuccess: data => {
|
||||
onSchemasLoad?.(data);
|
||||
|
||||
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes.
|
||||
SupersetClient.get({ endpoint })
|
||||
.then(({ json }) => {
|
||||
const options = json.result.map((s: string) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
title: s,
|
||||
}));
|
||||
if (onSchemasLoad) {
|
||||
onSchemasLoad(options);
|
||||
}
|
||||
setSchemaOptions(options);
|
||||
setLoadingSchemas(false);
|
||||
if (options.length === 1) changeSchema(options[0]);
|
||||
if (refresh > 0) addSuccessToast(t('List refreshed'));
|
||||
})
|
||||
.catch(err => {
|
||||
setLoadingSchemas(false);
|
||||
getClientErrorObject(err).then(clientError => {
|
||||
handleError(
|
||||
getClientErrorMessage(
|
||||
t('There was an error loading the schemas'),
|
||||
clientError,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [currentDb, onSchemasLoad, refresh]);
|
||||
if (data.length === 1) {
|
||||
changeSchema(data[0]);
|
||||
} else if (!data.find(schemaOption => schema === schemaOption.value)) {
|
||||
changeSchema(undefined);
|
||||
}
|
||||
|
||||
if (isFetched) {
|
||||
addSuccessToast('List refreshed');
|
||||
}
|
||||
},
|
||||
onError: () => handleError(t('There was an error loading the schemas')),
|
||||
});
|
||||
|
||||
const schemaOptions = data || EMPTY_SCHEMA_OPTIONS;
|
||||
|
||||
function changeDataBase(
|
||||
value: { label: string; value: number },
|
||||
@ -309,7 +292,7 @@ export default function DatabaseSelector({
|
||||
function renderSchemaSelect() {
|
||||
const refreshIcon = !readOnly && (
|
||||
<RefreshLabel
|
||||
onClick={() => setRefresh(refresh + 1)}
|
||||
onClick={() => refetch()}
|
||||
tooltipContent={t('Force refresh schema list')}
|
||||
/>
|
||||
);
|
||||
@ -323,7 +306,7 @@ export default function DatabaseSelector({
|
||||
name="select-schema"
|
||||
notFoundContent={t('No compatible schema found')}
|
||||
placeholder={t('Select schema or type schema name')}
|
||||
onChange={item => changeSchema(item as SchemaValue)}
|
||||
onChange={item => changeSchema(item as SchemaOption)}
|
||||
options={schemaOptions}
|
||||
showSearch
|
||||
value={currentSchema}
|
||||
|
@ -19,13 +19,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { queryClient } from 'src/views/QueryProvider';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TableSelector, { TableSelectorMultiple } from '.';
|
||||
|
||||
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
|
||||
|
||||
const createProps = (props = {}) => ({
|
||||
database: {
|
||||
id: 1,
|
||||
@ -37,37 +36,43 @@ const createProps = (props = {}) => ({
|
||||
...props,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const getSchemaMockFunction = async () =>
|
||||
const getSchemaMockFunction = () =>
|
||||
({
|
||||
json: {
|
||||
result: ['schema_a', 'schema_b'],
|
||||
},
|
||||
result: ['schema_a', 'schema_b'],
|
||||
} as any);
|
||||
|
||||
const getTableMockFunction = async () =>
|
||||
const getTableMockFunction = () =>
|
||||
({
|
||||
json: {
|
||||
count: 4,
|
||||
result: [
|
||||
{ label: 'table_a', value: 'table_a' },
|
||||
{ label: 'table_b', value: 'table_b' },
|
||||
{ label: 'table_c', value: 'table_c' },
|
||||
{ label: 'table_d', value: 'table_d' },
|
||||
],
|
||||
},
|
||||
count: 4,
|
||||
result: [
|
||||
{ label: 'table_a', value: 'table_a' },
|
||||
{ label: 'table_b', value: 'table_b' },
|
||||
{ label: 'table_c', value: 'table_c' },
|
||||
{ label: 'table_d', value: 'table_d' },
|
||||
],
|
||||
} as any);
|
||||
|
||||
const databaseApiRoute = 'glob:*/api/v1/database/?*';
|
||||
const schemaApiRoute = 'glob:*/api/v1/database/*/schemas/?*';
|
||||
const tablesApiRoute = 'glob:*/api/v1/database/*/tables/*';
|
||||
|
||||
const getSelectItemContainer = (select: HTMLElement) =>
|
||||
select.parentElement?.parentElement?.getElementsByClassName(
|
||||
'ant-select-selection-item',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
fetchMock.get(databaseApiRoute, { result: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
test('renders with default props', async () => {
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const props = createProps();
|
||||
render(<TableSelector {...props} />, { useRedux: true });
|
||||
@ -88,7 +93,8 @@ test('renders with default props', async () => {
|
||||
});
|
||||
|
||||
test('renders table options', async () => {
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const props = createProps();
|
||||
render(<TableSelector {...props} />, { useRedux: true });
|
||||
@ -105,7 +111,8 @@ test('renders table options', async () => {
|
||||
});
|
||||
|
||||
test('renders disabled without schema', async () => {
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const props = createProps();
|
||||
render(<TableSelector {...props} schema={undefined} />, { useRedux: true });
|
||||
@ -118,7 +125,7 @@ test('renders disabled without schema', async () => {
|
||||
});
|
||||
|
||||
test('table options are notified after schema selection', async () => {
|
||||
SupersetClientGet.mockImplementation(getSchemaMockFunction);
|
||||
fetchMock.get(schemaApiRoute, getSchemaMockFunction());
|
||||
|
||||
const callback = jest.fn();
|
||||
const props = createProps({
|
||||
@ -142,7 +149,7 @@ test('table options are notified after schema selection', async () => {
|
||||
await screen.findByRole('option', { name: 'schema_b' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
act(() => {
|
||||
userEvent.click(screen.getAllByText('schema_a')[1]);
|
||||
@ -159,7 +166,8 @@ test('table options are notified after schema selection', async () => {
|
||||
});
|
||||
|
||||
test('table select retain value if not in SQL Lab mode', async () => {
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const callback = jest.fn();
|
||||
const props = createProps({
|
||||
@ -182,7 +190,7 @@ test('table select retain value if not in SQL Lab mode', async () => {
|
||||
await screen.findByRole('option', { name: 'table_a' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
await waitFor(() => {
|
||||
userEvent.click(screen.getAllByText('table_a')[1]);
|
||||
});
|
||||
|
||||
@ -199,7 +207,8 @@ test('table select retain value if not in SQL Lab mode', async () => {
|
||||
});
|
||||
|
||||
test('table multi select retain all the values selected', async () => {
|
||||
SupersetClientGet.mockImplementation(getTableMockFunction);
|
||||
fetchMock.get(schemaApiRoute, { result: ['test_schema'] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const callback = jest.fn();
|
||||
const props = createProps({
|
||||
@ -217,23 +226,19 @@ test('table multi select retain all the values selected', async () => {
|
||||
|
||||
userEvent.click(tableSelect);
|
||||
|
||||
act(() => {
|
||||
const item = screen.getAllByText('table_b');
|
||||
await waitFor(async () => {
|
||||
const item = await screen.findAllByText('table_b');
|
||||
userEvent.click(item[item.length - 1]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const item = screen.getAllByText('table_c');
|
||||
await waitFor(async () => {
|
||||
const item = await screen.findAllByText('table_c');
|
||||
userEvent.click(item[item.length - 1]);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('option', { name: 'table_b' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
const selection1 = await screen.findByRole('option', { name: 'table_b' });
|
||||
expect(selection1).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
expect(screen.getByRole('option', { name: 'table_c' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
const selection2 = await screen.findByRole('option', { name: 'table_c' });
|
||||
expect(selection2).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
@ -275,26 +275,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
internalTableChange(value);
|
||||
};
|
||||
|
||||
function renderDatabaseSelector() {
|
||||
return (
|
||||
<DatabaseSelector
|
||||
db={database}
|
||||
emptyState={emptyState}
|
||||
formMode={formMode}
|
||||
getDbList={getDbList}
|
||||
handleError={handleError}
|
||||
onDbChange={readOnly ? undefined : internalDbChange}
|
||||
onEmptyResults={onEmptyResults}
|
||||
onSchemaChange={readOnly ? undefined : internalSchemaChange}
|
||||
onSchemasLoad={onSchemasLoad}
|
||||
schema={currentSchema}
|
||||
sqlLabMode={sqlLabMode}
|
||||
isDatabaseSelectEnabled={isDatabaseSelectEnabled && !readOnly}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFilterOption = useMemo(
|
||||
() => (search: string, option: TableOption) => {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
@ -346,7 +326,21 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<TableSelectorWrapper>
|
||||
{renderDatabaseSelector()}
|
||||
<DatabaseSelector
|
||||
db={database}
|
||||
emptyState={emptyState}
|
||||
formMode={formMode}
|
||||
getDbList={getDbList}
|
||||
handleError={handleError}
|
||||
onDbChange={readOnly ? undefined : internalDbChange}
|
||||
onEmptyResults={onEmptyResults}
|
||||
onSchemaChange={readOnly ? undefined : internalSchemaChange}
|
||||
onSchemasLoad={onSchemasLoad}
|
||||
schema={currentSchema}
|
||||
sqlLabMode={sqlLabMode}
|
||||
isDatabaseSelectEnabled={isDatabaseSelectEnabled && !readOnly}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{sqlLabMode && !formMode && <div className="divider" />}
|
||||
{renderTableSelect()}
|
||||
</TableSelectorWrapper>
|
||||
|
@ -29,3 +29,4 @@ export {
|
||||
export * from './charts';
|
||||
export * from './dashboards';
|
||||
export * from './tables';
|
||||
export * from './schemas';
|
||||
|
138
superset-frontend/src/hooks/apiResources/schemas.test.ts
Normal file
138
superset-frontend/src/hooks/apiResources/schemas.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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 rison from 'rison';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import QueryProvider, { queryClient } from 'src/views/QueryProvider';
|
||||
import { useSchemas } from './schemas';
|
||||
|
||||
const fakeApiResult = {
|
||||
result: ['test schema 1', 'test schema b'],
|
||||
};
|
||||
|
||||
const expectedResult = fakeApiResult.result.map((value: string) => ({
|
||||
value,
|
||||
label: value,
|
||||
title: value,
|
||||
}));
|
||||
|
||||
describe('useSchemas hook', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('returns api response mapping json result', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const forceRefresh = false;
|
||||
const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
|
||||
fetchMock.get(schemaApiRoute, fakeApiResult);
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSchemas({
|
||||
dbId: expectDbId,
|
||||
}),
|
||||
{
|
||||
wrapper: QueryProvider,
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(
|
||||
fetchMock.calls(
|
||||
`end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({
|
||||
force: forceRefresh,
|
||||
})}`,
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedResult);
|
||||
await act(async () => {
|
||||
result.current.refetch();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
|
||||
expect(
|
||||
fetchMock.calls(
|
||||
`end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({
|
||||
force: true,
|
||||
})}`,
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('returns cached data without api request', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
|
||||
fetchMock.get(schemaApiRoute, fakeApiResult);
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useSchemas({
|
||||
dbId: expectDbId,
|
||||
}),
|
||||
{
|
||||
wrapper: QueryProvider,
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
rerender();
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('returns refreshed data after expires', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
|
||||
fetchMock.get(schemaApiRoute, fakeApiResult);
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useSchemas({
|
||||
dbId: expectDbId,
|
||||
}),
|
||||
{
|
||||
wrapper: QueryProvider,
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
rerender();
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
queryClient.clear();
|
||||
rerender();
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
|
||||
expect(result.current.data).toEqual(expectedResult);
|
||||
});
|
||||
});
|
80
superset-frontend/src/hooks/apiResources/schemas.ts
Normal file
80
superset-frontend/src/hooks/apiResources/schemas.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 rison from 'rison';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
export type FetchSchemasQueryParams = {
|
||||
dbId?: string | number;
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
type QueryData = {
|
||||
json: { result: string[] };
|
||||
response: Response;
|
||||
};
|
||||
|
||||
export type SchemaOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function fetchSchemas({ dbId, forceRefresh }: FetchSchemasQueryParams) {
|
||||
const queryParams = rison.encode({ force: forceRefresh });
|
||||
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes.
|
||||
const endpoint = `/api/v1/database/${dbId}/schemas/?q=${queryParams}`;
|
||||
return SupersetClient.get({ endpoint }) as Promise<QueryData>;
|
||||
}
|
||||
|
||||
type Params = FetchSchemasQueryParams &
|
||||
Pick<UseQueryOptions<SchemaOption[]>, 'onSuccess' | 'onError'>;
|
||||
|
||||
export function useSchemas(options: Params) {
|
||||
const { dbId, onSuccess, onError } = options || {};
|
||||
const forceRefreshRef = useRef(false);
|
||||
const params = { dbId };
|
||||
const result = useQuery<QueryData, Error, SchemaOption[]>(
|
||||
['schemas', { dbId }],
|
||||
() => fetchSchemas({ ...params, forceRefresh: forceRefreshRef.current }),
|
||||
{
|
||||
select: ({ json }) =>
|
||||
json.result.map((value: string) => ({
|
||||
value,
|
||||
label: value,
|
||||
title: value,
|
||||
})),
|
||||
enabled: Boolean(dbId),
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled: () => {
|
||||
forceRefreshRef.current = false;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
refetch: () => {
|
||||
forceRefreshRef.current = true;
|
||||
return result.refetch();
|
||||
},
|
||||
};
|
||||
}
|
@ -16,78 +16,76 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import rison from 'rison';
|
||||
import fetchMock from 'fetch-mock';
|
||||
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: {
|
||||
count: 2,
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'fake api result1',
|
||||
label: 'fake api label1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'fake api result2',
|
||||
label: 'fake api label2',
|
||||
},
|
||||
],
|
||||
},
|
||||
count: 2,
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'fake api result1',
|
||||
label: 'fake api label1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'fake api result2',
|
||||
label: 'fake api label2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeHasMoreApiResult = {
|
||||
json: {
|
||||
count: 4,
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'fake api result1',
|
||||
label: 'fake api label1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'fake api result2',
|
||||
label: 'fake api label2',
|
||||
},
|
||||
],
|
||||
},
|
||||
count: 4,
|
||||
result: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'fake api result1',
|
||||
label: 'fake api label1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'fake api result2',
|
||||
label: 'fake api label2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeSchemaApiResult = ['schema1', 'schema2'];
|
||||
|
||||
const expectedData = {
|
||||
options: [...fakeApiResult.json.result],
|
||||
options: fakeApiResult.result,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
const expectedHasMoreData = {
|
||||
options: [...fakeHasMoreApiResult.json.result],
|
||||
options: fakeHasMoreApiResult.result,
|
||||
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(() => {
|
||||
fetchMock.reset();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns api response mapping json options', async () => {
|
||||
test('returns api response mapping json options', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const expectedSchema = 'schemaA';
|
||||
const forceRefresh = false;
|
||||
const expectedSchema = 'schema1';
|
||||
const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
|
||||
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
|
||||
fetchMock.get(tableApiRoute, fakeApiResult);
|
||||
fetchMock.get(schemaApiRoute, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
@ -101,29 +99,73 @@ describe('useTables hook', () => {
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(SupersetClient.get).toHaveBeenCalledWith({
|
||||
endpoint: `/api/v1/database/${expectDbId}/tables/?q=(force:!${
|
||||
forceRefresh ? 't' : 'f'
|
||||
},schema_name:${expectedSchema})`,
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(
|
||||
fetchMock.calls(
|
||||
`end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({
|
||||
force: false,
|
||||
schema_name: expectedSchema,
|
||||
})}`,
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedData);
|
||||
await act(async () => {
|
||||
result.current.refetch();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(2);
|
||||
expect(SupersetClient.get).toHaveBeenCalledWith({
|
||||
endpoint: `/api/v1/database/${expectDbId}/tables/?q=(force:!t,schema_name:${expectedSchema})`,
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(
|
||||
fetchMock.calls(
|
||||
`end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({
|
||||
force: true,
|
||||
schema_name: expectedSchema,
|
||||
})}`,
|
||||
).length,
|
||||
).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('returns hasMore when total is larger than result size', async () => {
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValueOnce(
|
||||
fakeHasMoreApiResult,
|
||||
);
|
||||
test('skips the deprecated schema option', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const expectedSchema = 'schemaA';
|
||||
const unexpectedSchema = 'invalid schema';
|
||||
const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
|
||||
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
|
||||
fetchMock.get(tableApiRoute, fakeApiResult);
|
||||
fetchMock.get(schemaApiRoute, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
dbId: expectDbId,
|
||||
schema: unexpectedSchema,
|
||||
}),
|
||||
{
|
||||
wrapper: QueryProvider,
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
|
||||
expect(result.current.data).toEqual(undefined);
|
||||
expect(
|
||||
fetchMock.calls(
|
||||
`end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({
|
||||
force: false,
|
||||
schema_name: unexpectedSchema,
|
||||
})}`,
|
||||
).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('returns hasMore when total is larger than result size', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const expectedSchema = 'schema2';
|
||||
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
|
||||
fetchMock.get(tableApiRoute, fakeHasMoreApiResult);
|
||||
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
@ -137,13 +179,18 @@ describe('useTables hook', () => {
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedHasMoreData);
|
||||
});
|
||||
|
||||
it('returns cached data without api request', async () => {
|
||||
test('returns cached data without api request', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const expectedSchema = 'schemaA';
|
||||
const expectedSchema = 'schema1';
|
||||
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
|
||||
fetchMock.get(tableApiRoute, fakeApiResult);
|
||||
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
@ -157,15 +204,20 @@ describe('useTables hook', () => {
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(1);
|
||||
rerender();
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(1);
|
||||
expect(result.current.data).toEqual(expectedData);
|
||||
});
|
||||
|
||||
it('returns refreshed data after expires', async () => {
|
||||
test('returns refreshed data after expires', async () => {
|
||||
const expectDbId = 'db1';
|
||||
const expectedSchema = 'schemaA';
|
||||
const expectedSchema = 'schema1';
|
||||
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
|
||||
fetchMock.get(tableApiRoute, fakeApiResult);
|
||||
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
@ -179,18 +231,18 @@ describe('useTables hook', () => {
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(1);
|
||||
rerender();
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(1);
|
||||
queryClient.clear();
|
||||
rerender();
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(SupersetClient.get).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.calls(tableApiRoute).length).toBe(2);
|
||||
expect(result.current.data).toEqual(expectedData);
|
||||
});
|
||||
});
|
||||
|
@ -16,11 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useQuery, UseQueryOptions } from 'react-query';
|
||||
import rison from 'rison';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
import { useSchemas } from './schemas';
|
||||
|
||||
export type FetchTablesQueryParams = {
|
||||
dbId?: string | number;
|
||||
schema?: string;
|
||||
@ -71,9 +73,16 @@ export function fetchTables({
|
||||
}
|
||||
|
||||
type Params = FetchTablesQueryParams &
|
||||
Pick<UseQueryOptions, 'onSuccess' | 'onError'>;
|
||||
Pick<UseQueryOptions<Data>, 'onSuccess' | 'onError'>;
|
||||
|
||||
export function useTables(options: Params) {
|
||||
const { data: schemaOptions, isFetching } = useSchemas({
|
||||
dbId: options.dbId,
|
||||
});
|
||||
const schemaOptionsMap = useMemo(
|
||||
() => new Set(schemaOptions?.map(({ value }) => value)),
|
||||
[schemaOptions],
|
||||
);
|
||||
const { dbId, schema, onSuccess, onError } = options || {};
|
||||
const forceRefreshRef = useRef(false);
|
||||
const params = { dbId, schema };
|
||||
@ -85,7 +94,9 @@ export function useTables(options: Params) {
|
||||
options: json.result,
|
||||
hasMore: json.count > json.result.length,
|
||||
}),
|
||||
enabled: Boolean(dbId && schema),
|
||||
enabled: Boolean(
|
||||
dbId && schema && !isFetching && schemaOptionsMap.has(schema),
|
||||
),
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled: () => {
|
||||
|
Loading…
Reference in New Issue
Block a user