Revert "chore: Changes the DatabaseSelector to use the new Select component (#16334)" (#16478)

This reverts commit c768941f2f.
This commit is contained in:
Erik Ritter 2021-08-26 18:28:04 -07:00 committed by GitHub
parent a413f796a6
commit 8adc31d14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 841 additions and 702 deletions

View File

@ -81,13 +81,9 @@ describe('Left Panel Expansion', () => {
</Provider> </Provider>
</ThemeProvider>, </ThemeProvider>,
); );
const dbSelect = screen.getByRole('combobox', { const dbSelect = screen.getByText(/select a database/i);
name: 'Select a database', const schemaSelect = screen.getByText(/select a schema \(0\)/i);
}); const dropdown = screen.getByText(/Select table/i);
const schemaSelect = screen.getByRole('combobox', {
name: 'Select a schema',
});
const dropdown = screen.getByText(/Select a table/i);
const abUser = screen.getByText(/ab_user/i); const abUser = screen.getByText(/ab_user/i);
expect(dbSelect).toBeInTheDocument(); expect(dbSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument(); expect(schemaSelect).toBeInTheDocument();

View File

@ -18,19 +18,19 @@
*/ */
import React from 'react'; import React from 'react';
import { t, supersetTheme } from '@superset-ui/core'; import { t, supersetTheme } from '@superset-ui/core';
import Icons, { IconType } from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
export interface CertifiedIconProps { export interface CertifiedIconProps {
certifiedBy?: string; certifiedBy?: string;
details?: string; details?: string;
size?: IconType['iconSize']; size?: number;
} }
function CertifiedIcon({ function CertifiedIcon({
certifiedBy, certifiedBy,
details, details,
size = 'l', size = 24,
}: CertifiedIconProps) { }: CertifiedIconProps) {
return ( return (
<Tooltip <Tooltip
@ -48,7 +48,8 @@ function CertifiedIcon({
> >
<Icons.Certified <Icons.Certified
iconColor={supersetTheme.colors.primary.base} iconColor={supersetTheme.colors.primary.base}
iconSize={size} height={size}
width={size}
/> />
</Tooltip> </Tooltip>
); );

View File

@ -26,11 +26,11 @@ import DatabaseSelector from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const createProps = () => ({ const createProps = () => ({
db: { id: 1, database_name: 'test', backend: 'postgresql' }, dbId: 1,
formMode: false, formMode: false,
isDatabaseSelectEnabled: true, isDatabaseSelectEnabled: true,
readOnly: false, readOnly: false,
schema: undefined, schema: 'public',
sqlLabMode: true, sqlLabMode: true,
getDbList: jest.fn(), getDbList: jest.fn(),
getTableList: jest.fn(), getTableList: jest.fn(),
@ -129,7 +129,7 @@ beforeEach(() => {
changed_on: '2021-03-09T19:02:07.141095', changed_on: '2021-03-09T19:02:07.141095',
changed_on_delta_humanized: 'a day ago', changed_on_delta_humanized: 'a day ago',
created_by: null, created_by: null,
database_name: 'test', database_name: 'examples',
explore_database_id: 1, explore_database_id: 1,
expose_in_sqllab: true, expose_in_sqllab: true,
force_ctas_schema: null, force_ctas_schema: null,
@ -153,62 +153,50 @@ test('Refresh should work', async () => {
render(<DatabaseSelector {...props} />); render(<DatabaseSelector {...props} />);
const select = screen.getByRole('combobox', {
name: 'Select a schema',
});
userEvent.click(select);
await waitFor(() => {
expect(SupersetClientGet).toBeCalledTimes(1);
expect(props.getDbList).toBeCalledTimes(0);
expect(props.getTableList).toBeCalledTimes(0);
expect(props.handleError).toBeCalledTimes(0);
expect(props.onDbChange).toBeCalledTimes(0);
expect(props.onSchemaChange).toBeCalledTimes(0);
expect(props.onSchemasLoad).toBeCalledTimes(0);
expect(props.onUpdate).toBeCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'refresh' }));
await waitFor(() => { await waitFor(() => {
expect(SupersetClientGet).toBeCalledTimes(2); expect(SupersetClientGet).toBeCalledTimes(2);
expect(props.getDbList).toBeCalledTimes(0); expect(props.getDbList).toBeCalledTimes(1);
expect(props.getTableList).toBeCalledTimes(0); expect(props.getTableList).toBeCalledTimes(0);
expect(props.handleError).toBeCalledTimes(0); expect(props.handleError).toBeCalledTimes(0);
expect(props.onDbChange).toBeCalledTimes(0); expect(props.onDbChange).toBeCalledTimes(0);
expect(props.onSchemaChange).toBeCalledTimes(0); expect(props.onSchemaChange).toBeCalledTimes(0);
expect(props.onSchemasLoad).toBeCalledTimes(2); expect(props.onSchemasLoad).toBeCalledTimes(1);
expect(props.onUpdate).toBeCalledTimes(0); expect(props.onUpdate).toBeCalledTimes(0);
}); });
userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(SupersetClientGet).toBeCalledTimes(3);
expect(props.getDbList).toBeCalledTimes(1);
expect(props.getTableList).toBeCalledTimes(0);
expect(props.handleError).toBeCalledTimes(0);
expect(props.onDbChange).toBeCalledTimes(1);
expect(props.onSchemaChange).toBeCalledTimes(1);
expect(props.onSchemasLoad).toBeCalledTimes(2);
expect(props.onUpdate).toBeCalledTimes(1);
});
}); });
test('Should database select display options', async () => { test('Should database select display options', async () => {
const props = createProps(); const props = createProps();
render(<DatabaseSelector {...props} />); render(<DatabaseSelector {...props} />);
const select = screen.getByRole('combobox', { const selector = await screen.findByText('Database:');
name: 'Select a database', expect(selector).toBeInTheDocument();
}); expect(selector.parentElement).toHaveTextContent(
expect(select).toBeInTheDocument(); 'Database:postgresql examples',
userEvent.click(select); );
expect(
await screen.findByRole('option', { name: 'postgresql: test' }),
).toBeInTheDocument();
}); });
test('Should schema select display options', async () => { test('Should schema select display options', async () => {
const props = createProps(); const props = createProps();
render(<DatabaseSelector {...props} />); render(<DatabaseSelector {...props} />);
const select = screen.getByRole('combobox', {
name: 'Select a schema', const selector = await screen.findByText('Schema:');
}); expect(selector).toBeInTheDocument();
expect(select).toBeInTheDocument(); expect(selector.parentElement).toHaveTextContent('Schema: public');
userEvent.click(select);
expect( userEvent.click(screen.getByRole('button'));
await screen.findByRole('option', { name: 'public' }),
).toBeInTheDocument(); expect(await screen.findByText('Select a schema (2)')).toBeInTheDocument();
expect(
await screen.findByRole('option', { name: 'information_schema' }),
).toBeInTheDocument();
}); });

View File

@ -16,51 +16,58 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { ReactNode, useState, useMemo } from 'react'; import React, { ReactNode, useEffect, useState } from 'react';
import { styled, SupersetClient, t } from '@superset-ui/core'; import { styled, SupersetClient, t } from '@superset-ui/core';
import rison from 'rison'; import rison from 'rison';
import { Select } from 'src/components'; import { Select } from 'src/components/Select';
import { FormLabel } from 'src/components/Form'; import Label from 'src/components/Label';
import RefreshLabel from 'src/components/RefreshLabel'; import RefreshLabel from 'src/components/RefreshLabel';
import SupersetAsyncSelect from 'src/components/AsyncSelect';
const DatabaseSelectorWrapper = styled.div` const FieldTitle = styled.p`
${({ theme }) => ` color: ${({ theme }) => theme.colors.secondary.light2};
.refresh { font-size: ${({ theme }) => theme.typography.sizes.s}px;
display: flex; margin: 20px 0 10px 0;
align-items: center; text-transform: uppercase;
width: 30px;
margin-left: ${theme.gridUnit}px;
margin-top: ${theme.gridUnit * 5}px;
}
.section {
display: flex;
flex-direction: row;
align-items: center;
}
.select {
flex: 1;
}
& > div {
margin-bottom: ${theme.gridUnit * 4}px;
}
`}
`; `;
type DatabaseValue = { label: string; value: number }; const DatabaseSelectorWrapper = styled.div`
.fa-refresh {
padding-left: 9px;
}
type SchemaValue = { label: string; value: string }; .refresh-col {
display: flex;
align-items: center;
width: 30px;
margin-left: ${({ theme }) => theme.gridUnit}px;
}
.section {
padding-bottom: 5px;
display: flex;
flex-direction: row;
}
.select {
flex-grow: 1;
}
`;
const DatabaseOption = styled.span`
display: inline-flex;
align-items: center;
`;
interface DatabaseSelectorProps { interface DatabaseSelectorProps {
db?: { id: number; database_name: string; backend: string }; dbId: number;
formMode?: boolean; formMode?: boolean;
getDbList?: (arg0: any) => {}; getDbList?: (arg0: any) => {};
getTableList?: (dbId: number, schema: string, force: boolean) => {};
handleError: (msg: string) => void; handleError: (msg: string) => void;
isDatabaseSelectEnabled?: boolean; isDatabaseSelectEnabled?: boolean;
onDbChange?: (db: any) => void; onDbChange?: (db: any) => void;
onSchemaChange?: (schema?: string) => void; onSchemaChange?: (arg0?: any) => {};
onSchemasLoad?: (schemas: Array<object>) => void; onSchemasLoad?: (schemas: Array<object>) => void;
readOnly?: boolean; readOnly?: boolean;
schema?: string; schema?: string;
@ -76,9 +83,10 @@ interface DatabaseSelectorProps {
} }
export default function DatabaseSelector({ export default function DatabaseSelector({
db, dbId,
formMode = false, formMode = false,
getDbList, getDbList,
getTableList,
handleError, handleError,
isDatabaseSelectEnabled = true, isDatabaseSelectEnabled = true,
onUpdate, onUpdate,
@ -89,189 +97,193 @@ export default function DatabaseSelector({
schema, schema,
sqlLabMode = false, sqlLabMode = false,
}: DatabaseSelectorProps) { }: DatabaseSelectorProps) {
const [currentDb, setCurrentDb] = useState( const [currentDbId, setCurrentDbId] = useState(dbId);
db const [currentSchema, setCurrentSchema] = useState<string | undefined>(
? { label: `${db.backend}: ${db.database_name}`, value: db.id } schema,
: undefined,
); );
const [currentSchema, setCurrentSchema] = useState<SchemaValue | undefined>( const [schemaLoading, setSchemaLoading] = useState(false);
schema ? { label: schema, value: schema } : undefined, const [schemaOptions, setSchemaOptions] = useState([]);
);
const [refresh, setRefresh] = useState(0);
const loadSchemas = useMemo( function fetchSchemas(databaseId: number, forceRefresh = false) {
() => async (): Promise<{ const actualDbId = databaseId || dbId;
data: SchemaValue[]; if (actualDbId) {
totalCount: number; setSchemaLoading(true);
}> => { const queryParams = rison.encode({
if (currentDb) { force: Boolean(forceRefresh),
const queryParams = rison.encode({ force: refresh > 0 }); });
const endpoint = `/api/v1/database/${currentDb.value}/schemas/?q=${queryParams}`; const endpoint = `/api/v1/database/${actualDbId}/schemas/?q=${queryParams}`;
return SupersetClient.get({ endpoint })
// TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. .then(({ json }) => {
return SupersetClient.get({ endpoint }).then(({ json }) => {
const options = json.result.map((s: string) => ({ const options = json.result.map((s: string) => ({
value: s, value: s,
label: s, label: s,
title: s, title: s,
})); }));
setSchemaOptions(options);
setSchemaLoading(false);
if (onSchemasLoad) { if (onSchemasLoad) {
onSchemasLoad(options); onSchemasLoad(options);
} }
return { })
data: options, .catch(() => {
totalCount: options.length, setSchemaOptions([]);
}; setSchemaLoading(false);
handleError(t('Error while fetching schema list'));
}); });
} }
return { return Promise.resolve();
data: [], }
totalCount: 0,
};
},
[currentDb, refresh, onSchemasLoad],
);
function onSelectChange({ useEffect(() => {
db, if (currentDbId) {
schema, fetchSchemas(currentDbId);
}: { }
db: DatabaseValue; }, [currentDbId]);
schema?: SchemaValue;
}) { function onSelectChange({ dbId, schema }: { dbId: number; schema?: string }) {
setCurrentDb(db); setCurrentDbId(dbId);
setCurrentSchema(schema); setCurrentSchema(schema);
if (onUpdate) { if (onUpdate) {
onUpdate({ onUpdate({ dbId, schema, tableName: undefined });
dbId: db.value,
schema: schema?.value,
tableName: undefined,
});
} }
} }
function changeDataBase(selectedValue: DatabaseValue) { function dbMutator(data: any) {
const actualDb = selectedValue || db; if (getDbList) {
getDbList(data.result);
}
if (data.result.length === 0) {
handleError(t("It seems you don't have access to any database"));
}
return data.result.map((row: any) => ({
...row,
// label is used for the typeahead
label: `${row.backend} ${row.database_name}`,
}));
}
function changeDataBase(db: any, force = false) {
const dbId = db ? db.id : null;
setSchemaOptions([]);
if (onSchemaChange) { if (onSchemaChange) {
onSchemaChange(undefined); onSchemaChange(null);
} }
if (onDbChange) { if (onDbChange) {
onDbChange(db); onDbChange(db);
} }
onSelectChange({ db: actualDb, schema: undefined }); fetchSchemas(dbId, force);
onSelectChange({ dbId, schema: undefined });
} }
function changeSchema(schema: SchemaValue) { function changeSchema(schemaOpt: any, force = false) {
const schema = schemaOpt ? schemaOpt.value : null;
if (onSchemaChange) { if (onSchemaChange) {
onSchemaChange(schema.value); onSchemaChange(schema);
} }
if (currentDb) { setCurrentSchema(schema);
onSelectChange({ db: currentDb, schema }); onSelectChange({ dbId: currentDbId, schema });
if (getTableList) {
getTableList(currentDbId, schema, force);
} }
} }
function renderDatabaseOption(db: any) {
return (
<DatabaseOption title={db.database_name}>
<Label type="default">{db.backend}</Label> {db.database_name}
</DatabaseOption>
);
}
function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
return ( return (
<div className="section"> <div className="section">
<span className="select">{select}</span> <span className="select">{select}</span>
<span className="refresh">{refreshBtn}</span> <span className="refresh-col">{refreshBtn}</span>
</div> </div>
); );
} }
const loadDatabases = useMemo(
() => async (
search: string,
page: number,
pageSize: number,
): Promise<{
data: DatabaseValue[];
totalCount: number;
}> => {
const queryParams = rison.encode({
order_columns: 'database_name',
order_direction: 'asc',
page,
page_size: pageSize,
...(formMode || !sqlLabMode
? { filters: [{ col: 'database_name', opr: 'ct', value: search }] }
: {
filters: [
{ col: 'database_name', opr: 'ct', value: search },
{
col: 'expose_in_sqllab',
opr: 'eq',
value: true,
},
],
}),
});
const endpoint = `/api/v1/database/?q=${queryParams}`;
return SupersetClient.get({ endpoint }).then(({ json }) => {
const { result } = json;
if (getDbList) {
getDbList(result);
}
if (result.length === 0) {
handleError(t("It seems you don't have access to any database"));
}
const options = result.map(
(row: { backend: string; database_name: string; id: number }) => ({
label: `${row.backend}: ${row.database_name}`,
value: row.id,
}),
);
return {
data: options,
totalCount: options.length,
};
});
},
[formMode, getDbList, handleError, sqlLabMode],
);
function renderDatabaseSelect() { function renderDatabaseSelect() {
const queryParams = rison.encode({
order_columns: 'database_name',
order_direction: 'asc',
page: 0,
page_size: -1,
...(formMode || !sqlLabMode
? {}
: {
filters: [
{
col: 'expose_in_sqllab',
opr: 'eq',
value: true,
},
],
}),
});
return renderSelectRow( return renderSelectRow(
<Select <SupersetAsyncSelect
ariaLabel={t('Select a database')}
data-test="select-database" data-test="select-database"
header={<FormLabel>{t('Database')}</FormLabel>} dataEndpoint={`/api/v1/database/?q=${queryParams}`}
onChange={changeDataBase} onChange={(db: any) => changeDataBase(db)}
value={currentDb} onAsyncError={() =>
handleError(t('Error while fetching database list'))
}
clearable={false}
value={currentDbId}
valueKey="id"
valueRenderer={(db: any) => (
<div>
<span className="text-muted m-r-5">{t('Database:')}</span>
{renderDatabaseOption(db)}
</div>
)}
optionRenderer={renderDatabaseOption}
mutator={dbMutator}
placeholder={t('Select a database')} placeholder={t('Select a database')}
disabled={!isDatabaseSelectEnabled || readOnly} autoSelect
options={loadDatabases} isDisabled={!isDatabaseSelectEnabled || readOnly}
/>, />,
null, null,
); );
} }
function renderSchemaSelect() { function renderSchemaSelect() {
const refreshIcon = !formMode && !readOnly && ( const value = schemaOptions.filter(({ value }) => currentSchema === value);
const refresh = !formMode && !readOnly && (
<RefreshLabel <RefreshLabel
onClick={() => setRefresh(refresh + 1)} onClick={() => changeDataBase({ id: dbId }, true)}
tooltipContent={t('Force refresh schema list')} tooltipContent={t('Force refresh schema list')}
/> />
); );
return renderSelectRow( return renderSelectRow(
<Select <Select
ariaLabel={t('Select a schema')}
disabled={readOnly}
header={<FormLabel>{t('Schema')}</FormLabel>}
name="select-schema" name="select-schema"
placeholder={t('Select a schema')} placeholder={t('Select a schema (%s)', schemaOptions.length)}
onChange={item => changeSchema(item as SchemaValue)} options={schemaOptions}
options={loadSchemas} value={value}
value={currentSchema} valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={schemaLoading}
autosize={false}
onChange={item => changeSchema(item)}
isDisabled={readOnly}
/>, />,
refreshIcon, refresh,
); );
} }
return ( return (
<DatabaseSelectorWrapper data-test="DatabaseSelector"> <DatabaseSelectorWrapper data-test="DatabaseSelector">
{formMode && <FieldTitle>{t('datasource')}</FieldTitle>}
{renderDatabaseSelect()} {renderDatabaseSelect()}
{formMode && <FieldTitle>{t('schema')}</FieldTitle>}
{renderSchemaSelect()} {renderSchemaSelect()}
</DatabaseSelectorWrapper> </DatabaseSelectorWrapper>
); );

View File

@ -53,21 +53,15 @@ export const Icon = (props: IconProps) => {
const name = fileName.replace('_', '-'); const name = fileName.replace('_', '-');
useEffect(() => { useEffect(() => {
let cancelled = false;
async function importIcon(): Promise<void> { async function importIcon(): Promise<void> {
ImportedSVG.current = ( ImportedSVG.current = (
await import( await import(
`!!@svgr/webpack?-svgo,+titleProp,+ref!images/icons/${fileName}.svg` `!!@svgr/webpack?-svgo,+titleProp,+ref!images/icons/${fileName}.svg`
) )
).default; ).default;
if (!cancelled) { setLoaded(true);
setLoaded(true);
}
} }
importIcon(); importIcon();
return () => {
cancelled = true;
};
}, [fileName, ImportedSVG]); }, [fileName, ImportedSVG]);
return ( return (

View File

@ -86,9 +86,12 @@ const StyledContainer = styled.div`
flex-direction: column; flex-direction: column;
`; `;
const StyledSelect = styled(AntdSelect)` const StyledSelect = styled(AntdSelect, {
${({ theme }) => ` shouldForwardProp: prop => prop !== 'hasHeader',
})<{ hasHeader: boolean }>`
${({ theme, hasHeader }) => `
width: 100%; width: 100%;
margin-top: ${hasHeader ? theme.gridUnit : 0}px;
&& .ant-select-selector { && .ant-select-selector {
border-radius: ${theme.gridUnit}px; border-radius: ${theme.gridUnit}px;
@ -186,7 +189,6 @@ const Select = ({
: 'multiple'; : 'multiple';
useEffect(() => { useEffect(() => {
fetchedQueries.current.clear();
setSelectOptions( setSelectOptions(
options && Array.isArray(options) ? options : EMPTY_OPTIONS, options && Array.isArray(options) ? options : EMPTY_OPTIONS,
); );
@ -364,45 +366,34 @@ const Select = ({
[options], [options],
); );
const handleOnSearch = useMemo( const handleOnSearch = debounce((search: string) => {
() => const searchValue = search.trim();
debounce((search: string) => { // enables option creation
const searchValue = search.trim(); if (allowNewOptions && isSingleMode) {
// enables option creation const firstOption = selectOptions.length > 0 && selectOptions[0].value;
if (allowNewOptions && isSingleMode) { // replaces the last search value entered with the new one
const firstOption = // only when the value wasn't part of the original options
selectOptions.length > 0 && selectOptions[0].value; if (
// replaces the last search value entered with the new one searchValue &&
// only when the value wasn't part of the original options firstOption === searchedValue &&
if ( !initialOptions.find(o => o.value === searchedValue)
searchValue && ) {
firstOption === searchedValue && selectOptions.shift();
!initialOptions.find(o => o.value === searchedValue) setSelectOptions(selectOptions);
) { }
selectOptions.shift(); if (searchValue && !hasOption(searchValue, selectOptions)) {
setSelectOptions(selectOptions); const newOption = {
} label: searchValue,
if (searchValue && !hasOption(searchValue, selectOptions)) { value: searchValue,
const newOption = { };
label: searchValue, // adds a custom option
value: searchValue, const newOptions = [...selectOptions, newOption];
}; setSelectOptions(newOptions);
// adds a custom option setSelectValue(searchValue);
const newOptions = [...selectOptions, newOption]; }
setSelectOptions(newOptions); }
setSelectValue(searchValue); setSearchedValue(searchValue);
} }, DEBOUNCE_TIMEOUT);
}
setSearchedValue(searchValue);
}, DEBOUNCE_TIMEOUT),
[
allowNewOptions,
initialOptions,
isSingleMode,
searchedValue,
selectOptions,
],
);
const handlePagination = (e: UIEvent<HTMLElement>) => { const handlePagination = (e: UIEvent<HTMLElement>) => {
const vScroll = e.currentTarget; const vScroll = e.currentTarget;
@ -495,6 +486,7 @@ const Select = ({
<StyledContainer> <StyledContainer>
{header} {header}
<StyledSelect <StyledSelect
hasHeader={!!header}
aria-label={ariaLabel || name} aria-label={ariaLabel || name}
dropdownRender={dropdownRender} dropdownRender={dropdownRender}
filterOption={handleFilterOption} filterOption={handleFilterOption}

View File

@ -0,0 +1,291 @@
/**
* 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 configureStore from 'redux-mock-store';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import DatabaseSelector from 'src/components/DatabaseSelector';
import TableSelector from 'src/components/TableSelector';
import { initialState, tables } from 'spec/javascripts/sqllab/fixtures';
const mockStore = configureStore([thunk]);
const store = mockStore(initialState);
const FETCH_SCHEMAS_ENDPOINT = 'glob:*/api/v1/database/*/schemas/*';
const GET_TABLE_ENDPOINT = 'glob:*/superset/tables/1/*/*';
const GET_TABLE_NAMES_ENDPOINT = 'glob:*/superset/tables/1/main/*';
const mockedProps = {
clearable: false,
database: { id: 1, database_name: 'main' },
dbId: 1,
formMode: false,
getDbList: sinon.stub(),
handleError: sinon.stub(),
horizontal: false,
onChange: sinon.stub(),
onDbChange: sinon.stub(),
onSchemaChange: sinon.stub(),
onTableChange: sinon.stub(),
sqlLabMode: true,
tableName: '',
tableNameSticky: true,
};
const schemaOptions = {
result: ['main', 'erf', 'superset'],
};
const selectedSchema = { label: 'main', title: 'main', value: 'main' };
const selectedTable = {
extra: null,
label: 'birth_names',
schema: 'main',
title: 'birth_names',
type: undefined,
value: 'birth_names',
};
async function mountAndWait(props = mockedProps) {
const mounted = mount(<TableSelector {...props} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
await waitForComponentToPaint(mounted);
return mounted;
}
describe('TableSelector', () => {
let wrapper;
beforeEach(async () => {
fetchMock.reset();
wrapper = await mountAndWait();
});
it('renders', () => {
expect(wrapper.find(TableSelector)).toExist();
expect(wrapper.find(DatabaseSelector)).toExist();
});
describe('change database', () => {
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch schemas', async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, { overwriteRoutes: true });
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
});
it('should fetch schema options', async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
overwriteRoutes: true,
});
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
wrapper.update();
expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
expect(
wrapper.find('[name="select-schema"]').first().props().options,
).toEqual([
{ value: 'main', label: 'main', title: 'main' },
{ value: 'erf', label: 'erf', title: 'erf' },
{ value: 'superset', label: 'superset', title: 'superset' },
]);
});
it('should clear table options', async () => {
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
const props = wrapper.find('[name="async-select-table"]').first().props();
expect(props.isDisabled).toBe(true);
expect(props.value).toEqual(undefined);
});
});
describe('change schema', () => {
beforeEach(async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
overwriteRoutes: true,
});
});
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch table', async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, { overwriteRoutes: true });
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
});
it('should fetch table options', async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
overwriteRoutes: true,
});
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[name="select-schema"]').first().props().value[0],
).toEqual(selectedSchema);
expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
const { options } = wrapper.find('[name="select-table"]').first().props();
expect({ options }).toEqual(tables);
});
});
describe('change table', () => {
beforeEach(async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
overwriteRoutes: true,
});
});
it('should change table value', async () => {
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-table"]')
.first()
.props()
.onChange(selectedTable);
});
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[name="select-table"]').first().props().value,
).toEqual('birth_names');
});
it('should call onTableChange with schema from table object', async () => {
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-table"]')
.first()
.props()
.onChange(selectedTable);
});
await waitForComponentToPaint(wrapper);
expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('birth_names');
expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('main');
});
});
describe('getTableNamesBySubStr', () => {
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should handle empty', async () => {
act(() => {
wrapper
.find('[name="async-select-table"]')
.first()
.props()
.loadOptions();
});
await waitForComponentToPaint(wrapper);
const props = wrapper.find('[name="async-select-table"]').first().props();
expect(props.isDisabled).toBe(true);
expect(props.value).toEqual('');
});
it('should handle table name', async () => {
wrapper.setProps({ schema: 'main' });
fetchMock.get(GET_TABLE_ENDPOINT, tables, {
overwriteRoutes: true,
});
act(() => {
wrapper
.find('[name="async-select-table"]')
.first()
.props()
.loadOptions();
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(GET_TABLE_ENDPOINT)).toHaveLength(1);
});
});
});

View File

@ -1,91 +0,0 @@
/**
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import userEvent from '@testing-library/user-event';
import TableSelector from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const createProps = () => ({
dbId: 1,
schema: 'test_schema',
handleError: jest.fn(),
});
beforeAll(() => {
SupersetClientGet.mockImplementation(
async () =>
({
json: {
options: [
{ label: 'table_a', value: 'table_a' },
{ label: 'table_b', value: 'table_b' },
],
},
} as any),
);
});
test('renders with default props', async () => {
const props = createProps();
render(<TableSelector {...props} />);
const databaseSelect = screen.getByRole('combobox', {
name: 'Select a database',
});
const schemaSelect = screen.getByRole('combobox', {
name: 'Select a database',
});
const tableSelect = screen.getByRole('combobox', {
name: 'Select a table',
});
await waitFor(() => {
expect(databaseSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
expect(tableSelect).toBeInTheDocument();
});
});
test('renders table options', async () => {
const props = createProps();
render(<TableSelector {...props} />);
const tableSelect = screen.getByRole('combobox', {
name: 'Select a table',
});
userEvent.click(tableSelect);
expect(
await screen.findByRole('option', { name: 'table_a' }),
).toBeInTheDocument();
expect(
await screen.findByRole('option', { name: 'table_b' }),
).toBeInTheDocument();
});
test('renders disabled without schema', async () => {
const props = createProps();
render(<TableSelector {...props} schema={undefined} />);
const tableSelect = screen.getByRole('combobox', {
name: 'Select a table',
});
await waitFor(() => {
expect(tableSelect).toBeDisabled();
});
});

View File

@ -18,49 +18,57 @@
*/ */
import React, { import React, {
FunctionComponent, FunctionComponent,
useEffect,
useState, useState,
ReactNode, ReactNode,
useMemo,
useEffect,
} from 'react'; } from 'react';
import { styled, SupersetClient, t } from '@superset-ui/core'; import { styled, SupersetClient, t } from '@superset-ui/core';
import { Select } from 'src/components'; import { AsyncSelect, CreatableSelect, Select } from 'src/components/Select';
import { FormLabel } from 'src/components/Form'; import { FormLabel } from 'src/components/Form';
import Icons from 'src/components/Icons';
import DatabaseSelector from 'src/components/DatabaseSelector'; import DatabaseSelector from 'src/components/DatabaseSelector';
import RefreshLabel from 'src/components/RefreshLabel'; import RefreshLabel from 'src/components/RefreshLabel';
import CertifiedIcon from 'src/components/CertifiedIcon'; import CertifiedIcon from 'src/components/CertifiedIcon';
import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip'; import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip';
const FieldTitle = styled.p`
color: ${({ theme }) => theme.colors.secondary.light2};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin: 20px 0 10px 0;
text-transform: uppercase;
`;
const TableSelectorWrapper = styled.div` const TableSelectorWrapper = styled.div`
${({ theme }) => ` .fa-refresh {
.refresh { padding-left: 9px;
display: flex; }
align-items: center;
width: 30px;
margin-left: ${theme.gridUnit}px;
margin-top: ${theme.gridUnit * 5}px;
}
.section { .refresh-col {
display: flex; display: flex;
flex-direction: row; align-items: center;
align-items: center; width: 30px;
} margin-left: ${({ theme }) => theme.gridUnit}px;
}
.divider { .section {
border-bottom: 1px solid ${theme.colors.secondary.light5}; padding-bottom: 5px;
margin: 15px 0; display: flex;
} flex-direction: row;
}
.table-length { .select {
color: ${theme.colors.grayscale.light1}; flex-grow: 1;
} }
.select { .divider {
flex: 1; border-bottom: 1px solid ${({ theme }) => theme.colors.secondary.light5};
} margin: 15px 0;
`} }
.table-length {
color: ${({ theme }) => theme.colors.grayscale.light1};
}
`; `;
const TableLabel = styled.span` const TableLabel = styled.span`
@ -90,15 +98,7 @@ interface TableSelectorProps {
schema?: string; schema?: string;
tableName?: string; tableName?: string;
}) => void; }) => void;
onDbChange?: ( onDbChange?: (db: any) => void;
db:
| {
id: number;
database_name: string;
backend: string;
}
| undefined,
) => void;
onSchemaChange?: (arg0?: any) => {}; onSchemaChange?: (arg0?: any) => {};
onSchemasLoad?: () => void; onSchemasLoad?: () => void;
onTableChange?: (tableName: string, schema: string) => void; onTableChange?: (tableName: string, schema: string) => void;
@ -110,52 +110,6 @@ interface TableSelectorProps {
tableNameSticky?: boolean; tableNameSticky?: boolean;
} }
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;
value: string;
}
const TableOption = ({ table }: { table: Table }) => {
const { label, type, extra } = table;
return (
<TableLabel title={label}>
{type === 'view' ? (
<Icons.Eye iconSize="m" />
) : (
<Icons.Table iconSize="m" />
)}
{extra?.certification && (
<CertifiedIcon
certifiedBy={extra.certification.certified_by}
details={extra.certification.details}
size="l"
/>
)}
{extra?.warning_markdown && (
<WarningIconWithTooltip
warningMarkdown={extra.warning_markdown}
size="l"
/>
)}
{label}
</TableLabel>
);
};
const TableSelector: FunctionComponent<TableSelectorProps> = ({ const TableSelector: FunctionComponent<TableSelectorProps> = ({
database, database,
dbId, dbId,
@ -175,187 +129,179 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
tableName, tableName,
tableNameSticky = true, tableNameSticky = true,
}) => { }) => {
const [currentDbId, setCurrentDbId] = useState<number | undefined>(dbId);
const [currentSchema, setCurrentSchema] = useState<string | undefined>( const [currentSchema, setCurrentSchema] = useState<string | undefined>(
schema, schema,
); );
const [currentTable, setCurrentTable] = useState<TableOption | undefined>(); const [currentTableName, setCurrentTableName] = useState<string | undefined>(
const [refresh, setRefresh] = useState(0); tableName,
const [previousRefresh, setPreviousRefresh] = useState(0);
const loadTable = useMemo(
() => async (dbId: number, schema: string, tableName: string) => {
const endpoint = encodeURI(
`/superset/tables/${dbId}/${schema}/${encodeURIComponent(
tableName,
)}/false/true`,
);
if (previousRefresh !== refresh) {
setPreviousRefresh(refresh);
}
return SupersetClient.get({ endpoint }).then(({ json }) => {
const options = json.options as Table[];
if (options && options.length > 0) {
return options[0];
}
return null;
});
},
[], // eslint-disable-line react-hooks/exhaustive-deps
); );
const [tableLoading, setTableLoading] = useState(false);
const [tableOptions, setTableOptions] = useState([]);
const loadTables = useMemo( function fetchTables(
() => async (search: string) => { databaseId?: number,
const dbSchema = schema || currentSchema; schema?: string,
if (currentDbId && dbSchema) { forceRefresh = false,
const encodedSchema = encodeURIComponent(dbSchema); substr = 'undefined',
const encodedSubstr = encodeURIComponent(search || 'undefined'); ) {
const forceRefresh = refresh !== previousRefresh; const dbSchema = schema || currentSchema;
const endpoint = encodeURI( const actualDbId = databaseId || dbId;
`/superset/tables/${currentDbId}/${encodedSchema}/${encodedSubstr}/${forceRefresh}/`, if (actualDbId && dbSchema) {
); const encodedSchema = encodeURIComponent(dbSchema);
const encodedSubstr = encodeURIComponent(substr);
if (previousRefresh !== refresh) { setTableLoading(true);
setPreviousRefresh(refresh); setTableOptions([]);
} const endpoint = encodeURI(
`/superset/tables/${actualDbId}/${encodedSchema}/${encodedSubstr}/${!!forceRefresh}/`,
return SupersetClient.get({ endpoint }).then(({ json }) => { );
const options = json.options return SupersetClient.get({ endpoint })
.map((table: Table) => ({ .then(({ json }) => {
value: table.value, const options = json.options.map((o: any) => ({
label: <TableOption table={table} />, value: o.value,
text: table.label, schema: o.schema,
})) label: o.label,
.sort((a: { text: string }, b: { text: string }) => title: o.title,
a.text.localeCompare(b.text), type: o.type,
); extra: o?.extra,
}));
setTableLoading(false);
setTableOptions(options);
if (onTablesLoad) { if (onTablesLoad) {
onTablesLoad(json.options); onTablesLoad(json.options);
} }
})
return { .catch(() => {
data: options, setTableLoading(false);
totalCount: options.length, setTableOptions([]);
}; handleError(t('Error while fetching table list'));
}); });
} }
return { data: [], totalCount: 0 }; setTableLoading(false);
}, setTableOptions([]);
// We are using the refresh state to re-trigger the query return Promise.resolve();
// previousRefresh should be out of dependencies array }
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentDbId, currentSchema, onTablesLoad, schema, refresh],
);
useEffect(() => { useEffect(() => {
async function fetchTable() { if (dbId && schema) {
if (schema && tableName) { fetchTables();
const table = await loadTable(dbId, schema, tableName);
if (table) {
setCurrentTable({
label: <TableOption table={table} />,
text: table.label,
value: table.value,
});
}
}
} }
fetchTable(); }, [dbId, schema]);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function onSelectionChange({ function onSelectionChange({
dbId, dbId,
schema, schema,
table, tableName,
}: { }: {
dbId: number; dbId: number;
schema?: string; schema?: string;
table?: TableOption; tableName?: string;
}) { }) {
setCurrentTable(table); setCurrentTableName(tableName);
setCurrentDbId(dbId);
setCurrentSchema(schema); setCurrentSchema(schema);
if (onUpdate) { if (onUpdate) {
onUpdate({ dbId, schema, tableName: table?.value }); onUpdate({ dbId, schema, tableName });
} }
} }
function changeTable(table: TableOption) { function getTableNamesBySubStr(substr = 'undefined') {
if (!table) { if (!dbId || !substr) {
setCurrentTable(undefined); const options: any[] = [];
return Promise.resolve({ options });
}
const encodedSchema = encodeURIComponent(schema || '');
const encodedSubstr = encodeURIComponent(substr);
return SupersetClient.get({
endpoint: encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/${encodedSubstr}`,
),
}).then(({ json }) => {
const options = json.options.map((o: any) => ({
value: o.value,
schema: o.schema,
label: o.label,
title: o.title,
type: o.type,
}));
return { options };
});
}
function changeTable(tableOpt: any) {
if (!tableOpt) {
setCurrentTableName('');
return; return;
} }
const tableOptTableName = table.value; const schemaName = tableOpt.schema;
if (currentDbId && tableNameSticky) { const tableOptTableName = tableOpt.value;
if (tableNameSticky) {
onSelectionChange({ onSelectionChange({
dbId: currentDbId, dbId,
schema: currentSchema, schema: schemaName,
table, tableName: tableOptTableName,
}); });
} }
if (onTableChange && currentSchema) { if (onTableChange) {
onTableChange(tableOptTableName, currentSchema); onTableChange(tableOptTableName, schemaName);
} }
} }
function onRefresh() { function changeSchema(schemaOpt: any, force = false) {
const value = schemaOpt ? schemaOpt.value : null;
if (onSchemaChange) { if (onSchemaChange) {
onSchemaChange(currentSchema); onSchemaChange(value);
} }
if (currentDbId && currentSchema) { onSelectionChange({
onSelectionChange({ dbId,
dbId: currentDbId, schema: value,
schema: currentSchema, tableName: undefined,
table: currentTable, });
}); fetchTables(dbId, currentSchema, force);
} }
setRefresh(refresh + 1);
function renderTableOption(option: any) {
return (
<TableLabel title={option.label}>
<small className="text-muted">
<i className={`fa fa-${option.type === 'view' ? 'eye' : 'table'}`} />
</small>
{option.extra?.certification && (
<CertifiedIcon
certifiedBy={option.extra.certification.certified_by}
details={option.extra.certification.details}
size={20}
/>
)}
{option.extra?.warning_markdown && (
<WarningIconWithTooltip
warningMarkdown={option.extra.warning_markdown}
size={20}
/>
)}
{option.label}
</TableLabel>
);
} }
function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
return ( return (
<div className="section"> <div className="section">
<span className="select">{select}</span> <span className="select">{select}</span>
<span className="refresh">{refreshBtn}</span> <span className="refresh-col">{refreshBtn}</span>
</div> </div>
); );
} }
const internalDbChange = (
db:
| {
id: number;
database_name: string;
backend: string;
}
| undefined,
) => {
setCurrentDbId(db?.id);
if (onDbChange) {
onDbChange(db);
}
};
const internalSchemaChange = (schema?: string) => {
setCurrentSchema(schema);
if (onSchemaChange) {
onSchemaChange(schema);
}
};
function renderDatabaseSelector() { function renderDatabaseSelector() {
return ( return (
<DatabaseSelector <DatabaseSelector
db={database} dbId={dbId}
formMode={formMode} formMode={formMode}
getDbList={getDbList} getDbList={getDbList}
getTableList={fetchTables}
handleError={handleError} handleError={handleError}
onUpdate={onSelectionChange} onUpdate={onSelectionChange}
onDbChange={readOnly ? undefined : internalDbChange} onDbChange={readOnly ? undefined : onDbChange}
onSchemaChange={readOnly ? undefined : internalSchemaChange} onSchemaChange={readOnly ? undefined : onSchemaChange}
onSchemasLoad={onSchemasLoad} onSchemasLoad={onSchemasLoad}
schema={currentSchema} schema={currentSchema}
sqlLabMode={sqlLabMode} sqlLabMode={sqlLabMode}
@ -365,54 +311,96 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
); );
} }
const handleFilterOption = useMemo(
() => (search: string, option: TableOption) => {
const searchValue = search.trim().toLowerCase();
const { text } = option;
return text.toLowerCase().includes(searchValue);
},
[],
);
function renderTableSelect() { function renderTableSelect() {
const disabled = const options = tableOptions;
(currentSchema && !formMode && readOnly) || let select = null;
(!currentSchema && !database?.allow_multi_schema_metadata_fetch); if (currentSchema && !formMode) {
// dataset editor
const header = sqlLabMode ? ( select = (
<FormLabel>{t('See table schema')}</FormLabel> <Select
) : ( name="select-table"
<FormLabel>{t('Table')}</FormLabel> isLoading={tableLoading}
); ignoreAccents={false}
placeholder={t('Select table or type table name')}
const select = ( autosize={false}
<Select onChange={changeTable}
ariaLabel={t('Select a table')} options={options}
disabled={disabled} // @ts-ignore
filterOption={handleFilterOption} value={currentTableName}
header={header} optionRenderer={renderTableOption}
name="select-table" valueRenderer={renderTableOption}
onChange={changeTable} isDisabled={readOnly}
options={loadTables} />
placeholder={t('Select a table')} );
value={currentTable} } else if (formMode) {
/> select = (
); <CreatableSelect
name="select-table"
isLoading={tableLoading}
ignoreAccents={false}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={changeTable}
options={options}
// @ts-ignore
value={currentTableName}
optionRenderer={renderTableOption}
/>
);
} else {
// sql lab
let tableSelectPlaceholder;
let tableSelectDisabled = false;
if (database && database.allow_multi_schema_metadata_fetch) {
tableSelectPlaceholder = t('Type to search ...');
} else {
tableSelectPlaceholder = t('Select table ');
tableSelectDisabled = true;
}
select = (
<AsyncSelect
name="async-select-table"
placeholder={tableSelectPlaceholder}
isDisabled={tableSelectDisabled}
autosize={false}
onChange={changeTable}
// @ts-ignore
value={currentTableName}
loadOptions={getTableNamesBySubStr}
optionRenderer={renderTableOption}
/>
);
}
const refresh = !formMode && !readOnly && ( const refresh = !formMode && !readOnly && (
<RefreshLabel <RefreshLabel
onClick={onRefresh} onClick={() => changeSchema({ value: schema }, true)}
tooltipContent={t('Force refresh table list')} tooltipContent={t('Force refresh table list')}
/> />
); );
return renderSelectRow(select, refresh); return renderSelectRow(select, refresh);
} }
function renderSeeTableLabel() {
return (
<div className="section">
<FormLabel>
{t('See table schema')}{' '}
{schema && (
<small className="table-length">
{tableOptions.length} in {schema}
</small>
)}
</FormLabel>
</div>
);
}
return ( return (
<TableSelectorWrapper> <TableSelectorWrapper>
{renderDatabaseSelector()} {renderDatabaseSelector()}
{sqlLabMode && !formMode && <div className="divider" />} {!formMode && <div className="divider" />}
{sqlLabMode && renderSeeTableLabel()}
{formMode && <FieldTitle>{t('Table')}</FieldTitle>}
{renderTableSelect()} {renderTableSelect()}
</TableSelectorWrapper> </TableSelectorWrapper>
); );

View File

@ -18,17 +18,16 @@
*/ */
import React from 'react'; import React from 'react';
import { useTheme, SafeMarkdown } from '@superset-ui/core'; import { useTheme, SafeMarkdown } from '@superset-ui/core';
import Icons, { IconType } from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
export interface WarningIconWithTooltipProps { export interface WarningIconWithTooltipProps {
warningMarkdown: string; warningMarkdown: string;
size?: IconType['iconSize']; size?: number;
} }
function WarningIconWithTooltip({ function WarningIconWithTooltip({
warningMarkdown, warningMarkdown,
size,
}: WarningIconWithTooltipProps) { }: WarningIconWithTooltipProps) {
const theme = useTheme(); const theme = useTheme();
return ( return (
@ -38,7 +37,6 @@ function WarningIconWithTooltip({
> >
<Icons.AlertSolid <Icons.AlertSolid
iconColor={theme.colors.alert.base} iconColor={theme.colors.alert.base}
iconSize={size}
css={{ marginRight: theme.gridUnit * 2 }} css={{ marginRight: theme.gridUnit * 2 }}
/> />
</Tooltip> </Tooltip>

View File

@ -775,47 +775,41 @@ class DatasourceEditor extends React.PureComponent {
<div> <div>
{this.state.isSqla && ( {this.state.isSqla && (
<> <>
<Col xs={24} md={12}> <Field
<Field fieldKey="databaseSelector"
fieldKey="databaseSelector" label={t('virtual')}
label={t('virtual')} control={
control={ <DatabaseSelector
<div css={{ marginTop: 8 }}> dbId={datasource.database.id}
<DatabaseSelector schema={datasource.schema}
db={datasource?.database} onSchemaChange={schema =>
schema={datasource.schema} this.state.isEditMode &&
onSchemaChange={schema => this.onDatasourcePropChange('schema', schema)
this.state.isEditMode &&
this.onDatasourcePropChange('schema', schema)
}
onDbChange={database =>
this.state.isEditMode &&
this.onDatasourcePropChange('database', database)
}
formMode={false}
handleError={this.props.addDangerToast}
readOnly={!this.state.isEditMode}
/>
</div>
}
/>
<div css={{ width: 'calc(100% - 34px)', marginTop: -16 }}>
<Field
fieldKey="table_name"
label={t('Dataset name')}
control={
<TextControl
controlId="table_name"
onChange={table => {
this.onDatasourcePropChange('table_name', table);
}}
placeholder={t('Dataset name')}
disabled={!this.state.isEditMode}
/>
} }
onDbChange={database =>
this.state.isEditMode &&
this.onDatasourcePropChange('database', database)
}
formMode={false}
handleError={this.props.addDangerToast}
readOnly={!this.state.isEditMode}
/> />
</div> }
</Col> />
<Field
fieldKey="table_name"
label={t('Dataset name')}
control={
<TextControl
controlId="table_name"
onChange={table => {
this.onDatasourcePropChange('table_name', table);
}}
placeholder={t('Dataset name')}
disabled={!this.state.isEditMode}
/>
}
/>
<Field <Field
fieldKey="sql" fieldKey="sql"
label={t('SQL')} label={t('SQL')}
@ -859,39 +853,33 @@ class DatasourceEditor extends React.PureComponent {
fieldKey="tableSelector" fieldKey="tableSelector"
label={t('Physical')} label={t('Physical')}
control={ control={
<div css={{ marginTop: 8 }}> <TableSelector
<TableSelector clearable={false}
clearable={false} dbId={datasource.database.id}
database={datasource.database} handleError={this.props.addDangerToast}
dbId={datasource.database.id} schema={datasource.schema}
handleError={this.props.addDangerToast} sqlLabMode={false}
schema={datasource.schema} tableName={datasource.table_name}
sqlLabMode={false} onSchemaChange={
tableName={datasource.table_name} this.state.isEditMode
onSchemaChange={ ? schema =>
this.state.isEditMode this.onDatasourcePropChange('schema', schema)
? schema => : undefined
this.onDatasourcePropChange('schema', schema) }
: undefined onDbChange={
} this.state.isEditMode
onDbChange={ ? database =>
this.state.isEditMode this.onDatasourcePropChange('database', database)
? database => : undefined
this.onDatasourcePropChange( }
'database', onTableChange={
database, this.state.isEditMode
) ? table =>
: undefined this.onDatasourcePropChange('table_name', table)
} : undefined
onTableChange={ }
this.state.isEditMode readOnly={!this.state.isEditMode}
? table => />
this.onDatasourcePropChange('table_name', table)
: undefined
}
readOnly={!this.state.isEditMode}
/>
</div>
} }
description={t( description={t(
'The pointer to a physical table (or view). Keep in mind that the chart is ' + 'The pointer to a physical table (or view). Keep in mind that the chart is ' +

View File

@ -227,7 +227,10 @@ class DatasourceControl extends React.PureComponent {
</Tooltip> </Tooltip>
)} )}
{extra?.warning_markdown && ( {extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} /> <WarningIconWithTooltip
warningMarkdown={extra.warning_markdown}
size={30}
/>
)} )}
<Dropdown <Dropdown
overlay={datasourceMenu} overlay={datasourceMenu}

View File

@ -243,13 +243,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<CertifiedIcon <CertifiedIcon
certifiedBy={parsedExtra.certification.certified_by} certifiedBy={parsedExtra.certification.certified_by}
details={parsedExtra.certification.details} details={parsedExtra.certification.details}
size="l"
/> />
)} )}
{parsedExtra?.warning_markdown && ( {parsedExtra?.warning_markdown && (
<WarningIconWithTooltip <WarningIconWithTooltip
warningMarkdown={parsedExtra.warning_markdown} warningMarkdown={parsedExtra.warning_markdown}
size="l"
/> />
)} )}
{titleLink} {titleLink}

View File

@ -160,7 +160,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"url", "url",
"extra", "extra",
] ]
show_columns = show_select_columns + ["columns.type_generic", "database.backend"] show_columns = show_select_columns + ["columns.type_generic"]
add_model_schema = DatasetPostSchema() add_model_schema = DatasetPostSchema()
edit_model_schema = DatasetPutSchema() edit_model_schema = DatasetPutSchema()
add_columns = ["database", "schema", "table_name", "owners"] add_columns = ["database", "schema", "table_name", "owners"]

View File

@ -1058,14 +1058,8 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@event_logger.log_this @event_logger.log_this
@expose("/tables/<int:db_id>/<schema>/<substr>/") @expose("/tables/<int:db_id>/<schema>/<substr>/")
@expose("/tables/<int:db_id>/<schema>/<substr>/<force_refresh>/") @expose("/tables/<int:db_id>/<schema>/<substr>/<force_refresh>/")
@expose("/tables/<int:db_id>/<schema>/<substr>/<force_refresh>/<exact_match>") def tables( # pylint: disable=too-many-locals,no-self-use
def tables( # pylint: disable=too-many-locals,no-self-use,too-many-arguments self, db_id: int, schema: str, substr: str, force_refresh: str = "false"
self,
db_id: int,
schema: str,
substr: str,
force_refresh: str = "false",
exact_match: str = "false",
) -> FlaskResponse: ) -> FlaskResponse:
"""Endpoint to fetch the list of tables for given database""" """Endpoint to fetch the list of tables for given database"""
# Guarantees database filtering by security access # Guarantees database filtering by security access
@ -1078,7 +1072,6 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
return json_error_response("Not found", 404) return json_error_response("Not found", 404)
force_refresh_parsed = force_refresh.lower() == "true" force_refresh_parsed = force_refresh.lower() == "true"
exact_match_parsed = exact_match.lower() == "true"
schema_parsed = utils.parse_js_uri_path_item(schema, eval_undefined=True) schema_parsed = utils.parse_js_uri_path_item(schema, eval_undefined=True)
substr_parsed = utils.parse_js_uri_path_item(substr, eval_undefined=True) substr_parsed = utils.parse_js_uri_path_item(substr, eval_undefined=True)
@ -1120,15 +1113,9 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
ds_name.table if schema_parsed else f"{ds_name.schema}.{ds_name.table}" ds_name.table if schema_parsed else f"{ds_name.schema}.{ds_name.table}"
) )
def is_match(src: str, target: utils.DatasourceName) -> bool:
target_label = get_datasource_label(target)
if exact_match_parsed:
return src == target_label
return src in target_label
if substr_parsed: if substr_parsed:
tables = [tn for tn in tables if is_match(substr_parsed, tn)] tables = [tn for tn in tables if substr_parsed in get_datasource_label(tn)]
views = [vn for vn in views if is_match(substr_parsed, vn)] views = [vn for vn in views if substr_parsed in get_datasource_label(vn)]
if not schema_parsed and database.default_schemas: if not schema_parsed and database.default_schemas:
user_schemas = ( user_schemas = (

View File

@ -222,7 +222,6 @@ class TestDatasetApi(SupersetTestCase):
Dataset API: Test get dataset item Dataset API: Test get dataset item
""" """
table = self.get_energy_usage_dataset() table = self.get_energy_usage_dataset()
main_db = get_main_database()
self.login(username="admin") self.login(username="admin")
uri = f"api/v1/dataset/{table.id}" uri = f"api/v1/dataset/{table.id}"
rv = self.get_assert_metric(uri, "get") rv = self.get_assert_metric(uri, "get")
@ -230,11 +229,7 @@ class TestDatasetApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8")) response = json.loads(rv.data.decode("utf-8"))
expected_result = { expected_result = {
"cache_timeout": None, "cache_timeout": None,
"database": { "database": {"database_name": "examples", "id": 1},
"backend": main_db.backend,
"database_name": "examples",
"id": 1,
},
"default_endpoint": None, "default_endpoint": None,
"description": "Energy consumption", "description": "Energy consumption",
"extra": None, "extra": None,
@ -249,10 +244,9 @@ class TestDatasetApi(SupersetTestCase):
"table_name": "energy_usage", "table_name": "energy_usage",
"template_params": None, "template_params": None,
} }
if response["result"]["database"]["backend"] not in ("presto", "hive"): assert {
assert { k: v for k, v in response["result"].items() if k in expected_result
k: v for k, v in response["result"].items() if k in expected_result } == expected_result
} == expected_result
assert len(response["result"]["columns"]) == 3 assert len(response["result"]["columns"]) == 3
assert len(response["result"]["metrics"]) == 2 assert len(response["result"]["metrics"]) == 2