feat(explore): Frontend implementation of dataset creation from infobox (#19855)

* Frontend implementation of create dataset from infobox

* Fixed sl_dataset type

* Fix test

* Fixed sl_dataset type (forgot to save)

* RTL testing

* Adjusted styling/text on infobox and save dataset modal

* Appease lint

* Make infobox invisible and fix tests

* Remove unnecessary placeholder

* Move types to sql lab

* Moved logic into save dataset modal

* Change DatasourceMeta type to Dataset

* Add ExploreDatasource union type to save dataset modal

* Get user info from redux inside save dataset modal

* Addressed comments

* Adjusting to new query type

* Fixed save dataset in explore and union type

* Added testing

* Defined d for queries

* Remove dataset from SaveDatasetModal

* Clarify useSelector parameter

* Fix dndControls union type

* Fix shared-controls union type

* Fix controlPanel union type

* Move ExploreRootState to explore type file

* Remove unnecessary testing playground

* Move datasource type check in DatasourcePanel to a function

* Make all sqllab Query imports reference @superset-ui/core Query type

* Deconstruct query props in ResultSet

* Fix union type in /legacy-plugin-chart-heatmap/src/controlPanel

* Change SaveDatasetModal tests to RTL

* Cleaned datasourceTypeCheck

* Fix infobox styling

* Fix SaveDatasetModal test

* Fix query fixture in sqllab and Query type in SaveDatasetModal test

* Fix Query type and make test query fixture

* Added columns to Query type, separated results property, created QueryResponse union type, and fixed all types affected

* Fixed a couple missed broken types

* Added ExploreDatasource to SqlLab type file

* Removed unneeded Query import from DatasourcePanel

* Address PR comments

* Fix columnChoices

* Fix all incorrect column property checks

* Fix logic on dndGroupByControl

* Dry up savedMetrics type check

* Fixed TIME_COLUMN_OPTION

* Dried savedMetrics type check even further

* Change savedMetricsTypeCheck to defineSavedMetrics

* Change datasourceTypeCheck to isValidDatasourceType

* Fix Query path in groupByControl

* dnd_granularity_sqla now sorts Query types with is_dttm at the top

* Fixed/cleaned query sort

* Add sortedQueryColumns and proper optional chaining to granularity_sqla

* Move testQuery to core-ui, add test coverage for Queries in columnChoices

* Moved DEFAULT_METRICS to core-ui and wrote a test for defineSavedMetrics

* Add license and clean dataset test object

* Change DatasourceType.Dataset to dataset
This commit is contained in:
Lyndsi Kay Williams 2022-06-07 15:03:45 -05:00 committed by GitHub
parent d1c24f81f2
commit ba0c37d3df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1125 additions and 685 deletions

View File

@ -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<string, string> = {
[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'),

View File

@ -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,
};
},

View File

@ -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 => <ColumnOption column={c} />,
valueKey: 'column_name',
mapStateToProps: state => {
const props: Partial<SelectControlConfig<ColumnMeta>> = {};
if (state.datasource) {
props.options = state.datasource.columns.filter(c => c.is_dttm);
const props: Partial<SelectControlConfig<ColumnMeta | QueryColumn>> = {};
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] : []),

View File

@ -25,6 +25,7 @@ import type {
JsonValue,
Metric,
QueryFormData,
QueryResponse,
QueryFormMetric,
QueryFormColumn,
} from '@superset-ui/core';
@ -53,7 +54,7 @@ export type ColumnMeta = Omit<Column, 'id'> & {
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]>;
}
/**

View File

@ -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,
) || []

View File

@ -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;

View File

@ -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';

View File

@ -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();
});
});

View File

@ -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,
);
});
});

View File

@ -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 {};

View File

@ -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> = T[keyof T];
export type ErrorType = ValueOf<typeof ErrorTypeEnum>;
// Keep in sync with superset/views/errors.py
export type ErrorLevel = 'info' | 'warning' | 'error';
export type ErrorSource = 'dashboard' | 'explore' | 'sqllab';
export type SupersetError<ExtraType = Record<string, any> | 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<string, any>;
user: Record<string, any>;
userId: number;
db: Record<string, any>;
started: string;
querylink: Record<string, any>;
queryId: number;
executedSql: string;
output: string | Record<string, any>;
actions: Record<string, any>;
type: DatasourceType.Query;
columns: QueryColumn[];
};
export type QueryResults = {
results: {
displayLimitReached: boolean;
columns: QueryColumn[];
data: Record<string, unknown>[];
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 {};

View File

@ -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,

View File

@ -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) =>

View File

@ -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 =

View File

@ -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] : []),

View File

@ -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,

View File

@ -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 };

View File

@ -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 =

View File

@ -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 = {

View File

@ -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;

View File

@ -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<string>('28 days ago');
const [to, setTo] = useState<string>('now');
const [status, setStatus] = useState<string>('success');
const [queriesArray, setQueriesArray] = useState<Query[]>([]);
const [queriesArray, setQueriesArray] = useState<QueryResponse[]>([]);
const [queriesLoading, setQueriesLoading] = useState<boolean>(true);
const getTimeFromSelection = (selection: string) => {

View File

@ -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;

View File

@ -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<Query, 'state' | 'sql' | 'progress' | 'results'> {
extends Omit<QueryResponse, 'state' | 'sql' | 'progress' | 'results'> {
state?: Record<string, any>;
sql?: Record<string, any>;
progress?: Record<string, any>;
@ -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<RootState, User>(state => state.sqlLab.user);
const user = useSelector<SqlLabRootState, User>(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);
};

View File

@ -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<string, any>;
@ -92,7 +55,7 @@ interface ResultSetProps {
database?: Record<string, any>;
displayLimit: number;
height: number;
query: Query;
query: QueryResponse;
search?: boolean;
showSql?: boolean;
visualize?: boolean;
@ -105,12 +68,6 @@ interface ResultSetState {
showExploreResultsButton: boolean;
data: Record<string, any>[];
showSaveDatasetModal: boolean;
newSaveDatasetName: string;
saveDatasetRadioBtnState: number;
shouldOverwriteDataSet: boolean;
datasetToOverwrite: Record<string, any>;
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<Record<string, any>>,
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<string, any>,
) => {
this.setState({ datasetToOverwrite: option });
};
handleDatasetNameChange = (e: React.FormEvent<HTMLInputElement>) => {
// @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 (
<ResultSetControls>
<SaveDatasetModal
visible={showSaveDatasetModal}
onOk={this.handleSaveInDataset}
saveDatasetRadioBtnState={saveDatasetRadioBtnState}
shouldOverwriteDataset={shouldOverwriteDataSet}
defaultCreateDatasetValue={newSaveDatasetName}
userDatasetOptions={userDatasetOptions}
disableSaveAndExploreBtn={disableSaveAndExploreBtn}
onHide={this.handleHideSaveModal}
handleDatasetNameChange={this.handleDatasetNameChange}
handleSaveDatasetRadioBtnState={this.handleSaveDatasetRadioBtnState}
handleOverwriteCancel={this.handleOverwriteCancel}
handleOverwriteDataset={this.handleOverwriteDataset}
handleOverwriteDatasetOption={this.handleOverwriteDatasetOption}
handleSaveDatasetModalSearch={this.handleSaveDatasetModalSearch}
filterAutocompleteOption={this.handleFilterAutocompleteOption}
onChangeAutoComplete={this.handleOnChangeAutoComplete}
onHide={() => 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}
/>
<ResultSetButtons>
{this.props.visualize &&
this.props.database?.allows_virtual_table_explore && (
<ExploreResultsButton
database={this.props.database}
onClick={this.handleExploreBtnClick}
onClick={() => this.setState({ showSaveDatasetModal: true })}
/>
)}
{this.props.csv && (
<Button
buttonSize="small"
href={`/superset/csv/${this.props.query.id}`}
>
<Button buttonSize="small" href={`/superset/csv/${query.id}`}>
<i className="fa fa-file-text-o" /> {t('Download to CSV')}
</Button>
)}
@ -587,10 +277,6 @@ export default class ResultSet extends React.PureComponent<
return <div />;
}
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 (
<ReturnedRows>
{!limitReached && !shouldUseDefaultDropdownAlert && (
<ResultSetRowsReturned title={tooltipText}>
<span title={tooltipText}>
{rowsReturnedMessage}
<LimitMessage>{limitMessage}</LimitMessage>
</ResultSetRowsReturned>
<span>{limitMessage}</span>
</span>
)}
{!limitReached && shouldUseDefaultDropdownAlert && (
<div ref={this.calculateAlertRefHeight}>
<Alert
type="warning"
message={t('%(rows)d rows returned', { rows })}
onClose={this.onAlertClose}
onClose={() => 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<
<div ref={this.calculateAlertRefHeight}>
<Alert
type="warning"
onClose={this.onAlertClose}
onClose={() => 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 = <HighlightedSql sql={query.sql} />;
}
if (this.props.showSql) sql = <HighlightedSql sql={query.sql} />;
if (query.state === 'stopped') {
return <Alert type="warning" message={t('Query was stopped')} />;

View File

@ -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(<SaveDatasetModal {...mockedProps} />);
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(<SaveDatasetModal {...mockedProps} />, { 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(<SaveDatasetModal {...mockedProps} />);
expect(wrapper.find(AutoComplete)).toExist();
it('renders an "Overwrite existing" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { 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(<SaveDatasetModal {...mockedProps} />);
expect(wrapper.find(Input)).toExist();
it('renders a save button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: /save/i })).toBeVisible();
});
it('renders a close button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: /close/i })).toBeVisible();
});
});

View File

@ -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<HTMLInputElement>) => void;
handleSaveDatasetModalSearch: (searchText: string) => Promise<void>;
filterAutocompleteOption: (
inputValue: string,
option: { value: string; datasetId: number },
) => boolean;
handleSaveDatasetRadioBtnState: (e: RadioChangeEvent) => void;
handleOverwriteCancel: () => void;
handleOverwriteDataset: () => void;
handleOverwriteDatasetOption: (
data: string,
option: Record<string, any>,
) => 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<Record<string, any>>,
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<SaveDatasetModalProps> = ({
visible,
onOk,
onHide,
handleDatasetNameChange,
handleSaveDatasetRadioBtnState,
saveDatasetRadioBtnState,
shouldOverwriteDataset,
handleOverwriteCancel,
handleOverwriteDataset,
handleOverwriteDatasetOption,
defaultCreateDatasetValue,
disableSaveAndExploreBtn,
handleSaveDatasetModalSearch,
filterAutocompleteOption,
userDatasetOptions,
onChangeAutoComplete,
}) => (
<StyledModal
show={visible}
title="Save or Overwrite Dataset"
onHide={onHide}
footer={
<>
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<string, any>
>({});
const [autocompleteValue, setAutocompleteValue] = useState('');
const user = useSelector<SqlLabExploreRootState, User>(user =>
getInitialState(user),
);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
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<string, any>,
) => setDatasetToOverwrite(option);
const handleDatasetNameChange = (e: React.FormEvent<HTMLInputElement>) => {
// @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 (
<StyledModal
show={visible}
title={t('Save or Overwrite Dataset')}
onHide={onHide}
footer={
<>
{!shouldOverwriteDataset && (
<Button
disabled={disableSaveAndExploreBtn}
buttonStyle="primary"
onClick={handleSaveInDataset}
>
{buttonTextOnSave}
</Button>
)}
{shouldOverwriteDataset && (
<>
<Button onClick={handleOverwriteCancel}>Back</Button>
<Button
className="md"
buttonStyle="primary"
onClick={handleOverwriteDataset}
disabled={disableSaveAndExploreBtn}
>
{buttonTextOnOverwrite}
</Button>
</>
)}
</>
}
>
<Styles>
{!shouldOverwriteDataset && (
<Button
disabled={disableSaveAndExploreBtn}
buttonStyle="primary"
onClick={onOk}
>
{t('Save & Explore')}
</Button>
<div className="sdm-body">
{modalDescription && (
<div className="sdm-prompt">{modalDescription}</div>
)}
<Radio.Group
onChange={(e: RadioChangeEvent) => {
setNewOrOverwrite(Number(e.target.value));
}}
value={newOrOverwrite}
>
<Radio className="sdm-radio" value={1}>
{t('Save as new')}
<Input
className="sdm-input"
defaultValue={datasetName}
onChange={handleDatasetNameChange}
disabled={newOrOverwrite !== 1}
/>
</Radio>
<Radio className="sdm-radio" value={2}>
{t('Overwrite existing')}
<AutoComplete
className="sdm-autocomplete"
options={userDatasetOptions}
onSelect={handleOverwriteDatasetOption}
onSearch={handleSaveDatasetModalSearch}
onChange={value => {
setDatasetToOverwrite({});
setAutocompleteValue(value);
}}
placeholder={t('Select or type dataset name')}
filterOption={filterAutocompleteOption}
disabled={newOrOverwrite !== 2}
value={autocompleteValue}
/>
</Radio>
</Radio.Group>
</div>
)}
{shouldOverwriteDataset && (
<>
<Button onClick={handleOverwriteCancel}>Back</Button>
<Button
className="md"
buttonStyle="primary"
onClick={handleOverwriteDataset}
disabled={disableSaveAndExploreBtn}
>
{t('Overwrite & Explore')}
</Button>
</>
)}
</>
}
>
<Styles>
{!shouldOverwriteDataset && (
<div className="smd-body">
<div className="smd-prompt">
Save this query as a virtual dataset to continue exploring
<div className="sdm-overwrite-msg">
{t('Are you sure you want to overwrite this dataset?')}
</div>
<Radio.Group
onChange={handleSaveDatasetRadioBtnState}
value={saveDatasetRadioBtnState}
>
<Radio className="smd-radio" value={1}>
Save as new
<Input
className="smd-input"
defaultValue={defaultCreateDatasetValue}
onChange={handleDatasetNameChange}
disabled={saveDatasetRadioBtnState !== 1}
/>
</Radio>
<Radio className="smd-radio" value={2}>
Overwrite existing
<AutoComplete
className="smd-autocomplete"
options={userDatasetOptions}
onSelect={handleOverwriteDatasetOption}
onSearch={handleSaveDatasetModalSearch}
onChange={onChangeAutoComplete}
placeholder="Select or type dataset name"
filterOption={filterAutocompleteOption}
disabled={saveDatasetRadioBtnState !== 2}
/>
</Radio>
</Radio.Group>
</div>
)}
{shouldOverwriteDataset && (
<div className="smd-overwrite-msg">
Are you sure you want to overwrite this dataset?
</div>
)}
</Styles>
</StyledModal>
);
)}
</Styles>
</StyledModal>
);
};

View File

@ -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;

View File

@ -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<string, unknown>[];
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<string, any>;
user: Record<string, any>;
userId: number;
db: Record<string, any>;
started: string;
querylink: Record<string, any>;
queryId: number;
executedSql: string;
output: string | Record<string, any>;
actions: Record<string, any>;
};
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];
}

View File

@ -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][];

View File

@ -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 (

View File

@ -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;

View File

@ -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 };
}

View File

@ -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[] = [];

View File

@ -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);
});

View File

@ -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')}
/>
<div className="field-selections">
{isValidDatasourceType && showInfoboxCheck() && (
<StyledInfoboxWrapper>
<Alert
closable
onClose={() => sessionStorage.setItem('showInfobox', 'false')}
type="info"
message=""
description={
<>
<span
role="button"
tabIndex={0}
onClick={() => setShowSaveDatasetModal(true)}
className="add-dataset-alert-description"
>
{t('Create a dataset')}
</span>
{t(' to edit or add columns and metrics.')}
</>
}
/>
</StyledInfoboxWrapper>
)}
<Collapse
defaultActiveKey={['metrics', 'column']}
expandIconPosition="right"
@ -399,6 +446,13 @@ export default function DataSourcePanel({
return (
<DatasourceContainer>
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={() => setShowSaveDatasetModal(false)}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
datasource={datasource}
/>
<Control {...datasourceControl} name="datasource" actions={actions} />
{datasource.id != null && mainBody}
</DatasourceContainer>

View File

@ -622,6 +622,7 @@ function ExploreViewContainer(props) {
controls={props.controls}
actions={props.actions}
shouldForceUpdate={shouldForceUpdate}
user={props.user}
/>
</Resizable>
{isCollapsed ? (

View File

@ -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,
) => {

View File

@ -99,7 +99,7 @@ export const controlPanelSectionsChartOptionsTable: ControlPanelSectionConfig[]
optionRenderer: c => <ColumnOption column={c} showType />,
valueKey: 'column_name',
mapStateToProps: stateRef => ({
options: stateRef.datasource ? stateRef.datasource.columns : [],
options: stateRef.datasource?.columns || [],
}),
freeForm: true,
} as ControlConfig<'SelectControl', ColumnMeta>,

View File

@ -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;

View File

@ -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: {};
};