fix(datasets): Replace left panel layout by TableSelector (#24599)

Co-authored-by: Justin Park <justinpark@apache.org>
This commit is contained in:
JUST.in DO IT
2023-07-20 11:59:31 -07:00
committed by GitHub
parent df106aa708
commit b2831b419e
7 changed files with 217 additions and 353 deletions

View File

@@ -104,6 +104,7 @@ interface TableSelectorProps {
tableValue?: string | string[];
onTableSelectChange?: (value?: string | string[], schema?: string) => void;
tableSelectMode?: 'single' | 'multiple';
customTableOptionLabelRenderer?: (table: Table) => JSX.Element;
}
export interface TableOption {
@@ -132,6 +133,7 @@ export const TableOption = ({ table }: { table: Table }) => {
<WarningIconWithTooltip
warningMarkdown={extra.warning_markdown}
size="l"
marginRight={4}
/>
)}
{value}
@@ -164,6 +166,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
tableSelectMode = 'single',
tableValue = undefined,
onTableSelectChange,
customTableOptionLabelRenderer,
}) => {
const { addSuccessToast } = useToasts();
const [currentSchema, setCurrentSchema] = useState<string | undefined>(
@@ -203,9 +206,12 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
value: table.value,
label: <TableOption table={table} />,
text: table.value,
...(customTableOptionLabelRenderer && {
customLabel: customTableOptionLabelRenderer(table),
}),
}))
: [],
[data],
[data, customTableOptionLabelRenderer],
);
useEffect(() => {

View File

@@ -41,6 +41,9 @@ export const Tooltip = (props: TooltipProps) => {
display: block;
}
}
.ant-tooltip-inner > p {
margin: 0;
}
`}
/>
<AntdTooltip

View File

@@ -24,11 +24,13 @@ import { Tooltip } from 'src/components/Tooltip';
export interface WarningIconWithTooltipProps {
warningMarkdown: string;
size?: IconType['iconSize'];
marginRight?: number;
}
function WarningIconWithTooltip({
warningMarkdown,
size,
marginRight,
}: WarningIconWithTooltipProps) {
const theme = useTheme();
return (
@@ -39,7 +41,7 @@ function WarningIconWithTooltip({
<Icons.AlertSolid
iconColor={theme.colors.alert.base}
iconSize={size}
css={{ marginRight: theme.gridUnit * 2 }}
css={{ marginRight: marginRight ?? theme.gridUnit * 2 }}
/>
</Tooltip>
);

View File

@@ -27,122 +27,128 @@ const databasesEndpoint = 'glob:*/api/v1/database/?q*';
const schemasEndpoint = 'glob:*/api/v1/database/*/schemas*';
const tablesEndpoint = 'glob:*/api/v1/database/*/tables/?q*';
fetchMock.get(databasesEndpoint, {
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_multi_schema_metadata_fetch: 'Allow Multi Schema Metadata Fetch',
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_multi_schema_metadata_fetch',
'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_multi_schema_metadata_fetch: 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,
beforeEach(() => {
fetchMock.get(databasesEndpoint, {
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_multi_schema_metadata_fetch: 'Allow Multi Schema Metadata Fetch',
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',
},
{
allow_csv_upload: false,
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_multi_schema_metadata_fetch: 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,
},
],
list_columns: [
'allow_file_upload',
'allow_ctas',
'allow_cvas',
'allow_dml',
'allow_multi_schema_metadata_fetch',
'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_multi_schema_metadata_fetch: 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_multi_schema_metadata_fetch: 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,
},
],
});
fetchMock.get(schemasEndpoint, {
result: ['information_schema', 'public'],
});
fetchMock.get(tablesEndpoint, {
count: 3,
result: [
{ value: 'Sheet1', type: 'table', extra: null },
{ value: 'Sheet2', type: 'table', extra: null },
{ value: 'Sheet3', type: 'table', extra: null },
],
});
});
fetchMock.get(schemasEndpoint, {
result: ['information_schema', 'public'],
});
fetchMock.get(tablesEndpoint, {
count: 3,
result: [
{ value: 'Sheet1', type: 'table', extra: null },
{ value: 'Sheet2', type: 'table', extra: null },
{ value: 'Sheet3', type: 'table', extra: null },
],
afterEach(() => {
fetchMock.reset();
});
const mockFun = jest.fn();
@@ -152,14 +158,16 @@ test('should render', async () => {
useRedux: true,
});
expect(
await screen.findByText(/select database & schema/i),
await screen.findByText(/Select database or type to search databases/i),
).toBeInTheDocument();
});
test('should render schema selector, database selector container, and selects', async () => {
render(<LeftPanel setDataset={mockFun} />, { useRedux: true });
expect(await screen.findByText(/select database & schema/i)).toBeVisible();
expect(
await screen.findByText(/Select database or type to search databases/i),
).toBeVisible();
const databaseSelect = screen.getByRole('combobox', {
name: 'Select database or type to search databases',
@@ -175,7 +183,7 @@ test('does not render blank state if there is nothing selected', async () => {
render(<LeftPanel setDataset={mockFun} />, { useRedux: true });
expect(
await screen.findByText(/select database & schema/i),
await screen.findByText(/Select database or type to search databases/i),
).toBeInTheDocument();
const emptyState = screen.queryByRole('img', { name: /empty/i });
expect(emptyState).not.toBeInTheDocument();
@@ -218,25 +226,45 @@ test('searches for a table name', async () => {
const schemaSelect = screen.getByRole('combobox', {
name: /select schema or type to search schemas/i,
});
const tableSelect = screen.getByRole('combobox', {
name: /select table or type to search tables/i,
});
await waitFor(() => expect(schemaSelect).toBeEnabled());
// Click 'public' schema to access tables
userEvent.click(schemaSelect);
userEvent.click(screen.getAllByText('public')[1]);
await waitFor(() => expect(fetchMock.calls(tablesEndpoint).length).toBe(1));
userEvent.click(tableSelect);
await waitFor(() => {
expect(screen.getByText('Sheet1')).toBeInTheDocument();
expect(screen.getByText('Sheet2')).toBeInTheDocument();
expect(screen.getByText('Sheet3')).toBeInTheDocument();
expect(
screen.queryByRole('option', {
name: /Sheet1/i,
}),
).toBeInTheDocument();
expect(
screen.queryByRole('option', {
name: /Sheet2/i,
}),
).toBeInTheDocument();
});
userEvent.type(screen.getByRole('textbox'), 'Sheet2');
userEvent.type(tableSelect, 'Sheet3');
await waitFor(() => {
expect(screen.queryByText('Sheet1')).not.toBeInTheDocument();
expect(screen.getByText('Sheet2')).toBeInTheDocument();
expect(screen.queryByText('Sheet3')).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: /Sheet1/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: /Sheet2/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', {
name: /Sheet3/i,
}),
).toBeInTheDocument();
});
});
@@ -262,6 +290,9 @@ test('renders a warning icon when a table name has a pre-existing dataset', asyn
const schemaSelect = screen.getByRole('combobox', {
name: /select schema or type to search schemas/i,
});
const tableSelect = screen.getByRole('combobox', {
name: /select table or type to search tables/i,
});
await waitFor(() => expect(schemaSelect).toBeEnabled());
@@ -273,11 +304,18 @@ test('renders a warning icon when a table name has a pre-existing dataset', asyn
// Click 'public' schema to access tables
userEvent.click(schemaSelect);
userEvent.click(screen.getAllByText('public')[1]);
userEvent.click(tableSelect);
await waitFor(() => {
expect(screen.getByText('Sheet2')).toBeInTheDocument();
expect(
screen.queryByRole('option', {
name: /Sheet2/i,
}),
).toBeInTheDocument();
});
userEvent.type(tableSelect, 'Sheet2');
// Sheet2 should now show the warning icon
expect(screen.getByRole('img', { name: 'warning' })).toBeVisible();
expect(screen.getByRole('img', { name: 'alert-solid' })).toBeInTheDocument();
});

View File

@@ -16,42 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, {
useEffect,
useState,
SetStateAction,
Dispatch,
useCallback,
} from 'react';
import rison from 'rison';
import {
SupersetClient,
t,
styled,
css,
useTheme,
logging,
} from '@superset-ui/core';
import { Input } from 'src/components/Input';
import { Form } from 'src/components/Form';
import Icons from 'src/components/Icons';
import { TableOption } from 'src/components/TableSelector';
import RefreshLabel from 'src/components/RefreshLabel';
import { Table } from 'src/hooks/apiResources';
import Loading from 'src/components/Loading';
import DatabaseSelector, {
DatabaseObject,
} from 'src/components/DatabaseSelector';
import {
EmptyStateMedium,
emptyStateComponent,
} from 'src/components/EmptyState';
import React, { useEffect, SetStateAction, Dispatch, useCallback } from 'react';
import { styled, t } from '@superset-ui/core';
import TableSelector, { TableOption } from 'src/components/TableSelector';
import { DatabaseObject } from 'src/components/DatabaseSelector';
import { emptyStateComponent } from 'src/components/EmptyState';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { LocalStorageKeys, getItem } from 'src/utils/localStorageHelpers';
import {
DatasetActionType,
DatasetObject,
} from 'src/features/datasets/AddDataset/types';
import { Table } from 'src/hooks/apiResources';
interface LeftPanelProps {
setDataset: Dispatch<SetStateAction<object>>;
@@ -59,10 +35,6 @@ interface LeftPanelProps {
datasetNames?: (string | null | undefined)[] | undefined;
}
const SearchIcon = styled(Icons.Search)`
color: ${({ theme }) => theme.colors.grayscale.light1};
`;
const LeftPanelStyle = styled.div`
${({ theme }) => `
max-width: ${theme.gridUnit * 87.5}px;
@@ -74,14 +46,6 @@ const LeftPanelStyle = styled.div`
height: auto;
margin-top: ${theme.gridUnit * 17.5}px;
}
.refresh {
position: absolute;
top: ${theme.gridUnit * 38.75}px;
left: ${theme.gridUnit * 16.75}px;
span[role="button"]{
font-size: ${theme.gridUnit * 4.25}px;
}
}
.section-title {
margin-top: ${theme.gridUnit * 5.5}px;
margin-bottom: ${theme.gridUnit * 11}px;
@@ -158,77 +122,28 @@ export default function LeftPanel({
dataset,
datasetNames,
}: LeftPanelProps) {
const theme = useTheme();
const [tableOptions, setTableOptions] = useState<Array<TableOption>>([]);
const [resetTables, setResetTables] = useState(false);
const [loadTables, setLoadTables] = useState(false);
const [searchVal, setSearchVal] = useState('');
const [refresh, setRefresh] = useState(false);
const [selectedTable, setSelectedTable] = useState<number | null>(null);
const { addDangerToast } = useToasts();
const setDatabase = useCallback(
(db: Partial<DatabaseObject>) => {
setDataset({ type: DatasetActionType.selectDatabase, payload: { db } });
setSelectedTable(null);
setResetTables(true);
},
[setDataset],
);
const setTable = (tableName: string, index: number) => {
setSelectedTable(index);
setDataset({
type: DatasetActionType.selectTable,
payload: { name: 'table_name', value: tableName },
});
};
const getTablesList = useCallback(
(url: string) => {
SupersetClient.get({ url })
.then(({ json }) => {
const options: TableOption[] = json.result.map((table: Table) => {
const option: TableOption = {
value: table.value,
label: <TableOption table={table} />,
text: table.label,
};
return option;
});
setTableOptions(options);
setLoadTables(false);
setResetTables(false);
setRefresh(false);
})
.catch(error => {
addDangerToast(t('There was an error fetching tables'));
logging.error(t('There was an error fetching tables'), error);
});
},
[addDangerToast],
);
const setSchema = (schema: string) => {
if (schema) {
setDataset({
type: DatasetActionType.selectSchema,
payload: { name: 'schema', value: schema },
});
setLoadTables(true);
}
setSelectedTable(null);
setResetTables(true);
};
const encodedSchema = dataset?.schema
? encodeURIComponent(dataset?.schema)
: undefined;
const setTable = (tableName: string) => {
setDataset({
type: DatasetActionType.selectTable,
payload: { name: 'table_name', value: tableName },
});
};
useEffect(() => {
const currentUserSelectedDb = getItem(
LocalStorageKeys.db,
@@ -239,140 +154,37 @@ export default function LeftPanel({
}
}, [setDatabase]);
useEffect(() => {
if (loadTables) {
const params = rison.encode({
force: refresh,
schema_name: encodedSchema,
});
const endpoint = `/api/v1/database/${dataset?.db?.id}/tables/?q=${params}`;
getTablesList(endpoint);
}
}, [loadTables, dataset?.db?.id, encodedSchema, getTablesList, refresh]);
useEffect(() => {
if (resetTables) {
setTableOptions([]);
setResetTables(false);
}
}, [resetTables]);
const filteredOptions = tableOptions.filter(option =>
option?.value?.toLowerCase().includes(searchVal.toLowerCase()),
const customTableOptionLabelRenderer = useCallback(
(table: Table) => (
<TableOption
table={
datasetNames?.includes(table.value)
? {
...table,
extra: {
warning_markdown: t('This table already has a dataset'),
},
}
: table
}
/>
),
[datasetNames],
);
const Loader = (inline: string) => (
<div className="loading-container">
<Loading position="inline" />
<p>{inline}</p>
</div>
);
const SELECT_DATABASE_AND_SCHEMA_TEXT = t('Select database & schema');
const TABLE_LOADING_TEXT = t('Table loading');
const NO_TABLES_FOUND_TITLE = t('No database tables found');
const NO_TABLES_FOUND_DESCRIPTION = t('Try selecting a different schema');
const SELECT_DATABASE_TABLE_TEXT = t('Select database table');
const REFRESH_TABLE_LIST_TOOLTIP = t('Refresh table list');
const REFRESH_TABLES_TEXT = t('Refresh tables');
const SEARCH_TABLES_PLACEHOLDER_TEXT = t('Search tables');
const optionsList = document.getElementsByClassName('options-list');
const scrollableOptionsList =
optionsList[0]?.scrollHeight > optionsList[0]?.clientHeight;
const [emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
const onEmptyResults = (searchText?: string) => {
setEmptyResultsWithSearch(!!searchText);
};
return (
<LeftPanelStyle>
<p className="section-title db-schema">
{SELECT_DATABASE_AND_SCHEMA_TEXT}
</p>
<DatabaseSelector
db={dataset?.db}
<TableSelector
database={dataset?.db}
handleError={addDangerToast}
emptyState={emptyStateComponent(false)}
onDbChange={setDatabase}
onSchemaChange={setSchema}
emptyState={emptyStateComponent(emptyResultsWithSearch)}
onEmptyResults={onEmptyResults}
onTableSelectChange={setTable}
sqlLabMode={false}
customTableOptionLabelRenderer={customTableOptionLabelRenderer}
{...(dataset?.schema && { schema: dataset.schema })}
/>
{loadTables && !refresh && Loader(TABLE_LOADING_TEXT)}
{dataset?.schema && !loadTables && !tableOptions.length && !searchVal && (
<div className="emptystate">
<EmptyStateMedium
image="empty-table.svg"
title={NO_TABLES_FOUND_TITLE}
description={NO_TABLES_FOUND_DESCRIPTION}
/>
</div>
)}
{dataset?.schema && (tableOptions.length > 0 || searchVal.length > 0) && (
<>
<Form>
<p className="table-title">{SELECT_DATABASE_TABLE_TEXT}</p>
<RefreshLabel
onClick={() => {
setLoadTables(true);
setRefresh(true);
}}
tooltipContent={REFRESH_TABLE_LIST_TOOLTIP}
/>
{refresh && Loader(REFRESH_TABLES_TEXT)}
{!refresh && (
<Input
value={searchVal}
prefix={<SearchIcon iconSize="l" />}
onChange={evt => {
setSearchVal(evt.target.value);
}}
className="table-form"
placeholder={SEARCH_TABLES_PLACEHOLDER_TEXT}
allowClear
/>
)}
</Form>
<div className="options-list" data-test="options-list">
{!refresh &&
filteredOptions.map((option, i) => (
<div
className={
selectedTable === i
? scrollableOptionsList
? 'options-highlighted'
: 'options-highlighted no-scrollbar'
: scrollableOptionsList
? 'options'
: 'options no-scrollbar'
}
key={i}
role="button"
tabIndex={0}
onClick={() => setTable(option.value, i)}
>
{option.label}
{datasetNames?.includes(option.value) && (
<Icons.Warning
iconColor={
selectedTable === i
? theme.colors.grayscale.light5
: theme.colors.info.base
}
iconSize="m"
css={css`
margin-right: ${theme.gridUnit * 2}px;
`}
/>
)}
</div>
))}
</div>
</>
)}
</LeftPanelStyle>
);
}

View File

@@ -59,7 +59,7 @@ describe('DatasetLayout', () => {
);
expect(
await screen.findByText(/select database & schema/i),
await screen.findByText(/Select database or type to search databases/i),
).toBeInTheDocument();
expect(LeftPanel).toBeTruthy();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { SupersetClient, logging, t } from '@superset-ui/core';
import rison from 'rison';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -83,7 +83,10 @@ const useDatasetsList = (
}
}, [db?.id, schema, encodedSchema, getDatasetsList]);
const datasetNames = datasets?.map(dataset => dataset.table_name);
const datasetNames = useMemo(
() => datasets?.map(dataset => dataset.table_name),
[datasets],
);
return { datasets, datasetNames };
};