diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts index 5e16956c60e6b..265874f5e6661 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts @@ -16,7 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { t, QueryMode, DTTM_ALIAS, GenericDataType } from '@superset-ui/core'; +import { + t, + QueryMode, + DTTM_ALIAS, + GenericDataType, + QueryColumn, + DatasourceType, +} from '@superset-ui/core'; import { ColumnMeta } from './types'; // eslint-disable-next-line import/prefer-default-export @@ -32,7 +39,7 @@ export const COLUMN_NAME_ALIASES: Record = { [DTTM_ALIAS]: t('Time'), }; -export const TIME_COLUMN_OPTION: ColumnMeta = { +export const DATASET_TIME_COLUMN_OPTION: ColumnMeta = { verbose_name: COLUMN_NAME_ALIASES[DTTM_ALIAS], column_name: DTTM_ALIAS, type_generic: GenericDataType.TEMPORAL, @@ -41,6 +48,12 @@ export const TIME_COLUMN_OPTION: ColumnMeta = { ), }; +export const QUERY_TIME_COLUMN_OPTION: QueryColumn = { + name: DTTM_ALIAS, + type: DatasourceType.Query, + is_dttm: false, +}; + export const QueryModeLabel = { [QueryMode.aggregate]: t('Aggregate'), [QueryMode.raw]: t('Raw records'), diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx index 44e0d2fb6381c..ce63590f740bb 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/dndControls.tsx @@ -20,11 +20,14 @@ import { FeatureFlag, isFeatureEnabled, + QueryColumn, + QueryResponse, t, validateNonEmpty, } from '@superset-ui/core'; -import { ExtraControlProps, SharedControlConfig } from '../types'; -import { TIME_COLUMN_OPTION, TIME_FILTER_LABELS } from '../constants'; +import { ExtraControlProps, SharedControlConfig, Dataset } from '../types'; +import { DATASET_TIME_COLUMN_OPTION, TIME_FILTER_LABELS } from '../constants'; +import { QUERY_TIME_COLUMN_OPTION, defineSavedMetrics } from '..'; export const dndGroupByControl: SharedControlConfig<'DndColumnSelect'> = { type: 'DndColumnSelect', @@ -36,15 +39,25 @@ export const dndGroupByControl: SharedControlConfig<'DndColumnSelect'> = { ), mapStateToProps(state, { includeTime }) { const newState: ExtraControlProps = {}; - if (state.datasource) { - const options = state.datasource.columns.filter(c => c.groupby); + const { datasource } = state; + if (datasource?.columns[0]?.hasOwnProperty('groupby')) { + const options = (datasource as Dataset).columns.filter(c => c.groupby); if (includeTime) { - options.unshift(TIME_COLUMN_OPTION); + options.unshift(DATASET_TIME_COLUMN_OPTION); } newState.options = Object.fromEntries( options.map(option => [option.column_name, option]), ); - newState.savedMetrics = state.datasource.metrics || []; + newState.savedMetrics = (datasource as Dataset).metrics || []; + } else { + const options = datasource?.columns; + if (includeTime) { + (options as QueryColumn[])?.unshift(QUERY_TIME_COLUMN_OPTION); + } + newState.options = Object.fromEntries( + (options as QueryColumn[])?.map(option => [option.name, option]), + ); + newState.options = datasource?.columns; } return newState; }, @@ -83,8 +96,10 @@ export const dnd_adhoc_filters: SharedControlConfig<'DndFilterSelect'> = { default: [], description: '', mapStateToProps: ({ datasource, form_data }) => ({ - columns: datasource?.columns.filter(c => c.filterable) || [], - savedMetrics: datasource?.metrics || [], + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns.filter(c => c.filterable) + : datasource?.columns || [], + savedMetrics: defineSavedMetrics(datasource), // current active adhoc metrics selectedMetrics: form_data.metrics || (form_data.metric ? [form_data.metric] : []), @@ -99,8 +114,8 @@ export const dnd_adhoc_metrics: SharedControlConfig<'DndMetricSelect'> = { label: t('Metrics'), validators: [validateNonEmpty], mapStateToProps: ({ datasource }) => ({ - columns: datasource ? datasource.columns : [], - savedMetrics: datasource ? datasource.metrics : [], + columns: datasource?.columns || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, }), @@ -130,7 +145,7 @@ export const dnd_sort_by: SharedControlConfig<'DndMetricSelect'> = { ), mapStateToProps: ({ datasource }) => ({ columns: datasource?.columns || [], - savedMetrics: datasource?.metrics || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, }), @@ -178,14 +193,31 @@ export const dnd_granularity_sqla: typeof dndGroupByControl = { : 'Drop temporal column here', ), mapStateToProps: ({ datasource }) => { - const temporalColumns = datasource?.columns.filter(c => c.is_dttm) ?? []; + if (datasource?.columns[0]?.hasOwnProperty('column_name')) { + const temporalColumns = + (datasource as Dataset)?.columns?.filter(c => c.is_dttm) ?? []; + const options = Object.fromEntries( + temporalColumns.map(option => [option.column_name, option]), + ); + return { + options, + default: + (datasource as Dataset)?.main_dttm_col || + temporalColumns[0]?.column_name || + null, + isTemporal: true, + }; + } + + const sortedQueryColumns = (datasource as QueryResponse)?.columns?.sort( + query => (query?.is_dttm ? -1 : 1), + ); const options = Object.fromEntries( - temporalColumns.map(option => [option.column_name, option]), + sortedQueryColumns.map(option => [option.name, option]), ); return { options, - default: - datasource?.main_dttm_col || temporalColumns[0]?.column_name || null, + default: sortedQueryColumns[0]?.name || null, isTemporal: true, }; }, diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx index 713fbf1c2dd27..5ff32d50b0785 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/index.tsx @@ -45,6 +45,8 @@ import { legacyValidateInteger, validateNonEmpty, ComparisionType, + QueryResponse, + QueryColumn, } from '@superset-ui/core'; import { @@ -55,14 +57,16 @@ import { D3_TIME_FORMAT_DOCS, DEFAULT_TIME_FORMAT, DEFAULT_NUMBER_FORMAT, + defineSavedMetrics, } from '../utils'; -import { TIME_FILTER_LABELS, TIME_COLUMN_OPTION } from '../constants'; +import { TIME_FILTER_LABELS, DATASET_TIME_COLUMN_OPTION } from '../constants'; import { Metric, SharedControlConfig, ColumnMeta, ExtraControlProps, SelectControlConfig, + Dataset, } from '../types'; import { ColumnOption } from '../components/ColumnOption'; @@ -82,6 +86,7 @@ import { dndSeries, dnd_adhoc_metric_2, } from './dndControls'; +import { QUERY_TIME_COLUMN_OPTION } from '..'; const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); const sequentialSchemeRegistry = getSequentialSchemeRegistry(); @@ -131,11 +136,14 @@ const groupByControl: SharedControlConfig<'SelectControl', ColumnMeta> = { promptTextCreator: (label: unknown) => label, mapStateToProps(state, { includeTime }) { const newState: ExtraControlProps = {}; - if (state.datasource) { - const options = state.datasource.columns.filter(c => c.groupby); - if (includeTime) { - options.unshift(TIME_COLUMN_OPTION); - } + const { datasource } = state; + if (datasource?.columns[0]?.hasOwnProperty('groupby')) { + const options = (datasource as Dataset).columns.filter(c => c.groupby); + if (includeTime) options.unshift(DATASET_TIME_COLUMN_OPTION); + newState.options = options; + } else { + const options = (datasource as QueryResponse).columns; + if (includeTime) options.unshift(QUERY_TIME_COLUMN_OPTION); newState.options = options; } return newState; @@ -149,8 +157,8 @@ const metrics: SharedControlConfig<'MetricsControl'> = { label: t('Metrics'), validators: [validateNonEmpty], mapStateToProps: ({ datasource }) => ({ - columns: datasource ? datasource.columns : [], - savedMetrics: datasource ? datasource.metrics : [], + columns: datasource?.columns || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, }), @@ -292,15 +300,23 @@ const granularity_sqla: SharedControlConfig<'SelectControl', ColumnMeta> = { valueRenderer: c => , valueKey: 'column_name', mapStateToProps: state => { - const props: Partial> = {}; - if (state.datasource) { - props.options = state.datasource.columns.filter(c => c.is_dttm); + const props: Partial> = {}; + const { datasource } = state; + if (datasource?.columns[0]?.hasOwnProperty('main_dttm_col')) { + const dataset = datasource as Dataset; + props.options = dataset.columns.filter((c: ColumnMeta) => c.is_dttm); props.default = null; - if (state.datasource.main_dttm_col) { - props.default = state.datasource.main_dttm_col; - } else if (props.options && props.options.length > 0) { - props.default = props.options[0].column_name; + if (dataset.main_dttm_col) { + props.default = dataset.main_dttm_col; + } else if (props?.options) { + props.default = (props.options[0] as ColumnMeta).column_name; } + } else { + const sortedQueryColumns = (datasource as QueryResponse)?.columns?.sort( + query => (query?.is_dttm ? -1 : 1), + ); + props.options = sortedQueryColumns; + if (props?.options) props.default = props.options[0]?.name; } return props; }, @@ -318,7 +334,7 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = { 'engine basis in the Superset source code.', ), mapStateToProps: ({ datasource }) => ({ - choices: datasource?.time_grain_sqla || null, + choices: (datasource as Dataset)?.time_grain_sqla || null, }), }; @@ -335,7 +351,7 @@ const time_range: SharedControlConfig<'DateFilterControl'> = { "using the engine's local timezone. Note one can explicitly set the timezone " + 'per the ISO 8601 format if specifying either the start and/or end time.', ), - mapStateToProps: ({ datasource, form_data }) => ({ + mapStateToProps: ({ datasource }) => ({ datasource, }), }; @@ -401,7 +417,7 @@ const sort_by: SharedControlConfig<'MetricsControl'> = { ), mapStateToProps: ({ datasource }) => ({ columns: datasource?.columns || [], - savedMetrics: datasource?.metrics || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, }), @@ -493,8 +509,10 @@ const adhoc_filters: SharedControlConfig<'AdhocFilterControl'> = { default: [], description: '', mapStateToProps: ({ datasource, form_data }) => ({ - columns: datasource?.columns.filter(c => c.filterable) || [], - savedMetrics: datasource?.metrics || [], + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns.filter(c => c.filterable) + : datasource?.columns || [], + savedMetrics: defineSavedMetrics(datasource), // current active adhoc metrics selectedMetrics: form_data.metrics || (form_data.metric ? [form_data.metric] : []), diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index 7926ffd759e8c..a7bd128be9138 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -25,6 +25,7 @@ import type { JsonValue, Metric, QueryFormData, + QueryResponse, QueryFormMetric, QueryFormColumn, } from '@superset-ui/core'; @@ -53,7 +54,7 @@ export type ColumnMeta = Omit & { id?: number; } & AnyDict; -export interface DatasourceMeta { +export interface Dataset { id: number; type: DatasourceType; columns: ColumnMeta[]; @@ -71,7 +72,7 @@ export interface DatasourceMeta { export interface ControlPanelState { form_data: QueryFormData; - datasource: DatasourceMeta | null; + datasource: Dataset | QueryResponse | null; controls: ControlStateMapping; } @@ -90,7 +91,7 @@ export interface ActionDispatcher< * Mapping of action dispatchers */ export interface ControlPanelActionDispatchers { - setDatasource: ActionDispatcher<[DatasourceMeta]>; + setDatasource: ActionDispatcher<[Dataset]>; } /** diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts index 3725f175e7ffc..0387717ff7e3f 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/columnChoices.ts @@ -16,20 +16,30 @@ * specific language governing permissions and limitations * under the License. */ -import { DatasourceMeta } from '../types'; +import { QueryResponse } from '@superset-ui/core'; +import { Dataset } from '../types'; /** * Convert Datasource columns to column choices */ export default function columnChoices( - datasource?: DatasourceMeta | null, + datasource?: Dataset | QueryResponse | null, ): [string, string][] { + if (datasource?.columns[0]?.hasOwnProperty('column_name')) { + return ( + (datasource as Dataset)?.columns + .map((col): [string, string] => [ + col.column_name, + col.verbose_name || col.column_name, + ]) + .sort((opt1, opt2) => + opt1[1].toLowerCase() > opt2[1].toLowerCase() ? 1 : -1, + ) || [] + ); + } return ( - datasource?.columns - .map((col): [string, string] => [ - col.column_name, - col.verbose_name || col.column_name, - ]) + (datasource as QueryResponse)?.columns + .map((col): [string, string] => [col.name, col.name]) .sort((opt1, opt2) => opt1[1].toLowerCase() > opt2[1].toLowerCase() ? 1 : -1, ) || [] diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/defineSavedMetrics.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/defineSavedMetrics.ts new file mode 100644 index 0000000000000..431b6cb4be3d0 --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/defineSavedMetrics.ts @@ -0,0 +1,29 @@ +/* eslint-disable camelcase */ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { QueryResponse, DEFAULT_METRICS } from '@superset-ui/core'; +import { Dataset } from '../types'; + +export const defineSavedMetrics = ( + datasource: Dataset | QueryResponse | null, +) => + datasource?.hasOwnProperty('metrics') + ? (datasource as Dataset)?.metrics || [] + : DEFAULT_METRICS; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts index bfb5b5e824e64..11c03e4ca1fac 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/index.ts @@ -22,3 +22,4 @@ export * from './expandControlConfig'; export * from './getColorFormatters'; export { default as mainMetric } from './mainMetric'; export { default as columnChoices } from './columnChoices'; +export * from './defineSavedMetrics'; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx index d4e34c79c7fea..3224bbcc26d5c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/columnChoices.test.tsx @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { DatasourceType } from '@superset-ui/core'; +import { DatasourceType, QueryResponse, testQuery } from '@superset-ui/core'; import { columnChoices } from '../../src'; describe('columnChoices()', () => { - it('should convert columns to choices', () => { + it('should convert columns to choices when source is a Dataset', () => { expect( columnChoices({ id: 1, @@ -56,4 +56,13 @@ describe('columnChoices()', () => { it('should return empty array when no columns', () => { expect(columnChoices(undefined)).toEqual([]); }); + + it('should convert columns to choices when source is a Query', () => { + expect(columnChoices(testQuery as QueryResponse)).toEqual([ + ['Column 1', 'Column 1'], + ['Column 2', 'Column 2'], + ['Column 3', 'Column 3'], + ]); + expect.anything(); + }); }); diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx new file mode 100644 index 0000000000000..59036bf60495d --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/defineSavedMetrics.test.tsx @@ -0,0 +1,60 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + DatasourceType, + DEFAULT_METRICS, + QueryResponse, + testQuery, +} from '@superset-ui/core'; +import { defineSavedMetrics } from '@superset-ui/chart-controls'; + +describe('defineSavedMetrics', () => { + it('defines saved metrics if source is a Dataset', () => { + expect( + defineSavedMetrics({ + id: 1, + metrics: [ + { + metric_name: 'COUNT(*) non-default-dataset-metric', + expression: 'COUNT(*) non-default-dataset-metric', + }, + ], + type: DatasourceType.Table, + main_dttm_col: 'test', + time_grain_sqla: 'P1D', + columns: [], + verbose_map: {}, + column_format: {}, + datasource_name: 'my_datasource', + description: 'this is my datasource', + }), + ).toEqual([ + { + metric_name: 'COUNT(*) non-default-dataset-metric', + expression: 'COUNT(*) non-default-dataset-metric', + }, + ]); + }); + + it('returns default saved metrics if souce is a Query', () => { + expect(defineSavedMetrics(testQuery as QueryResponse)).toEqual( + DEFAULT_METRICS, + ); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts index 073bdf90c1b5d..03916dee5ebb6 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts @@ -22,6 +22,10 @@ import { Metric } from './Metric'; export enum DatasourceType { Table = 'table', Druid = 'druid', + Query = 'query', + Dataset = 'dataset', + SlTable = 'sl_table', + SavedQuery = 'saved_query', } /** @@ -43,4 +47,11 @@ export interface Datasource { }; } +export const DEFAULT_METRICS = [ + { + metric_name: 'COUNT(*)', + expression: 'COUNT(*)', + }, +]; + export default {}; diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts index c9961cc7cb415..d4b672a7a3ad9 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts @@ -165,4 +165,214 @@ export interface QueryContext { form_data?: QueryFormData; } +export const ErrorTypeEnum = { + // Frontend errors + FRONTEND_CSRF_ERROR: 'FRONTEND_CSRF_ERROR', + FRONTEND_NETWORK_ERROR: 'FRONTEND_NETWORK_ERROR', + FRONTEND_TIMEOUT_ERROR: 'FRONTEND_TIMEOUT_ERROR', + + // DB Engine errors + GENERIC_DB_ENGINE_ERROR: 'GENERIC_DB_ENGINE_ERROR', + COLUMN_DOES_NOT_EXIST_ERROR: 'COLUMN_DOES_NOT_EXIST_ERROR', + TABLE_DOES_NOT_EXIST_ERROR: 'TABLE_DOES_NOT_EXIST_ERROR', + SCHEMA_DOES_NOT_EXIST_ERROR: 'SCHEMA_DOES_NOT_EXIST_ERROR', + CONNECTION_INVALID_USERNAME_ERROR: 'CONNECTION_INVALID_USERNAME_ERROR', + CONNECTION_INVALID_PASSWORD_ERROR: 'CONNECTION_INVALID_PASSWORD_ERROR', + CONNECTION_INVALID_HOSTNAME_ERROR: 'CONNECTION_INVALID_HOSTNAME_ERROR', + CONNECTION_PORT_CLOSED_ERROR: 'CONNECTION_PORT_CLOSED_ERROR', + CONNECTION_INVALID_PORT_ERROR: 'CONNECTION_INVALID_PORT_ERROR', + CONNECTION_HOST_DOWN_ERROR: 'CONNECTION_HOST_DOWN_ERROR', + CONNECTION_ACCESS_DENIED_ERROR: 'CONNECTION_ACCESS_DENIED_ERROR', + CONNECTION_UNKNOWN_DATABASE_ERROR: 'CONNECTION_UNKNOWN_DATABASE_ERROR', + CONNECTION_DATABASE_PERMISSIONS_ERROR: + 'CONNECTION_DATABASE_PERMISSIONS_ERROR', + CONNECTION_MISSING_PARAMETERS_ERRORS: 'CONNECTION_MISSING_PARAMETERS_ERRORS', + OBJECT_DOES_NOT_EXIST_ERROR: 'OBJECT_DOES_NOT_EXIST_ERROR', + SYNTAX_ERROR: 'SYNTAX_ERROR', + + // Viz errors + VIZ_GET_DF_ERROR: 'VIZ_GET_DF_ERROR', + UNKNOWN_DATASOURCE_TYPE_ERROR: 'UNKNOWN_DATASOURCE_TYPE_ERROR', + FAILED_FETCHING_DATASOURCE_INFO_ERROR: + 'FAILED_FETCHING_DATASOURCE_INFO_ERROR', + + // Security access errors + TABLE_SECURITY_ACCESS_ERROR: 'TABLE_SECURITY_ACCESS_ERROR', + DATASOURCE_SECURITY_ACCESS_ERROR: 'DATASOURCE_SECURITY_ACCESS_ERROR', + DATABASE_SECURITY_ACCESS_ERROR: 'DATABASE_SECURITY_ACCESS_ERROR', + QUERY_SECURITY_ACCESS_ERROR: 'QUERY_SECURITY_ACCESS_ERROR', + MISSING_OWNERSHIP_ERROR: 'MISSING_OWNERSHIP_ERROR', + + // Other errors + BACKEND_TIMEOUT_ERROR: 'BACKEND_TIMEOUT_ERROR', + DATABASE_NOT_FOUND_ERROR: 'DATABASE_NOT_FOUND_ERROR', + + // Sqllab error + MISSING_TEMPLATE_PARAMS_ERROR: 'MISSING_TEMPLATE_PARAMS_ERROR', + INVALID_TEMPLATE_PARAMS_ERROR: 'INVALID_TEMPLATE_PARAMS_ERROR', + RESULTS_BACKEND_NOT_CONFIGURED_ERROR: 'RESULTS_BACKEND_NOT_CONFIGURED_ERROR', + DML_NOT_ALLOWED_ERROR: 'DML_NOT_ALLOWED_ERROR', + INVALID_CTAS_QUERY_ERROR: 'INVALID_CTAS_QUERY_ERROR', + INVALID_CVAS_QUERY_ERROR: 'INVALID_CVAS_QUERY_ERROR', + SQLLAB_TIMEOUT_ERROR: 'SQLLAB_TIMEOUT_ERROR', + RESULTS_BACKEND_ERROR: 'RESULTS_BACKEND_ERROR', + ASYNC_WORKERS_ERROR: 'ASYNC_WORKERS_ERROR', + + // Generic errors + GENERIC_COMMAND_ERROR: 'GENERIC_COMMAND_ERROR', + GENERIC_BACKEND_ERROR: 'GENERIC_BACKEND_ERROR', + + // API errors + INVALID_PAYLOAD_FORMAT_ERROR: 'INVALID_PAYLOAD_FORMAT_ERROR', + INVALID_PAYLOAD_SCHEMA_ERROR: 'INVALID_PAYLOAD_SCHEMA_ERROR', +} as const; + +type ValueOf = T[keyof T]; + +export type ErrorType = ValueOf; + +// Keep in sync with superset/views/errors.py +export type ErrorLevel = 'info' | 'warning' | 'error'; + +export type ErrorSource = 'dashboard' | 'explore' | 'sqllab'; + +export type SupersetError | null> = { + error_type: ErrorType; + extra: ExtraType; + level: ErrorLevel; + message: string; +}; + +export const CtasEnum = { + TABLE: 'TABLE', + VIEW: 'VIEW', +}; + +export type QueryColumn = { + name: string; + type: string | null; + is_dttm: boolean; +}; + +export type QueryState = + | 'stopped' + | 'failed' + | 'pending' + | 'running' + | 'scheduled' + | 'success' + | 'fetching' + | 'timed_out'; + +export type Query = { + cached: boolean; + ctas: boolean; + ctas_method?: keyof typeof CtasEnum; + dbId: number; + errors?: SupersetError[]; + errorMessage: string | null; + extra: { + progress: string | null; + }; + id: string; + isDataPreview: boolean; + link?: string; + progress: number; + resultsKey: string | null; + schema?: string; + sql: string; + sqlEditorId: string; + state: QueryState; + tab: string | null; + tempSchema: string | null; + tempTable: string; + trackingUrl: string | null; + templateParams: any; + rows: number; + queryLimit: number; + limitingFactor: string; + endDttm: number; + duration: string; + startDttm: number; + time: Record; + user: Record; + userId: number; + db: Record; + started: string; + querylink: Record; + queryId: number; + executedSql: string; + output: string | Record; + actions: Record; + type: DatasourceType.Query; + columns: QueryColumn[]; +}; + +export type QueryResults = { + results: { + displayLimitReached: boolean; + columns: QueryColumn[]; + data: Record[]; + expanded_columns: QueryColumn[]; + selected_columns: QueryColumn[]; + query: { limit: number }; + }; +}; + +export type QueryResponse = Query & QueryResults; + +export const testQuery: Query = { + id: 'clientId2353', + dbId: 1, + sql: 'SELECT * FROM something', + sqlEditorId: 'dfsadfs', + tab: 'unimportant', + tempTable: '', + ctas: false, + cached: false, + errorMessage: null, + extra: { progress: null }, + isDataPreview: false, + progress: 0, + resultsKey: null, + state: 'success', + tempSchema: null, + trackingUrl: null, + templateParams: null, + rows: 42, + queryLimit: 100, + limitingFactor: '', + endDttm: 1476910579693, + duration: '', + startDttm: 1476910566092.96, + time: {}, + user: {}, + userId: 1, + db: {}, + started: '', + querylink: {}, + queryId: 1, + executedSql: '', + output: '', + actions: {}, + type: DatasourceType.Query, + columns: [ + { + name: 'Column 1', + type: DatasourceType.Query, + is_dttm: false, + }, + { + name: 'Column 2', + type: DatasourceType.Query, + is_dttm: true, + }, + { + name: 'Column 3', + type: DatasourceType.Query, + is_dttm: false, + }, + ], +}; + export default {}; diff --git a/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx b/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx index 11a0b88987883..22d5c8ce4eeba 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx +++ b/superset-frontend/plugins/legacy-plugin-chart-event-flow/src/controlPanel.tsx @@ -109,7 +109,7 @@ const config: ControlPanelConfig = { valueKey: 'column_name', allowAll: true, mapStateToProps: state => ({ - options: state.datasource ? state.datasource.columns : [], + options: state.datasource?.columns || [], }), commaChoosesOption: false, freeForm: true, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.jsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.jsx index 03092e7316af6..9e6d2b0d84665 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.jsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/utilities/controls.jsx @@ -17,7 +17,7 @@ * under the License. */ export function columnChoices(datasource) { - if (datasource && datasource.columns) { + if (datasource?.columns) { return datasource.columns .map(col => [col.column_name, col.verbose_name || col.column_name]) .sort((opt1, opt2) => diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx index fd24bb75fbb20..3aec61dc4060a 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx @@ -21,6 +21,8 @@ import { ControlSetItem, ExtraControlProps, sharedControls, + Dataset, + ColumnMeta, } from '@superset-ui/chart-controls'; import { ensureIsArray, @@ -63,10 +65,12 @@ const dndAllColumns: typeof sharedControls.groupby = { mapStateToProps({ datasource, controls }, controlState) { const newState: ExtraControlProps = {}; if (datasource) { - const options = datasource.columns; - newState.options = Object.fromEntries( - options.map(option => [option.column_name, option]), - ); + if (datasource?.columns[0]?.hasOwnProperty('filterable')) { + const options = (datasource as Dataset).columns; + newState.options = Object.fromEntries( + options.map((option: ColumnMeta) => [option.column_name, option]), + ); + } else newState.options = datasource.columns; } newState.queryMode = getQueryMode(controls); newState.externalValidationErrors = diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx index 7df35e6a668cb..96eab55f92974 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx @@ -21,6 +21,9 @@ import { ControlSetItem, ControlState, sharedControls, + Dataset, + ColumnMeta, + defineSavedMetrics, } from '@superset-ui/chart-controls'; import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; import { getQueryMode, isAggMode, validateAggControlValues } from './shared'; @@ -36,7 +39,7 @@ const percentMetrics: typeof sharedControls.metrics = { resetOnHide: false, mapStateToProps: ({ datasource, controls }, controlState) => ({ columns: datasource?.columns || [], - savedMetrics: datasource?.metrics || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, queryMode: getQueryMode(controls), @@ -74,8 +77,12 @@ export const metricsControlSetItem: ControlSetItem = { { controls, datasource, form_data }: ControlPanelState, controlState: ControlState, ) => ({ - columns: datasource?.columns.filter(c => c.filterable) || [], - savedMetrics: datasource?.metrics || [], + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns?.filter( + (c: ColumnMeta) => c.filterable, + ) + : datasource?.columns, + savedMetrics: defineSavedMetrics(datasource), // current active adhoc metrics selectedMetrics: form_data.metrics || (form_data.metric ? [form_data.metric] : []), diff --git a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx index b7c8f8e2406bf..93002bd49b361 100644 --- a/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx +++ b/superset-frontend/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlSetItem } from '@superset-ui/chart-controls'; +import { ControlSetItem, Dataset } from '@superset-ui/chart-controls'; import { t } from '@superset-ui/core'; import { isAggMode, isRawMode } from './shared'; @@ -29,7 +29,9 @@ export const orderByControlSetItem: ControlSetItem = { multi: true, default: [], mapStateToProps: ({ datasource }) => ({ - choices: datasource?.order_by_choices || [], + choices: datasource?.hasOwnProperty('order_by_choices') + ? (datasource as Dataset)?.order_by_choices + : datasource?.columns || [], }), visibility: isRawMode, resetOnHide: false, diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx index acbdb04bc75bf..ce09ae1345ae1 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx @@ -31,6 +31,7 @@ import { sections, sharedControls, emitFilterControl, + Dataset, } from '@superset-ui/chart-controls'; import { MetricsLayoutEnum } from '../types'; @@ -350,7 +351,11 @@ const config: ControlPanelConfig = { const values = (explore?.controls?.metrics?.value as QueryFormMetric[]) ?? []; - const verboseMap = explore?.datasource?.verbose_map ?? {}; + const verboseMap = explore?.datasource?.hasOwnProperty( + 'verbose_map', + ) + ? (explore?.datasource as Dataset)?.verbose_map + : explore?.datasource?.columns ?? {}; const metricColumn = values.map(value => { if (typeof value === 'string') { return { value, label: verboseMap[value] ?? value }; diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 4f5530f6b9025..427f671c1a68a 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -44,6 +44,9 @@ import { ExtraControlProps, ControlState, emitFilterControl, + Dataset, + ColumnMeta, + defineSavedMetrics, } from '@superset-ui/chart-controls'; import i18n from './i18n'; @@ -127,12 +130,12 @@ const dnd_all_columns: typeof sharedControls.groupby = { default: [], mapStateToProps({ datasource, controls }, controlState) { const newState: ExtraControlProps = {}; - if (datasource) { - const options = datasource.columns; + if (datasource?.columns[0]?.hasOwnProperty('column_name')) { + const options = (datasource as Dataset).columns; newState.options = Object.fromEntries( - options.map(option => [option.column_name, option]), + options.map((option: ColumnMeta) => [option.column_name, option]), ); - } + } else newState.options = datasource?.columns; newState.queryMode = getQueryMode(controls); newState.externalValidationErrors = isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0 @@ -155,7 +158,7 @@ const percent_metrics: typeof sharedControls.metrics = { resetOnHide: false, mapStateToProps: ({ datasource, controls }, controlState) => ({ columns: datasource?.columns || [], - savedMetrics: datasource?.metrics || [], + savedMetrics: defineSavedMetrics(datasource), datasource, datasourceType: datasource?.type, queryMode: getQueryMode(controls), @@ -229,8 +232,12 @@ const config: ControlPanelConfig = { { controls, datasource, form_data }: ControlPanelState, controlState: ControlState, ) => ({ - columns: datasource?.columns.filter(c => c.filterable) || [], - savedMetrics: datasource?.metrics || [], + columns: datasource?.columns[0]?.hasOwnProperty('filterable') + ? (datasource as Dataset)?.columns?.filter( + (c: ColumnMeta) => c.filterable, + ) + : datasource?.columns, + savedMetrics: defineSavedMetrics(datasource), // current active adhoc metrics selectedMetrics: form_data.metrics || @@ -280,7 +287,9 @@ const config: ControlPanelConfig = { multi: true, default: [], mapStateToProps: ({ datasource }) => ({ - choices: datasource?.order_by_choices || [], + choices: datasource?.hasOwnProperty('order_by_choices') + ? (datasource as Dataset)?.order_by_choices + : datasource?.columns || [], }), visibility: isRawMode, resetOnHide: false, @@ -505,7 +514,11 @@ const config: ControlPanelConfig = { return true; }, mapStateToProps(explore, _, chart) { - const verboseMap = explore?.datasource?.verbose_map ?? {}; + const verboseMap = explore?.datasource?.hasOwnProperty( + 'verbose_map', + ) + ? (explore?.datasource as Dataset)?.verbose_map + : explore?.datasource?.columns ?? {}; const { colnames, coltypes } = chart?.queriesResponse?.[0] ?? {}; const numericColumns = diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx index 9adb5dc40261a..fbcdc15bc5a37 100644 --- a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx @@ -22,7 +22,7 @@ import { t } from '@superset-ui/core'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import Button from 'src/components/Button'; import { exploreChart } from 'src/explore/exploreUtils'; -import { RootState } from 'src/SqlLab/types'; +import { SqlLabRootState } from 'src/SqlLab/types'; interface ExploreCtasResultsButtonProps { actions: { @@ -45,7 +45,7 @@ const ExploreCtasResultsButton = ({ }: ExploreCtasResultsButtonProps) => { const { createCtasDatasource, addInfoToast, addDangerToast } = actions; const errorMessage = useSelector( - (state: RootState) => state.sqlLab.errorMessage, + (state: SqlLabRootState) => state.sqlLab.errorMessage, ); const buildVizOptions = { diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx index c41ace1ead01b..86f28069209da 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/index.tsx @@ -18,12 +18,11 @@ */ import React from 'react'; import { EmptyStateMedium } from 'src/components/EmptyState'; -import { t, styled } from '@superset-ui/core'; -import { Query } from 'src/SqlLab/types'; +import { t, styled, QueryResponse } from '@superset-ui/core'; import QueryTable from 'src/SqlLab/components/QueryTable'; interface QueryHistoryProps { - queries: Query[]; + queries: QueryResponse[]; actions: { queryEditorSetAndSaveSql: Function; cloneQueryToNewTab: Function; diff --git a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx index e1e994133a9cc..635603e255f14 100644 --- a/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx +++ b/superset-frontend/src/SqlLab/components/QuerySearch/index.tsx @@ -19,7 +19,7 @@ import React, { useState, useEffect } from 'react'; import Button from 'src/components/Button'; import Select from 'src/components/Select'; -import { styled, t, SupersetClient } from '@superset-ui/core'; +import { styled, t, SupersetClient, QueryResponse } from '@superset-ui/core'; import { debounce } from 'lodash'; import Loading from 'src/components/Loading'; import { @@ -29,7 +29,6 @@ import { epochTimeXYearsAgo, } from 'src/utils/dates'; import AsyncSelect from 'src/components/AsyncSelect'; -import { Query } from 'src/SqlLab/types'; import { STATUS_OPTIONS, TIME_OPTIONS } from 'src/SqlLab/constants'; import QueryTable from '../QueryTable'; @@ -85,7 +84,7 @@ function QuerySearch({ actions, displayLimit }: QuerySearchProps) { const [from, setFrom] = useState('28 days ago'); const [to, setTo] = useState('now'); const [status, setStatus] = useState('success'); - const [queriesArray, setQueriesArray] = useState([]); + const [queriesArray, setQueriesArray] = useState([]); const [queriesLoading, setQueriesLoading] = useState(true); const getTimeFromSelection = (selection: string) => { diff --git a/superset-frontend/src/SqlLab/components/QueryStateLabel/index.tsx b/superset-frontend/src/SqlLab/components/QueryStateLabel/index.tsx index b2704843dfaec..6168a2af713a4 100644 --- a/superset-frontend/src/SqlLab/components/QueryStateLabel/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryStateLabel/index.tsx @@ -19,7 +19,7 @@ import React from 'react'; import Label from 'src/components/Label'; import { STATE_TYPE_MAP } from 'src/SqlLab/constants'; -import { Query } from 'src/SqlLab/types'; +import { Query } from '@superset-ui/core'; interface QueryStateLabelProps { query: Query; diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index 90d2219497583..54edb7f97e8d3 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -21,14 +21,14 @@ import moment from 'moment'; import Card from 'src/components/Card'; import ProgressBar from 'src/components/ProgressBar'; import Label from 'src/components/Label'; -import { t, useTheme } from '@superset-ui/core'; +import { t, useTheme, QueryResponse } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import TableView from 'src/components/TableView'; import Button from 'src/components/Button'; import { fDuration } from 'src/utils/dates'; import Icons from 'src/components/Icons'; import { Tooltip } from 'src/components/Tooltip'; -import { Query, RootState } from 'src/SqlLab/types'; +import { SqlLabRootState } from 'src/SqlLab/types'; import ModalTrigger from 'src/components/ModalTrigger'; import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes'; import ResultSet from '../ResultSet'; @@ -36,7 +36,7 @@ import HighlightedSql from '../HighlightedSql'; import { StaticPosition, verticalAlign, StyledTooltip } from './styles'; interface QueryTableQuery - extends Omit { + extends Omit { state?: Record; sql?: Record; progress?: Record; @@ -52,7 +52,7 @@ interface QueryTableProps { clearQueryResults: Function; removeQuery: Function; }; - queries?: Query[]; + queries?: QueryResponse[]; onUserClicked?: Function; onDbClicked?: Function; displayLimit: number; @@ -91,7 +91,7 @@ const QueryTable = ({ [columns], ); - const user = useSelector(state => state.sqlLab.user); + const user = useSelector(state => state.sqlLab.user); const { queryEditorSetAndSaveSql, @@ -102,15 +102,15 @@ const QueryTable = ({ } = actions; const data = useMemo(() => { - const restoreSql = (query: Query) => { + const restoreSql = (query: QueryResponse) => { queryEditorSetAndSaveSql({ id: query.sqlEditorId }, query.sql); }; - const openQueryInNewTab = (query: Query) => { + const openQueryInNewTab = (query: QueryResponse) => { cloneQueryToNewTab(query, true); }; - const openAsyncResults = (query: Query, displayLimit: number) => { + const openAsyncResults = (query: QueryResponse, displayLimit: number) => { fetchQueryResults(query, displayLimit); }; diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index 39c897c8d4e3c..339303ba57928 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -19,19 +19,9 @@ import React, { CSSProperties } from 'react'; import ButtonGroup from 'src/components/ButtonGroup'; import Alert from 'src/components/Alert'; -import moment from 'moment'; -import { RadioChangeEvent } from 'src/components'; import Button from 'src/components/Button'; import shortid from 'shortid'; -import rison from 'rison'; -import { - styled, - t, - makeApi, - SupersetClient, - JsonResponse, -} from '@superset-ui/core'; -import { debounce } from 'lodash'; +import { styled, t, QueryResponse } from '@superset-ui/core'; import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace'; import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; @@ -42,26 +32,12 @@ import FilterableTable, { } from 'src/components/FilterableTable'; import CopyToClipboard from 'src/components/CopyToClipboard'; import { prepareCopyToClipboardTabularData } from 'src/utils/common'; -import { exploreChart } from 'src/explore/exploreUtils'; import { CtasEnum } from 'src/SqlLab/actions/sqlLab'; -import { Query } from 'src/SqlLab/types'; import ExploreCtasResultsButton from '../ExploreCtasResultsButton'; import ExploreResultsButton from '../ExploreResultsButton'; import HighlightedSql from '../HighlightedSql'; import QueryStateLabel from '../QueryStateLabel'; -enum DatasetRadioState { - SAVE_NEW = 1, - OVERWRITE_DATASET = 2, -} - -const EXPLORE_CHART_DEFAULT = { - metrics: [], - groupby: [], - time_range: 'No filter', - viz_type: 'table', -}; - enum LIMITING_FACTOR { QUERY = 'QUERY', QUERY_AND_DROPDOWN = 'QUERY_AND_DROPDOWN', @@ -71,19 +47,6 @@ enum LIMITING_FACTOR { const LOADING_STYLES: CSSProperties = { position: 'relative', minHeight: 100 }; -interface DatasetOwner { - first_name: string; - id: number; - last_name: string; - username: string; -} - -interface DatasetOptionAutocomplete { - value: string; - datasetId: number; - owners: [DatasetOwner]; -} - interface ResultSetProps { showControls?: boolean; actions: Record; @@ -92,7 +55,7 @@ interface ResultSetProps { database?: Record; displayLimit: number; height: number; - query: Query; + query: QueryResponse; search?: boolean; showSql?: boolean; visualize?: boolean; @@ -105,12 +68,6 @@ interface ResultSetState { showExploreResultsButton: boolean; data: Record[]; showSaveDatasetModal: boolean; - newSaveDatasetName: string; - saveDatasetRadioBtnState: number; - shouldOverwriteDataSet: boolean; - datasetToOverwrite: Record; - saveModalAutocompleteValue: string; - userDatasetOptions: DatasetOptionAutocomplete[]; alertIsOpen: boolean; } @@ -145,44 +102,6 @@ const ResultSetErrorMessage = styled.div` padding-top: ${({ theme }) => 4 * theme.gridUnit}px; `; -const ResultSetRowsReturned = styled.span` - white-space: nowrap; - text-overflow: ellipsis; - width: 100%; - overflow: hidden; - display: inline-block; -`; - -const LimitMessage = styled.span` - color: ${({ theme }) => theme.colors.secondary.light1}; - margin-left: ${({ theme }) => theme.gridUnit * 2}px; -`; - -const updateDataset = async ( - dbId: number, - datasetId: number, - sql: string, - columns: Array>, - owners: [number], - overrideColumns: boolean, -) => { - const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`; - const headers = { 'Content-Type': 'application/json' }; - const body = JSON.stringify({ - sql, - columns, - owners, - database_id: dbId, - }); - - const data: JsonResponse = await SupersetClient.put({ - endpoint, - headers, - body, - }); - return data.json.result; -}; - export default class ResultSet extends React.PureComponent< ResultSetProps, ResultSetState @@ -203,12 +122,6 @@ export default class ResultSet extends React.PureComponent< showExploreResultsButton: false, data: [], showSaveDatasetModal: false, - newSaveDatasetName: this.getDefaultDatasetName(), - saveDatasetRadioBtnState: DatasetRadioState.SAVE_NEW, - shouldOverwriteDataSet: false, - datasetToOverwrite: {}, - saveModalAutocompleteValue: '', - userDatasetOptions: [], alertIsOpen: false, }; this.changeSearch = this.changeSearch.bind(this); @@ -217,31 +130,11 @@ export default class ResultSet extends React.PureComponent< this.reFetchQueryResults = this.reFetchQueryResults.bind(this); this.toggleExploreResultsButton = this.toggleExploreResultsButton.bind(this); - this.handleSaveInDataset = this.handleSaveInDataset.bind(this); - this.handleHideSaveModal = this.handleHideSaveModal.bind(this); - this.handleDatasetNameChange = this.handleDatasetNameChange.bind(this); - this.handleSaveDatasetRadioBtnState = - this.handleSaveDatasetRadioBtnState.bind(this); - this.handleOverwriteCancel = this.handleOverwriteCancel.bind(this); - this.handleOverwriteDataset = this.handleOverwriteDataset.bind(this); - this.handleOverwriteDatasetOption = - this.handleOverwriteDatasetOption.bind(this); - this.handleSaveDatasetModalSearch = debounce( - this.handleSaveDatasetModalSearch.bind(this), - 1000, - ); - this.handleFilterAutocompleteOption = - this.handleFilterAutocompleteOption.bind(this); - this.handleOnChangeAutoComplete = - this.handleOnChangeAutoComplete.bind(this); - this.handleExploreBtnClick = this.handleExploreBtnClick.bind(this); } async componentDidMount() { // only do this the first time the component is rendered/mounted this.reRunQueryIfSessionTimeoutErrorOnMount(); - const userDatasetsOwned = await this.getUserDatasets(); - this.setState({ userDatasetOptions: userDatasetsOwned }); } UNSAFE_componentWillReceiveProps(nextProps: ResultSetProps) { @@ -273,186 +166,7 @@ export default class ResultSet extends React.PureComponent< } }; - getDefaultDatasetName = () => - `${this.props.query.tab} ${moment().format('MM/DD/YYYY HH:mm:ss')}`; - - handleOnChangeAutoComplete = () => { - this.setState({ datasetToOverwrite: {} }); - }; - - handleOverwriteDataset = async () => { - const { sql, results, dbId } = this.props.query; - const { datasetToOverwrite } = this.state; - - await updateDataset( - dbId, - datasetToOverwrite.datasetId, - sql, - results.selected_columns.map(d => ({ - column_name: d.name, - type: d.type, - is_dttm: d.is_dttm, - })), - datasetToOverwrite.owners.map((o: DatasetOwner) => o.id), - true, - ); - - this.setState({ - showSaveDatasetModal: false, - shouldOverwriteDataSet: false, - datasetToOverwrite: {}, - newSaveDatasetName: this.getDefaultDatasetName(), - }); - - exploreChart({ - ...EXPLORE_CHART_DEFAULT, - datasource: `${datasetToOverwrite.datasetId}__table`, - all_columns: results.selected_columns.map(d => d.name), - }); - }; - - handleSaveInDataset = () => { - // if user wants to overwrite a dataset we need to prompt them - if ( - this.state.saveDatasetRadioBtnState === - DatasetRadioState.OVERWRITE_DATASET - ) { - this.setState({ shouldOverwriteDataSet: true }); - return; - } - - const { schema, sql, dbId } = this.props.query; - let { templateParams } = this.props.query; - const selectedColumns = this.props.query?.results?.selected_columns || []; - - // The filters param is only used to test jinja templates. - // Remove the special filters entry from the templateParams - // before saving the dataset. - if (templateParams) { - const p = JSON.parse(templateParams); - /* eslint-disable-next-line no-underscore-dangle */ - if (p._filters) { - /* eslint-disable-next-line no-underscore-dangle */ - delete p._filters; - templateParams = JSON.stringify(p); - } - } - - this.props.actions - .createDatasource({ - schema, - sql, - dbId, - templateParams, - datasourceName: this.state.newSaveDatasetName, - columns: selectedColumns, - }) - .then((data: { table_id: number }) => { - exploreChart({ - datasource: `${data.table_id}__table`, - metrics: [], - groupby: [], - time_range: 'No filter', - viz_type: 'table', - all_columns: selectedColumns.map(c => c.name), - row_limit: 1000, - }); - }) - .catch(() => { - this.props.actions.addDangerToast( - t('An error occurred saving dataset'), - ); - }); - - this.setState({ - showSaveDatasetModal: false, - newSaveDatasetName: this.getDefaultDatasetName(), - }); - }; - - handleOverwriteDatasetOption = ( - _data: string, - option: Record, - ) => { - this.setState({ datasetToOverwrite: option }); - }; - - handleDatasetNameChange = (e: React.FormEvent) => { - // @ts-expect-error - this.setState({ newSaveDatasetName: e.target.value }); - }; - - handleHideSaveModal = () => { - this.setState({ - showSaveDatasetModal: false, - shouldOverwriteDataSet: false, - }); - }; - - handleSaveDatasetRadioBtnState = (e: RadioChangeEvent) => { - this.setState({ saveDatasetRadioBtnState: Number(e.target.value) }); - }; - - handleOverwriteCancel = () => { - this.setState({ shouldOverwriteDataSet: false, datasetToOverwrite: {} }); - }; - - handleExploreBtnClick = () => { - this.setState({ - showSaveDatasetModal: true, - }); - }; - - getUserDatasets = async (searchText = '') => { - // Making sure that autocomplete input has a value before rendering the dropdown - // Transforming the userDatasetsOwned data for SaveModalComponent) - const { userId } = this.props.user; - if (userId) { - const queryParams = rison.encode({ - filters: [ - { - col: 'table_name', - opr: 'ct', - value: searchText, - }, - { - col: 'owners', - opr: 'rel_m_m', - value: userId, - }, - ], - order_column: 'changed_on_delta_humanized', - order_direction: 'desc', - }); - - const response = await makeApi({ - method: 'GET', - endpoint: '/api/v1/dataset', - })(`q=${queryParams}`); - - return response.result.map( - (r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({ - value: r.table_name, - datasetId: r.id, - owners: r.owners, - }), - ); - } - - return null; - }; - - handleSaveDatasetModalSearch = async (searchText: string) => { - const userDatasetsOwned = await this.getUserDatasets(searchText); - this.setState({ userDatasetOptions: userDatasetsOwned }); - }; - - handleFilterAutocompleteOption = ( - inputValue: string, - option: { value: string; datasetId: number }, - ) => option.value.toLowerCase().includes(inputValue.toLowerCase()); - - clearQueryResults(query: Query) { + clearQueryResults(query: QueryResponse) { this.props.actions.clearQueryResults(query); } @@ -477,11 +191,11 @@ export default class ResultSet extends React.PureComponent< this.setState({ searchText: event.target.value }); } - fetchResults(query: Query) { + fetchResults(query: QueryResponse) { this.props.actions.fetchQueryResults(query, this.props.displayLimit); } - reFetchQueryResults(query: Query) { + reFetchQueryResults(query: QueryResponse) { this.props.actions.reFetchQueryResults(query); } @@ -503,55 +217,31 @@ export default class ResultSet extends React.PureComponent< } const { columns } = this.props.query.results; // Added compute logic to stop user from being able to Save & Explore - const { - saveDatasetRadioBtnState, - newSaveDatasetName, - datasetToOverwrite, - saveModalAutocompleteValue, - shouldOverwriteDataSet, - userDatasetOptions, - showSaveDatasetModal, - } = this.state; - const disableSaveAndExploreBtn = - (saveDatasetRadioBtnState === DatasetRadioState.SAVE_NEW && - newSaveDatasetName.length === 0) || - (saveDatasetRadioBtnState === DatasetRadioState.OVERWRITE_DATASET && - Object.keys(datasetToOverwrite).length === 0 && - saveModalAutocompleteValue.length === 0); + const { showSaveDatasetModal } = this.state; + const { query } = this.props; return ( this.setState({ showSaveDatasetModal: false })} + buttonTextOnSave={t('Save & Explore')} + buttonTextOnOverwrite={t('Overwrite & Explore')} + modalDescription={t( + 'Save this query as a virtual dataset to continue exploring', + )} + datasource={query} /> {this.props.visualize && this.props.database?.allows_virtual_table_explore && ( this.setState({ showSaveDatasetModal: true })} /> )} {this.props.csv && ( - )} @@ -587,10 +277,6 @@ export default class ResultSet extends React.PureComponent< return
; } - onAlertClose = () => { - this.setState({ alertIsOpen: false }); - }; - renderRowsReturned() { const { results, rows, queryLimit, limitingFactor } = this.props.query; let limitMessage; @@ -646,17 +332,17 @@ export default class ResultSet extends React.PureComponent< return ( {!limitReached && !shouldUseDefaultDropdownAlert && ( - + {rowsReturnedMessage} - {limitMessage} - + {limitMessage} + )} {!limitReached && shouldUseDefaultDropdownAlert && (
this.setState({ alertIsOpen: false })} description={t( 'The number of rows displayed is limited to %s by the dropdown.', rows, @@ -668,7 +354,7 @@ export default class ResultSet extends React.PureComponent<
this.setState({ alertIsOpen: false })} message={t('%(rows)d rows returned', { rows: rowsCount })} description={ isAdmin @@ -691,9 +377,7 @@ export default class ResultSet extends React.PureComponent< exploreDBId = this.props.database.explore_database_id; } - if (this.props.showSql) { - sql = ; - } + if (this.props.showSql) sql = ; if (query.state === 'stopped') { return ; diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx index cab55599941a4..c35b5eb2b6022 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx @@ -17,44 +17,60 @@ * under the License. */ import React from 'react'; -import { shallow } from 'enzyme'; -import { Radio } from 'src/components/Radio'; -import { AutoComplete } from 'src/components'; -import { Input } from 'src/components/Input'; +import { QueryResponse, testQuery } from '@superset-ui/core'; import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; +import { render, screen } from 'spec/helpers/testing-library'; -describe('SaveDatasetModal', () => { - const mockedProps = { - visible: false, - onOk: () => {}, - onHide: () => {}, - handleDatasetNameChange: () => {}, - handleSaveDatasetRadioBtnState: () => {}, - saveDatasetRadioBtnState: 1, - handleOverwriteCancel: () => {}, - handleOverwriteDataset: () => {}, - handleOverwriteDatasetOption: () => {}, - defaultCreateDatasetValue: 'someDatasets', - shouldOverwriteDataset: false, - userDatasetOptions: [], - disableSaveAndExploreBtn: false, - handleSaveDatasetModalSearch: () => Promise, - filterAutocompleteOption: () => false, - onChangeAutoComplete: () => {}, - }; - it('renders a radio group btn', () => { - // @ts-ignore - const wrapper = shallow(); - expect(wrapper.find(Radio.Group)).toExist(); +const mockedProps = { + visible: true, + onHide: () => {}, + buttonTextOnSave: 'Save', + buttonTextOnOverwrite: 'Overwrite', + datasource: testQuery as QueryResponse, +}; + +describe('SaveDatasetModal RTL', () => { + it('renders a "Save as new" field', () => { + render(, { useRedux: true }); + + const saveRadioBtn = screen.getByRole('radio', { + name: /save as new unimportant/i, + }); + const fieldLabel = screen.getByText(/save as new/i); + const inputField = screen.getByRole('textbox'); + const inputFieldText = screen.getByDisplayValue(/unimportant/i); + + expect(saveRadioBtn).toBeVisible(); + expect(fieldLabel).toBeVisible(); + expect(inputField).toBeVisible(); + expect(inputFieldText).toBeVisible(); }); - it('renders a autocomplete', () => { - // @ts-ignore - const wrapper = shallow(); - expect(wrapper.find(AutoComplete)).toExist(); + + it('renders an "Overwrite existing" field', () => { + render(, { useRedux: true }); + + const overwriteRadioBtn = screen.getByRole('radio', { + name: /overwrite existing select or type dataset name/i, + }); + const fieldLabel = screen.getByText(/overwrite existing/i); + const inputField = screen.getByRole('combobox'); + const placeholderText = screen.getByText(/select or type dataset name/i); + + expect(overwriteRadioBtn).toBeVisible(); + expect(fieldLabel).toBeVisible(); + expect(inputField).toBeVisible(); + expect(placeholderText).toBeVisible(); }); - it('renders an input form', () => { - // @ts-ignore - const wrapper = shallow(); - expect(wrapper.find(Input)).toExist(); + + it('renders a save button', () => { + render(, { useRedux: true }); + + expect(screen.getByRole('button', { name: /save/i })).toBeVisible(); + }); + + it('renders a close button', () => { + render(, { useRedux: true }); + + expect(screen.getByRole('button', { name: /close/i })).toBeVisible(); }); }); diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx index 21884dbe8f13b..94b67a6fb309f 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx @@ -17,153 +17,356 @@ * under the License. */ -import React, { FunctionComponent } from 'react'; -import { AutoCompleteProps } from 'antd/lib/auto-complete'; +import React, { FunctionComponent, useState } from 'react'; import { Radio } from 'src/components/Radio'; import { AutoComplete, RadioChangeEvent } from 'src/components'; import { Input } from 'src/components/Input'; import StyledModal from 'src/components/Modal'; import Button from 'src/components/Button'; -import { styled, t } from '@superset-ui/core'; +import { + styled, + t, + SupersetClient, + makeApi, + JsonResponse, + JsonObject, + QueryResponse, +} from '@superset-ui/core'; +import { useSelector, useDispatch } from 'react-redux'; +import moment from 'moment'; +import rison from 'rison'; +import { createDatasource } from 'src/SqlLab/actions/sqlLab'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes'; +import { + DatasetRadioState, + EXPLORE_CHART_DEFAULT, + DatasetOwner, + DatasetOptionAutocomplete, + SqlLabExploreRootState, + getInitialState, + ExploreDatasource, +} from 'src/SqlLab/types'; +import { exploreChart } from 'src/explore/exploreUtils'; interface SaveDatasetModalProps { visible: boolean; - onOk: () => void; onHide: () => void; - handleDatasetNameChange: (e: React.FormEvent) => void; - handleSaveDatasetModalSearch: (searchText: string) => Promise; - filterAutocompleteOption: ( - inputValue: string, - option: { value: string; datasetId: number }, - ) => boolean; - handleSaveDatasetRadioBtnState: (e: RadioChangeEvent) => void; - handleOverwriteCancel: () => void; - handleOverwriteDataset: () => void; - handleOverwriteDatasetOption: ( - data: string, - option: Record, - ) => void; - onChangeAutoComplete: () => void; - defaultCreateDatasetValue: string; - disableSaveAndExploreBtn: boolean; - saveDatasetRadioBtnState: number; - shouldOverwriteDataset: boolean; - userDatasetOptions: AutoCompleteProps['options']; + buttonTextOnSave: string; + buttonTextOnOverwrite: string; + modalDescription?: string; + datasource: ExploreDatasource; } const Styles = styled.div` - .smd-body { + .sdm-body { margin: 0 8px; } - .smd-input { + .sdm-input { margin-left: 45px; width: 401px; } - .smd-autocomplete { + .sdm-autocomplete { margin-left: 8px; width: 401px; } - .smd-radio { + .sdm-radio { display: block; height: 30px; margin: 10px 0px; line-height: 30px; } - .smd-overwrite-msg { + .sdm-overwrite-msg { margin: 7px; } `; +const updateDataset = async ( + dbId: number, + datasetId: number, + sql: string, + columns: Array>, + owners: [number], + overrideColumns: boolean, +) => { + const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`; + const headers = { 'Content-Type': 'application/json' }; + const body = JSON.stringify({ + sql, + columns, + owners, + database_id: dbId, + }); + + const data: JsonResponse = await SupersetClient.put({ + endpoint, + headers, + body, + }); + return data.json.result; +}; + // eslint-disable-next-line no-empty-pattern export const SaveDatasetModal: FunctionComponent = ({ visible, - onOk, onHide, - handleDatasetNameChange, - handleSaveDatasetRadioBtnState, - saveDatasetRadioBtnState, - shouldOverwriteDataset, - handleOverwriteCancel, - handleOverwriteDataset, - handleOverwriteDatasetOption, - defaultCreateDatasetValue, - disableSaveAndExploreBtn, - handleSaveDatasetModalSearch, - filterAutocompleteOption, - userDatasetOptions, - onChangeAutoComplete, -}) => ( - - {!shouldOverwriteDataset && ( - - )} - {shouldOverwriteDataset && ( - <> - + buttonTextOnSave, + buttonTextOnOverwrite, + modalDescription, + datasource, +}) => { + const query = datasource as QueryResponse; + const getDefaultDatasetName = () => + `${query.tab} ${moment().format('MM/DD/YYYY HH:mm:ss')}`; + const [datasetName, setDatasetName] = useState(getDefaultDatasetName()); + const [newOrOverwrite, setNewOrOverwrite] = useState( + DatasetRadioState.SAVE_NEW, + ); + const [shouldOverwriteDataset, setShouldOverwriteDataset] = useState(false); + const [userDatasetOptions, setUserDatasetOptions] = useState< + DatasetOptionAutocomplete[] + >([]); + const [datasetToOverwrite, setDatasetToOverwrite] = useState< + Record + >({}); + const [autocompleteValue, setAutocompleteValue] = useState(''); + + const user = useSelector(user => + getInitialState(user), + ); + const dispatch = useDispatch<(dispatch: any) => Promise>(); + + const handleOverwriteDataset = async () => { + await updateDataset( + query.dbId, + datasetToOverwrite.datasetId, + query.sql, + query.results.selected_columns.map( + (d: { name: string; type: string; is_dttm: boolean }) => ({ + column_name: d.name, + type: d.type, + is_dttm: d.is_dttm, + }), + ), + datasetToOverwrite.owners.map((o: DatasetOwner) => o.id), + true, + ); + + setShouldOverwriteDataset(false); + setDatasetToOverwrite({}); + setDatasetName(getDefaultDatasetName()); + + exploreChart({ + ...EXPLORE_CHART_DEFAULT, + datasource: `${datasetToOverwrite.datasetId}__table`, + all_columns: query.results.selected_columns.map( + (d: { name: string; type: string; is_dttm: boolean }) => d.name, + ), + }); + }; + + const getUserDatasets = async (searchText = '') => { + // Making sure that autocomplete input has a value before rendering the dropdown + // Transforming the userDatasetsOwned data for SaveModalComponent) + const { userId } = user; + if (userId) { + const queryParams = rison.encode({ + filters: [ + { + col: 'table_name', + opr: 'ct', + value: searchText, + }, + { + col: 'owners', + opr: 'rel_m_m', + value: userId, + }, + ], + order_column: 'changed_on_delta_humanized', + order_direction: 'desc', + }); + + const response = await makeApi({ + method: 'GET', + endpoint: '/api/v1/dataset', + })(`q=${queryParams}`); + + return response.result.map( + (r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({ + value: r.table_name, + datasetId: r.id, + owners: r.owners, + }), + ); + } + + return null; + }; + + const handleSaveInDataset = () => { + // if user wants to overwrite a dataset we need to prompt them + if (newOrOverwrite === DatasetRadioState.OVERWRITE_DATASET) { + setShouldOverwriteDataset(true); + return; + } + + const selectedColumns = query.results.selected_columns || []; + + // The filters param is only used to test jinja templates. + // Remove the special filters entry from the templateParams + // before saving the dataset. + if (query.templateParams) { + const p = JSON.parse(query.templateParams); + /* eslint-disable-next-line no-underscore-dangle */ + if (p._filters) { + /* eslint-disable-next-line no-underscore-dangle */ + delete p._filters; + // eslint-disable-next-line no-param-reassign + query.templateParams = JSON.stringify(p); + } + } + + dispatch( + createDatasource({ + schema: query.schema, + sql: query.sql, + dbId: query.dbId, + templateParams: query.templateParams, + datasourceName: datasetName, + columns: selectedColumns, + }), + ) + .then((data: { table_id: number }) => { + exploreChart({ + datasource: `${data.table_id}__table`, + metrics: [], + groupby: [], + time_range: 'No filter', + viz_type: 'table', + all_columns: selectedColumns.map(c => c.name), + row_limit: 1000, + }); + }) + .catch(() => { + addDangerToast(t('An error occurred saving dataset')); + }); + + setDatasetName(getDefaultDatasetName()); + onHide(); + }; + + const handleSaveDatasetModalSearch = async (searchText: string) => { + const userDatasetsOwned = await getUserDatasets(searchText); + setUserDatasetOptions(userDatasetsOwned); + }; + + const handleOverwriteDatasetOption = ( + _data: string, + option: Record, + ) => setDatasetToOverwrite(option); + + const handleDatasetNameChange = (e: React.FormEvent) => { + // @ts-expect-error + setDatasetName(e.target.value); + }; + + const handleOverwriteCancel = () => { + setShouldOverwriteDataset(false); + setDatasetToOverwrite({}); + }; + + const disableSaveAndExploreBtn = + (newOrOverwrite === DatasetRadioState.SAVE_NEW && + datasetName.length === 0) || + (newOrOverwrite === DatasetRadioState.OVERWRITE_DATASET && + Object.keys(datasetToOverwrite).length === 0 && + autocompleteValue.length === 0); + + const filterAutocompleteOption = ( + inputValue: string, + option: { value: string; datasetId: number }, + ) => option.value.toLowerCase().includes(inputValue.toLowerCase()); + + return ( + + {!shouldOverwriteDataset && ( - + )} + {shouldOverwriteDataset && ( + <> + + + + )} + + } + > + + {!shouldOverwriteDataset && ( +
+ {modalDescription && ( +
{modalDescription}
+ )} + { + setNewOrOverwrite(Number(e.target.value)); + }} + value={newOrOverwrite} + > + + {t('Save as new')} + + + + {t('Overwrite existing')} + { + setDatasetToOverwrite({}); + setAutocompleteValue(value); + }} + placeholder={t('Select or type dataset name')} + filterOption={filterAutocompleteOption} + disabled={newOrOverwrite !== 2} + value={autocompleteValue} + /> + + +
)} - - } - > - - {!shouldOverwriteDataset && ( -
-
- Save this query as a virtual dataset to continue exploring + {shouldOverwriteDataset && ( +
+ {t('Are you sure you want to overwrite this dataset?')}
- - - Save as new - - - - Overwrite existing - - - -
- )} - {shouldOverwriteDataset && ( -
- Are you sure you want to overwrite this dataset? -
- )} - - -); + )} + + + ); +}; diff --git a/superset-frontend/src/SqlLab/components/TabStatusIcon/index.tsx b/superset-frontend/src/SqlLab/components/TabStatusIcon/index.tsx index 070e749288eaa..799124fb9c099 100644 --- a/superset-frontend/src/SqlLab/components/TabStatusIcon/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabStatusIcon/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { QueryState } from 'src/SqlLab/types'; +import { QueryState } from '@superset-ui/core'; interface TabStatusIconProps { tabState: QueryState; diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index e1714791638c8..fb3993fe84f6b 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -17,76 +17,13 @@ * under the License. */ import { SupersetError } from 'src/components/ErrorMessage/types'; -import { CtasEnum } from 'src/SqlLab/actions/sqlLab'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { ToastType } from 'src/components/MessageToasts/types'; +import { Dataset } from '@superset-ui/chart-controls'; +import { Query, QueryResponse } from '@superset-ui/core'; +import { ExploreRootState } from 'src/explore/types'; -// same as superset.result_set.ResultSetColumnType -export type Column = { - name: string; - type: string | null; - is_dttm: boolean; -}; - -export type QueryState = - | 'stopped' - | 'failed' - | 'pending' - | 'running' - | 'scheduled' - | 'success' - | 'fetching' - | 'timed_out'; - -export type Query = { - cached: boolean; - ctas: boolean; - ctas_method?: keyof typeof CtasEnum; - dbId: number; - errors?: SupersetError[]; - errorMessage: string | null; - extra: { - progress: string | null; - }; - id: string; - isDataPreview: boolean; - link?: string; - progress: number; - results: { - displayLimitReached: boolean; - columns: Column[]; - data: Record[]; - expanded_columns: Column[]; - selected_columns: Column[]; - query: { limit: number }; - }; - resultsKey: string | null; - schema?: string; - sql: string; - sqlEditorId: string; - state: QueryState; - tab: string | null; - tempSchema: string | null; - tempTable: string; - trackingUrl: string | null; - templateParams: any; - rows: number; - queryLimit: number; - limitingFactor: string; - endDttm: number; - duration: string; - startDttm: number; - time: Record; - user: Record; - userId: number; - db: Record; - started: string; - querylink: Record; - queryId: number; - executedSql: string; - output: string | Record; - actions: Record; -}; +export type ExploreDatasource = Dataset | QueryResponse; export interface QueryEditor { dbId?: number; @@ -109,7 +46,7 @@ export type toastState = { noDuplicate: boolean; }; -export type RootState = { +export type SqlLabRootState = { sqlLab: { activeSouthPaneTab: string | number; // default is string; action.newQuery.id is number alerts: any[]; @@ -128,3 +65,44 @@ export type RootState = { messageToasts: toastState[]; common: {}; }; + +export type SqlLabExploreRootState = SqlLabRootState | ExploreRootState; + +export const getInitialState = (state: SqlLabExploreRootState) => { + if (state.hasOwnProperty('sqlLab')) { + const { + sqlLab: { user }, + } = state as SqlLabRootState; + return user; + } + + const { + explore: { user }, + } = state as ExploreRootState; + return user; +}; + +export enum DatasetRadioState { + SAVE_NEW = 1, + OVERWRITE_DATASET = 2, +} + +export const EXPLORE_CHART_DEFAULT = { + metrics: [], + groupby: [], + time_range: 'No filter', + viz_type: 'table', +}; + +export interface DatasetOwner { + first_name: string; + id: number; + last_name: string; + username: string; +} + +export interface DatasetOptionAutocomplete { + value: string; + datasetId: number; + owners: [DatasetOwner]; +} diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts b/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts index 0b84c73411db2..6d9ab6fe9c9d6 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts +++ b/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts @@ -42,8 +42,8 @@ export enum IndicatorStatus { const TIME_GRANULARITY_FIELDS = new Set(Object.values(TIME_FILTER_MAP)); -// As of 2020-09-28, the DatasourceMeta type in superset-ui is incorrect. -// Should patch it here until the DatasourceMeta type is updated. +// As of 2020-09-28, the Dataset type in superset-ui is incorrect. +// Should patch it here until the Dataset type is updated. type Datasource = { time_grain_sqla?: [string, string][]; granularity?: [string, string][]; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts index e946cb671405f..8fdb3b0325997 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts @@ -19,7 +19,7 @@ import { flatMapDeep } from 'lodash'; import { FormInstance } from 'src/components'; import React from 'react'; -import { CustomControlItem, DatasourceMeta } from '@superset-ui/chart-controls'; +import { CustomControlItem, Dataset } from '@superset-ui/chart-controls'; import { Column, ensureIsArray, GenericDataType } from '@superset-ui/core'; import { DatasourcesState, ChartsState } from 'src/dashboard/types'; @@ -80,16 +80,16 @@ type DatasetSelectValue = { }; export const datasetToSelectOption = ( - item: DatasourceMeta & { table_name: string }, + item: Dataset & { table_name: string }, ): DatasetSelectValue => ({ value: item.id, label: item.table_name, }); -// TODO: add column_types field to DatasourceMeta +// TODO: add column_types field to Dataset // We return true if column_types is undefined or empty as a precaution against backend failing to return column_types export const hasTemporalColumns = ( - dataset: DatasourceMeta & { column_types: GenericDataType[] }, + dataset: Dataset & { column_types: GenericDataType[] }, ) => { const columnTypes = ensureIsArray(dataset?.column_types); return ( diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index c0b312d434423..e4b8227689ce4 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -24,7 +24,7 @@ import { JsonObject, NativeFiltersState, } from '@superset-ui/core'; -import { DatasourceMeta } from '@superset-ui/chart-controls'; +import { Dataset } from '@superset-ui/chart-controls'; import { chart } from 'src/components/Chart/chartReducer'; import componentTypes from 'src/dashboard/util/componentTypes'; @@ -84,7 +84,7 @@ export type DashboardInfo = { export type ChartsState = { [key: string]: Chart }; -export type Datasource = DatasourceMeta & { +export type Datasource = Dataset & { uid: string; column_types: GenericDataType[]; table_name: string; diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index fe45d0b63e110..2b6c0f549059d 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -17,7 +17,7 @@ * under the License. */ /* eslint camelcase: 0 */ -import { DatasourceMeta } from '@superset-ui/chart-controls'; +import { Dataset } from '@superset-ui/chart-controls'; import { t, SupersetClient, @@ -39,12 +39,12 @@ export function setDatasourceType(datasourceType: DatasourceType) { } export const SET_DATASOURCE = 'SET_DATASOURCE'; -export function setDatasource(datasource: DatasourceMeta) { +export function setDatasource(datasource: Dataset) { return { type: SET_DATASOURCE, datasource }; } export const SET_DATASOURCES = 'SET_DATASOURCES'; -export function setDatasources(datasources: DatasourceMeta[]) { +export function setDatasources(datasources: Dataset[]) { return { type: SET_DATASOURCES, datasources }; } diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index ef9517e4ddf87..6ed73f2b3869e 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -40,7 +40,7 @@ import { ControlPanelSectionConfig, ControlState, CustomControlItem, - DatasourceMeta, + Dataset, ExpandedControlItem, InfoTooltipWithTrigger, sections, @@ -174,13 +174,13 @@ const isTimeSection = (section: ControlPanelSectionConfig): boolean => (sections.legacyRegularTime.label === section.label || sections.legacyTimeseriesTime.label === section.label); -const hasTimeColumn = (datasource: DatasourceMeta): boolean => +const hasTimeColumn = (datasource: Dataset): boolean => datasource?.columns?.some(c => c.is_dttm) || datasource.type === DatasourceType.Druid; const sectionsToExpand = ( sections: ControlPanelSectionConfig[], - datasource: DatasourceMeta, + datasource: Dataset, ): string[] => // avoid expanding time section if datasource doesn't include time column sections.reduce( @@ -193,7 +193,7 @@ const sectionsToExpand = ( function getState( vizType: string, - datasource: DatasourceMeta, + datasource: Dataset, datasourceType: DatasourceType, ) { const querySections: ControlPanelSectionConfig[] = []; diff --git a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx index 99c596b80ed9c..4b19c5b2f3818 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanel.test.tsx @@ -92,19 +92,19 @@ function search(value: string, input: HTMLElement) { } test('should render', () => { - const { container } = render(setup(props)); + const { container } = render(setup(props), { useRedux: true }); expect(container).toBeVisible(); }); test('should display items in controls', () => { - render(setup(props)); + render(setup(props), { useRedux: true }); expect(screen.getByText('birth_names')).toBeInTheDocument(); expect(screen.getByText('Metrics')).toBeInTheDocument(); expect(screen.getByText('Columns')).toBeInTheDocument(); }); test('should render the metrics', () => { - render(setup(props)); + render(setup(props), { useRedux: true }); const metricsNum = metrics.length; metrics.forEach(metric => expect(screen.getByText(metric.metric_name)).toBeInTheDocument(), @@ -115,7 +115,7 @@ test('should render the metrics', () => { }); test('should render the columns', () => { - render(setup(props)); + render(setup(props), { useRedux: true }); const columnsNum = columns.length; columns.forEach(col => expect(screen.getByText(col.column_name)).toBeInTheDocument(), @@ -126,7 +126,7 @@ test('should render the columns', () => { }); test('should render 0 search results', async () => { - render(setup(props)); + render(setup(props), { useRedux: true }); const searchInput = screen.getByPlaceholderText('Search Metrics & Columns'); search('nothing', searchInput); @@ -134,7 +134,7 @@ test('should render 0 search results', async () => { }); test('should search and render matching columns', async () => { - render(setup(props)); + render(setup(props), { useRedux: true }); const searchInput = screen.getByPlaceholderText('Search Metrics & Columns'); search(columns[0].column_name, searchInput); @@ -146,7 +146,7 @@ test('should search and render matching columns', async () => { }); test('should search and render matching metrics', async () => { - render(setup(props)); + render(setup(props), { useRedux: true }); const searchInput = screen.getByPlaceholderText('Search Metrics & Columns'); search(metrics[0].metric_name, searchInput); @@ -174,8 +174,68 @@ test('should render a warning', async () => { }, }, }), + { useRedux: true }, ); expect( await screen.findByRole('img', { name: 'alert-solid' }), ).toBeInTheDocument(); }); + +test('should render a create dataset infobox', () => { + render( + setup({ + ...props, + datasource: { + ...datasource, + type: DatasourceType.Query, + }, + }), + { useRedux: true }, + ); + + const createButton = screen.getByRole('button', { + name: /create a dataset/i, + }); + const infoboxText = screen.getByText(/to edit or add columns and metrics./i); + + expect(createButton).toBeVisible(); + expect(infoboxText).toBeVisible(); +}); + +test('should render a save dataset modal when "Create a dataset" is clicked', () => { + render( + setup({ + ...props, + datasource: { + ...datasource, + type: DatasourceType.Query, + }, + }), + { useRedux: true }, + ); + + const createButton = screen.getByRole('button', { + name: /create a dataset/i, + }); + + userEvent.click(createButton); + + const saveDatasetModalTitle = screen.getByText(/save or overwrite dataset/i); + + expect(saveDatasetModalTitle).toBeVisible(); +}); + +test('should not render a save dataset modal when datasource is not query or dataset', () => { + render( + setup({ + ...props, + datasource: { + ...datasource, + type: DatasourceType.Table, + }, + }), + { useRedux: true }, + ); + + expect(screen.queryByText(/create a dataset/i)).toBe(null); +}); diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx index 8bd39aa52f02c..e8ea306814107 100644 --- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx +++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx @@ -17,32 +17,33 @@ * under the License. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { css, styled, t } from '@superset-ui/core'; +import { css, styled, t, DatasourceType } from '@superset-ui/core'; import { ControlConfig, - DatasourceMeta, + Dataset, ColumnMeta, } from '@superset-ui/chart-controls'; import { debounce } from 'lodash'; import { matchSorter, rankings } from 'match-sorter'; import Collapse from 'src/components/Collapse'; +import Alert from 'src/components/Alert'; +import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; import { Input } from 'src/components/Input'; import { FAST_DEBOUNCE } from 'src/constants'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { ExploreActions } from 'src/explore/actions/exploreActions'; import Control from 'src/explore/components/Control'; -import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { ExploreDatasource } from 'src/SqlLab/types'; import DatasourcePanelDragOption from './DatasourcePanelDragOption'; import { DndItemType } from '../DndItemType'; import { StyledColumnOption, StyledMetricOption } from '../optionRenderers'; interface DatasourceControl extends ControlConfig { - datasource?: DatasourceMeta; - user: UserWithPermissionsAndRoles; + datasource?: ExploreDatasource; } export interface Props { - datasource: DatasourceMeta; + datasource: Dataset; controls: { datasource: DatasourceControl; }; @@ -154,6 +155,16 @@ const SectionHeader = styled.span` `} `; +const StyledInfoboxWrapper = styled.div` + ${({ theme }) => css` + margin: 0 ${theme.gridUnit * 2.5}px; + + span { + text-decoration: underline; + } + `} +`; + const LabelContainer = (props: { children: React.ReactElement; className: string; @@ -192,6 +203,7 @@ export default function DataSourcePanel({ [_columns], ); + const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false); const [inputValue, setInputValue] = useState(''); const [lists, setList] = useState({ columns, @@ -279,6 +291,7 @@ export default function DataSourcePanel({ : lists.metrics.slice(0, DEFAULT_MAX_METRICS_LENGTH), [lists.metrics, showAllMetrics], ); + const columnSlice = useMemo( () => showAllColumns @@ -289,6 +302,17 @@ export default function DataSourcePanel({ [lists.columns, showAllColumns], ); + const showInfoboxCheck = () => { + if (sessionStorage.getItem('showInfobox') === 'false') return false; + return true; + }; + + const isValidDatasourceType = + datasource.type === DatasourceType.Dataset || + datasource.type === DatasourceType.SlTable || + datasource.type === DatasourceType.SavedQuery || + datasource.type === DatasourceType.Query; + const mainBody = useMemo( () => ( <> @@ -303,6 +327,29 @@ export default function DataSourcePanel({ placeholder={t('Search Metrics & Columns')} />
+ {isValidDatasourceType && showInfoboxCheck() && ( + + sessionStorage.setItem('showInfobox', 'false')} + type="info" + message="" + description={ + <> + setShowSaveDatasetModal(true)} + className="add-dataset-alert-description" + > + {t('Create a dataset')} + + {t(' to edit or add columns and metrics.')} + + } + /> + + )} + setShowSaveDatasetModal(false)} + buttonTextOnSave={t('Save')} + buttonTextOnOverwrite={t('Overwrite')} + datasource={datasource} + /> {datasource.id != null && mainBody} diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index e102f2dc970a8..9f33e9df824d1 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -622,6 +622,7 @@ function ExploreViewContainer(props) { controls={props.controls} actions={props.actions} shouldForceUpdate={shouldForceUpdate} + user={props.user} /> {isCollapsed ? ( diff --git a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.ts b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.ts index efd594cb7ff86..e070e82464d52 100644 --- a/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.ts +++ b/superset-frontend/src/explore/controlUtils/getControlValuesCompatibleWithDatasource.ts @@ -17,11 +17,7 @@ * under the License. */ -import { - ControlState, - DatasourceMeta, - Metric, -} from '@superset-ui/chart-controls'; +import { ControlState, Dataset, Metric } from '@superset-ui/chart-controls'; import { Column, isAdhocMetricSimple, @@ -33,7 +29,7 @@ import { import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric'; const isControlValueCompatibleWithDatasource = ( - datasource: DatasourceMeta, + datasource: Dataset, controlState: ControlState, value: any, ) => { @@ -78,7 +74,7 @@ const isControlValueCompatibleWithDatasource = ( }; export const getControlValuesCompatibleWithDatasource = ( - datasource: DatasourceMeta, + datasource: Dataset, controlState: ControlState, value: JsonValue, ) => { diff --git a/superset-frontend/src/explore/fixtures.tsx b/superset-frontend/src/explore/fixtures.tsx index 7ce2626069df2..78579b82e49c3 100644 --- a/superset-frontend/src/explore/fixtures.tsx +++ b/superset-frontend/src/explore/fixtures.tsx @@ -99,7 +99,7 @@ export const controlPanelSectionsChartOptionsTable: ControlPanelSectionConfig[] optionRenderer: c => , valueKey: 'column_name', mapStateToProps: stateRef => ({ - options: stateRef.datasource ? stateRef.datasource.columns : [], + options: stateRef.datasource?.columns || [], }), freeForm: true, } as ControlConfig<'SelectControl', ColumnMeta>, diff --git a/superset-frontend/src/explore/reducers/getInitialState.ts b/superset-frontend/src/explore/reducers/getInitialState.ts index 45440f6f5b4b9..4d598fb722de8 100644 --- a/superset-frontend/src/explore/reducers/getInitialState.ts +++ b/superset-frontend/src/explore/reducers/getInitialState.ts @@ -18,10 +18,7 @@ */ import shortid from 'shortid'; import { DatasourceType, JsonObject, QueryFormData } from '@superset-ui/core'; -import { - ControlStateMapping, - DatasourceMeta, -} from '@superset-ui/chart-controls'; +import { ControlStateMapping, Dataset } from '@superset-ui/chart-controls'; import { CommonBootstrapData, UserWithPermissionsAndRoles, @@ -41,7 +38,7 @@ export interface ExplorePageBootstrapData extends JsonObject { can_download: boolean; can_overwrite: boolean; common: CommonBootstrapData; - datasource: DatasourceMeta; + datasource: Dataset; datasource_id: number; datasource_type: DatasourceType; forced_height: string | null; diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index fe6436ab86d0e..4d50b449c500f 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -22,8 +22,10 @@ import { AnnotationData, AdhocMetric, } from '@superset-ui/core'; -import { ColumnMeta, DatasourceMeta } from '@superset-ui/chart-controls'; +import { ColumnMeta, Dataset } from '@superset-ui/chart-controls'; import { DatabaseObject } from 'src/views/CRUD/types'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { toastState } from 'src/SqlLab/types'; export { Slice, Chart } from 'src/types/Chart'; @@ -56,9 +58,35 @@ export type OptionSortType = Partial< ColumnMeta & AdhocMetric & { saved_metric_name: string } >; -export type Datasource = DatasourceMeta & { +export type Datasource = Dataset & { database?: DatabaseObject; datasource?: string; schema?: string; is_sqllab_view?: boolean; }; + +export type ExploreRootState = { + explore: { + can_add: boolean; + can_download: boolean; + common: object; + controls: object; + controlsTransferred: object; + datasource: object; + datasource_id: number; + datasource_type: string; + force: boolean; + forced_height: object; + form_data: object; + isDatasourceMetaLoading: boolean; + isStarred: boolean; + slice: object; + sliceName: string; + standalone: boolean; + timeFormattedColumns: object; + user: UserWithPermissionsAndRoles; + }; + localStorageUsageInKilobytes: number; + messageToasts: toastState[]; + common: {}; +};