feat: add tableselector to dataset creation page (#21075)

Co-authored-by: Lyndsi Kay Williams <55605634+lyndsiWilliams@users.noreply.github.com>
This commit is contained in:
Phillip Kelley-Dotson 2022-09-07 13:42:47 -07:00 committed by GitHub
parent ce3d38d2e7
commit 8c2719b11c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 482 additions and 46 deletions

View File

@ -105,13 +105,13 @@ interface TableSelectorProps {
tableSelectMode?: 'single' | 'multiple';
}
interface TableOption {
export interface TableOption {
label: JSX.Element;
text: string;
value: string;
}
const TableOption = ({ table }: { table: Table }) => {
export const TableOption = ({ table }: { table: Table }) => {
const { label, type, extra } = table;
return (
<TableLabel title={label}>

View File

@ -22,7 +22,7 @@ import AddDataset from 'src/views/CRUD/data/dataset/AddDataset';
describe('AddDataset', () => {
it('renders a blank state AddDataset', () => {
render(<AddDataset />);
render(<AddDataset />, { useRedux: true });
const blankeStateImgs = screen.getAllByRole('img', { name: /empty/i });
@ -30,13 +30,9 @@ describe('AddDataset', () => {
expect(screen.getByText(/header/i)).toBeVisible();
// Left panel
expect(blankeStateImgs[0]).toBeVisible();
expect(screen.getByText(/no database tables found/i)).toBeVisible();
// Database panel
expect(blankeStateImgs[1]).toBeVisible();
expect(screen.getByText(/select dataset source/i)).toBeVisible();
// Footer
expect(screen.getByText(/footer/i)).toBeVisible();
expect(blankeStateImgs.length).toBe(2);
expect(blankeStateImgs.length).toBe(1);
});
});

View File

@ -17,15 +17,211 @@
* under the License.
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import LeftPanel from 'src/views/CRUD/data/dataset/AddDataset/LeftPanel';
import { act } from 'react-dom/test-utils';
describe('LeftPanel', () => {
it('renders a blank state LeftPanel', () => {
render(<LeftPanel />);
const mockFun = jest.fn();
expect(screen.getByRole('img', { name: /empty/i })).toBeVisible();
expect(screen.getByText(/no database tables found/i)).toBeVisible();
expect(screen.getByText(/try selecting a different schema/i)).toBeVisible();
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
beforeEach(() => {
jest.resetAllMocks();
SupersetClientGet.mockImplementation(
async ({ endpoint }: { endpoint: string }) => {
if (endpoint.includes('schemas')) {
return {
json: { result: ['information_schema', 'public'] },
} 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_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,
},
{
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,
},
],
},
} as any;
},
);
});
const getTableMockFunction = async () =>
({
json: {
options: [
{ 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);
it('should render', () => {
const { container } = render(<LeftPanel setDataset={mockFun} />, {
useRedux: true,
});
expect(container).toBeInTheDocument();
});
it('should render tableselector and databaselector container and selects', () => {
render(<LeftPanel setDataset={mockFun} />, { useRedux: true });
expect(screen.getByText(/select database & schema/i)).toBeVisible();
const databaseSelect = screen.getByRole('combobox', {
name: 'Select database or type database name',
});
const schemaSelect = screen.getByRole('combobox', {
name: 'Select schema or type schema name',
});
expect(databaseSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
});
it('does not render blank state if there is nothing selected', () => {
render(<LeftPanel setDataset={mockFun} />, { useRedux: true });
const emptyState = screen.queryByRole('img', { name: /empty/i });
expect(emptyState).not.toBeInTheDocument();
});
it('renders list of options when user clicks on schema', async () => {
render(<LeftPanel setDataset={mockFun} schema="schema_a" dbId={1} />, {
useRedux: true,
});
const databaseSelect = screen.getByRole('combobox', {
name: 'Select database or type database name',
});
userEvent.click(databaseSelect);
expect(await screen.findByText('test-postgres')).toBeInTheDocument();
act(() => {
userEvent.click(screen.getAllByText('test-postgres')[0]);
});
const tableSelect = screen.getByRole('combobox', {
name: /select schema or type schema name/i,
});
await waitFor(() => {
expect(tableSelect).toBeEnabled();
});
userEvent.click(tableSelect);
expect(
await screen.findByRole('option', { name: 'information_schema' }),
).toBeInTheDocument();
expect(
await screen.findByRole('option', { name: 'public' }),
).toBeInTheDocument();
SupersetClientGet.mockImplementation(getTableMockFunction);
act(() => {
userEvent.click(screen.getAllByText('public')[1]);
});
// Todo: (Phillip) finish testing for showing list of options once table is implemented
// expect(screen.getByTestId('options-list')).toBeInTheDocument();
});
});

View File

@ -16,18 +16,250 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { t } from '@superset-ui/core';
import React, {
useEffect,
useState,
useMemo,
SetStateAction,
Dispatch,
} from 'react';
import { SupersetClient, t, styled, FAST_DEBOUNCE } 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 from 'src/components/DatabaseSelector';
import { debounce } from 'lodash';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { DatasetActionType, DatasetObject } from '../types';
interface LeftPanelProps {
setDataset: Dispatch<SetStateAction<object>>;
schema?: string | undefined | null;
dbId?: number;
}
const SearchIcon = styled(Icons.Search)`
color: ${({ theme }) => theme.colors.grayscale.light1};
`;
const LeftPanelStyle = styled.div`
${({ theme }) => `
max-width: ${theme.gridUnit * 87.5}px;
padding: ${theme.gridUnit * 4}px;
height: 100%;
background-color: ${theme.colors.grayscale.light5};
position: relative;
.emptystate {
height: auto;
margin-top: ${theme.gridUnit * 17.5}px;
}
.refresh {
position: absolute;
top: ${theme.gridUnit * 43.25}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;
font-weight: ${theme.typography.weights.bold};
}
.table-title {
margin-top: ${theme.gridUnit * 11}px;
margin-bottom: ${theme.gridUnit * 6}px;
font-weight: ${theme.typography.weights.bold};
}
.options-list {
overflow: auto;
position: absolute;
bottom: 0;
top: ${theme.gridUnit * 97.5}px;
left: ${theme.gridUnit * 3.25}px;
right: 0;
.options {
padding: ${theme.gridUnit * 1.75}px;
border-radius: ${theme.borderRadius}px;
}
}
form > span[aria-label="refresh"] {
position: absolute;
top: ${theme.gridUnit * 73}px;
left: ${theme.gridUnit * 42.75}px;
font-size: ${theme.gridUnit * 4.25}px;
}
.table-form {
margin-bottom: ${theme.gridUnit * 8}px;
}
.loading-container {
position: absolute;
top: 359px;
left: 0;
right: 0;
text-align: center;
img {
width: ${theme.gridUnit * 20}px;
margin-bottom: 10px;
}
p {
color: ${theme.colors.grayscale.light1}
}
}
}
`}
`;
export default function LeftPanel({
setDataset,
schema,
dbId,
}: LeftPanelProps) {
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 { addDangerToast } = useToasts();
const setDatabase = (db: Partial<DatasetObject>) => {
setDataset({ type: DatasetActionType.selectDatabase, payload: db });
setResetTables(true);
};
const getTablesList = (url: string) => {
SupersetClient.get({ url })
.then(({ json }) => {
const options: TableOption[] = json.options.map((table: Table) => {
const option: TableOption = {
value: table.value,
label: <TableOption table={table} />,
text: table.label,
};
return option;
});
setTableOptions(options);
setLoadTables(false);
setResetTables(false);
setRefresh(false);
})
.catch(e => {
console.log('error', e);
});
};
const setSchema = (schema: string) => {
if (schema) {
setDataset({
type: DatasetActionType.selectSchema,
payload: { name: 'schema', value: schema },
});
setLoadTables(true);
}
setResetTables(true);
};
const encodedSchema = schema ? encodeURIComponent(schema) : undefined;
useEffect(() => {
if (loadTables) {
const endpoint = encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/undefined/${refresh}/`,
);
getTablesList(endpoint);
}
}, [loadTables]);
useEffect(() => {
if (resetTables) {
setTableOptions([]);
setResetTables(false);
}
}, [resetTables]);
const search = useMemo(
() =>
debounce((value: string) => {
const encodeTableName =
value === '' ? undefined : encodeURIComponent(value);
const endpoint = encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/${encodeTableName}/`,
);
getTablesList(endpoint);
}, FAST_DEBOUNCE),
[dbId, encodedSchema],
);
const Loader = (inline: string) => (
<div className="loading-container">
<Loading position="inline" />
<p>{inline} </p>
</div>
);
export default function LeftPanel() {
return (
<>
<EmptyStateMedium
image="empty-table.svg"
title={t('No database tables found')}
description={t('Try selecting a different schema')}
<LeftPanelStyle>
<p className="section-title db-schema">Select database & schema</p>
<DatabaseSelector
handleError={addDangerToast}
onDbChange={setDatabase}
onSchemaChange={setSchema}
/>
</>
{loadTables && !refresh && Loader('Table loading')}
{schema && !loadTables && !tableOptions.length && !searchVal && (
<div className="emptystate">
<EmptyStateMedium
image="empty-table.svg"
title={t('No database tables found')}
description={t('Try selecting a different schema')}
/>
</div>
)}
{schema && (tableOptions.length > 0 || searchVal.length > 0) && (
<>
<Form>
<p className="table-title">Select database table</p>
<RefreshLabel
onClick={() => {
setLoadTables(true);
setRefresh(true);
}}
tooltipContent={t('Refresh table list')}
/>
{refresh && Loader('Refresh tables')}
{!refresh && (
<Input
value={searchVal}
prefix={<SearchIcon iconSize="l" />}
onChange={evt => {
search(evt.target.value);
setSearchVal(evt.target.value);
}}
className="table-form"
placeholder={t('Search tables')}
/>
)}
</Form>
<div className="options-list" data-test="options-list">
{!refresh &&
tableOptions.map((o, i) => (
<div className="options" key={i}>
{o.label}
</div>
))}
</div>
</>
)}
</LeftPanelStyle>
);
}

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useReducer, Reducer } from 'react';
import Header from './Header';
import DatasetPanel from './DatasetPanel';
import LeftPanel from './LeftPanel';
@ -24,13 +24,18 @@ import Footer from './Footer';
import { DatasetActionType, DatasetObject, DSReducerActionType } from './types';
import DatasetLayout from '../DatasetLayout';
type Schema = {
schema: string;
};
export function datasetReducer(
state: Partial<DatasetObject> | null,
state: DatasetObject | null,
action: DSReducerActionType,
): Partial<DatasetObject> | null {
): Partial<DatasetObject> | Schema | null {
const trimmedState = {
...(state || {}),
};
switch (action.type) {
case DatasetActionType.selectDatabase:
return {
@ -42,7 +47,7 @@ export function datasetReducer(
case DatasetActionType.selectSchema:
return {
...trimmedState,
...action.payload,
[action.payload.name]: action.payload.value,
table_name: null,
};
case DatasetActionType.selectTable:
@ -61,16 +66,22 @@ export function datasetReducer(
}
export default function AddDataset() {
// this is commented out for now, but can be commented in as the component
// is built up. Uncomment the useReducer in imports too
// const [dataset, setDataset] = useReducer<
// Reducer<Partial<DatasetObject> | null, DSReducerActionType>
// >(datasetReducer, null);
const [dataset, setDataset] = useReducer<
Reducer<Partial<DatasetObject> | null, DSReducerActionType>
>(datasetReducer, null);
const LeftPanelComponent = () => (
<LeftPanel
setDataset={setDataset}
schema={dataset?.schema}
dbId={dataset?.id}
/>
);
return (
<DatasetLayout
header={Header()}
leftPanel={LeftPanel()}
leftPanel={LeftPanelComponent()}
datasetPanel={DatasetPanel()}
footer={Footer()}
/>

View File

@ -24,11 +24,9 @@ export enum DatasetActionType {
}
export interface DatasetObject {
database: {
id: string;
database_name: string;
};
owners: number[];
id: number;
database_name?: string;
owners?: number[];
schema?: string | null;
dataset_name: string;
table_name?: string | null;
@ -39,15 +37,16 @@ interface DatasetReducerPayloadType {
value?: string;
}
export type Schema = {
schema?: string | null | undefined;
};
export type DSReducerActionType =
| {
type:
| DatasetActionType.selectDatabase
| DatasetActionType.selectSchema
| DatasetActionType.selectTable;
type: DatasetActionType.selectDatabase | DatasetActionType.selectTable;
payload: Partial<DatasetObject>;
}
| {
type: DatasetActionType.changeDataset;
type: DatasetActionType.changeDataset | DatasetActionType.selectSchema;
payload: DatasetReducerPayloadType;
};

View File

@ -40,10 +40,12 @@ describe('DatasetLayout', () => {
});
it('renders a LeftPanel when passed in', () => {
render(<DatasetLayout leftPanel={LeftPanel()} />);
render(
<DatasetLayout leftPanel={<LeftPanel setDataset={() => null} />} />,
{ useRedux: true },
);
expect(screen.getByRole('img', { name: /empty/i })).toBeVisible();
expect(screen.getByText(/no database tables found/i)).toBeVisible();
expect(LeftPanel).toBeTruthy();
});
it('renders a DatasetPanel when passed in', () => {