diff --git a/superset-frontend/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx b/superset-frontend/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx index 1ba1ac821fb1..4271b5911857 100644 --- a/superset-frontend/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx @@ -81,9 +81,13 @@ describe('Left Panel Expansion', () => { , ); - const dbSelect = screen.getByText(/select a database/i); - const schemaSelect = screen.getByText(/select a schema \(0\)/i); - const dropdown = screen.getByText(/Select table/i); + const dbSelect = screen.getByRole('combobox', { + name: 'Select database or type database name', + }); + const schemaSelect = screen.getByRole('combobox', { + name: 'Select schema or type schema name', + }); + const dropdown = screen.getByText(/Select table or type table name/i); const abUser = screen.getByText(/ab_user/i); expect(dbSelect).toBeInTheDocument(); expect(schemaSelect).toBeInTheDocument(); diff --git a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx index b634d2c41f8f..d9adfde3d696 100644 --- a/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx +++ b/superset-frontend/spec/javascripts/sqllab/SqlEditor_spec.jsx @@ -36,13 +36,14 @@ import { Dropdown } from 'src/common/components'; import { queryEditorSetFunctionNames, queryEditorSetSelectedText, + queryEditorSetSchemaOptions, } from 'src/SqlLab/actions/sqlLab'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { initialState, queries, table } from './fixtures'; const MOCKED_SQL_EDITOR_HEIGHT = 500; -fetchMock.get('glob:*/api/v1/database/*', {}); +fetchMock.get('glob:*/api/v1/database/*', { result: [] }); const middlewares = [thunk]; const mockStore = configureStore(middlewares); @@ -53,6 +54,7 @@ describe('SqlEditor', () => { actions: { queryEditorSetFunctionNames, queryEditorSetSelectedText, + queryEditorSetSchemaOptions, addDangerToast: jest.fn(), }, database: {}, diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx index 3989a23b6a76..02a383b8508e 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx @@ -91,7 +91,13 @@ export default class SqlEditorLeftBar extends React.PureComponent { } onTableChange(tableName, schemaName) { - this.props.actions.addTable(this.props.queryEditor, tableName, schemaName); + if (tableName && schemaName) { + this.props.actions.addTable( + this.props.queryEditor, + tableName, + schemaName, + ); + } } onToggleTable(tables) { @@ -171,7 +177,6 @@ export default class SqlEditorLeftBar extends React.PureComponent { onTablesLoad={this.onTablesLoad} schema={qe.schema} sqlLabMode - tableNameSticky={false} />
diff --git a/superset-frontend/src/components/CertifiedIcon/index.tsx b/superset-frontend/src/components/CertifiedIcon/index.tsx index f08e9bf6047c..4aa0dad236b1 100644 --- a/superset-frontend/src/components/CertifiedIcon/index.tsx +++ b/superset-frontend/src/components/CertifiedIcon/index.tsx @@ -18,19 +18,19 @@ */ import React from 'react'; import { t, supersetTheme } from '@superset-ui/core'; -import Icons from 'src/components/Icons'; +import Icons, { IconType } from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; export interface CertifiedIconProps { certifiedBy?: string; details?: string; - size?: number; + size?: IconType['iconSize']; } function CertifiedIcon({ certifiedBy, details, - size = 24, + size = 'l', }: CertifiedIconProps) { return ( ); diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx index 0d812824d1cf..8e96941f5b88 100644 --- a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx +++ b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx @@ -26,11 +26,11 @@ import DatabaseSelector from '.'; const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); const createProps = () => ({ - dbId: 1, + db: { id: 1, database_name: 'test', backend: 'test-postgresql' }, formMode: false, isDatabaseSelectEnabled: true, readOnly: false, - schema: 'public', + schema: undefined, sqlLabMode: true, getDbList: jest.fn(), getTableList: jest.fn(), @@ -57,9 +57,9 @@ beforeEach(() => { } return { json: { - count: 1, + count: 2, description_columns: {}, - ids: [1], + ids: [1, 2], label_columns: { allow_csv_upload: 'Allow Csv Upload', allow_ctas: 'Allow Ctas', @@ -129,12 +129,32 @@ beforeEach(() => { changed_on: '2021-03-09T19:02:07.141095', changed_on_delta_humanized: 'a day ago', created_by: null, - database_name: 'examples', + 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, + 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; @@ -153,50 +173,95 @@ test('Refresh should work', async () => { render(); + const select = screen.getByRole('combobox', { + name: 'Select schema or type schema name', + }); + + userEvent.click(select); + await waitFor(() => { - expect(SupersetClientGet).toBeCalledTimes(2); - expect(props.getDbList).toBeCalledTimes(1); + 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(1); + expect(props.onSchemasLoad).toBeCalledTimes(0); expect(props.onUpdate).toBeCalledTimes(0); }); - userEvent.click(screen.getByRole('button')); + userEvent.click(screen.getByRole('button', { name: 'refresh' })); await waitFor(() => { - expect(SupersetClientGet).toBeCalledTimes(3); - expect(props.getDbList).toBeCalledTimes(1); + expect(SupersetClientGet).toBeCalledTimes(2); + expect(props.getDbList).toBeCalledTimes(0); expect(props.getTableList).toBeCalledTimes(0); expect(props.handleError).toBeCalledTimes(0); - expect(props.onDbChange).toBeCalledTimes(1); - expect(props.onSchemaChange).toBeCalledTimes(1); + expect(props.onDbChange).toBeCalledTimes(0); + expect(props.onSchemaChange).toBeCalledTimes(0); expect(props.onSchemasLoad).toBeCalledTimes(2); - expect(props.onUpdate).toBeCalledTimes(1); + expect(props.onUpdate).toBeCalledTimes(0); }); }); test('Should database select display options', async () => { const props = createProps(); render(); - const selector = await screen.findByText('Database:'); - expect(selector).toBeInTheDocument(); - expect(selector.parentElement).toHaveTextContent( - 'Database:postgresql examples', - ); + const select = screen.getByRole('combobox', { + name: 'Select database or type database name', + }); + expect(select).toBeInTheDocument(); + userEvent.click(select); + expect(await screen.findByText('test-mysql')).toBeInTheDocument(); }); test('Should schema select display options', async () => { const props = createProps(); render(); + const select = screen.getByRole('combobox', { + name: 'Select schema or type schema name', + }); + expect(select).toBeInTheDocument(); + userEvent.click(select); + expect( + await screen.findByRole('option', { name: 'public' }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('option', { name: 'information_schema' }), + ).toBeInTheDocument(); +}); - const selector = await screen.findByText('Schema:'); - expect(selector).toBeInTheDocument(); - expect(selector.parentElement).toHaveTextContent('Schema: public'); - - userEvent.click(screen.getByRole('button')); +test('Sends the correct db when changing the database', async () => { + const props = createProps(); + render(); + const select = screen.getByRole('combobox', { + name: 'Select database or type database name', + }); + expect(select).toBeInTheDocument(); + userEvent.click(select); + userEvent.click(await screen.findByText('test-mysql')); + await waitFor(() => + expect(props.onDbChange).toHaveBeenCalledWith( + expect.objectContaining({ + id: 2, + database_name: 'test-mysql', + backend: 'mysql', + }), + ), + ); +}); - expect(await screen.findByText('Select a schema (2)')).toBeInTheDocument(); +test('Sends the correct schema when changing the schema', async () => { + const props = createProps(); + render(); + const select = screen.getByRole('combobox', { + name: 'Select schema or type schema name', + }); + expect(select).toBeInTheDocument(); + userEvent.click(select); + const schemaOption = await screen.findAllByText('information_schema'); + userEvent.click(schemaOption[1]); + await waitFor(() => + expect(props.onSchemaChange).toHaveBeenCalledWith('information_schema'), + ); }); diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx index 0282e4a4a4c0..12f785556138 100644 --- a/superset-frontend/src/components/DatabaseSelector/index.tsx +++ b/superset-frontend/src/components/DatabaseSelector/index.tsx @@ -16,80 +16,94 @@ * specific language governing permissions and limitations * under the License. */ -import React, { ReactNode, useEffect, useState } from 'react'; +import React, { ReactNode, useState, useMemo, useEffect } from 'react'; import { styled, SupersetClient, t } from '@superset-ui/core'; import rison from 'rison'; -import { Select } from 'src/components/Select'; +import { Select } from 'src/components'; import Label from 'src/components/Label'; +import { FormLabel } from 'src/components/Form'; import RefreshLabel from 'src/components/RefreshLabel'; -import SupersetAsyncSelect from 'src/components/AsyncSelect'; - -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 DatabaseSelectorWrapper = styled.div` - .fa-refresh { - padding-left: 9px; - } + ${({ theme }) => ` + .refresh { + display: flex; + align-items: center; + width: 30px; + margin-left: ${theme.gridUnit}px; + margin-top: ${theme.gridUnit * 5}px; + } - .refresh-col { - display: flex; - align-items: center; - width: 30px; - margin-left: ${({ theme }) => theme.gridUnit}px; - } + .section { + display: flex; + flex-direction: row; + align-items: center; + } - .section { - padding-bottom: 5px; - display: flex; - flex-direction: row; - } + .select { + flex: 1; + } - .select { - flex-grow: 1; - } + & > div { + margin-bottom: ${theme.gridUnit * 4}px; + } + `} `; -const DatabaseOption = styled.span` - display: inline-flex; +const LabelStyle = styled.div` + display: flex; + flex-direction: row; align-items: center; + margin-left: ${({ theme }) => theme.gridUnit - 2}px; `; +type DatabaseValue = { + label: React.ReactNode; + value: number; + id: number; + database_name: string; + backend: string; +}; + +type SchemaValue = { label: string; value: string }; + interface DatabaseSelectorProps { - dbId: number; + db?: { id: number; database_name: string; backend: string }; formMode?: boolean; getDbList?: (arg0: any) => {}; - getTableList?: (dbId: number, schema: string, force: boolean) => {}; handleError: (msg: string) => void; isDatabaseSelectEnabled?: boolean; - onDbChange?: (db: any) => void; - onSchemaChange?: (arg0?: any) => {}; + onDbChange?: (db: { + id: number; + database_name: string; + backend: string; + }) => void; + onSchemaChange?: (schema?: string) => void; onSchemasLoad?: (schemas: Array) => void; readOnly?: boolean; schema?: string; sqlLabMode?: boolean; - onUpdate?: ({ - dbId, - schema, - }: { - dbId: number; - schema?: string; - tableName?: string; - }) => void; } +const SelectLabel = ({ + backend, + databaseName, +}: { + backend: string; + databaseName: string; +}) => ( + + + {databaseName} + +); + export default function DatabaseSelector({ - dbId, + db, formMode = false, getDbList, - getTableList, handleError, isDatabaseSelectEnabled = true, - onUpdate, onDbChange, onSchemaChange, onSchemasLoad, @@ -97,193 +111,188 @@ export default function DatabaseSelector({ schema, sqlLabMode = false, }: DatabaseSelectorProps) { - const [currentDbId, setCurrentDbId] = useState(dbId); - const [currentSchema, setCurrentSchema] = useState( - schema, + const [loadingSchemas, setLoadingSchemas] = useState(false); + const [schemaOptions, setSchemaOptions] = useState([]); + const [currentDb, setCurrentDb] = useState( + db + ? { + label: ( + + ), + value: db.id, + ...db, + } + : undefined, + ); + const [currentSchema, setCurrentSchema] = useState( + schema ? { label: schema, value: schema } : undefined, ); - const [schemaLoading, setSchemaLoading] = useState(false); - const [schemaOptions, setSchemaOptions] = useState([]); + const [refresh, setRefresh] = useState(0); - function fetchSchemas(databaseId: number, forceRefresh = false) { - const actualDbId = databaseId || dbId; - if (actualDbId) { - setSchemaLoading(true); + const loadDatabases = useMemo( + () => async ( + search: string, + page: number, + pageSize: number, + ): Promise<{ + data: DatabaseValue[]; + totalCount: number; + }> => { const queryParams = rison.encode({ - force: Boolean(forceRefresh), + 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/${actualDbId}/schemas/?q=${queryParams}`; - return SupersetClient.get({ endpoint }) - .then(({ json }) => { - const options = json.result.map((s: string) => ({ - value: s, - label: s, - title: s, - })); - setSchemaOptions(options); - setSchemaLoading(false); + 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: { id: number; database_name: string; backend: string }) => ({ + label: ( + + ), + value: row.id, + id: row.id, + database_name: row.database_name, + backend: row.backend, + }), + ); + return { + data: options, + totalCount: options.length, + }; + }); + }, + [formMode, getDbList, handleError, sqlLabMode], + ); + + useEffect(() => { + if (currentDb) { + setLoadingSchemas(true); + const queryParams = rison.encode({ force: refresh > 0 }); + const endpoint = `/api/v1/database/${currentDb.value}/schemas/?q=${queryParams}`; + + try { + // TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. + SupersetClient.get({ endpoint }).then(({ json }) => { + const options = json.result + .map((s: string) => ({ + value: s, + label: s, + title: s, + })) + .sort((a: { label: string }, b: { label: string }) => + a.label.localeCompare(b.label), + ); if (onSchemasLoad) { onSchemasLoad(options); } - }) - .catch(() => { - setSchemaOptions([]); - setSchemaLoading(false); - handleError(t('Error while fetching schema list')); + setSchemaOptions(options); }); + } finally { + setLoadingSchemas(false); + } } - return Promise.resolve(); - } + }, [currentDb, onSchemasLoad, refresh]); - useEffect(() => { - if (currentDbId) { - fetchSchemas(currentDbId); - } - }, [currentDbId]); - - function onSelectChange({ dbId, schema }: { dbId: number; schema?: string }) { - setCurrentDbId(dbId); - setCurrentSchema(schema); - if (onUpdate) { - onUpdate({ dbId, schema, tableName: undefined }); - } - } - - function dbMutator(data: any) { - if (getDbList) { - getDbList(data.result); - } - if (data.result.length === 0) { - handleError(t("It seems you don't have access to any database")); + function changeDataBase( + value: { label: string; value: number }, + database: DatabaseValue, + ) { + setCurrentDb(database); + setCurrentSchema(undefined); + if (onDbChange) { + onDbChange(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) { - onSchemaChange(null); + onSchemaChange(undefined); } - if (onDbChange) { - onDbChange(db); - } - fetchSchemas(dbId, force); - onSelectChange({ dbId, schema: undefined }); } - function changeSchema(schemaOpt: any, force = false) { - const schema = schemaOpt ? schemaOpt.value : null; - if (onSchemaChange) { - onSchemaChange(schema); - } + function changeSchema(schema: SchemaValue) { setCurrentSchema(schema); - onSelectChange({ dbId: currentDbId, schema }); - if (getTableList) { - getTableList(currentDbId, schema, force); + if (onSchemaChange) { + onSchemaChange(schema.value); } } - function renderDatabaseOption(db: any) { - return ( - - {db.database_name} - - ); - } - function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) { return (
{select} - {refreshBtn} + {refreshBtn}
); } 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( - changeDataBase(db)} - onAsyncError={() => - handleError(t('Error while fetching database list')) - } - clearable={false} - value={currentDbId} - valueKey="id" - valueRenderer={(db: any) => ( -
- {t('Database:')} - {renderDatabaseOption(db)} -
- )} - optionRenderer={renderDatabaseOption} - mutator={dbMutator} - placeholder={t('Select a database')} - autoSelect - isDisabled={!isDatabaseSelectEnabled || readOnly} + header={{t('Database')}} + onChange={changeDataBase} + value={currentDb} + placeholder={t('Select database or type database name')} + disabled={!isDatabaseSelectEnabled || readOnly} + options={loadDatabases} />, null, ); } function renderSchemaSelect() { - const value = schemaOptions.filter(({ value }) => currentSchema === value); - const refresh = !formMode && !readOnly && ( + const refreshIcon = !formMode && !readOnly && ( changeDataBase({ id: dbId }, true)} + onClick={() => setRefresh(refresh + 1)} tooltipContent={t('Force refresh schema list')} /> ); return renderSelectRow( - ); - } else if (formMode) { - select = ( - - ); - } 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 = ( - - ); - } - const refresh = !formMode && !readOnly && ( + const disabled = + (currentSchema && !formMode && readOnly) || + (!currentSchema && !database?.allow_multi_schema_metadata_fetch); + + const header = sqlLabMode ? ( + {t('See table schema')} + ) : ( + {t('Table')} + ); + + const select = ( +