This is an automated email from the ASF dual-hosted git repository.
pkdotson pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8c2719b11c feat: add tableselector to dataset creation page (#21075)
8c2719b11c is described below
commit 8c2719b11cebe451c5ba68193cf8dd51e4dce8e3
Author: Phillip Kelley-Dotson <[email protected]>
AuthorDate: Wed Sep 7 13:42:47 2022 -0700
feat: add tableselector to dataset creation page (#21075)
Co-authored-by: Lyndsi Kay Williams
<[email protected]>
---
.../src/components/TableSelector/index.tsx | 4 +-
.../data/dataset/AddDataset/AddDataset.test.tsx | 8 +-
.../AddDataset/LeftPanel/LeftPanel.test.tsx | 208 ++++++++++++++++-
.../data/dataset/AddDataset/LeftPanel/index.tsx | 250 ++++++++++++++++++++-
.../views/CRUD/data/dataset/AddDataset/index.tsx | 31 ++-
.../views/CRUD/data/dataset/AddDataset/types.tsx | 19 +-
.../dataset/DatasetLayout/DatasetLayout.test.tsx | 8 +-
7 files changed, 482 insertions(+), 46 deletions(-)
diff --git a/superset-frontend/src/components/TableSelector/index.tsx
b/superset-frontend/src/components/TableSelector/index.tsx
index d285b61a4c..ecf657e52e 100644
--- a/superset-frontend/src/components/TableSelector/index.tsx
+++ b/superset-frontend/src/components/TableSelector/index.tsx
@@ -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}>
diff --git
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx
index 23c5c3b471..cee1cea5e2 100644
---
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx
+++
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/AddDataset.test.tsx
@@ -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);
});
});
diff --git
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/LeftPanel.test.tsx
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/LeftPanel.test.tsx
index 9fdb1aee8f..4e9d1a89ca 100644
---
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/LeftPanel.test.tsx
+++
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/LeftPanel.test.tsx
@@ -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();
});
});
diff --git
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/index.tsx
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/index.tsx
index 908cf1a833..9d79d5cc8f 100644
---
a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/index.tsx
+++
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/LeftPanel/index.tsx
@@ -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>
);
}
diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx
index 677f3f52ae..a1ec33ad17 100644
--- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx
+++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/index.tsx
@@ -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()}
/>
diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/types.tsx
b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/types.tsx
index 3d5d67f7e1..530ed8dd33 100644
--- a/superset-frontend/src/views/CRUD/data/dataset/AddDataset/types.tsx
+++ b/superset-frontend/src/views/CRUD/data/dataset/AddDataset/types.tsx
@@ -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;
};
diff --git
a/superset-frontend/src/views/CRUD/data/dataset/DatasetLayout/DatasetLayout.test.tsx
b/superset-frontend/src/views/CRUD/data/dataset/DatasetLayout/DatasetLayout.test.tsx
index 59c3ee3ed1..dbdc89e2ae 100644
---
a/superset-frontend/src/views/CRUD/data/dataset/DatasetLayout/DatasetLayout.test.tsx
+++
b/superset-frontend/src/views/CRUD/data/dataset/DatasetLayout/DatasetLayout.test.tsx
@@ -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', () => {