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 ( 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(); + render(, { 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(); + 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(, { + useRedux: true, + }); + expect(container).toBeInTheDocument(); + }); + + it('should render tableselector and databaselector container and selects', () => { + render(, { 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(, { 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(, { + 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>; + 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>([]); + 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) => { + 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: , + 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) => ( +
+ +

{inline}

+
+ ); -export default function LeftPanel() { return ( - <> - +

Select database & schema

+ - + {loadTables && !refresh && Loader('Table loading')} + + {schema && !loadTables && !tableOptions.length && !searchVal && ( +
+ +
+ )} + + {schema && (tableOptions.length > 0 || searchVal.length > 0) && ( + <> +
+

Select database table

+ { + setLoadTables(true); + setRefresh(true); + }} + tooltipContent={t('Refresh table list')} + /> + {refresh && Loader('Refresh tables')} + {!refresh && ( + } + onChange={evt => { + search(evt.target.value); + setSearchVal(evt.target.value); + }} + className="table-form" + placeholder={t('Search tables')} + /> + )} + +
+ {!refresh && + tableOptions.map((o, i) => ( +
+ {o.label} +
+ ))} +
+ + )} + ); } 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 | null, + state: DatasetObject | null, action: DSReducerActionType, -): Partial | null { +): Partial | 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 | null, DSReducerActionType> - // >(datasetReducer, null); + const [dataset, setDataset] = useReducer< + Reducer | null, DSReducerActionType> + >(datasetReducer, null); + + const LeftPanelComponent = () => ( + + ); return ( 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; } | { - 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(); + render( + 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', () => {