You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/01/06 18:53:04 UTC
(superset) branch master updated: chore(frontend): migrate SqlLab and explore JS/JSX files to TypeScript (#36760)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 9aff89c1b4 chore(frontend): migrate SqlLab and explore JS/JSX files to TypeScript (#36760)
9aff89c1b4 is described below
commit 9aff89c1b4b783c7cd8809ea8d46a273c12f6f4e
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Tue Jan 6 10:52:58 2026 -0800
chore(frontend): migrate SqlLab and explore JS/JSX files to TypeScript (#36760)
Co-authored-by: Claude Opus 4.5 <no...@anthropic.com>
---
.../playwright/tests/experimental/README.md | 7 +
.../src/SqlLab/actions/{sqlLab.js => sqlLab.ts} | 794 +++++++++++++++------
.../AceEditorWrapper/useKeywords.test.ts | 6 +-
.../components/AceEditorWrapper/useKeywords.ts | 6 +-
.../SqlLab/components/QueryAutoRefresh/index.tsx | 2 +-
.../SqlLab/components/SaveDatasetModal/index.tsx | 6 +-
.../src/SqlLab/components/SouthPane/index.tsx | 4 +-
.../src/SqlLab/components/SqlEditor/index.tsx | 9 +-
.../SqlLab/components/SqlEditorLeftBar/index.tsx | 5 +-
.../src/SqlLab/components/TableElement/index.tsx | 2 +-
.../middlewares/persistSqlLabStateEnhancer.js | 136 ----
.../middlewares/persistSqlLabStateEnhancer.ts | 185 +++++
.../src/SqlLab/reducers/{sqlLab.js => sqlLab.ts} | 333 +++++----
superset-frontend/src/core/sqlLab/index.ts | 55 +-
.../FiltersConfigForm/FiltersConfigForm.tsx | 4 +-
.../AnnotationLayer.test.tsx | 39 +-
.../{AnnotationLayer.jsx => AnnotationLayer.tsx} | 417 +++++++----
.../{AnnotationTypes.js => AnnotationTypes.ts} | 40 +-
...undsControl.test.jsx => BoundsControl.test.tsx} | 0
.../{CheckboxControl.jsx => CheckboxControl.tsx} | 52 +-
.../CollectionControl/{index.jsx => index.tsx} | 90 ++-
.../DatasourceControl/DatasourceControl.test.tsx | 110 +--
.../DatasourceControl/{index.jsx => index.tsx} | 157 +++-
.../DndColumnMetricSelect.tsx | 15 +-
...erTitle.jsx => DndColumnSelectPopoverTitle.tsx} | 11 +-
.../DndFilterSelect.test.tsx | 14 +-
.../DndColumnSelectControl/DndMetricSelect.tsx | 23 +-
.../{AdhocFilter.test.js => AdhocFilter.test.ts} | 0
.../AdhocFilter/{index.js => index.ts} | 110 ++-
.../AdhocFilterControl/AdhocFilterControl.test.tsx | 49 +-
.../AdhocFilterControl/{index.jsx => index.tsx} | 160 +++--
...er.test.jsx => AdhocFilterEditPopover.test.tsx} | 24 +-
.../{index.jsx => index.tsx} | 159 +++--
...AdhocFilterEditPopoverSimpleTabContent.test.tsx | 133 ++--
.../index.tsx | 88 ++-
.../FilterControl/AdhocFilterOption/index.tsx | 25 +-
.../AdhocFilterPopoverTrigger/index.tsx | 2 +-
.../utils/useDatePickerInAdhocFilter.tsx | 6 +-
.../FilterControl/utils/useGetTimeRangeLabel.tsx | 31 +-
.../FixedOrMetricControl/{index.jsx => index.tsx} | 88 ++-
.../{AdhocMetric.test.js => AdhocMetric.test.ts} | 4 +-
.../{AdhocMetric.js => AdhocMetric.ts} | 102 ++-
.../{index.jsx => index.tsx} | 162 ++++-
...cOption.test.jsx => AdhocMetricOption.test.tsx} | 27 +-
...AdhocMetricOption.jsx => AdhocMetricOption.tsx} | 46 +-
.../MetricControl/AdhocMetricPopoverTrigger.tsx | 20 +-
...on.test.jsx => FilterDefinitionOption.test.tsx} | 6 +-
...nitionOption.jsx => FilterDefinitionOption.tsx} | 21 +-
...lue.test.jsx => MetricDefinitionValue.test.tsx} | 26 +-
...finitionValue.jsx => MetricDefinitionValue.tsx} | 31 +-
...icsControl.test.jsx => MetricsControl.test.tsx} | 17 +-
.../{MetricsControl.jsx => MetricsControl.tsx} | 93 ++-
.../{adhocMetricType.js => adhocMetricType.ts} | 0
.../components/controls/MetricControl/types.ts | 2 +
.../components/controls/OptionControls/index.tsx | 4 +-
...lectControl.test.jsx => SelectControl.test.tsx} | 34 +-
.../{SelectControl.jsx => SelectControl.tsx} | 152 ++--
.../{SpatialControl.jsx => SpatialControl.tsx} | 123 ++--
...eaControl.test.jsx => TextAreaControl.test.tsx} | 0
.../{TextAreaControl.jsx => TextAreaControl.tsx} | 104 ++-
.../components/controls/TextControl/index.tsx | 4 +
.../{index.jsx => index.tsx} | 109 ++-
...rtControl.test.jsx => ViewportControl.test.tsx} | 0
.../{ViewportControl.jsx => ViewportControl.tsx} | 82 ++-
.../components/controls/XAxisSortControl.tsx | 16 +-
.../explore/controlUtils/standardizedFormData.ts | 24 +-
.../src/explore/{controls.jsx => controls.tsx} | 79 +-
.../src/explore/{store.test.jsx => store.test.tsx} | 5 +-
.../src/explore/{store.js => store.ts} | 62 +-
69 files changed, 3271 insertions(+), 1481 deletions(-)
diff --git a/superset-frontend/playwright/tests/experimental/README.md b/superset-frontend/playwright/tests/experimental/README.md
index a1511695b1..55a2399170 100644
--- a/superset-frontend/playwright/tests/experimental/README.md
+++ b/superset-frontend/playwright/tests/experimental/README.md
@@ -28,12 +28,14 @@ This directory contains **experimental** Playwright E2E tests that are being dev
### Running Tests
**By default (CI and local), experimental tests are EXCLUDED:**
+
```bash
npm run playwright:test
# Only runs stable tests (tests/auth/*)
```
**To include experimental tests, set the environment variable:**
+
```bash
INCLUDE_EXPERIMENTAL=true npm run playwright:test
# Runs all tests including experimental/
@@ -60,6 +62,7 @@ testIgnore: process.env.INCLUDE_EXPERIMENTAL
```
This ensures:
+
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
@@ -77,11 +80,13 @@ Add tests to `experimental/` when:
Once an experimental test has proven stable (consistent CI passes over time):
1. **Move the test file** from `experimental/` to the appropriate stable directory:
+
```bash
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
```
2. **Commit the move** with a clear message:
+
```bash
git commit -m "test(playwright): promote my-test from experimental to stable"
```
@@ -102,11 +107,13 @@ Once an experimental test has proven stable (consistent CI passes over time):
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
✅ **Correct locations:**
+
- `playwright/components/` - Components used by any tests
- `playwright/pages/` - Page objects for any features
- `playwright/helpers/api/` - API helpers for test data setup
❌ **Avoid:**
+
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.ts
similarity index 60%
rename from superset-frontend/src/SqlLab/actions/sqlLab.js
rename to superset-frontend/src/SqlLab/actions/sqlLab.ts
index a4aead7e12..5d9bb391cb 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.ts
@@ -18,6 +18,8 @@
*/
import { nanoid } from 'nanoid';
import rison from 'rison';
+import type { ThunkAction } from 'redux-thunk';
+import type { QueryColumn, SupersetError } from '@superset-ui/core';
import {
FeatureFlag,
SupersetClient,
@@ -38,10 +40,76 @@ import {
import { LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY } from 'src/logger/LogUtils';
import getBootstrapData from 'src/utils/getBootstrapData';
import { logEvent } from 'src/logger/actions';
+import type { QueryEditor, SqlLabRootState, Table } from '../types';
import { newQueryTabName } from '../utils/newQueryTabName';
import getInitialState from '../reducers/getInitialState';
import { rehydratePersistedState } from '../utils/reduxStateToLocalStorageHelper';
+// Type definitions for SqlLab actions
+export interface Query {
+ id: string;
+ dbId?: number;
+ sql: string;
+ sqlEditorId?: string | null;
+ sqlEditorImmutableId?: string;
+ tab?: string | null;
+ catalog?: string | null;
+ schema?: string | null;
+ tempTable?: string | null;
+ templateParams?: string;
+ queryLimit?: number;
+ runAsync?: boolean;
+ ctas?: boolean;
+ ctas_method?: string;
+ isDataPreview?: boolean;
+ progress?: number;
+ startDttm?: number;
+ endDttm?: number;
+ state?: string;
+ cached?: boolean;
+ resultsKey?: string | null;
+ updateTabState?: boolean;
+ tableName?: string;
+ link?: string;
+ inLocalStorage?: boolean;
+ executedSql?: string;
+ query_id?: number;
+}
+
+export interface Database {
+ id: number;
+ allow_run_async: boolean;
+ disable_data_preview?: boolean;
+}
+
+/**
+ * Query result data from the SQL execute API (matches QueryResultSchema)
+ */
+export interface SqlExecuteQueryResult {
+ endDttm?: number;
+ executedSql?: string;
+ limit?: number;
+ limitingFactor?: string;
+ tempTable?: string | null;
+ progress?: number;
+ state?: string;
+ [key: string]: unknown;
+}
+
+/**
+ * Response from /api/v1/sqllab/execute/
+ * This matches QueryExecutionResponseSchema from the backend
+ */
+export interface SqlExecuteResponse {
+ status?: string;
+ data: Record<string, unknown>[];
+ columns: QueryColumn[];
+ selected_columns?: QueryColumn[];
+ expanded_columns?: QueryColumn[];
+ query: SqlExecuteQueryResult;
+ query_id?: number;
+}
+
export const RESET_STATE = 'RESET_STATE';
export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR';
export const UPDATE_QUERY_EDITOR = 'UPDATE_QUERY_EDITOR';
@@ -104,6 +172,71 @@ export const SET_EDITOR_TAB_LAST_UPDATE = 'SET_EDITOR_TAB_LAST_UPDATE';
export const SET_LAST_UPDATED_ACTIVE_TAB = 'SET_LAST_UPDATED_ACTIVE_TAB';
export const CLEAR_DESTROYED_QUERY_EDITOR = 'CLEAR_DESTROYED_QUERY_EDITOR';
+// SqlLab action interface with optional properties for reducer compatibility.
+// Uses explicit property types instead of `any` for better type safety.
+export interface SqlLabAction {
+ type: string;
+ // Query editor related
+ queryEditor?: QueryEditor | Partial<QueryEditor>;
+ alterations?: Partial<QueryEditor>;
+ oldQueryEditor?: QueryEditor;
+ newQueryEditor?: QueryEditor;
+ queryEditorId?: string;
+ // Query related
+ query?: Query | Partial<Query>;
+ queries?: Query[];
+ alteredQueries?: Record<string, Partial<Query>>;
+ queryId?: string;
+ // Table related
+ table?: Table | Partial<Table>;
+ tables?: Table[];
+ oldTable?: Table;
+ newTable?: Table;
+ // Database related
+ databases?: Database[] | Record<string, Database>;
+ dbId?: number;
+ // State and config
+ sqlLabInitialState?: SqlLabRootState['sqlLab'];
+ sql?: string | null;
+ name?: string;
+ catalog?: string | null;
+ schema?: string | null;
+ autorun?: boolean;
+ templateParams?: string;
+ queryLimit?: number;
+ position?: { row: number; column: number };
+ functionNames?: string[];
+ northPercent?: number;
+ southPercent?: number;
+ hideLeftBar?: boolean;
+ // Results and errors
+ results?: SqlExecuteResponse;
+ errors?: SupersetError[];
+ error?: string;
+ msg?: string;
+ link?: string;
+ err?: string;
+ // Misc
+ tabId?: string | number;
+ timestamp?: number;
+ interval?: number;
+ offline?: boolean;
+ datasource?: unknown;
+ clientId?: string;
+ result?: { remoteId: number };
+ prepend?: boolean;
+ json?: { result: unknown };
+ oldQueryId?: string;
+ newQuery?: { id: string };
+}
+
+type SqlLabThunkAction<R = void> = ThunkAction<
+ R,
+ SqlLabRootState,
+ unknown,
+ SqlLabAction
+>;
+
export const addInfoToast = addInfoToastAction;
export const addSuccessToast = addSuccessToastAction;
export const addDangerToast = addDangerToastAction;
@@ -112,13 +245,14 @@ export const addWarningToast = addWarningToastAction;
export const CtasEnum = {
Table: 'TABLE',
View: 'VIEW',
-};
+} as const;
+
const ERR_MSG_CANT_LOAD_QUERY = t("The query couldn't be loaded");
// a map of SavedQuery field names to the different names used client-side,
// because for now making the names consistent is too complicated
// so it might as well only happen in one place
-const queryClientMapping = {
+const queryClientMapping: Record<string, string> = {
id: 'remoteId',
db_id: 'dbId',
label: 'name',
@@ -127,26 +261,36 @@ const queryClientMapping = {
const queryServerMapping = invert(queryClientMapping);
// uses a mapping like those above to convert object key names to another style
-const fieldConverter = mapping => obj =>
- mapKeys(obj, (value, key) => (key in mapping ? mapping[key] : key));
+const fieldConverter =
+ (mapping: Record<string, string>) =>
+ <T extends Record<string, unknown>>(obj: T): Record<string, unknown> =>
+ mapKeys(obj, (_value, key) => (key in mapping ? mapping[key] : key));
export const convertQueryToServer = fieldConverter(queryServerMapping);
export const convertQueryToClient = fieldConverter(queryClientMapping);
-export function getUpToDateQuery(rootState, queryEditor, key) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function getUpToDateQuery(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ rootState: any,
+ queryEditor: Partial<QueryEditor>,
+ key?: string,
+): QueryEditor {
const {
sqlLab: { unsavedQueryEditor, queryEditors },
} = rootState;
const id = key ?? queryEditor.id;
return {
id,
- ...queryEditors.find(qe => qe.id === id),
+ ...queryEditors.find((qe: QueryEditor) => qe.id === id),
...(id === unsavedQueryEditor.id && unsavedQueryEditor),
- };
+ } as QueryEditor;
}
-export function resetState(data) {
- return (dispatch, getState) => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function resetState(data?: Record<string, unknown>): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any, getState: any) => {
const { common } = getState();
const initialState = getInitialState({
...getBootstrapData(),
@@ -158,20 +302,25 @@ export function resetState(data) {
type: RESET_STATE,
sqlLabInitialState: initialState.sqlLab,
});
- rehydratePersistedState(dispatch, initialState);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ rehydratePersistedState(dispatch, initialState as any);
};
}
-export function updateQueryEditor(alterations) {
+export function updateQueryEditor(
+ alterations: Partial<QueryEditor>,
+): SqlLabAction {
return { type: UPDATE_QUERY_EDITOR, alterations };
}
-export function setEditorTabLastUpdate(timestamp) {
+export function setEditorTabLastUpdate(timestamp: number): SqlLabAction {
return { type: SET_EDITOR_TAB_LAST_UPDATE, timestamp };
}
-export function scheduleQuery(query) {
- return dispatch =>
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function scheduleQuery(query: Record<string, unknown>): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any) =>
SupersetClient.post({
endpoint: '/api/v1/saved_query/',
jsonPayload: query,
@@ -191,8 +340,10 @@ export function scheduleQuery(query) {
);
}
-export function estimateQueryCost(queryEditor) {
- return (dispatch, getState) => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function estimateQueryCost(queryEditor: QueryEditor): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any, getState: any) => {
const { dbId, catalog, schema, sql, selectedText, templateParams } =
getUpToDateQuery(getState(), queryEditor);
const requestSql = selectedText || sql;
@@ -232,11 +383,11 @@ export function estimateQueryCost(queryEditor) {
};
}
-export function clearInactiveQueries(interval) {
+export function clearInactiveQueries(interval: number): SqlLabAction {
return { type: CLEAR_INACTIVE_QUERIES, interval };
}
-export function startQuery(query, runPreviewOnly) {
+export function startQuery(query: Query, runPreviewOnly?: boolean) {
Object.assign(query, {
id: query.id ? query.id : nanoid(11),
progress: 0,
@@ -244,14 +395,17 @@ export function startQuery(query, runPreviewOnly) {
state: query.runAsync ? 'pending' : 'running',
cached: false,
});
- return { type: START_QUERY, query, runPreviewOnly };
+ return { type: START_QUERY, query, runPreviewOnly } as const;
}
-export function querySuccess(query, results) {
- return { type: QUERY_SUCCESS, query, results };
+export function querySuccess(query: Query, results: SqlExecuteResponse) {
+ return { type: QUERY_SUCCESS, query, results } as const;
}
-export function logFailedQuery(query, errors) {
+export function logFailedQuery(
+ query: Query,
+ errors?: SupersetError[],
+): SqlLabThunkAction<void> {
return function (dispatch) {
const eventData = {
has_err: true,
@@ -259,7 +413,9 @@ export function logFailedQuery(query, errors) {
ts: new Date().getTime(),
};
errors?.forEach(({ error_type: errorType, message, extra }) => {
- const issueCodes = extra?.issue_codes?.map(({ code }) => code) || [-1];
+ const issueCodes = (
+ extra as { issue_codes?: { code: number }[] }
+ )?.issue_codes?.map(({ code }) => code) || [-1];
dispatch(
logEvent(LOG_ACTIONS_SQLLAB_FETCH_FAILED_QUERY, {
...eventData,
@@ -272,35 +428,51 @@ export function logFailedQuery(query, errors) {
};
}
-export function createQueryFailedAction(query, msg, link, errors) {
- return { type: QUERY_FAILED, query, msg, link, errors };
+export function createQueryFailedAction(
+ query: Query,
+ msg: string,
+ link?: string,
+ errors?: SupersetError[],
+) {
+ return { type: QUERY_FAILED, query, msg, link, errors } as const;
}
-export function queryFailed(query, msg, link, errors) {
+export function queryFailed(
+ query: Query,
+ msg: string,
+ link?: string,
+ errors?: SupersetError[],
+): SqlLabThunkAction<void> {
return function (dispatch) {
dispatch(logFailedQuery(query, errors));
dispatch(createQueryFailedAction(query, msg, link, errors));
};
}
-export function stopQuery(query) {
- return { type: STOP_QUERY, query };
+export function stopQuery(query: Query) {
+ return { type: STOP_QUERY, query } as const;
}
-export function clearQueryResults(query) {
+export function clearQueryResults(query: Query): SqlLabAction {
return { type: CLEAR_QUERY_RESULTS, query };
}
-export function removeDataPreview(table) {
+export function removeDataPreview(table: Table): SqlLabAction {
return { type: REMOVE_DATA_PREVIEW, table };
}
-export function requestQueryResults(query) {
+export function requestQueryResults(query: Query): SqlLabAction {
return { type: REQUEST_QUERY_RESULTS, query };
}
-export function fetchQueryResults(query, displayLimit, timeoutInMs) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function fetchQueryResults(
+ query: Query,
+ displayLimit?: number,
+ timeoutInMs?: number,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { SQLLAB_QUERY_RESULT_TIMEOUT } = getState().common?.conf ?? {};
dispatch(requestQueryResults(query));
@@ -315,7 +487,9 @@ export function fetchQueryResults(query, displayLimit, timeoutInMs) {
parseMethod: 'json-bigint',
...(timeout && { timeout, signal: controller.signal }),
})
- .then(({ json }) => dispatch(querySuccess(query, json)))
+ .then(({ json }) =>
+ dispatch(querySuccess(query, json as SqlExecuteResponse)),
+ )
.catch(response => {
controller.abort();
getClientErrorObject(response).then(error => {
@@ -332,8 +506,10 @@ export function fetchQueryResults(query, displayLimit, timeoutInMs) {
};
}
-export function runQuery(query, runPreviewOnly) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function runQuery(query: Query, runPreviewOnly?: boolean): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
dispatch(startQuery(query, runPreviewOnly));
const postPayload = {
client_id: query.id,
@@ -361,7 +537,7 @@ export function runQuery(query, runPreviewOnly) {
})
.then(({ json }) => {
if (!query.runAsync) {
- dispatch(querySuccess(query, json));
+ dispatch(querySuccess(query, json as SqlExecuteResponse));
}
})
.catch(response =>
@@ -380,17 +556,20 @@ export function runQuery(query, runPreviewOnly) {
};
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function runQueryFromSqlEditor(
- database,
- queryEditor,
- defaultQueryLimit,
- tempTable,
- ctas,
- ctasMethod,
-) {
- return function (dispatch, getState) {
+ database: Database | null,
+ queryEditor: QueryEditor,
+ defaultQueryLimit: number,
+ tempTable?: string,
+ ctas?: boolean,
+ ctasMethod?: string,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const qe = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
- const query = {
+ const query: Query = {
+ id: nanoid(11),
dbId: qe.dbId,
sql: qe.selectedText || qe.sql,
sqlEditorId: qe.tabViewId ?? qe.id,
@@ -410,15 +589,19 @@ export function runQueryFromSqlEditor(
};
}
-export function reRunQuery(query) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function reRunQuery(query: Query): any {
// run Query with a new id
- return function (dispatch) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
dispatch(runQuery({ ...query, id: nanoid(11) }));
};
}
-export function postStopQuery(query) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function postStopQuery(query: Query): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
return SupersetClient.post({
endpoint: '/api/v1/query/stop',
body: JSON.stringify({ client_id: query.id }),
@@ -430,11 +613,17 @@ export function postStopQuery(query) {
};
}
-export function setDatabases(databases) {
+export function setDatabases(databases: Database[]): SqlLabAction {
return { type: SET_DATABASES, databases };
}
-function migrateTable(table, queryEditorId, dispatch) {
+function migrateTable(
+ table: Table,
+ queryEditorId: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dispatch: any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): Promise<any> {
return SupersetClient.post({
endpoint: encodeURI('/tableschemaview/'),
postPayload: { table: { ...table, queryEditorId } },
@@ -459,7 +648,13 @@ function migrateTable(table, queryEditorId, dispatch) {
);
}
-function migrateQuery(queryId, queryEditorId, dispatch) {
+function migrateQuery(
+ queryId: string,
+ queryEditorId: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ dispatch: any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): Promise<any> {
return SupersetClient.post({
endpoint: encodeURI(`/tabstateview/${queryEditorId}/migrate_query`),
postPayload: { queryId },
@@ -486,14 +681,18 @@ function migrateQuery(queryId, queryEditorId, dispatch) {
* stored in local storage will also be synchronized to the backend
* through syncQueryEditor.
*/
-export function syncQueryEditor(queryEditor) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function syncQueryEditor(queryEditor: QueryEditor): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { tables, queries } = getState().sqlLab;
const localStorageTables = tables.filter(
- table => table.inLocalStorage && table.queryEditorId === queryEditor.id,
+ (table: Table) =>
+ table.inLocalStorage && table.queryEditorId === queryEditor.id,
);
const localStorageQueries = Object.values(queries).filter(
- query => query.inLocalStorage && query.sqlEditorId === queryEditor.id,
+ (query: Query) =>
+ query.inLocalStorage && query.sqlEditorId === queryEditor.id,
);
return SupersetClient.post({
endpoint: '/tabstateview/',
@@ -512,11 +711,11 @@ export function syncQueryEditor(queryEditor) {
newQueryEditor,
});
return Promise.all([
- ...localStorageTables.map(table =>
- migrateTable(table, newQueryEditor.tabViewId, dispatch),
+ ...localStorageTables.map((table: Table) =>
+ migrateTable(table, newQueryEditor.tabViewId!, dispatch),
),
- ...localStorageQueries.map(query =>
- migrateQuery(query.id, newQueryEditor.tabViewId, dispatch),
+ ...localStorageQueries.map((query: Query) =>
+ migrateQuery(query.id, newQueryEditor.tabViewId!, dispatch),
),
]);
})
@@ -533,7 +732,9 @@ export function syncQueryEditor(queryEditor) {
};
}
-export function addQueryEditor(queryEditor) {
+export function addQueryEditor(
+ queryEditor: Partial<QueryEditor>,
+): SqlLabAction {
const newQueryEditor = {
...queryEditor,
id: nanoid(11),
@@ -547,24 +748,28 @@ export function addQueryEditor(queryEditor) {
};
}
-export function addNewQueryEditor() {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function addNewQueryEditor(): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const {
sqlLab: { queryEditors, tabHistory, unsavedQueryEditor, databases },
common,
} = getState();
const defaultDbId = common.conf.SQLLAB_DEFAULT_DBID;
const activeQueryEditor = queryEditors.find(
- qe => qe.id === tabHistory[tabHistory.length - 1],
+ (qe: QueryEditor) => qe.id === tabHistory[tabHistory.length - 1],
+ );
+ const dbIds = Object.values(databases).map(
+ (database: Database) => database.id,
);
- const dbIds = Object.values(databases).map(database => database.id);
const firstDbId = dbIds.length > 0 ? Math.min(...dbIds) : undefined;
const { dbId, catalog, schema, queryLimit, autorun } = {
...queryEditors[0],
...activeQueryEditor,
...(unsavedQueryEditor.id === activeQueryEditor?.id &&
unsavedQueryEditor),
- };
+ } as Partial<QueryEditor>;
const warning = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? ''
: t(
@@ -572,7 +777,7 @@ export function addNewQueryEditor() {
);
const name = newQueryTabName(
- queryEditors?.map(qe => ({
+ queryEditors?.map((qe: QueryEditor) => ({
...qe,
...(qe.id === unsavedQueryEditor.id && unsavedQueryEditor),
})) || [],
@@ -581,8 +786,8 @@ export function addNewQueryEditor() {
return dispatch(
addQueryEditor({
dbId: dbId || defaultDbId || firstDbId,
- catalog: catalog ?? null,
- schema: schema ?? null,
+ catalog,
+ schema,
autorun: autorun ?? false,
sql: `${warning}SELECT ...`,
queryLimit: queryLimit || common.conf.DEFAULT_SQLLAB_LIMIT,
@@ -592,20 +797,25 @@ export function addNewQueryEditor() {
};
}
-export function cloneQueryToNewTab(query, autorun) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function cloneQueryToNewTab(query: Query, autorun: boolean): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const state = getState();
const { queryEditors, unsavedQueryEditor, tabHistory } = state.sqlLab;
const sourceQueryEditor = {
- ...queryEditors.find(qe => qe.id === tabHistory[tabHistory.length - 1]),
+ ...queryEditors.find(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (qe: any) => qe.id === tabHistory[tabHistory.length - 1],
+ ),
...(tabHistory[tabHistory.length - 1] === unsavedQueryEditor.id &&
unsavedQueryEditor),
};
const queryEditor = {
name: t('Copy of %s', sourceQueryEditor.name),
- dbId: query.dbId ? query.dbId : null,
- catalog: query.catalog ? query.catalog : null,
- schema: query.schema ? query.schema : null,
+ dbId: query.dbId,
+ catalog: query.catalog,
+ schema: query.schema ?? undefined,
autorun,
sql: query.sql,
queryLimit: sourceQueryEditor.queryLimit,
@@ -616,26 +826,31 @@ export function cloneQueryToNewTab(query, autorun) {
};
}
-export function setLastUpdatedActiveTab(queryEditorId) {
+export function setLastUpdatedActiveTab(queryEditorId: string): SqlLabAction {
return {
type: SET_LAST_UPDATED_ACTIVE_TAB,
queryEditorId,
};
}
-export function setActiveQueryEditor(queryEditor) {
+export function setActiveQueryEditor(queryEditor: QueryEditor): SqlLabAction {
return {
type: SET_ACTIVE_QUERY_EDITOR,
queryEditor,
};
}
-export function switchQueryEditor(goBackward = false) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function switchQueryEditor(goBackward = false): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { sqlLab } = getState();
const { queryEditors, tabHistory } = sqlLab;
const qeid = tabHistory[tabHistory.length - 1];
- const currentIndex = queryEditors.findIndex(qe => qe.id === qeid);
+ const currentIndex = queryEditors.findIndex(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (qe: any) => qe.id === qeid,
+ );
const nextIndex = goBackward
? currentIndex - 1 + queryEditors.length
: currentIndex + 1;
@@ -645,14 +860,32 @@ export function switchQueryEditor(goBackward = false) {
};
}
-export function loadQueryEditor(queryEditor) {
+export function loadQueryEditor(queryEditor: QueryEditor): SqlLabAction {
return { type: LOAD_QUERY_EDITOR, queryEditor };
}
-export function setTables(tableSchemas) {
+interface TableSchema {
+ description: {
+ columns: unknown[];
+ selectStar: string;
+ primaryKey: unknown;
+ foreignKeys: unknown[];
+ indexes: unknown[];
+ dataPreviewQueryId: string;
+ } | null;
+ database_id: number;
+ tab_state_id: number;
+ catalog: string;
+ schema: string;
+ table: string;
+ expanded: boolean;
+ id: string;
+}
+
+export function setTables(tableSchemas: TableSchema[]): SqlLabAction {
const tables = tableSchemas
- .filter(tableSchema => tableSchema.description !== null)
- .map(tableSchema => {
+ .filter((tableSchema: TableSchema) => tableSchema.description !== null)
+ .map((tableSchema: TableSchema) => {
const {
columns,
selectStar,
@@ -660,7 +893,7 @@ export function setTables(tableSchemas) {
foreignKeys,
indexes,
dataPreviewQueryId,
- } = tableSchema.description;
+ } = tableSchema.description!;
return {
dbId: tableSchema.database_id,
queryEditorId: tableSchema.tab_state_id.toString(),
@@ -682,8 +915,13 @@ export function setTables(tableSchemas) {
return { type: SET_TABLES, tables };
}
-export function fetchQueryEditor(queryEditor, displayLimit) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function fetchQueryEditor(
+ queryEditor: QueryEditor,
+ displayLimit: number,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const queryEditorId = queryEditor.tabViewId ?? queryEditor.id;
SupersetClient.get({
endpoint: encodeURI(`/tabstateview/${queryEditorId}`),
@@ -705,9 +943,9 @@ export function fetchQueryEditor(queryEditor, displayLimit) {
remoteId: json.saved_query?.id,
hideLeftBar: json.hide_left_bar,
};
- dispatch(loadQueryEditor(loadedQueryEditor));
+ dispatch(loadQueryEditor(loadedQueryEditor as unknown as QueryEditor));
dispatch(setTables(json.table_schemas || []));
- if (json.latest_query && json.latest_query.resultsKey) {
+ if (json.latest_query?.resultsKey) {
dispatch(fetchQueryResults(json.latest_query, displayLimit));
}
})
@@ -722,11 +960,11 @@ export function fetchQueryEditor(queryEditor, displayLimit) {
};
}
-export function setActiveSouthPaneTab(tabId) {
+export function setActiveSouthPaneTab(tabId: string): SqlLabAction {
return { type: SET_ACTIVE_SOUTHPANE_TAB, tabId };
}
-export function toggleLeftBar(queryEditor) {
+export function toggleLeftBar(queryEditor: QueryEditor): SqlLabAction {
const hideLeftBar = !queryEditor.hideLeftBar;
return {
type: QUERY_EDITOR_TOGGLE_LEFT_BAR,
@@ -735,18 +973,21 @@ export function toggleLeftBar(queryEditor) {
};
}
-export function clearDestoryedQueryEditor(queryEditorId) {
+export function clearDestoryedQueryEditor(queryEditorId: string): SqlLabAction {
return { type: CLEAR_DESTROYED_QUERY_EDITOR, queryEditorId };
}
-export function removeQueryEditor(queryEditor) {
+export function removeQueryEditor(queryEditor: QueryEditor): SqlLabAction {
return { type: REMOVE_QUERY_EDITOR, queryEditor };
}
-export function removeAllOtherQueryEditors(queryEditor) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function removeAllOtherQueryEditors(queryEditor: QueryEditor): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { sqlLab } = getState();
- sqlLab.queryEditors?.forEach(otherQueryEditor => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sqlLab.queryEditors?.forEach((otherQueryEditor: any) => {
if (otherQueryEditor.id !== queryEditor.id) {
dispatch(removeQueryEditor(otherQueryEditor));
}
@@ -754,8 +995,10 @@ export function removeAllOtherQueryEditors(queryEditor) {
};
}
-export function removeQuery(query) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function removeQuery(query: Query): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const queryEditorId = query.sqlEditorId ?? query.id;
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? SupersetClient.delete({
@@ -779,11 +1022,17 @@ export function removeQuery(query) {
};
}
-export function queryEditorSetDb(queryEditor, dbId) {
+export function queryEditorSetDb(
+ queryEditor: Partial<QueryEditor>,
+ dbId: number,
+): SqlLabAction {
return { type: QUERY_EDITOR_SETDB, queryEditor, dbId };
}
-export function queryEditorSetCatalog(queryEditor, catalog) {
+export function queryEditorSetCatalog(
+ queryEditor: Partial<QueryEditor> | null,
+ catalog: string | null,
+): SqlLabAction {
return {
type: QUERY_EDITOR_SET_CATALOG,
queryEditor: queryEditor || {},
@@ -791,7 +1040,10 @@ export function queryEditorSetCatalog(queryEditor, catalog) {
};
}
-export function queryEditorSetSchema(queryEditor, schema) {
+export function queryEditorSetSchema(
+ queryEditor: Partial<QueryEditor> | null,
+ schema: string | null,
+): SqlLabAction {
return {
type: QUERY_EDITOR_SET_SCHEMA,
queryEditor: queryEditor || {},
@@ -799,11 +1051,18 @@ export function queryEditorSetSchema(queryEditor, schema) {
};
}
-export function queryEditorSetAutorun(queryEditor, autorun) {
+export function queryEditorSetAutorun(
+ queryEditor: Partial<QueryEditor>,
+ autorun: boolean,
+): SqlLabAction {
return { type: QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun };
}
-export function queryEditorSetTitle(queryEditor, name, id) {
+export function queryEditorSetTitle(
+ queryEditor: Partial<QueryEditor>,
+ name: string,
+ id: string,
+): SqlLabAction {
return {
type: QUERY_EDITOR_SET_TITLE,
queryEditor: { ...queryEditor, id },
@@ -811,13 +1070,19 @@ export function queryEditorSetTitle(queryEditor, name, id) {
};
}
-export function saveQuery(query, clientId) {
- const { id, ...payload } = convertQueryToServer(query);
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function saveQuery(query: Partial<QueryEditor>, clientId: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { id: _id, ...payload } = convertQueryToServer(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ query as any,
+ );
- return dispatch =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any) =>
SupersetClient.post({
endpoint: '/api/v1/saved_query/',
- jsonPayload: convertQueryToServer(payload),
+ jsonPayload: convertQueryToServer(payload as Record<string, unknown>),
})
.then(result => {
const savedQuery = convertQueryToClient({
@@ -830,7 +1095,7 @@ export function saveQuery(query, clientId) {
clientId,
result: savedQuery,
});
- dispatch(queryEditorSetTitle(query, query.name, clientId));
+ dispatch(queryEditorSetTitle(query, query.name ?? '', clientId));
return savedQuery;
})
.catch(() =>
@@ -838,36 +1103,49 @@ export function saveQuery(query, clientId) {
);
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const addSavedQueryToTabState =
- (queryEditor, savedQuery) => dispatch => {
- const queryEditorId = queryEditor.tabViewId ?? queryEditor.id;
- const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
- ? SupersetClient.put({
- endpoint: `/tabstateview/${queryEditorId}`,
- postPayload: { saved_query_id: savedQuery.remoteId },
- })
- : Promise.resolve();
-
- return sync
- .catch(() => {
- dispatch(addDangerToast(t('Your query was not properly saved')));
- })
- .then(() => {
- dispatch(addSuccessToast(t('Your query was saved')));
- });
- };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (queryEditor: QueryEditor, savedQuery: { remoteId: string }): any =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (dispatch: any) => {
+ const queryEditorId = queryEditor.tabViewId ?? queryEditor.id;
+ const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
+ ? SupersetClient.put({
+ endpoint: `/tabstateview/${queryEditorId}`,
+ postPayload: { saved_query_id: savedQuery.remoteId },
+ })
+ : Promise.resolve();
-export function updateSavedQuery(query, clientId) {
- const { id, ...payload } = convertQueryToServer(query);
+ return sync
+ .catch(() => {
+ dispatch(addDangerToast(t('Your query was not properly saved')));
+ })
+ .then(() => {
+ dispatch(addSuccessToast(t('Your query was saved')));
+ });
+ };
- return dispatch =>
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function updateSavedQuery(
+ query: Partial<QueryEditor>,
+ clientId: string,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { id: _id, ...payload } = convertQueryToServer(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ query as any,
+ );
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any) =>
SupersetClient.put({
endpoint: `/api/v1/saved_query/${query.remoteId}`,
- jsonPayload: convertQueryToServer(payload),
+ jsonPayload: convertQueryToServer(payload as Record<string, unknown>),
})
.then(() => {
dispatch(addSuccessToast(t('Your query was updated')));
- dispatch(queryEditorSetTitle(query, query.name, clientId));
+ dispatch(queryEditorSetTitle(query, query.name ?? '', clientId));
})
.catch(e => {
const message = t('Your query could not be updated');
@@ -878,16 +1156,29 @@ export function updateSavedQuery(query, clientId) {
.then(() => dispatch(updateQueryEditor(query)));
}
-export function queryEditorSetSql(queryEditor, sql, queryId) {
+export function queryEditorSetSql(
+ queryEditor: Partial<QueryEditor>,
+ sql: string,
+ queryId?: string,
+): SqlLabAction {
return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql, queryId };
}
-export function queryEditorSetCursorPosition(queryEditor, position) {
+export function queryEditorSetCursorPosition(
+ queryEditor: Partial<QueryEditor>,
+ position: { row: number; column: number },
+): SqlLabAction {
return { type: QUERY_EDITOR_SET_CURSOR_POSITION, queryEditor, position };
}
-export function queryEditorSetAndSaveSql(targetQueryEditor, sql, queryId) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function queryEditorSetAndSaveSql(
+ targetQueryEditor: Partial<QueryEditor>,
+ sql: string,
+ queryId?: string,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const queryEditor = getUpToDateQuery(getState(), targetQueryEditor);
// saved query and set tab state use this action
dispatch(queryEditorSetSql(queryEditor, sql, queryId));
@@ -912,13 +1203,19 @@ export function queryEditorSetAndSaveSql(targetQueryEditor, sql, queryId) {
};
}
-export function formatQuery(queryEditor) {
- return function (dispatch, getState) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function formatQuery(queryEditor: QueryEditor): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { sql, dbId, templateParams } = getUpToDateQuery(
getState(),
queryEditor,
);
- const body = { sql };
+ const body: {
+ sql: string;
+ database_id?: number;
+ template_params?: string;
+ } = { sql };
// Include database_id and template_params if available for Jinja processing
if (dbId) {
@@ -942,7 +1239,10 @@ export function formatQuery(queryEditor) {
};
}
-export function queryEditorSetQueryLimit(queryEditor, queryLimit) {
+export function queryEditorSetQueryLimit(
+ queryEditor: Partial<QueryEditor>,
+ queryLimit: number,
+): SqlLabAction {
return {
type: QUERY_EDITOR_SET_QUERY_LIMIT,
queryEditor,
@@ -950,7 +1250,10 @@ export function queryEditorSetQueryLimit(queryEditor, queryLimit) {
};
}
-export function queryEditorSetTemplateParams(queryEditor, templateParams) {
+export function queryEditorSetTemplateParams(
+ queryEditor: Partial<QueryEditor>,
+ templateParams: string,
+): SqlLabAction {
return {
type: QUERY_EDITOR_SET_TEMPLATE_PARAMS,
queryEditor,
@@ -958,22 +1261,31 @@ export function queryEditorSetTemplateParams(queryEditor, templateParams) {
};
}
-export function queryEditorSetSelectedText(queryEditor, sql) {
+export function queryEditorSetSelectedText(
+ queryEditor: Partial<QueryEditor>,
+ sql: string | null,
+): SqlLabAction {
return { type: QUERY_EDITOR_SET_SELECTED_TEXT, queryEditor, sql };
}
-export function mergeTable(table, query, prepend) {
+export function mergeTable(
+ table: Partial<Table>,
+ query?: Query,
+ prepend?: boolean,
+): SqlLabAction {
return { type: MERGE_TABLE, table, query, prepend };
}
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function addTable(
- queryEditor,
- tableName,
- catalogName,
- schemaName,
+ queryEditor: Partial<QueryEditor>,
+ tableName: string,
+ catalogName: string | null,
+ schemaName: string,
expanded = true,
-) {
- return function (dispatch, getState) {
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
dbId,
@@ -992,8 +1304,24 @@ export function addTable(
};
}
-export function runTablePreviewQuery(newTable, runPreviewOnly) {
- return function (dispatch, getState) {
+interface NewTable {
+ id?: string;
+ dbId: number | string;
+ catalog?: string | null;
+ schema?: string;
+ name: string;
+ queryEditorId?: string;
+ selectStar?: string;
+ previewQueryId?: string;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function runTablePreviewQuery(
+ newTable: NewTable,
+ runPreviewOnly?: boolean,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any, getState: any) {
const {
sqlLab: { databases },
} = getState();
@@ -1001,12 +1329,12 @@ export function runTablePreviewQuery(newTable, runPreviewOnly) {
const { dbId, catalog, schema } = newTable;
if (database && !database.disable_data_preview) {
- const dataPreviewQuery = {
+ const dataPreviewQuery: Query = {
id: newTable.previewQueryId ?? nanoid(11),
- dbId,
- catalog,
- schema,
- sql: newTable.selectStar,
+ dbId: dbId as number | undefined,
+ catalog: catalog ?? undefined,
+ schema: schema ?? '',
+ sql: newTable.selectStar ?? '',
tableName: newTable.name,
sqlEditorId: null,
tab: '',
@@ -1022,9 +1350,9 @@ export function runTablePreviewQuery(newTable, runPreviewOnly) {
mergeTable(
{
id: newTable.id,
- dbId: newTable.dbId,
- catalog: newTable.catalog,
- schema: newTable.schema,
+ dbId: newTable.dbId as number | undefined,
+ catalog: newTable.catalog ?? undefined,
+ schema: newTable.schema ?? '',
name: newTable.name,
queryEditorId: newTable.queryEditorId,
dataPreviewQueryId: dataPreviewQuery.id,
@@ -1039,8 +1367,22 @@ export function runTablePreviewQuery(newTable, runPreviewOnly) {
};
}
-export function syncTable(table, tableMetadata, finalQueryEditorId) {
- return function (dispatch) {
+interface TableMetaData {
+ columns?: unknown[];
+ selectStar?: string;
+ primaryKey?: unknown;
+ foreignKeys?: unknown[];
+ indexes?: unknown[];
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function syncTable(
+ table: Table,
+ tableMetadata: TableMetaData,
+ finalQueryEditorId?: string,
+): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const finalTable = { ...table, queryEditorId: finalQueryEditorId };
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? SupersetClient.post({
@@ -1073,13 +1415,18 @@ export function syncTable(table, tableMetadata, finalQueryEditorId) {
};
}
-export function changeDataPreviewId(oldQueryId, newQuery) {
+export function changeDataPreviewId(
+ oldQueryId: string,
+ newQuery: Query,
+): SqlLabAction {
return { type: CHANGE_DATA_PREVIEW_ID, oldQueryId, newQuery };
}
-export function reFetchQueryResults(query) {
- return function (dispatch) {
- const newQuery = {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function reFetchQueryResults(query: Query): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
+ const newQuery: Query = {
id: nanoid(),
dbId: query.dbId,
sql: query.sql,
@@ -1096,8 +1443,10 @@ export function reFetchQueryResults(query) {
};
}
-export function expandTable(table) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function expandTable(table: Table): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const sync =
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
table.initialized
@@ -1122,8 +1471,10 @@ export function expandTable(table) {
};
}
-export function collapseTable(table) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function collapseTable(table: Table): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const sync =
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
table.initialized
@@ -1148,12 +1499,14 @@ export function collapseTable(table) {
};
}
-export function removeTables(tables) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function removeTables(tables: Table[]): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const tablesToRemove = tables?.filter(Boolean) ?? [];
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? Promise.all(
- tablesToRemove.map(table =>
+ tablesToRemove.map((table: Table) =>
table.initialized
? SupersetClient.delete({
endpoint: encodeURI(`/tableschemaview/${table.id}`),
@@ -1178,15 +1531,21 @@ export function removeTables(tables) {
};
}
-export function refreshQueries(alteredQueries) {
+export function refreshQueries(
+ alteredQueries: Record<string, Query>,
+): SqlLabAction {
return { type: REFRESH_QUERIES, alteredQueries };
}
-export function setUserOffline(offline) {
+export function setUserOffline(offline: boolean): SqlLabAction {
return { type: SET_USER_OFFLINE, offline };
}
-export function persistEditorHeight(queryEditor, northPercent, southPercent) {
+export function persistEditorHeight(
+ queryEditor: QueryEditor,
+ northPercent: number,
+ southPercent: number,
+): SqlLabAction {
return {
type: QUERY_EDITOR_PERSIST_HEIGHT,
queryEditor,
@@ -1195,16 +1554,18 @@ export function persistEditorHeight(queryEditor, northPercent, southPercent) {
};
}
-export function popPermalink(key) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function popPermalink(key: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
return SupersetClient.get({ endpoint: `/api/v1/sqllab/permalink/${key}` })
.then(({ json }) =>
dispatch(
addQueryEditor({
name: json.name ? json.name : t('Shared query'),
- dbId: json.dbId ? parseInt(json.dbId, 10) : null,
- catalog: json.catalog ? json.catalog : null,
- schema: json.schema ? json.schema : null,
+ dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
+ catalog: json.catalog ?? null,
+ schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
@@ -1215,8 +1576,10 @@ export function popPermalink(key) {
};
}
-export function popStoredQuery(urlId) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function popStoredQuery(urlId: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
return SupersetClient.get({
endpoint: `/api/v1/sqllab/permalink/kv:${urlId}`,
})
@@ -1224,9 +1587,9 @@ export function popStoredQuery(urlId) {
dispatch(
addQueryEditor({
name: json.name ? json.name : t('Shared query'),
- dbId: json.dbId ? parseInt(json.dbId, 10) : null,
- catalog: json.catalog ? json.catalog : null,
- schema: json.schema ? json.schema : null,
+ dbId: json.dbId ? parseInt(json.dbId, 10) : undefined,
+ catalog: json.catalog ?? null,
+ schema: json.schema ?? undefined,
autorun: json.autorun ? json.autorun : false,
sql: json.sql ? json.sql : 'SELECT ...',
templateParams: json.templateParams,
@@ -1236,8 +1599,10 @@ export function popStoredQuery(urlId) {
.catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY)));
};
}
-export function popSavedQuery(saveQueryId) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function popSavedQuery(saveQueryId: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
return SupersetClient.get({
endpoint: `/api/v1/saved_query/${saveQueryId}`,
})
@@ -1246,23 +1611,25 @@ export function popSavedQuery(saveQueryId) {
...convertQueryToClient(json.result),
loaded: true,
autorun: false,
- };
+ } as Record<string, unknown>;
const tmpAdaptedProps = {
- name: queryEditorProps.name,
- dbId: queryEditorProps.database.id,
- catalog: queryEditorProps.catalog,
- schema: queryEditorProps.schema,
- sql: queryEditorProps.sql,
- templateParams: queryEditorProps.templateParams,
- remoteId: queryEditorProps.remoteId,
+ name: queryEditorProps.name as string,
+ dbId: (queryEditorProps.database as { id: number }).id,
+ catalog: queryEditorProps.catalog as string,
+ schema: queryEditorProps.schema as string,
+ sql: queryEditorProps.sql as string,
+ templateParams: queryEditorProps.templateParams as string,
+ remoteId: queryEditorProps.remoteId as number | null,
};
return dispatch(addQueryEditor(tmpAdaptedProps));
})
.catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY)));
};
}
-export function popQuery(queryId) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function popQuery(queryId: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
return SupersetClient.get({
endpoint: `/api/v1/query/${queryId}`,
})
@@ -1281,8 +1648,10 @@ export function popQuery(queryId) {
.catch(() => dispatch(addDangerToast(ERR_MSG_CANT_LOAD_QUERY)));
};
}
-export function popDatasourceQuery(datasourceKey, sql) {
- return function (dispatch) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function popDatasourceQuery(datasourceKey: string, sql?: string): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return function (dispatch: any) {
const QUERY_TEXT = t('Query');
const datasetId = datasourceKey.split('__')[0];
@@ -1310,19 +1679,30 @@ export function popDatasourceQuery(datasourceKey, sql) {
);
};
}
-export function createDatasourceStarted() {
+export function createDatasourceStarted(): SqlLabAction {
return { type: CREATE_DATASOURCE_STARTED };
}
-export function createDatasourceSuccess(data) {
+export function createDatasourceSuccess(data: { id: number }): SqlLabAction {
const datasource = `${data.id}__table`;
return { type: CREATE_DATASOURCE_SUCCESS, datasource };
}
-export function createDatasourceFailed(err) {
+export function createDatasourceFailed(err: string): SqlLabAction {
return { type: CREATE_DATASOURCE_FAILED, err };
}
-export function createDatasource(vizOptions) {
- return dispatch => {
+interface VizOptions {
+ dbId: number;
+ catalog?: string | null;
+ schema: string;
+ datasourceName: string;
+ sql: string;
+ templateParams?: string;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function createDatasource(vizOptions: VizOptions): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any) => {
dispatch(createDatasourceStarted());
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
vizOptions;
@@ -1341,7 +1721,7 @@ export function createDatasource(vizOptions) {
}),
})
.then(({ json }) => {
- dispatch(createDatasourceSuccess(json));
+ dispatch(createDatasourceSuccess(json as { id: number }));
return Promise.resolve(json);
})
@@ -1360,8 +1740,10 @@ export function createDatasource(vizOptions) {
};
}
-export function createCtasDatasource(vizOptions) {
- return dispatch => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function createCtasDatasource(vizOptions: Record<string, unknown>): any {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (dispatch: any) => {
dispatch(createDatasourceStarted());
return SupersetClient.post({
endpoint: '/api/v1/dataset/get_or_create/',
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
index e0f49b70e3..3e4a756467 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
@@ -232,7 +232,8 @@ test('returns column keywords among selected tables', async () => {
);
storeWithSqlLab.dispatch(
addTable(
- { id: expectQueryEditorId },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { id: expectQueryEditorId } as any,
expectTable,
expectCatalog,
expectSchema,
@@ -276,7 +277,8 @@ test('returns column keywords among selected tables', async () => {
act(() => {
storeWithSqlLab.dispatch(
addTable(
- { id: expectQueryEditorId },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { id: expectQueryEditorId } as any,
unexpectedTable,
expectCatalog,
expectSchema,
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
index 1a8eb7b368..4ab5a31964 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
@@ -149,10 +149,10 @@ export function useKeywords(
if (data.meta === 'table') {
dispatch(
addTable(
- { id: queryEditorId, dbId, tabViewId },
+ { id: String(queryEditorId), dbId: dbId as number, tabViewId },
data.value,
- catalog,
- schema,
+ catalog ?? null,
+ schema ?? '',
false, // Don't auto-expand/switch tabs when adding via autocomplete
),
);
diff --git a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx
index f941fb4587..2bf1aca284 100644
--- a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/index.tsx
@@ -122,7 +122,7 @@ function QueryAutoRefresh({
dispatch(
queryFailed(
query,
- query.errorMessage,
+ query.errorMessage ?? '',
query.extra?.errors?.[0]?.extra?.link,
query.extra?.errors,
),
diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
index 17c5de8085..048486bcdb 100644
--- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
@@ -344,9 +344,9 @@ export const SaveDatasetModal = ({
dispatch(
createDatasource({
sql: datasource.sql,
- dbId: datasource.dbId || datasource?.database?.id,
- catalog: datasource?.catalog,
- schema: datasource?.schema,
+ dbId: (datasource.dbId ?? datasource?.database?.id) as number,
+ catalog: datasource?.catalog ?? null,
+ schema: datasource?.schema ?? '',
templateParams,
datasourceName: datasetName,
}),
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index b154371add..84256491ec 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -143,7 +143,9 @@ const SouthPane = ({
({ dbId, catalog, schema, name }) =>
[dbId, catalog, schema, name].join(':') === key,
);
- dispatch(removeTables([table]));
+ if (table) {
+ dispatch(removeTables([table]));
+ }
}
},
[dispatch, pinnedTables],
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index 9977778430..b3d3c75cfd 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -280,7 +280,8 @@ const SqlEditor: FC<Props> = ({
dispatch(
runQueryFromSqlEditor(
- database,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ database as any,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
@@ -565,8 +566,8 @@ const SqlEditor: FC<Props> = ({
};
const setQueryEditorAndSaveSql = useCallback(
- sql => {
- dispatch(queryEditorSetAndSaveSql(queryEditor, sql));
+ (sql: string) => {
+ dispatch(queryEditorSetAndSaveSql(queryEditor, sql, undefined));
},
[dispatch, queryEditor],
);
@@ -578,7 +579,7 @@ const SqlEditor: FC<Props> = ({
const onSqlChanged = useEffectEvent((sql: string) => {
currentSQL.current = sql;
- dispatch(queryEditorSetSql(queryEditor, sql));
+ dispatch(queryEditorSetSql(queryEditor, sql, undefined));
});
const getQueryCostEstimate = () => {
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index 5158ef7e3c..060ea2c6af 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -31,6 +31,7 @@ import {
setDatabases,
addDangerToast,
resetState,
+ type Database,
} from 'src/SqlLab/actions/sqlLab';
import { Button, EmptyState, Icons } from '@superset-ui/core/components';
import { type DatabaseObject } from 'src/components';
@@ -194,8 +195,8 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
);
const handleDbList = useCallback(
- (result: DatabaseObject) => {
- dispatch(setDatabases(result));
+ (result: DatabaseObject[]) => {
+ dispatch(setDatabases(result as unknown as Database[]));
},
[dispatch],
);
diff --git a/superset-frontend/src/SqlLab/components/TableElement/index.tsx b/superset-frontend/src/SqlLab/components/TableElement/index.tsx
index 251c3cb7dc..0923db10f8 100644
--- a/superset-frontend/src/SqlLab/components/TableElement/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TableElement/index.tsx
@@ -171,7 +171,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
dispatch(
tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: name }]),
);
- dispatch(syncTable(table, tableData));
+ dispatch(syncTable(table, tableData, table.queryEditorId));
};
const renderWell = () => {
diff --git a/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js b/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js
deleted file mode 100644
index 9fa11de0da..0000000000
--- a/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * 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 persistState from 'redux-localstorage';
-import { pickBy } from 'lodash';
-import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
-import { filterUnsavedQueryEditorList } from 'src/SqlLab/components/EditorAutoSync';
-import {
- emptyTablePersistData,
- emptyQueryResults,
- clearQueryEditors,
-} from '../utils/reduxStateToLocalStorageHelper';
-import { BYTES_PER_CHAR, KB_STORAGE } from '../constants';
-
-const CLEAR_ENTITY_HELPERS_MAP = {
- tables: emptyTablePersistData,
- queries: emptyQueryResults,
- queryEditors: clearQueryEditors,
- unsavedQueryEditor: qe => clearQueryEditors([qe])[0],
-};
-
-const sqlLabPersistStateConfig = {
- paths: ['sqlLab'],
- config: {
- slicer: paths => state => {
- const subset = {};
- paths.forEach(path => {
- if (isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)) {
- const {
- queryEditors,
- editorTabLastUpdatedAt,
- unsavedQueryEditor,
- tables,
- queries,
- tabHistory,
- lastUpdatedActiveTab,
- destroyedQueryEditors,
- } = state.sqlLab;
- const unsavedQueryEditors = filterUnsavedQueryEditorList(
- queryEditors,
- unsavedQueryEditor,
- editorTabLastUpdatedAt,
- );
- const hasUnsavedActiveTabState =
- tabHistory.slice(-1)[0] !== lastUpdatedActiveTab;
- const hasUnsavedDeletedQueryEditors =
- Object.keys(destroyedQueryEditors).length > 0;
- if (
- unsavedQueryEditors.length > 0 ||
- hasUnsavedActiveTabState ||
- hasUnsavedDeletedQueryEditors
- ) {
- const hasFinishedMigrationFromLocalStorage =
- unsavedQueryEditors.every(
- ({ inLocalStorage }) => !inLocalStorage,
- );
- subset.sqlLab = {
- queryEditors: unsavedQueryEditors,
- ...(!hasFinishedMigrationFromLocalStorage && {
- tabHistory,
- tables: tables.filter(table => table.inLocalStorage),
- queries: pickBy(
- queries,
- query => query.inLocalStorage && !query.isDataPreview,
- ),
- }),
- ...(hasUnsavedActiveTabState && {
- tabHistory,
- }),
- destroyedQueryEditors,
- };
- }
- return;
- }
- // this line is used to remove old data from browser localStorage.
- // we used to persist all redux state into localStorage, but
- // it caused configurations passed from server-side got override.
- // see PR 6257 for details
- delete state[path].common; // eslint-disable-line no-param-reassign
- if (path === 'sqlLab') {
- subset[path] = Object.fromEntries(
- Object.entries(state[path]).map(([key, value]) => [
- key,
- CLEAR_ENTITY_HELPERS_MAP[key]?.(value) ?? value,
- ]),
- );
- }
- });
-
- const data = JSON.stringify(subset);
- // 2 digit precision
- const currentSize =
- Math.round(((data.length * BYTES_PER_CHAR) / KB_STORAGE) * 100) / 100;
- if (state.localStorageUsageInKilobytes !== currentSize) {
- state.localStorageUsageInKilobytes = currentSize; // eslint-disable-line no-param-reassign
- }
-
- return subset;
- },
- merge: (initialState, persistedState = {}) => {
- const result = {
- ...initialState,
- ...persistedState,
- sqlLab: {
- ...persistedState?.sqlLab,
- // Overwrite initialState over persistedState for sqlLab
- // since a logic in getInitialState overrides the value from persistedState
- ...initialState.sqlLab,
- },
- };
- return result;
- },
- },
-};
-
-// TODO: requires redux-localstorage > 1.0 for typescript support
-/** @type {any} */
-export const persistSqlLabStateEnhancer = persistState(
- sqlLabPersistStateConfig.paths,
- sqlLabPersistStateConfig.config,
-);
diff --git a/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.ts b/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.ts
new file mode 100644
index 0000000000..730482cf6b
--- /dev/null
+++ b/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.ts
@@ -0,0 +1,185 @@
+/**
+ * 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 type { StoreEnhancer } from 'redux';
+import persistState from 'redux-localstorage';
+import { pickBy } from 'lodash';
+import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
+import { filterUnsavedQueryEditorList } from 'src/SqlLab/components/EditorAutoSync';
+import type {
+ SqlLabRootState,
+ QueryEditor,
+ UnsavedQueryEditor,
+ Table,
+} from '../types';
+import {
+ emptyTablePersistData,
+ emptyQueryResults,
+ clearQueryEditors,
+} from '../utils/reduxStateToLocalStorageHelper';
+import { BYTES_PER_CHAR, KB_STORAGE } from '../constants';
+
+type SqlLabState = SqlLabRootState['sqlLab'];
+
+type ClearEntityHelperValue =
+ | Table[]
+ | SqlLabState['queries']
+ | QueryEditor[]
+ | UnsavedQueryEditor;
+
+interface ClearEntityHelpersMap {
+ tables: (tables: Table[]) => ReturnType<typeof emptyTablePersistData>;
+ queries: (
+ queries: SqlLabState['queries'],
+ ) => ReturnType<typeof emptyQueryResults>;
+ queryEditors: (
+ queryEditors: QueryEditor[],
+ ) => ReturnType<typeof clearQueryEditors>;
+ unsavedQueryEditor: (
+ qe: UnsavedQueryEditor,
+ ) => ReturnType<typeof clearQueryEditors>[number];
+}
+
+const CLEAR_ENTITY_HELPERS_MAP: ClearEntityHelpersMap = {
+ tables: emptyTablePersistData,
+ queries: emptyQueryResults,
+ queryEditors: clearQueryEditors,
+ unsavedQueryEditor: (qe: UnsavedQueryEditor) =>
+ clearQueryEditors([qe as QueryEditor])[0],
+};
+
+interface PersistedSqlLabState {
+ sqlLab?: Partial<SqlLabState>;
+ localStorageUsageInKilobytes?: number;
+}
+
+const sqlLabPersistStateConfig = {
+ paths: ['sqlLab'],
+ config: {
+ slicer:
+ (paths: string[]) =>
+ (state: SqlLabRootState): PersistedSqlLabState => {
+ const subset: PersistedSqlLabState = {};
+ paths.forEach(path => {
+ if (isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)) {
+ const {
+ queryEditors,
+ editorTabLastUpdatedAt,
+ unsavedQueryEditor,
+ tables,
+ queries,
+ tabHistory,
+ lastUpdatedActiveTab,
+ destroyedQueryEditors,
+ } = state.sqlLab;
+ const unsavedQueryEditors = filterUnsavedQueryEditorList(
+ queryEditors,
+ unsavedQueryEditor,
+ editorTabLastUpdatedAt,
+ );
+ const hasUnsavedActiveTabState =
+ tabHistory.slice(-1)[0] !== lastUpdatedActiveTab;
+ const hasUnsavedDeletedQueryEditors =
+ Object.keys(destroyedQueryEditors).length > 0;
+ if (
+ unsavedQueryEditors.length > 0 ||
+ hasUnsavedActiveTabState ||
+ hasUnsavedDeletedQueryEditors
+ ) {
+ const hasFinishedMigrationFromLocalStorage =
+ unsavedQueryEditors.every(
+ ({ inLocalStorage }) => !inLocalStorage,
+ );
+ subset.sqlLab = {
+ queryEditors: unsavedQueryEditors,
+ ...(!hasFinishedMigrationFromLocalStorage && {
+ tabHistory,
+ tables: tables.filter(table => table.inLocalStorage),
+ queries: pickBy(
+ queries,
+ query => query.inLocalStorage && !query.isDataPreview,
+ ),
+ }),
+ ...(hasUnsavedActiveTabState && {
+ tabHistory,
+ }),
+ destroyedQueryEditors,
+ };
+ }
+ return;
+ }
+ // this line is used to remove old data from browser localStorage.
+ // we used to persist all redux state into localStorage, but
+ // it caused configurations passed from server-side got override.
+ // see PR 6257 for details
+ const statePath = state[path as keyof SqlLabRootState];
+ if (
+ statePath &&
+ typeof statePath === 'object' &&
+ 'common' in statePath
+ ) {
+ delete (statePath as Record<string, unknown>).common; // eslint-disable-line no-param-reassign
+ }
+ if (path === 'sqlLab') {
+ subset[path] = Object.fromEntries(
+ Object.entries(state[path]).map(([key, value]) => {
+ const helper = CLEAR_ENTITY_HELPERS_MAP[
+ key as keyof ClearEntityHelpersMap
+ ] as ((val: ClearEntityHelperValue) => unknown) | undefined;
+ return [
+ key,
+ helper?.(value as ClearEntityHelperValue) ?? value,
+ ];
+ }),
+ );
+ }
+ });
+
+ const data = JSON.stringify(subset);
+ // 2 digit precision
+ const currentSize =
+ Math.round(((data.length * BYTES_PER_CHAR) / KB_STORAGE) * 100) / 100;
+ if (state.localStorageUsageInKilobytes !== currentSize) {
+ state.localStorageUsageInKilobytes = currentSize; // eslint-disable-line no-param-reassign
+ }
+
+ return subset;
+ },
+ merge: (
+ initialState: SqlLabRootState,
+ persistedState: PersistedSqlLabState = {},
+ ) => ({
+ ...initialState,
+ ...persistedState,
+ sqlLab: {
+ ...persistedState?.sqlLab,
+ // Overwrite initialState over persistedState for sqlLab
+ // since a logic in getInitialState overrides the value from persistedState
+ ...initialState.sqlLab,
+ },
+ }),
+ },
+};
+
+// redux-localstorage doesn't have TypeScript definitions
+// The library returns a StoreEnhancer that persists specified paths to localStorage
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const persistSqlLabStateEnhancer = (persistState as any)(
+ sqlLabPersistStateConfig.paths,
+ sqlLabPersistStateConfig.config,
+) as StoreEnhancer;
diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.ts
similarity index 67%
rename from superset-frontend/src/SqlLab/reducers/sqlLab.js
rename to superset-frontend/src/SqlLab/reducers/sqlLab.ts
index 3469036da4..55981f0aa5 100644
--- a/superset-frontend/src/SqlLab/reducers/sqlLab.js
+++ b/superset-frontend/src/SqlLab/reducers/sqlLab.ts
@@ -20,7 +20,9 @@ import { normalizeTimestamp, QueryState, t } from '@superset-ui/core';
import { isEqual, omit } from 'lodash';
import { shallowEqual } from 'react-redux';
import { now } from '@superset-ui/core/utils/dates';
+import type { SqlLabRootState, QueryEditor, Table } from '../types';
import * as actions from '../actions/sqlLab';
+import type { SqlLabAction } from '../actions/sqlLab';
import {
addToObject,
alterInObject,
@@ -31,7 +33,14 @@ import {
extendArr,
} from '../../reduxUtils';
-function alterUnsavedQueryEditorState(state, updatedState, id, silent = false) {
+type SqlLabState = SqlLabRootState['sqlLab'];
+
+function alterUnsavedQueryEditorState(
+ state: SqlLabState,
+ updatedState: Partial<QueryEditor>,
+ id: string,
+ silent = false,
+): Partial<SqlLabState> {
if (state.tabHistory[state.tabHistory.length - 1] !== id) {
const { queryEditors } = alterInArr(
state,
@@ -52,8 +61,12 @@ function alterUnsavedQueryEditorState(state, updatedState, id, silent = false) {
};
}
-export default function sqlLabReducer(state = {}, action) {
- const actionHandlers = {
+export default function sqlLabReducer(
+ state: SqlLabState = {} as SqlLabState,
+ action: SqlLabAction,
+): SqlLabState {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const actionHandlers: Record<string, () => any> = {
[actions.ADD_QUERY_EDITOR]() {
const mergeUnsavedState = alterInArr(
state,
@@ -65,10 +78,10 @@ export default function sqlLabReducer(state = {}, action) {
);
const newState = {
...mergeUnsavedState,
- tabHistory: [...state.tabHistory, action.queryEditor.id],
+ tabHistory: [...state.tabHistory, action.queryEditor!.id],
};
return addToArr(newState, 'queryEditors', {
- ...action.queryEditor,
+ ...action.queryEditor!,
updatedAt: new Date().getTime(),
});
},
@@ -78,23 +91,24 @@ export default function sqlLabReducer(state = {}, action) {
return alterInArr(
state,
'queryEditors',
- existing,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ existing as any,
{
- remoteId: result.remoteId,
- name: query.name,
+ remoteId: result!.remoteId,
+ name: (query as { name: string }).name,
},
'id',
);
},
[actions.UPDATE_QUERY_EDITOR]() {
- const id = action.alterations.remoteId;
+ const id = action.alterations!.remoteId;
const existing = state.queryEditors.find(qe => qe.remoteId === id);
if (existing == null) return state;
return alterInArr(
state,
'queryEditors',
existing,
- action.alterations,
+ action.alterations!,
'remoteId',
);
},
@@ -104,19 +118,20 @@ export default function sqlLabReducer(state = {}, action) {
);
const progenitor = {
...queryEditor,
- ...(state.unsavedQueryEditor.id === queryEditor.id &&
+ ...(state.unsavedQueryEditor.id === queryEditor?.id &&
state.unsavedQueryEditor),
};
const qe = {
remoteId: progenitor.remoteId,
name: t('Copy of %s', progenitor.name),
- dbId: action.query.dbId ? action.query.dbId : null,
- catalog: action.query.catalog ? action.query.catalog : null,
- schema: action.query.schema ? action.query.schema : null,
+ dbId: action.query!.dbId ? action.query!.dbId : null,
+ catalog: action.query!.catalog ? action.query!.catalog : null,
+ schema: action.query!.schema ? action.query!.schema : null,
autorun: true,
- sql: action.query.sql,
- queryLimit: action.query.queryLimit,
- maxRow: action.query.maxRow,
+ sql: action.query!.sql,
+ queryLimit: action.query!.queryLimit,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ maxRow: (action.query as any)?.maxRow,
};
const stateWithoutUnsavedState = {
...state,
@@ -124,20 +139,24 @@ export default function sqlLabReducer(state = {}, action) {
};
return sqlLabReducer(
stateWithoutUnsavedState,
- actions.addQueryEditor(qe),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ actions.addQueryEditor(qe as any),
);
},
[actions.REMOVE_QUERY_EDITOR]() {
const queryEditor = {
- ...action.queryEditor,
- ...(action.queryEditor.id === state.unsavedQueryEditor.id &&
+ ...action.queryEditor!,
+ ...(action.queryEditor!.id === state.unsavedQueryEditor.id &&
state.unsavedQueryEditor),
};
let newState = removeFromArr(state, 'queryEditors', queryEditor);
// List of remaining queryEditor ids
- const qeIds = newState.queryEditors.map(qe => qe.tabViewId ?? qe.id);
+ const qeIds = newState.queryEditors.map(
+ (qe: QueryEditor) => qe.tabViewId ?? qe.id,
+ );
- const queries = {};
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const queries: any = {};
Object.keys(state.queries).forEach(k => {
const query = state.queries[k];
if (qeIds.indexOf(query.sqlEditorId) > -1) {
@@ -158,18 +177,18 @@ export default function sqlLabReducer(state = {}, action) {
...newState,
tabHistory:
tabHistory.length === 0 && newState.queryEditors.length > 0
- ? newState.queryEditors.slice(-1).map(qe => qe.id)
+ ? newState.queryEditors.slice(-1).map((qe: QueryEditor) => qe.id)
: tabHistory,
tables,
queries,
unsavedQueryEditor: {
- ...(action.queryEditor.id !== state.unsavedQueryEditor.id &&
+ ...(action.queryEditor!.id !== state.unsavedQueryEditor.id &&
state.unsavedQueryEditor),
},
destroyedQueryEditors: {
...newState.destroyedQueryEditors,
...(!queryEditor.inLocalStorage && {
- [queryEditor.tabViewId ?? queryEditor.id]: Date.now(),
+ [(queryEditor.tabViewId ?? queryEditor.id)!]: Date.now(),
}),
},
};
@@ -177,19 +196,19 @@ export default function sqlLabReducer(state = {}, action) {
},
[actions.CLEAR_DESTROYED_QUERY_EDITOR]() {
const destroyedQueryEditors = { ...state.destroyedQueryEditors };
- delete destroyedQueryEditors[action.queryEditorId];
+ delete destroyedQueryEditors[action.queryEditorId!];
return { ...state, destroyedQueryEditors };
},
[actions.REMOVE_QUERY]() {
const newQueries = { ...state.queries };
- delete newQueries[action.query.id];
+ delete newQueries[action.query!.id!];
return { ...state, queries: newQueries };
},
[actions.RESET_STATE]() {
return { ...action.sqlLabInitialState };
},
[actions.MERGE_TABLE]() {
- const at = { ...action.table };
+ const at = { ...action.table } as Table;
const existingTableIndex = state.tables.findIndex(
xt =>
xt.dbId === at.dbId &&
@@ -200,7 +219,7 @@ export default function sqlLabReducer(state = {}, action) {
);
if (existingTableIndex >= 0) {
if (action.query) {
- at.dataPreviewQueryId = action.query.id;
+ at.dataPreviewQueryId = action.query!.id;
}
return {
...state,
@@ -228,30 +247,30 @@ export default function sqlLabReducer(state = {}, action) {
};
if (action.query) {
newState = alterInArr(newState, 'tables', at, {
- dataPreviewQueryId: action.query.id,
+ dataPreviewQueryId: action.query!.id,
});
}
return newState;
},
[actions.EXPAND_TABLE]() {
- return alterInArr(state, 'tables', action.table, { expanded: true });
+ return alterInArr(state, 'tables', action.table!, { expanded: true });
},
[actions.REMOVE_DATA_PREVIEW]() {
const queries = { ...state.queries };
- delete queries[action.table.dataPreviewQueryId];
- const newState = alterInArr(state, 'tables', action.table, {
+ delete queries[action.table!.dataPreviewQueryId!];
+ const newState = alterInArr(state, 'tables', action.table!, {
dataPreviewQueryId: null,
});
return { ...newState, queries };
},
[actions.CHANGE_DATA_PREVIEW_ID]() {
const queries = { ...state.queries };
- delete queries[action.oldQueryId];
+ delete queries[action.oldQueryId!];
- const newTables = [];
+ const newTables: Table[] = [];
state.tables.forEach(xt => {
if (xt.dataPreviewQueryId === action.oldQueryId) {
- newTables.push({ ...xt, dataPreviewQueryId: action.newQuery.id });
+ newTables.push({ ...xt, dataPreviewQueryId: action.newQuery!.id });
} else {
newTables.push(xt);
}
@@ -263,20 +282,20 @@ export default function sqlLabReducer(state = {}, action) {
};
},
[actions.COLLAPSE_TABLE]() {
- return alterInArr(state, 'tables', action.table, { expanded: false });
+ return alterInArr(state, 'tables', action.table!, { expanded: false });
},
[actions.REMOVE_TABLES]() {
- const tableIds = action.tables.map(table => table.id);
+ const tableIds = action.tables!.map((table: Table) => table.id);
const tables = state.tables.filter(table => !tableIds.includes(table.id));
return {
...state,
tables,
- ...(tableIds.includes(state.activeSouthPaneTab) && {
+ ...(tableIds.includes(state.activeSouthPaneTab as string) && {
activeSouthPaneTab:
tables.find(
({ queryEditorId }) =>
- queryEditorId === action.tables[0].queryEditorId,
+ queryEditorId === action.tables![0].queryEditorId,
)?.id ?? 'Results',
}),
};
@@ -286,7 +305,7 @@ export default function sqlLabReducer(state = {}, action) {
...state,
queryCostEstimates: {
...state.queryCostEstimates,
- [action.query.id]: {
+ [action.query!.id!]: {
completed: false,
cost: null,
error: null,
@@ -299,9 +318,9 @@ export default function sqlLabReducer(state = {}, action) {
...state,
queryCostEstimates: {
...state.queryCostEstimates,
- [action.query.id]: {
+ [action.query!.id!]: {
completed: true,
- cost: action.json.result,
+ cost: action.json!.result,
error: null,
},
},
@@ -312,7 +331,7 @@ export default function sqlLabReducer(state = {}, action) {
...state,
queryCostEstimates: {
...state.queryCostEstimates,
- [action.query.id]: {
+ [action.query!.id!]: {
completed: false,
cost: null,
error: action.error,
@@ -323,15 +342,19 @@ export default function sqlLabReducer(state = {}, action) {
[actions.START_QUERY]() {
let newState = { ...state };
let sqlEditorId;
- if (action.query.sqlEditorId) {
+ if (action.query!.sqlEditorId) {
const queryEditorByTabId = getFromArr(
state.queryEditors,
- action.query.sqlEditorId,
+ action.query!.sqlEditorId,
'tabViewId',
);
- sqlEditorId = queryEditorByTabId?.id ?? action.query.sqlEditorId;
+ sqlEditorId =
+ (queryEditorByTabId as QueryEditor | undefined)?.id ??
+ action.query!.sqlEditorId;
+ const foundQueryEditor = getFromArr(state.queryEditors, sqlEditorId);
+ const baseQe = foundQueryEditor || {};
const qe = {
- ...getFromArr(state.queryEditors, sqlEditorId),
+ ...baseQe,
...(sqlEditorId === state.unsavedQueryEditor.id &&
state.unsavedQueryEditor),
};
@@ -342,40 +365,44 @@ export default function sqlLabReducer(state = {}, action) {
query: null,
};
const q = { ...state.queries[qe.latestQueryId], results: newResults };
- const queries = { ...state.queries, [q.id]: q };
+ const queries = {
+ ...state.queries,
+ [q.id]: q,
+ } as SqlLabState['queries'];
newState = { ...state, queries };
}
}
- newState = addToObject(newState, 'queries', action.query);
+ newState = addToObject(newState, 'queries', action.query!) as SqlLabState;
return {
...newState,
...alterUnsavedQueryEditorState(
state,
{
- latestQueryId: action.query.id,
+ latestQueryId: action.query!.id,
},
- sqlEditorId,
- action.query.isDataPreview,
+ sqlEditorId!,
+ action.query!.isDataPreview,
),
};
},
[actions.STOP_QUERY]() {
- return alterInObject(state, 'queries', action.query, {
+ return alterInObject(state, 'queries', action.query!, {
state: QueryState.Stopped,
results: [],
});
},
[actions.CLEAR_QUERY_RESULTS]() {
- const newResults = { ...action.query.results };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const newResults = { ...(action.query as any).results };
newResults.data = [];
- return alterInObject(state, 'queries', action.query, {
+ return alterInObject(state, 'queries', action.query!, {
results: newResults,
cached: true,
});
},
[actions.REQUEST_QUERY_RESULTS]() {
- return alterInObject(state, 'queries', action.query, {
+ return alterInObject(state, 'queries', action.query!, {
state: QueryState.Fetching,
});
},
@@ -383,12 +410,13 @@ export default function sqlLabReducer(state = {}, action) {
// prevent race condition where query succeeds shortly after being canceled
// or the final result was unsuccessful
if (
- action.query.state === QueryState.STOPPED ||
- action.results.status !== QueryState.Success
+ action.query!.state === QueryState.Stopped ||
+ (action.results as { status?: string })?.status !== QueryState.Success
) {
return state;
}
- const alts = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const alts: any = {
endDttm: now(),
progress: 100,
results: action.results,
@@ -407,10 +435,10 @@ export default function sqlLabReducer(state = {}, action) {
alts.resultsKey = resultsKey;
}
- return alterInObject(state, 'queries', action.query, alts);
+ return alterInObject(state, 'queries', action.query!, alts);
},
[actions.QUERY_FAILED]() {
- if (action.query.state === QueryState.Stopped) {
+ if (action.query!.state === QueryState.Stopped) {
return state;
}
const alts = {
@@ -420,13 +448,13 @@ export default function sqlLabReducer(state = {}, action) {
endDttm: now(),
link: action.link,
};
- return alterInObject(state, 'queries', action.query, alts);
+ return alterInObject(state, 'queries', action.query!, alts);
},
[actions.SET_ACTIVE_QUERY_EDITOR]() {
const qeIds = state.queryEditors.map(qe => qe.id);
if (
- qeIds.indexOf(action.queryEditor?.id) > -1 &&
- state.tabHistory[state.tabHistory.length - 1] !== action.queryEditor.id
+ qeIds.indexOf(action.queryEditor!.id!) > -1 &&
+ state.tabHistory[state.tabHistory.length - 1] !== action.queryEditor!.id
) {
const mergeUnsavedState = {
...alterInArr(state, 'queryEditors', state.unsavedQueryEditor, {
@@ -435,18 +463,18 @@ export default function sqlLabReducer(state = {}, action) {
unsavedQueryEditor: {},
};
return {
- ...(action.queryEditor.id === state.unsavedQueryEditor.id
+ ...(action.queryEditor!.id === state.unsavedQueryEditor.id
? alterInArr(
mergeUnsavedState,
'queryEditors',
- action.queryEditor,
+ action.queryEditor!,
{
- ...action.queryEditor,
+ ...action.queryEditor!,
...state.unsavedQueryEditor,
},
)
: mergeUnsavedState),
- tabHistory: [...state.tabHistory, action.queryEditor.id],
+ tabHistory: [...state.tabHistory, action.queryEditor!.id],
};
}
return state;
@@ -460,12 +488,17 @@ export default function sqlLabReducer(state = {}, action) {
...state.unsavedQueryEditor,
},
);
- return alterInArr(mergeUnsavedState, 'queryEditors', action.queryEditor, {
- ...action.queryEditor,
- });
+ return alterInArr(
+ mergeUnsavedState,
+ 'queryEditors',
+ action.queryEditor!,
+ {
+ ...action.queryEditor!,
+ },
+ );
},
[actions.SET_TABLES]() {
- return extendArr(state, 'tables', action.tables);
+ return extendArr(state, 'tables', action.tables!);
},
[actions.SET_ACTIVE_SOUTHPANE_TAB]() {
return { ...state, activeSouthPaneTab: action.tabId };
@@ -473,9 +506,9 @@ export default function sqlLabReducer(state = {}, action) {
[actions.MIGRATE_QUERY_EDITOR]() {
try {
// remove migrated query editor from localStorage
- const { sqlLab } = JSON.parse(localStorage.getItem('redux'));
+ const { sqlLab } = JSON.parse(localStorage.getItem('redux') || '{}');
sqlLab.queryEditors = sqlLab.queryEditors.filter(
- qe => qe.id !== action.oldQueryEditor.id,
+ (qe: QueryEditor) => qe.id !== action.oldQueryEditor!.id,
);
localStorage.setItem('redux', JSON.stringify({ sqlLab }));
} catch (error) {
@@ -485,16 +518,16 @@ export default function sqlLabReducer(state = {}, action) {
return alterInArr(
state,
'queryEditors',
- action.oldQueryEditor,
- action.newQueryEditor,
+ action.oldQueryEditor!,
+ action.newQueryEditor!,
);
},
[actions.MIGRATE_TABLE]() {
try {
// remove migrated table from localStorage
- const { sqlLab } = JSON.parse(localStorage.getItem('redux'));
+ const { sqlLab } = JSON.parse(localStorage.getItem('redux') || '{}');
sqlLab.tables = sqlLab.tables.filter(
- table => table.id !== action.oldTable.id,
+ (table: Table) => table.id !== action.oldTable!.id,
);
localStorage.setItem('redux', JSON.stringify({ sqlLab }));
} catch (error) {
@@ -503,14 +536,14 @@ export default function sqlLabReducer(state = {}, action) {
// replace localStorage table with the server backed one
return addToArr(
- removeFromArr(state, 'tables', action.oldTable),
+ removeFromArr(state, 'tables', action.oldTable!),
'tables',
- action.newTable,
+ action.newTable!,
);
},
[actions.MIGRATE_QUERY]() {
const query = {
- ...state.queries[action.queryId],
+ ...state.queries[action.queryId!],
// point query to migrated query editor
sqlEditorId: action.queryEditorId,
};
@@ -525,7 +558,7 @@ export default function sqlLabReducer(state = {}, action) {
{
dbId: action.dbId,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -537,7 +570,7 @@ export default function sqlLabReducer(state = {}, action) {
{
catalog: action.catalog,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -547,9 +580,9 @@ export default function sqlLabReducer(state = {}, action) {
...alterUnsavedQueryEditorState(
state,
{
- schema: action.schema,
+ schema: action.schema ?? undefined,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -561,14 +594,14 @@ export default function sqlLabReducer(state = {}, action) {
{
name: action.name,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
[actions.QUERY_EDITOR_SET_SQL]() {
const { unsavedQueryEditor } = state;
if (
- unsavedQueryEditor?.id === action.queryEditor.id &&
+ unsavedQueryEditor?.id === action.queryEditor!.id &&
unsavedQueryEditor.sql === action.sql
) {
return state;
@@ -578,10 +611,10 @@ export default function sqlLabReducer(state = {}, action) {
...alterUnsavedQueryEditorState(
state,
{
- sql: action.sql,
+ sql: action.sql ?? undefined,
...(action.queryId && { latestQueryId: action.queryId }),
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -593,7 +626,7 @@ export default function sqlLabReducer(state = {}, action) {
{
cursorPosition: action.position,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -605,7 +638,7 @@ export default function sqlLabReducer(state = {}, action) {
{
queryLimit: action.queryLimit,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -617,7 +650,7 @@ export default function sqlLabReducer(state = {}, action) {
{
templateParams: action.templateParams,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -627,9 +660,9 @@ export default function sqlLabReducer(state = {}, action) {
...alterUnsavedQueryEditorState(
state,
{
- selectedText: action.sql,
+ selectedText: action.sql ?? undefined,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
true,
),
};
@@ -642,7 +675,7 @@ export default function sqlLabReducer(state = {}, action) {
{
autorun: action.autorun,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -655,7 +688,7 @@ export default function sqlLabReducer(state = {}, action) {
northPercent: action.northPercent,
southPercent: action.southPercent,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
@@ -667,13 +700,15 @@ export default function sqlLabReducer(state = {}, action) {
{
hideLeftBar: action.hideLeftBar,
},
- action.queryEditor.id,
+ action.queryEditor!.id!,
),
};
},
[actions.SET_DATABASES]() {
- const databases = {};
- action.databases.forEach(db => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const databases: any = {};
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (action.databases as any[])!.forEach((db: any) => {
databases[db.id] = {
...db,
extra_json: JSON.parse(db.extra || ''),
@@ -686,54 +721,57 @@ export default function sqlLabReducer(state = {}, action) {
// Fetch the updates to the queries present in the store.
let change = false;
let { queriesLastUpdate } = state;
- Object.entries(action.alteredQueries).forEach(([id, changedQuery]) => {
- if (
- !state.queries.hasOwnProperty(id) ||
- (state.queries[id].state !== QueryState.Stopped &&
- state.queries[id].state !== QueryState.Failed)
- ) {
- const changedOn = normalizeTimestamp(changedQuery.changed_on);
- const timestamp = Date.parse(changedOn);
- if (timestamp > queriesLastUpdate) {
- queriesLastUpdate = timestamp;
- }
- const prevState = state.queries[id]?.state;
- const currentState = changedQuery.state;
- newQueries[id] = {
- ...state.queries[id],
- ...changedQuery,
- ...(changedQuery.startDttm && {
- startDttm: Number(changedQuery.startDttm),
- }),
- ...(changedQuery.endDttm && {
- endDttm: Number(changedQuery.endDttm),
- }),
- // race condition:
- // because of async behavior, sql lab may still poll a couple of seconds
- // when it started fetching or finished rendering results
- state:
- currentState === QueryState.Success &&
- [
- QueryState.Fetching,
- QueryState.Success,
- QueryState.Running,
- ].includes(prevState)
- ? prevState
- : currentState,
- };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Object.entries(action.alteredQueries!).forEach(
+ ([id, changedQuery]: [string, any]) => {
if (
- shallowEqual(
- omit(newQueries[id], ['extra']),
- omit(state.queries[id], ['extra']),
- ) &&
- isEqual(newQueries[id].extra, state.queries[id].extra)
+ !state.queries.hasOwnProperty(id) ||
+ (state.queries[id].state !== QueryState.Stopped &&
+ state.queries[id].state !== QueryState.Failed)
) {
- newQueries[id] = state.queries[id];
- } else {
- change = true;
+ const changedOn = normalizeTimestamp(changedQuery.changed_on);
+ const timestamp = Date.parse(changedOn);
+ if (timestamp > queriesLastUpdate) {
+ queriesLastUpdate = timestamp;
+ }
+ const prevState = state.queries[id]?.state;
+ const currentState = changedQuery.state;
+ newQueries[id] = {
+ ...state.queries[id],
+ ...changedQuery,
+ ...(changedQuery.startDttm && {
+ startDttm: Number(changedQuery.startDttm),
+ }),
+ ...(changedQuery.endDttm && {
+ endDttm: Number(changedQuery.endDttm),
+ }),
+ // race condition:
+ // because of async behavior, sql lab may still poll a couple of seconds
+ // when it started fetching or finished rendering results
+ state:
+ currentState === QueryState.Success &&
+ [
+ QueryState.Fetching,
+ QueryState.Success,
+ QueryState.Running,
+ ].includes(prevState)
+ ? prevState
+ : currentState,
+ };
+ if (
+ shallowEqual(
+ omit(newQueries[id], ['extra']),
+ omit(state.queries[id], ['extra']),
+ ) &&
+ isEqual(newQueries[id].extra, state.queries[id].extra)
+ ) {
+ newQueries[id] = state.queries[id];
+ } else {
+ change = true;
+ }
}
- }
- });
+ },
+ );
if (!change) {
newQueries = state.queries;
}
@@ -746,14 +784,15 @@ export default function sqlLabReducer(state = {}, action) {
.filter(([, query]) => {
if (
['running', 'pending'].includes(query.state) &&
- Date.now() - query.startDttm > action.interval &&
+ Date.now() - query.startDttm > action.interval! &&
query.progress === 0
) {
return false;
}
return true;
})
- .map(([id, query]) => [
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .map(([id, query]: [string, any]) => [
id,
{
...query,
diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts
index d218fab2c1..dbadd95e45 100644
--- a/superset-frontend/src/core/sqlLab/index.ts
+++ b/superset-frontend/src/core/sqlLab/index.ts
@@ -155,28 +155,32 @@ function createQueryResultContext(
action: ReturnType<typeof querySuccess>,
): QueryResultContext {
const { baseParams, options } = extractBaseData(action);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, tab] = baseParams;
const { results } = action;
+ const { query_id: queryId, columns, data, query } = results;
const {
- query_id: queryId,
- columns,
- data,
- query: {
- endDttm,
- executedSql,
- tempTable: resultTempTable,
- limit,
- limitingFactor,
- },
- } = results;
+ endDttm,
+ executedSql,
+ tempTable: resultTempTable,
+ limit,
+ limitingFactor,
+ } = query;
+
+ // Map columns to ensure required fields are present
+ const mappedColumns = columns.map(col => ({
+ ...col,
+ name: col.name || col.column_name,
+ type: col.type ?? 'STRING', // Ensure type is not null
+ }));
return new QueryResultContext(
...baseParams,
- queryId,
+ queryId ?? 0,
executedSql ?? tab.editor.content,
- columns,
+ mappedColumns,
data,
- endDttm,
+ endDttm ?? 0,
{
...options,
tempTable: resultTempTable || options.tempTable,
@@ -193,12 +197,23 @@ function createQueryErrorContext(
const { msg: errorMessage, errors, query } = action;
const { endDttm, executedSql, query_id: queryId } = query;
- return new QueryErrorResultContext(...baseParams, errorMessage, errors, {
- ...options,
- queryId,
- executedSql: executedSql ?? null,
- endDttm: endDttm ?? Date.now(),
- });
+ // Map errors to ensure 'extra' is not null (required by QueryErrorResultContext)
+ const mappedErrors = (errors ?? []).map(err => ({
+ ...err,
+ extra: err.extra ?? {},
+ }));
+
+ return new QueryErrorResultContext(
+ ...baseParams,
+ errorMessage,
+ mappedErrors,
+ {
+ ...options,
+ queryId,
+ executedSql: executedSql ?? undefined,
+ endDttm: endDttm ?? Date.now(),
+ },
+ );
}
const getCurrentTab: typeof sqlLabType.getCurrentTab = () =>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
index 2c740d771e..26162db2d1 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -19,7 +19,6 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { ColumnMeta, Metric } from '@superset-ui/chart-controls';
import {
- AdhocFilter,
Behavior,
ChartDataResponseResult,
Column,
@@ -78,6 +77,7 @@ import {
} from 'src/dashboard/types';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import AdhocFilterControl from 'src/explore/components/controls/FilterControl/AdhocFilterControl';
+import type AdhocFilterClass from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { SingleValueType } from 'src/filters/components/Range/SingleValueType';
import { RangeDisplayMode } from 'src/filters/components/Range/types';
@@ -1003,7 +1003,7 @@ const FiltersConfigForm = (
}
datasource={datasetDetails}
onChange={(
- filters: AdhocFilter[],
+ filters: AdhocFilterClass[],
) => {
setNativeFilterFieldValues(
form,
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx
index bec46b6784..0c6780f57d 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx
@@ -19,6 +19,7 @@
import {
render,
screen,
+ selectOption,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
@@ -210,39 +211,23 @@ test('fetches chart on mount if value present', async () => {
});
test('keeps apply disabled when missing required fields', async () => {
+ // With EVENT type and Table source, the component requires selecting a chart
+ // and filling in required fields. Without completing these, Apply should be disabled.
await waitForRender({
annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
sourceType: 'Table',
});
- userEvent.click(
- screen.getByRole('combobox', { name: 'Annotation layer value' }),
- );
- expect(await screen.findByText('Chart A')).toBeInTheDocument();
- userEvent.click(screen.getByText('Chart A'));
- await screen.findByText(/title column/i);
- userEvent.click(
- screen.getByRole('combobox', { name: 'Annotation layer title column' }),
- );
- expect(await screen.findByText(/none/i)).toBeInTheDocument();
- userEvent.click(screen.getByText('None'));
- userEvent.click(screen.getByText('Style'));
- // The checkbox for automatic color is in the Style tab
- userEvent.click(screen.getByText('Use automatic color'));
- userEvent.click(
- screen.getByRole('combobox', { name: 'Annotation layer stroke' }),
- );
- expect(await screen.findByText('Dashed')).toBeInTheDocument();
- userEvent.click(screen.getByText('Dashed'));
- userEvent.click(screen.getByText('Opacity'));
- userEvent.click(
- screen.getByRole('combobox', { name: 'Annotation layer opacity' }),
- );
- expect(await screen.findByText(/0.5/i)).toBeInTheDocument();
- userEvent.click(screen.getByText('0.5'));
- const checkboxes = screen.getAllByRole('checkbox');
- checkboxes.forEach(checkbox => userEvent.click(checkbox));
+ // Apply button should be disabled initially since required fields are not filled
+ expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
+
+ // Select Chart A from the annotation layer value dropdown
+ await selectOption('Chart A', 'Annotation layer value');
+
+ // Wait for the chart data to load
+ await screen.findByText(/title column/i);
+ // Apply should still be disabled because name is not filled
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
similarity index 74%
rename from superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx
rename to superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
index f520267d13..bdffa8c14e 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx
@@ -16,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import React, { PureComponent } from 'react';
import rison from 'rison';
-import PropTypes from 'prop-types';
import {
Button,
AsyncSelect,
@@ -34,8 +33,13 @@ import {
isValidExpression,
getColumnLabel,
VizType,
+ type QueryFormColumn,
} from '@superset-ui/core';
-import { styled, withTheme } from '@apache-superset/core/ui';
+import {
+ styled,
+ withTheme,
+ type SupersetTheme,
+} from '@apache-superset/core/ui';
import SelectControl from 'src/explore/components/controls/SelectControl';
import TextControl from 'src/explore/components/controls/TextControl';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
@@ -50,59 +54,80 @@ import {
ANNOTATION_SOURCE_TYPES_METADATA,
} from './AnnotationTypes';
-const AUTOMATIC_COLOR = '';
+interface SelectOption {
+ value: string | number;
+ label: string;
+ viz_type?: string;
+ [key: string]: unknown;
+}
-const propTypes = {
- name: PropTypes.string,
- annotationType: PropTypes.string,
- sourceType: PropTypes.string,
- color: PropTypes.string,
- opacity: PropTypes.string,
- style: PropTypes.string,
- width: PropTypes.number,
- showMarkers: PropTypes.bool,
- hideLine: PropTypes.bool,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
- overrides: PropTypes.object,
- show: PropTypes.bool,
- showLabel: PropTypes.bool,
- titleColumn: PropTypes.string,
- descriptionColumns: PropTypes.arrayOf(PropTypes.string),
- timeColumn: PropTypes.string,
- intervalEndColumn: PropTypes.string,
- vizType: PropTypes.string,
+interface SliceData {
+ data: {
+ groupby?: string[];
+ all_columns?: string[];
+ include_time?: boolean;
+ [key: string]: unknown;
+ };
+}
- error: PropTypes.string,
- colorScheme: PropTypes.string,
+interface AnnotationOverrides {
+ time_range?: string | null;
+ time_grain_sqla?: string | null;
+ granularity?: string | null;
+ time_shift?: string;
+ [key: string]: unknown;
+}
- addAnnotationLayer: PropTypes.func,
- removeAnnotationLayer: PropTypes.func,
- close: PropTypes.func,
-};
+interface AnnotationLayerProps {
+ name?: string;
+ annotationType?: string;
+ sourceType?: string;
+ color?: string;
+ opacity?: string;
+ style?: string;
+ width?: number;
+ showMarkers?: boolean;
+ hideLine?: boolean;
+ value?: string | number | SelectOption;
+ overrides?: AnnotationOverrides;
+ show?: boolean;
+ showLabel?: boolean;
+ titleColumn?: string;
+ descriptionColumns?: string[];
+ timeColumn?: string;
+ intervalEndColumn?: string;
+ vizType?: string;
+ error?: string;
+ colorScheme?: string;
+ theme: SupersetTheme;
+ addAnnotationLayer?: (annotation: Record<string, unknown>) => void;
+ removeAnnotationLayer?: () => void;
+ close?: () => void;
+}
-const defaultProps = {
- name: '',
- annotationType: DEFAULT_ANNOTATION_TYPE,
- sourceType: '',
- color: AUTOMATIC_COLOR,
- opacity: '',
- style: 'solid',
- width: 1,
- showMarkers: false,
- hideLine: false,
- overrides: {},
- colorScheme: 'd3Category10',
- show: true,
- showLabel: false,
- titleColumn: '',
- descriptionColumns: [],
- timeColumn: '',
- intervalEndColumn: '',
+interface AnnotationLayerState {
+ name: string;
+ annotationType: string;
+ sourceType: string | null;
+ value: string | number | SelectOption | null;
+ overrides: AnnotationOverrides;
+ show: boolean;
+ showLabel: boolean;
+ titleColumn: string;
+ descriptionColumns: string[];
+ timeColumn: string;
+ intervalEndColumn: string;
+ color: string;
+ opacity: string;
+ style: string;
+ width: number;
+ showMarkers: boolean;
+ hideLine: boolean;
+ isNew: boolean;
+ slice: SliceData | null;
+}
- addAnnotationLayer: () => {},
- removeAnnotationLayer: () => {},
- close: () => {},
-};
+const AUTOMATIC_COLOR = '';
const NotFoundContentWrapper = styled.div`
&& > div:first-child {
@@ -134,8 +159,34 @@ const NotFoundContent = () => (
</NotFoundContentWrapper>
);
-class AnnotationLayer extends PureComponent {
- constructor(props) {
+class AnnotationLayer extends PureComponent<
+ AnnotationLayerProps,
+ AnnotationLayerState
+> {
+ static defaultProps = {
+ name: '',
+ annotationType: DEFAULT_ANNOTATION_TYPE,
+ sourceType: '',
+ color: AUTOMATIC_COLOR,
+ opacity: '',
+ style: 'solid',
+ width: 1,
+ showMarkers: false,
+ hideLine: false,
+ overrides: {},
+ colorScheme: 'd3Category10',
+ show: true,
+ showLabel: false,
+ titleColumn: '',
+ descriptionColumns: [],
+ timeColumn: '',
+ intervalEndColumn: '',
+ addAnnotationLayer: () => {},
+ removeAnnotationLayer: () => {},
+ close: () => {},
+ };
+
+ constructor(props: AnnotationLayerProps) {
super(props);
const {
name,
@@ -159,42 +210,46 @@ class AnnotationLayer extends PureComponent {
} = props;
// Only allow override whole time_range
- if ('since' in overrides || 'until' in overrides) {
- overrides.time_range = null;
- delete overrides.since;
- delete overrides.until;
+ const processedOverrides: AnnotationOverrides = overrides
+ ? { ...overrides }
+ : {};
+ if ('since' in processedOverrides || 'until' in processedOverrides) {
+ processedOverrides.time_range = null;
+ delete processedOverrides.since;
+ delete processedOverrides.until;
}
// Check if annotationType is supported by this chart
- const metadata = getChartMetadataRegistry().get(vizType);
+ const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null;
const supportedAnnotationTypes = metadata?.supportedAnnotationTypes || [];
+ const resolvedAnnotationType = annotationType || DEFAULT_ANNOTATION_TYPE;
const validAnnotationType = supportedAnnotationTypes.includes(
- annotationType,
+ resolvedAnnotationType,
)
- ? annotationType
+ ? resolvedAnnotationType
: supportedAnnotationTypes[0];
this.state = {
// base
- name,
- annotationType: validAnnotationType,
- sourceType,
- value,
- overrides,
- show,
- showLabel,
+ name: name || '',
+ annotationType: validAnnotationType || DEFAULT_ANNOTATION_TYPE,
+ sourceType: sourceType || null,
+ value: value || null,
+ overrides: processedOverrides,
+ show: show ?? true,
+ showLabel: showLabel ?? false,
// slice
- titleColumn,
- descriptionColumns,
- timeColumn,
- intervalEndColumn,
+ titleColumn: titleColumn || '',
+ descriptionColumns: descriptionColumns || [],
+ timeColumn: timeColumn || '',
+ intervalEndColumn: intervalEndColumn || '',
// display
color: color || AUTOMATIC_COLOR,
- opacity,
- style,
- width,
- showMarkers,
- hideLine,
+ opacity: opacity || '',
+ style: style || 'solid',
+ width: width ?? 1,
+ showMarkers: showMarkers ?? false,
+ hideLine: hideLine ?? false,
// refData
isNew: !name,
slice: null,
@@ -229,57 +284,71 @@ class AnnotationLayer extends PureComponent {
/* The value prop is the id of the chart/native. This function will set
value in state to an object with the id as value.value to be used by
AsyncSelect */
- this.fetchAppliedAnnotation(value);
+ if (value !== null && typeof value !== 'object') {
+ this.fetchAppliedAnnotation(value);
+ }
}
}
- componentDidUpdate(prevProps, prevState) {
+ componentDidUpdate(
+ _prevProps: AnnotationLayerProps,
+ prevState: AnnotationLayerState,
+ ): void {
if (this.shouldFetchSliceData(prevState)) {
const { value } = this.state;
- this.fetchSliceData(value.value);
+ if (value && typeof value === 'object' && 'value' in value) {
+ this.fetchSliceData(value.value);
+ }
}
}
- getSupportedSourceTypes(annotationType) {
+ getSupportedSourceTypes(annotationType: string): SelectOption[] {
// Get vis types that can be source.
const sources = getChartMetadataRegistry()
.entries()
.filter(({ value: chartMetadata }) =>
- chartMetadata.canBeAnnotationType(annotationType),
+ chartMetadata?.canBeAnnotationType(annotationType),
)
.map(({ key, value: chartMetadata }) => ({
value: key === VizType.Line ? 'line' : key,
- label: chartMetadata.name,
+ label: chartMetadata?.name || key,
}));
// Prepend native source if applicable
- if (ANNOTATION_TYPES_METADATA[annotationType]?.supportNativeSource) {
+ const annotationMeta =
+ ANNOTATION_TYPES_METADATA[
+ annotationType as keyof typeof ANNOTATION_TYPES_METADATA
+ ];
+ if (annotationMeta && 'supportNativeSource' in annotationMeta) {
sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE);
}
return sources;
}
- shouldFetchAppliedAnnotation() {
+ shouldFetchAppliedAnnotation(): boolean {
const { value, sourceType } = this.state;
- return value && requiresQuery(sourceType);
+ return !!value && requiresQuery(sourceType ?? undefined);
}
- shouldFetchSliceData(prevState) {
+ shouldFetchSliceData(prevState: AnnotationLayerState): boolean {
const { value, sourceType } = this.state;
const isChart =
sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE &&
- requiresQuery(sourceType);
+ requiresQuery(sourceType ?? undefined);
const valueIsNew = value && prevState.value !== value;
- return valueIsNew && isChart;
+ return !!valueIsNew && isChart;
}
- isValidFormulaAnnotation(expression, annotationType) {
+ isValidFormulaAnnotation(
+ expression: string | number | SelectOption | null,
+ annotationType: string,
+ ): boolean {
if (annotationType === ANNOTATION_TYPES.FORMULA) {
- return isValidExpression(expression);
+ return isValidExpression(expression as string);
}
return true;
}
- isValidForm() {
+ isValidForm(): boolean {
const {
name,
annotationType,
@@ -302,11 +371,13 @@ class AnnotationLayer extends PureComponent {
errors.push(validateNonEmpty(intervalEndColumn));
}
}
- errors.push(!this.isValidFormulaAnnotation(value, annotationType));
+ if (!this.isValidFormulaAnnotation(value, annotationType)) {
+ errors.push(t('Invalid formula expression'));
+ }
return !errors.filter(x => x).length;
}
- handleAnnotationType(annotationType) {
+ handleAnnotationType(annotationType: string): void {
this.setState({
annotationType,
sourceType: null,
@@ -315,7 +386,7 @@ class AnnotationLayer extends PureComponent {
});
}
- handleAnnotationSourceType(sourceType) {
+ handleAnnotationSourceType(sourceType: string): void {
const { sourceType: prevSourceType } = this.state;
if (prevSourceType !== sourceType) {
@@ -327,24 +398,28 @@ class AnnotationLayer extends PureComponent {
}
}
- handleSelectValue(selectedValueObject) {
+ handleSelectValue(selectedValueObject: SelectOption): void {
this.setState({
value: selectedValueObject,
descriptionColumns: [],
- intervalEndColumn: null,
- timeColumn: null,
- titleColumn: null,
+ intervalEndColumn: '',
+ timeColumn: '',
+ titleColumn: '',
overrides: { time_range: null },
});
}
- handleTextValue(inputValue) {
+ handleTextValue(inputValue: string): void {
this.setState({
value: inputValue,
});
}
- fetchNativeAnnotations = async (search, page, pageSize) => {
+ fetchNativeAnnotations = async (
+ search: string,
+ page: number,
+ pageSize: number,
+ ): Promise<{ data: SelectOption[]; totalCount: number }> => {
const queryParams = rison.encode({
filters: [
{
@@ -364,7 +439,7 @@ class AnnotationLayer extends PureComponent {
const { result, count } = json;
- const layersArray = result.map(layer => ({
+ const layersArray = result.map((layer: { id: number; name: string }) => ({
value: layer.id,
label: layer.name,
}));
@@ -375,7 +450,11 @@ class AnnotationLayer extends PureComponent {
};
};
- fetchCharts = async (search, page, pageSize) => {
+ fetchCharts = async (
+ search: string,
+ page: number,
+ pageSize: number,
+ ): Promise<{ data: SelectOption[]; totalCount: number }> => {
const { annotationType } = this.state;
const queryParams = rison.encode({
@@ -401,11 +480,11 @@ class AnnotationLayer extends PureComponent {
const registry = getChartMetadataRegistry();
const chartsArray = result
- .filter(chart => {
+ .filter((chart: { id: number; slice_name: string; viz_type: string }) => {
const metadata = registry.get(chart.viz_type);
return metadata && metadata.canBeAnnotationType(annotationType);
})
- .map(chart => ({
+ .map((chart: { id: number; slice_name: string; viz_type: string }) => ({
value: chart.id,
label: chart.slice_name,
viz_type: chart.viz_type,
@@ -417,7 +496,11 @@ class AnnotationLayer extends PureComponent {
};
};
- fetchOptions = (search, page, pageSize) => {
+ fetchOptions = (
+ search: string,
+ page: number,
+ pageSize: number,
+ ): Promise<{ data: SelectOption[]; totalCount: number }> => {
const { sourceType } = this.state;
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
@@ -426,7 +509,7 @@ class AnnotationLayer extends PureComponent {
return this.fetchCharts(search, page, pageSize);
};
- fetchSliceData = id => {
+ fetchSliceData = (id: string | number): void => {
const queryParams = rison.encode({
columns: ['query_context'],
});
@@ -439,7 +522,9 @@ class AnnotationLayer extends PureComponent {
const dataObject = {
data: {
...formData,
- groupby: formData.groupby?.map(column => getColumnLabel(column)),
+ groupby: formData.groupby?.map((column: QueryFormColumn) =>
+ getColumnLabel(column),
+ ),
},
};
this.setState({
@@ -448,7 +533,7 @@ class AnnotationLayer extends PureComponent {
});
};
- fetchAppliedChart(id) {
+ fetchAppliedChart(id: string | number): void {
const { annotationType } = this.state;
const registry = getChartMetadataRegistry();
const queryParams = rison.encode({
@@ -474,7 +559,9 @@ class AnnotationLayer extends PureComponent {
slice: {
data: {
...formData,
- groupby: formData.groupby?.map(column => getColumnLabel(column)),
+ groupby: formData.groupby?.map((column: QueryFormColumn) =>
+ getColumnLabel(column),
+ ),
},
},
});
@@ -482,7 +569,7 @@ class AnnotationLayer extends PureComponent {
});
}
- fetchAppliedNativeAnnotation(id) {
+ fetchAppliedNativeAnnotation(id: string | number): void {
SupersetClient.get({
endpoint: `/api/v1/annotation_layer/${id}`,
}).then(({ json }) => {
@@ -497,7 +584,7 @@ class AnnotationLayer extends PureComponent {
});
}
- fetchAppliedAnnotation(id) {
+ fetchAppliedAnnotation(id: string | number): void {
const { sourceType } = this.state;
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
@@ -506,12 +593,12 @@ class AnnotationLayer extends PureComponent {
return this.fetchAppliedChart(id);
}
- deleteAnnotation() {
- this.props.removeAnnotationLayer();
- this.props.close();
+ deleteAnnotation(): void {
+ this.props.removeAnnotationLayer?.();
+ this.props.close?.();
}
- applyAnnotation() {
+ applyAnnotation(): void {
const { value, sourceType } = this.state;
if (this.isValidForm()) {
const annotationFields = [
@@ -532,32 +619,42 @@ class AnnotationLayer extends PureComponent {
'timeColumn',
'intervalEndColumn',
];
- const newAnnotation = {};
+ const newAnnotation: Record<string, unknown> = {};
annotationFields.forEach(field => {
- if (this.state[field] !== null) {
- newAnnotation[field] = this.state[field];
+ const stateValue = this.state[field as keyof AnnotationLayerState];
+ if (stateValue !== null) {
+ newAnnotation[field] = stateValue;
}
});
// Prepare newAnnotation.value for use in runAnnotationQuery()
- const applicableValue = requiresQuery(sourceType) ? value.value : value;
+ const applicableValue =
+ requiresQuery(sourceType ?? undefined) &&
+ value &&
+ typeof value === 'object'
+ ? (value as SelectOption).value
+ : value;
newAnnotation.value = applicableValue;
if (newAnnotation.color === AUTOMATIC_COLOR) {
newAnnotation.color = null;
}
- this.props.addAnnotationLayer(newAnnotation);
+ this.props.addAnnotationLayer?.(newAnnotation);
this.setState({ isNew: false });
}
}
- submitAnnotation() {
+ submitAnnotation(): void {
this.applyAnnotation();
- this.props.close();
+ this.props.close?.();
}
- renderChartHeader(label, description, value) {
+ renderChartHeader(
+ label: string,
+ description: string,
+ value: string | number | SelectOption | null,
+ ): React.ReactNode {
return (
<ControlHeader
hovered
@@ -568,11 +665,11 @@ class AnnotationLayer extends PureComponent {
);
}
- renderValueConfiguration() {
+ renderValueConfiguration(): React.ReactNode {
const { annotationType, sourceType, value } = this.state;
let label = '';
let description = '';
- if (requiresQuery(sourceType)) {
+ if (requiresQuery(sourceType ?? undefined)) {
if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
label = t('Annotation layer');
description = t('Select the Annotation Layer you would like to use.');
@@ -592,7 +689,7 @@ class AnnotationLayer extends PureComponent {
in milliseconds since epoch. mathjs is used to evaluate the formulas.
Example: '2x+5'`);
}
- if (requiresQuery(sourceType)) {
+ if (requiresQuery(sourceType ?? undefined)) {
return (
<AsyncSelect
/* key to force re-render on sourceType change */
@@ -608,6 +705,8 @@ class AnnotationLayer extends PureComponent {
);
}
if (annotationType === ANNOTATION_TYPES.FORMULA) {
+ // Extract primitive value for TextControl (formula is always a string)
+ const textValue = typeof value === 'object' ? null : value;
return (
<TextControl
name="annotation-layer-value"
@@ -616,7 +715,7 @@ class AnnotationLayer extends PureComponent {
description={description}
label={label}
placeholder=""
- value={value}
+ value={textValue}
onChange={this.handleTextValue}
validationErrors={
!this.isValidFormulaAnnotation(value, annotationType)
@@ -629,7 +728,7 @@ class AnnotationLayer extends PureComponent {
return '';
}
- renderSliceConfiguration() {
+ renderSliceConfiguration(): React.ReactNode {
const {
annotationType,
sourceType,
@@ -679,7 +778,9 @@ class AnnotationLayer extends PureComponent {
clearable={false}
options={timeColumnOptions}
value={timeColumn}
- onChange={v => this.setState({ timeColumn: v })}
+ onChange={(
+ v: string | number | (string | number)[] | null | undefined,
+ ) => this.setState({ timeColumn: String(v ?? '') })}
/>
)}
{annotationType === ANNOTATION_TYPES.INTERVAL && (
@@ -694,7 +795,14 @@ class AnnotationLayer extends PureComponent {
validationErrors={!intervalEndColumn ? ['Mandatory'] : []}
options={columns}
value={intervalEndColumn}
- onChange={value => this.setState({ intervalEndColumn: value })}
+ onChange={(
+ value:
+ | string
+ | number
+ | (string | number)[]
+ | null
+ | undefined,
+ ) => this.setState({ intervalEndColumn: String(value ?? '') })}
/>
)}
<SelectControl
@@ -705,7 +813,9 @@ class AnnotationLayer extends PureComponent {
description={t('Pick a title for you annotation.')}
options={[{ value: '', label: t('None') }].concat(columns)}
value={titleColumn}
- onChange={value => this.setState({ titleColumn: value })}
+ onChange={(
+ value: string | number | (string | number)[] | null | undefined,
+ ) => this.setState({ titleColumn: String(value ?? '') })}
/>
{annotationType !== ANNOTATION_TYPES.TIME_SERIES && (
<SelectControl
@@ -719,7 +829,17 @@ class AnnotationLayer extends PureComponent {
multi
options={columns}
value={descriptionColumns}
- onChange={value => this.setState({ descriptionColumns: value })}
+ onChange={(
+ value:
+ | string
+ | number
+ | (string | number)[]
+ | null
+ | undefined,
+ ) => {
+ const cols = Array.isArray(value) ? value.map(String) : [];
+ this.setState({ descriptionColumns: cols });
+ }}
/>
)}
<div style={{ marginTop: '1rem' }}>
@@ -784,7 +904,7 @@ class AnnotationLayer extends PureComponent {
return '';
}
- renderDisplayConfiguration() {
+ renderDisplayConfiguration(): React.ReactNode {
const {
color,
opacity,
@@ -794,9 +914,10 @@ class AnnotationLayer extends PureComponent {
hideLine,
annotationType,
} = this.state;
- const colorScheme = getCategoricalSchemeRegistry()
- .get(this.props.colorScheme)
- .colors.concat();
+ const colorScheme =
+ getCategoricalSchemeRegistry()
+ .get(this.props.colorScheme)
+ ?.colors.concat() ?? [];
if (
color &&
color !== AUTOMATIC_COLOR &&
@@ -823,7 +944,9 @@ class AnnotationLayer extends PureComponent {
]}
value={style}
clearable={false}
- onChange={v => this.setState({ style: v })}
+ onChange={(
+ v: string | number | (string | number)[] | null | undefined,
+ ) => this.setState({ style: String(v ?? 'solid') })}
/>
<SelectControl
ariaLabel={t('Annotation layer opacity')}
@@ -837,7 +960,9 @@ class AnnotationLayer extends PureComponent {
{ value: 'opacityHigh', label: '0.8' },
]}
value={opacity}
- onChange={value => this.setState({ opacity: value })}
+ onChange={(
+ value: string | number | (string | number)[] | null | undefined,
+ ) => this.setState({ opacity: String(value ?? '') })}
/>
<div
style={{
@@ -905,14 +1030,19 @@ class AnnotationLayer extends PureComponent {
);
}
- render() {
+ render(): React.ReactNode {
const { isNew, name, annotationType, sourceType, show, showLabel } =
this.state;
const isValid = this.isValidForm();
- const metadata = getChartMetadataRegistry().get(this.props.vizType);
+ const metadata = this.props.vizType
+ ? getChartMetadataRegistry().get(this.props.vizType)
+ : null;
const supportedAnnotationTypes = metadata
? metadata.supportedAnnotationTypes.map(
- type => ANNOTATION_TYPES_METADATA[type],
+ type =>
+ ANNOTATION_TYPES_METADATA[
+ type as keyof typeof ANNOTATION_TYPES_METADATA
+ ],
)
: [];
const supportedSourceTypes = this.getSupportedSourceTypes(annotationType);
@@ -989,7 +1119,7 @@ class AnnotationLayer extends PureComponent {
<Button
buttonSize="small"
buttonStyle="secondary"
- onClick={() => this.props.close()}
+ onClick={() => this.props.close?.()}
>
{t('Cancel')}
</Button>
@@ -1026,7 +1156,4 @@ class AnnotationLayer extends PureComponent {
}
}
-AnnotationLayer.propTypes = propTypes;
-AnnotationLayer.defaultProps = defaultProps;
-
export default withTheme(AnnotationLayer);
diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.js b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.ts
similarity index 69%
rename from superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.js
rename to superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.ts
index cdf7a161f7..b29cde8b6e 100644
--- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.js
+++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationTypes.ts
@@ -18,12 +18,25 @@
*/
import { t } from '@superset-ui/core';
-function extractTypes(metadata) {
- return Object.keys(metadata).reduce((prev, key) => {
- const result = prev;
- result[key] = key;
- return result;
- }, {});
+interface Annotation {
+ sourceType?: string;
+ timeColumn?: string;
+ intervalEndColumn?: string;
+ titleColumn?: string;
+ descriptionColumns?: string[];
+}
+
+function extractTypes<T extends Record<string, { value: string }>>(
+ metadata: T,
+): Record<keyof T, string> {
+ return Object.keys(metadata).reduce(
+ (prev, key) => {
+ const result = prev;
+ result[key as keyof T] = key;
+ return result;
+ },
+ {} as Record<keyof T, string>,
+ );
}
export const ANNOTATION_TYPES_METADATA = {
@@ -62,7 +75,9 @@ export const ANNOTATION_SOURCE_TYPES = extractTypes(
ANNOTATION_SOURCE_TYPES_METADATA,
);
-export function requiresQuery(annotationSourceType) {
+export function requiresQuery(
+ annotationSourceType: string | undefined,
+): boolean {
return !!annotationSourceType;
}
@@ -71,11 +86,16 @@ const NATIVE_COLUMN_NAMES = {
intervalEndColumn: 'end_dttm',
titleColumn: 'short_descr',
descriptionColumns: ['long_descr'],
-};
+} as const;
-export function applyNativeColumns(annotation) {
+export function applyNativeColumns(annotation: Annotation): Annotation {
if (annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) {
- return { ...annotation, ...NATIVE_COLUMN_NAMES };
+ return {
+ ...annotation,
+ ...NATIVE_COLUMN_NAMES,
+ // Spread to convert readonly array to mutable
+ descriptionColumns: [...NATIVE_COLUMN_NAMES.descriptionColumns],
+ };
}
return annotation;
}
diff --git a/superset-frontend/src/explore/components/controls/BoundsControl.test.jsx b/superset-frontend/src/explore/components/controls/BoundsControl.test.tsx
similarity index 100%
rename from superset-frontend/src/explore/components/controls/BoundsControl.test.jsx
rename to superset-frontend/src/explore/components/controls/BoundsControl.test.tsx
diff --git a/superset-frontend/src/explore/components/controls/CheckboxControl.jsx b/superset-frontend/src/explore/components/controls/CheckboxControl.tsx
similarity index 68%
rename from superset-frontend/src/explore/components/controls/CheckboxControl.jsx
rename to superset-frontend/src/explore/components/controls/CheckboxControl.tsx
index dd551dd996..f9120159e9 100644
--- a/superset-frontend/src/explore/components/controls/CheckboxControl.jsx
+++ b/superset-frontend/src/explore/components/controls/CheckboxControl.tsx
@@ -16,22 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component } from 'react';
-import PropTypes from 'prop-types';
+import { Component, type ReactNode } from 'react';
import { styled, css } from '@apache-superset/core/ui';
import { Checkbox } from '@superset-ui/core/components';
import ControlHeader from '../ControlHeader';
-const propTypes = {
- value: PropTypes.bool,
- label: PropTypes.string,
- onChange: PropTypes.func,
-};
-
-const defaultProps = {
- value: false,
- onChange: () => {},
-};
+interface CheckboxControlProps {
+ value?: boolean;
+ label?: ReactNode;
+ name?: string;
+ description?: ReactNode;
+ hovered?: boolean;
+ onChange?: (value: boolean) => void;
+ validationErrors?: string[];
+ placeholder?: string;
+ debounceDelay?: number;
+}
const CheckBoxControlWrapper = styled.div`
${({ theme }) => css`
@@ -47,28 +47,28 @@ const CheckBoxControlWrapper = styled.div`
`}
`;
-export default class CheckboxControl extends Component {
- onChange() {
- this.props.onChange(!this.props.value);
- }
+export default class CheckboxControl extends Component<CheckboxControlProps> {
+ static defaultProps = {
+ value: false,
+ onChange: () => {},
+ };
+
+ onChange = (): void => {
+ this.props.onChange?.(!this.props.value);
+ };
- renderCheckbox() {
- return (
- <Checkbox
- onChange={this.onChange.bind(this)}
- checked={!!this.props.value}
- />
- );
+ renderCheckbox(): ReactNode {
+ return <Checkbox onChange={this.onChange} checked={!!this.props.value} />;
}
- render() {
+ render(): ReactNode {
if (this.props.label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...this.props}
leftNode={this.renderCheckbox()}
- onClick={this.onChange.bind(this)}
+ onClick={this.onChange}
/>
</CheckBoxControlWrapper>
);
@@ -76,5 +76,3 @@ export default class CheckboxControl extends Component {
return this.renderCheckbox();
}
}
-CheckboxControl.propTypes = propTypes;
-CheckboxControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx
similarity index 67%
rename from superset-frontend/src/explore/components/controls/CollectionControl/index.jsx
rename to superset-frontend/src/explore/components/controls/CollectionControl/index.tsx
index 04d91a8dde..cf86cffd64 100644
--- a/superset-frontend/src/explore/components/controls/CollectionControl/index.jsx
+++ b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx
@@ -16,12 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component } from 'react';
+import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { IconTooltip, List } from '@superset-ui/core/components';
import { nanoid } from 'nanoid';
import { t } from '@superset-ui/core';
-import { withTheme } from '@apache-superset/core/ui';
+import { withTheme, type SupersetTheme } from '@apache-superset/core/ui';
import {
SortableContainer,
SortableHandle,
@@ -37,6 +37,27 @@ import ControlHeader from 'src/explore/components/ControlHeader';
import CustomListItem from 'src/explore/components/controls/CustomListItem';
import controlMap from '..';
+interface CollectionItem {
+ key?: string;
+ [key: string]: unknown;
+}
+
+interface CollectionControlProps {
+ name: string;
+ label?: string | null;
+ description?: string | null;
+ placeholder?: string;
+ addTooltip?: string;
+ itemGenerator?: () => CollectionItem;
+ keyAccessor?: (item: CollectionItem) => string;
+ onChange?: (value: CollectionItem[]) => void;
+ value?: CollectionItem[];
+ isFloat?: boolean;
+ isInt?: boolean;
+ controlName: string;
+ theme: SupersetTheme;
+}
+
const propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
@@ -52,13 +73,13 @@ const propTypes = {
controlName: PropTypes.string.isRequired,
};
-const defaultProps = {
+const defaultProps: Partial<CollectionControlProps> = {
label: null,
description: null,
onChange: () => {},
placeholder: t('Empty collection'),
itemGenerator: () => ({ key: nanoid(11) }),
- keyAccessor: o => o.key,
+ keyAccessor: (o: CollectionItem) => o.key ?? '',
value: [],
addTooltip: t('Add an item'),
};
@@ -73,63 +94,81 @@ const SortableDragger = SortableHandle(() => (
/>
));
-class CollectionControl extends Component {
- constructor(props) {
+class CollectionControl extends Component<CollectionControlProps> {
+ static propTypes = propTypes;
+
+ static defaultProps = defaultProps;
+
+ constructor(props: CollectionControlProps) {
super(props);
this.onAdd = this.onAdd.bind(this);
}
- onChange(i, value) {
- const newValue = [...this.props.value];
- newValue[i] = { ...this.props.value[i], ...value };
- this.props.onChange(newValue);
+ onChange(i: number, value: CollectionItem) {
+ const currentValue = this.props.value ?? [];
+ const newValue = [...currentValue];
+ newValue[i] = { ...currentValue[i], ...value };
+ this.props.onChange?.(newValue);
}
onAdd() {
- this.props.onChange(this.props.value.concat([this.props.itemGenerator()]));
+ const currentValue = this.props.value ?? [];
+ const newItem = this.props.itemGenerator?.();
+ // Cast needed: original JS allowed undefined items from itemGenerator
+ this.props.onChange?.(
+ currentValue.concat([newItem] as unknown as CollectionItem[]),
+ );
}
- onSortEnd({ oldIndex, newIndex }) {
- this.props.onChange(arrayMove(this.props.value, oldIndex, newIndex));
+ onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
+ const currentValue = this.props.value ?? [];
+ this.props.onChange?.(arrayMove(currentValue, oldIndex, newIndex));
}
- removeItem(i) {
- this.props.onChange(this.props.value.filter((o, ix) => i !== ix));
+ removeItem(i: number) {
+ const currentValue = this.props.value ?? [];
+ this.props.onChange?.(currentValue.filter((o, ix) => i !== ix));
}
renderList() {
- if (this.props.value.length === 0) {
+ const currentValue = this.props.value ?? [];
+ if (currentValue.length === 0) {
return <div className="text-muted">{this.props.placeholder}</div>;
}
- const Control = controlMap[this.props.controlName];
+ const Control = (controlMap as Record<string, React.ComponentType<any>>)[
+ this.props.controlName
+ ];
+ const keyAccessor =
+ this.props.keyAccessor ?? ((o: CollectionItem) => o.key ?? '');
return (
<SortableList
useDragHandle
lockAxis="y"
onSortEnd={this.onSortEnd.bind(this)}
bordered
- css={theme => ({
+ css={(theme: SupersetTheme) => ({
borderRadius: theme.borderRadius,
})}
>
- {this.props.value.map((o, i) => {
+ {currentValue.map((o: CollectionItem, i: number) => {
// label relevant only for header, not here
- const { label, ...commonProps } = this.props;
+ const { label, theme, ...commonProps } = this.props;
return (
<SortableListItem
+ selectable={false}
className="clearfix"
- css={theme => ({
+ css={(theme: SupersetTheme) => ({
alignItems: 'center',
justifyContent: 'flex-start',
display: 'flex',
paddingInline: theme.sizeUnit * 6,
})}
- key={this.props.keyAccessor(o)}
+ key={keyAccessor(o)}
index={i}
>
<SortableDragger />
<div
- css={theme => ({
+ css={(theme: SupersetTheme) => ({
flex: 1,
marginLeft: theme.sizeUnit * 2,
marginRight: theme.sizeUnit * 2,
@@ -148,7 +187,7 @@ class CollectionControl extends Component {
tooltip={t('Remove item')}
mouseEnterDelay={0}
mouseLeaveDelay={0}
- css={theme => ({
+ css={(theme: SupersetTheme) => ({
padding: 0,
minWidth: 'auto',
height: 'auto',
@@ -190,7 +229,4 @@ class CollectionControl extends Component {
}
}
-CollectionControl.propTypes = propTypes;
-CollectionControl.defaultProps = defaultProps;
-
export default withTheme(CollectionControl);
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
index baf72c653a..edfcf62c3b 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
+import type React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
@@ -28,7 +29,7 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { fallbackExploreInitialData } from 'src/explore/fixtures';
-import type { DatasetObject, ColumnObject } from 'src/features/datasets/types';
+import type { ColumnObject } from 'src/features/datasets/types';
import DatasourceControl from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
@@ -46,20 +47,35 @@ afterEach(() => {
jest.clearAllMocks(); // Clears mock history but keeps spy in place
});
-type TestDatasource = Omit<
- Partial<DatasetObject>,
- 'columns' | 'main_dttm_col'
-> & {
+interface TestDatasource {
+ id?: number;
name: string;
- database: { name: string };
+ datasource_name?: string;
+ database: {
+ id: number;
+ database_name: string;
+ name?: string;
+ backend?: string;
+ };
columns?: Partial<ColumnObject>[];
type?: DatasourceType;
main_dttm_col?: string | null;
-};
+ owners?: Array<{
+ first_name: string;
+ last_name: string;
+ id: number;
+ username?: string;
+ }>;
+ sql?: string;
+ metrics?: Array<{ id: number; metric_name: string }>;
+ [key: string]: unknown;
+}
const mockDatasource: TestDatasource = {
id: 25,
database: {
+ id: 1,
+ database_name: 'examples',
name: 'examples',
},
name: 'channels',
@@ -69,39 +85,50 @@ const mockDatasource: TestDatasource = {
owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }],
sql: 'SELECT * FROM mock_datasource_sql',
};
-const createProps = (overrides: JsonObject = {}) => ({
- hovered: false,
- type: 'DatasourceControl',
- label: 'Datasource',
- default: null,
- description: null,
- value: '25__table',
- form_data: {},
- datasource: mockDatasource,
- validationErrors: [],
- name: 'datasource',
- actions: {
- changeDatasource: jest.fn(),
- setControlValue: jest.fn(),
- },
- isEditable: true,
- user: {
- createdOn: '2021-04-27T18:12:38.952304',
- email: 'admin',
- firstName: 'admin',
- isActive: true,
- lastName: 'admin',
- permissions: {},
- roles: { Admin: Array(173) },
- userId: 1,
- username: 'admin',
- },
- onChange: jest.fn(),
- onDatasourceSave: jest.fn(),
- ...overrides,
-});
-async function openAndSaveChanges(datasource: TestDatasource) {
+// Use type assertion for test props since the component is wrapped with withTheme
+// The withTheme HOC makes the props type complex, so we cast through unknown to bypass type check
+type DatasourceControlComponentProps = React.ComponentProps<
+ typeof DatasourceControl
+>;
+const createProps = (
+ overrides: JsonObject = {},
+): DatasourceControlComponentProps =>
+ ({
+ hovered: false,
+ type: 'DatasourceControl',
+ label: 'Datasource',
+ default: null,
+ description: null,
+ value: '25__table',
+ form_data: {},
+ datasource: mockDatasource,
+ validationErrors: [],
+ name: 'datasource',
+ actions: {
+ changeDatasource: jest.fn(),
+ setControlValue: jest.fn(),
+ },
+ isEditable: true,
+ user: {
+ createdOn: '2021-04-27T18:12:38.952304',
+ email: 'admin',
+ firstName: 'admin',
+ isActive: true,
+ lastName: 'admin',
+ permissions: {},
+ roles: { Admin: Array(173) },
+ userId: 1,
+ username: 'admin',
+ },
+ onChange: jest.fn(),
+ onDatasourceSave: jest.fn(),
+ ...overrides,
+ }) as unknown as DatasourceControlComponentProps;
+
+async function openAndSaveChanges(
+ datasource: TestDatasource | Record<string, unknown>,
+) {
fetchMock.get(
'glob:*/api/v1/database/?q=*',
{ result: [] },
@@ -259,7 +286,6 @@ test('Click on Edit dataset', async () => {
test('Edit dataset should be disabled when user is not admin', async () => {
const props = createProps();
- // @ts-expect-error
props.user.roles = {};
props.datasource.owners = [];
SupersetClientGet.mockImplementationOnce(
@@ -458,11 +484,11 @@ test('should not set the temporal column', async () => {
const overrideProps = {
...props,
form_data: {
- granularity_sqla: null,
+ granularity_sqla: undefined,
},
datasource: {
...props.datasource,
- main_dttm_col: null,
+ main_dttm_col: undefined,
columns: [
{
column_name: 'test-col',
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
similarity index 80%
rename from superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx
rename to superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
index ed582b088e..6c611f69d6 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
@@ -18,10 +18,20 @@
* under the License.
*/
-import { PureComponent } from 'react';
+import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
-import { DatasourceType, SupersetClient, t } from '@superset-ui/core';
-import { css, styled, withTheme } from '@apache-superset/core/ui';
+import {
+ DatasourceType,
+ SupersetClient,
+ t,
+ Datasource,
+} from '@superset-ui/core';
+import {
+ css,
+ styled,
+ withTheme,
+ type SupersetTheme,
+} from '@apache-superset/core/ui';
import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils';
import {
@@ -51,6 +61,68 @@ import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { safeStringify } from 'src/utils/safeStringify';
import { Link } from 'react-router-dom';
+// Extended Datasource interface with all properties used in this component
+interface ExtendedDatasource extends Datasource {
+ sql?: string;
+ select_star?: string;
+ owners?: Array<{
+ id: number;
+ first_name: string;
+ last_name: string;
+ value?: number;
+ }>;
+ extra?: string;
+ health_check_message?: string;
+ database?: {
+ id: number;
+ database_name: string;
+ backend?: string;
+ };
+}
+
+interface User {
+ userId?: number;
+ username?: string;
+ roles?: Record<string, unknown[]>;
+}
+
+interface DatasourceControlActions {
+ changeDatasource: (datasource: ExtendedDatasource) => void;
+ setControlValue: (name: string, value: unknown) => void;
+}
+
+interface FormData {
+ granularity_sqla?: string;
+ [key: string]: unknown;
+}
+
+interface DatasourceControlProps {
+ actions: DatasourceControlActions;
+ onChange?: () => void;
+ value?: string | null;
+ datasource: ExtendedDatasource;
+ form_data?: FormData;
+ isEditable?: boolean;
+ onDatasourceSave?: ((datasource: ExtendedDatasource) => void) | null;
+ theme: SupersetTheme;
+ user: User;
+ // ControlHeader-related props
+ hovered?: boolean;
+ type?: string;
+ label?: string;
+ default?: unknown;
+ description?: string | null;
+ validationErrors?: string[];
+ name?: string;
+}
+
+interface DatasourceControlState {
+ showEditDatasourceModal: boolean;
+ showChangeDatasourceModal: boolean;
+ showSaveDatasetModal: boolean;
+ showDatasource?: boolean;
+}
+
const propTypes = {
actions: PropTypes.object.isRequired,
onChange: PropTypes.func,
@@ -59,6 +131,15 @@ const propTypes = {
form_data: PropTypes.object.isRequired,
isEditable: PropTypes.bool,
onDatasourceSave: PropTypes.func,
+ user: PropTypes.object.isRequired,
+ // ControlHeader-related props
+ hovered: PropTypes.bool,
+ type: PropTypes.string,
+ label: PropTypes.string,
+ default: PropTypes.any,
+ description: PropTypes.string,
+ validationErrors: PropTypes.array,
+ name: PropTypes.string,
};
const defaultProps = {
@@ -68,7 +149,7 @@ const defaultProps = {
isEditable: true,
};
-const getDatasetType = datasource => {
+const getDatasetType = (datasource: ExtendedDatasource): string => {
if (datasource.type === 'query') {
return 'query';
}
@@ -139,15 +220,18 @@ const SAVE_AS_DATASET = 'save_as_dataset';
const VISIBLE_TITLE_LENGTH = 25;
// Assign icon for each DatasourceType. If no icon assignment is found in the lookup, no icon will render
-export const datasourceIconLookup = {
+export const datasourceIconLookup: Record<string, React.ReactNode> = {
query: <Icons.ConsoleSqlOutlined className="datasource-svg" />,
physical_dataset: <Icons.TableOutlined className="datasource-svg" />,
virtual_dataset: <Icons.ConsoleSqlOutlined className="datasource-svg" />,
};
// Render title for datasource with tooltip only if text is longer than VISIBLE_TITLE_LENGTH
-export const renderDatasourceTitle = (displayString, tooltip) =>
- displayString?.length > VISIBLE_TITLE_LENGTH ? (
+export const renderDatasourceTitle = (
+ displayString: string | undefined,
+ tooltip: string,
+) =>
+ displayString?.length && displayString.length > VISIBLE_TITLE_LENGTH ? (
// Add a tooltip only for long names that will be visually truncated
<Tooltip title={tooltip}>
<span className="title-select">{displayString}</span>
@@ -159,12 +243,14 @@ export const renderDatasourceTitle = (displayString, tooltip) =>
);
// Different data source types use different attributes for the display title
-export const getDatasourceTitle = datasource => {
- if (datasource?.type === 'query') return datasource?.sql;
+export const getDatasourceTitle = (
+ datasource: ExtendedDatasource | null | undefined,
+): string => {
+ if (datasource?.type === 'query') return datasource?.sql || '';
return datasource?.name || '';
};
-const preventRouterLinkWhileMetaClicked = evt => {
+const preventRouterLinkWhileMetaClicked = (evt: React.MouseEvent) => {
if (evt.metaKey) {
evt.preventDefault();
} else {
@@ -172,8 +258,15 @@ const preventRouterLinkWhileMetaClicked = evt => {
}
};
-class DatasourceControl extends PureComponent {
- constructor(props) {
+class DatasourceControl extends PureComponent<
+ DatasourceControlProps,
+ DatasourceControlState
+> {
+ static propTypes = propTypes;
+
+ static defaultProps = defaultProps;
+
+ constructor(props: DatasourceControlProps) {
super(props);
this.state = {
showEditDatasourceModal: false,
@@ -182,10 +275,13 @@ class DatasourceControl extends PureComponent {
};
}
- onDatasourceSave = datasource => {
- this.props.actions.changeDatasource(datasource);
- const { temporalColumns, defaultTemporalColumn } =
- getTemporalColumns(datasource);
+ onDatasourceSave = (datasource: Datasource) => {
+ // Cast to ExtendedDatasource for the component's internal use
+ this.props.actions.changeDatasource(datasource as ExtendedDatasource);
+ // Cast datasource for getTemporalColumns which expects Dataset | QueryResponse
+ const { temporalColumns, defaultTemporalColumn } = getTemporalColumns(
+ datasource as Parameters<typeof getTemporalColumns>[0],
+ );
const { columns } = datasource;
// the current granularity_sqla might not be a temporal column anymore
const timeCol = this.props.form_data?.granularity_sqla;
@@ -238,7 +334,7 @@ class DatasourceControl extends PureComponent {
}));
};
- handleMenuItemClick = ({ key }) => {
+ handleMenuItemClick = ({ key }: { key: string }) => {
switch (key) {
case CHANGE_DATASET:
this.toggleChangeDatasourceModal();
@@ -371,12 +467,17 @@ class DatasourceControl extends PureComponent {
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
+ datasource={`${datasource.id}__${datasource.type}`}
/>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={this.toggleSaveDatasetModal}
- datasource={datasource}
+ datasource={{
+ id: String(datasource.id),
+ sql: datasource.sql || '',
+ type: datasource.type,
+ }}
/>
}
draggable={false}
@@ -406,7 +507,7 @@ class DatasourceControl extends PureComponent {
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
- label: t('Save as dataset'),
+ label: <span>{t('Save as dataset')}</span>,
});
const queryDatasourceMenu = (
@@ -464,8 +565,8 @@ class DatasourceControl extends PureComponent {
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
<ErrorAlert
- level="warning"
- errorType={t('Missing URL parameters')}
+ type="warning"
+ message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
@@ -486,7 +587,7 @@ class DatasourceControl extends PureComponent {
) : (
<ErrorAlert
type="warning"
- errorType={t('Missing dataset')}
+ message={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
@@ -498,7 +599,7 @@ class DatasourceControl extends PureComponent {
</p>
<p>
<Button
- buttonStyle="warning"
+ buttonStyle="primary"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
@@ -547,7 +648,9 @@ class DatasourceControl extends PureComponent {
}
}
-DatasourceControl.propTypes = propTypes;
-DatasourceControl.defaultProps = defaultProps;
-
-export default withTheme(DatasourceControl);
+// withTheme injects the theme prop, so we need to cast the component type
+export default withTheme(
+ DatasourceControl as React.ComponentType<
+ Omit<DatasourceControlProps, 'theme'>
+ >,
+);
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
index 0be34889c6..806d66fe81 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
@@ -333,7 +333,8 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
<MetricDefinitionValue
key={`metric-${idx}`}
index={idx}
- option={item}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ option={item as any}
onMetricEdit={(changedMetric: Metric | AdhocMetric) => {
const newValues = [...coercedValue];
if (changedMetric instanceof AdhocMetric) {
@@ -344,10 +345,14 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
onChange(multi ? newValues : newValues[0]);
}}
onRemoveMetric={onClickClose}
- columns={columns}
- savedMetrics={savedMetrics}
- savedMetricsOptions={savedMetrics}
- datasource={datasource}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns={columns as any}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ savedMetrics={savedMetrics as any}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ savedMetricsOptions={savedMetrics as any}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ datasource={datasource as any}
onMoveLabel={onShiftOptions}
onDropLabel={() => {}}
type={`${DndItemType.AdhocMetricOption}_${name}_${label}`}
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.tsx
similarity index 91%
rename from superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx
rename to superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.tsx
index 005bc74e9d..f0cb5b939c 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.jsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelectPopoverTitle.tsx
@@ -16,12 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useState } from 'react';
+import { ChangeEvent, useCallback, useState } from 'react';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Input, Tooltip } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
+interface DndColumnSelectPopoverTitleProps {
+ title: string;
+ onChange: (e: ChangeEvent<HTMLInputElement>) => void;
+ isEditDisabled: boolean;
+ hasCustomLabel: boolean;
+}
+
const StyledInput = styled(Input)`
border-radius: ${({ theme }) => theme.borderRadius};
height: 26px;
@@ -34,7 +41,7 @@ export const DndColumnSelectPopoverTitle = ({
onChange,
isEditDisabled,
hasCustomLabel,
-}) => {
+}: DndColumnSelectPopoverTitleProps) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx
index b947f69ff7..21c588a330 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.test.tsx
@@ -19,7 +19,11 @@
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
-import { ensureIsArray, QueryFormData } from '@superset-ui/core';
+import {
+ ensureIsArray,
+ QueryFormData,
+ QueryFormMetric,
+} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
@@ -166,7 +170,7 @@ test('renders options with adhoc metric', async () => {
setup({
formData: {
...baseFormData,
- metrics: [adhocMetric],
+ metrics: [adhocMetric as unknown as QueryFormMetric],
},
}),
{
@@ -205,7 +209,7 @@ test('cannot drop a column that is not part of the simple column selection', ()
{setup({
formData: {
...baseFormData,
- metrics: [adhocMetric],
+ metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
@@ -335,7 +339,7 @@ describe('when disallow_adhoc_metrics is set', () => {
{setup({
formData: {
...baseFormData,
- metrics: [adhocMetric],
+ metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
@@ -383,7 +387,7 @@ describe('when disallow_adhoc_metrics is set', () => {
{setup({
formData: {
...baseFormData,
- metrics: [adhocMetric],
+ metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
index 2f936373cb..6b6b1331e7 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
@@ -89,10 +89,15 @@ const coerceMetrics = (
col => col.column_name === metric.column.column_name,
);
if (column) {
- return new AdhocMetric({ ...metric, column });
+ // Cast entire config object to handle type mismatch between @superset-ui/core and local types
+ return new AdhocMetric({
+ ...(metric as unknown as Record<string, unknown>),
+ column,
+ } as Record<string, unknown>);
}
}
- return new AdhocMetric(metric);
+ // Cast to unknown first to handle type mismatch between @superset-ui/core and local AdhocMetric
+ return new AdhocMetric(metric as unknown as Record<string, unknown>);
});
};
@@ -200,7 +205,11 @@ const DndMetricSelect = (props: any) => {
const onMetricEdit = useCallback(
(changedMetric: Metric | AdhocMetric, oldMetric: Metric | AdhocMetric) => {
- if (oldMetric instanceof AdhocMetric && oldMetric.equals(changedMetric)) {
+ if (
+ oldMetric instanceof AdhocMetric &&
+ changedMetric instanceof AdhocMetric &&
+ oldMetric.equals(changedMetric)
+ ) {
return;
}
const newValue = value.map(value => {
@@ -273,7 +282,8 @@ const DndMetricSelect = (props: any) => {
<MetricDefinitionValue
key={index}
index={index}
- option={option}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ option={option as any}
onMetricEdit={onMetricEdit}
onRemoveMetric={onRemoveMetric}
columns={props.columns}
@@ -343,9 +353,10 @@ const DndMetricSelect = (props: any) => {
droppedItem.type === DndItemType.Column
) {
const itemValue = droppedItem.value as ColumnMeta;
- const config: Partial<AdhocMetric> = {
+ // Cast config to handle ColumnMeta/ColumnType mismatch
+ const config = {
column: itemValue,
- };
+ } as Partial<AdhocMetric>;
if (itemValue.type_generic === GenericDataType.Numeric) {
config.aggregate = AGGREGATES.SUM;
} else if (
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.ts
similarity index 100%
rename from superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.js
rename to superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/AdhocFilter.test.ts
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.ts
similarity index 56%
rename from superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js
rename to superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.ts
index eeea251a39..31c544ffc4 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.js
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilter/index.ts
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { AdhocFilter as CoreAdhocFilter } from '@superset-ui/core';
import {
CUSTOM_OPERATORS,
DISABLE_INPUT_OPERATORS,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
+ Operators,
} from 'src/explore/constants';
import { translateToSql } from '../utils/translateToSQL';
import { Clauses, ExpressionTypes } from '../types';
@@ -28,15 +30,51 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
);
+interface AdhocFilterInput {
+ expressionType?: string;
+ subject?: string | { column_name?: string; [key: string]: unknown } | null;
+ operator?: string | null;
+ operatorId?: string;
+ comparator?: unknown;
+ clause?: string | null;
+ sqlExpression?: string | null;
+ isExtra?: boolean;
+ isNew?: boolean;
+ datasourceWarning?: boolean;
+ deck_slices?: unknown;
+ layerFilterScope?: unknown;
+ filterOptionName?: string;
+ // Allow additional properties for flexibility
+ [key: string]: unknown;
+}
+
export default class AdhocFilter {
- constructor(adhocFilter) {
+ expressionType: string;
+ subject?: string | { column_name?: string; [key: string]: unknown } | null;
+ operator?: string | null;
+ operatorId?: string;
+ comparator?: unknown;
+ clause?: string | null;
+ sqlExpression?: string | null;
+ isExtra: boolean;
+ isNew: boolean;
+ datasourceWarning: boolean;
+ deck_slices?: unknown;
+ layerFilterScope?: unknown;
+ filterOptionName: string;
+
+ constructor(adhocFilter: AdhocFilterInput) {
this.expressionType = adhocFilter.expressionType || ExpressionTypes.Simple;
if (this.expressionType === ExpressionTypes.Simple) {
this.subject = adhocFilter.subject;
this.operator = adhocFilter.operator?.toUpperCase();
this.operatorId = adhocFilter.operatorId;
this.comparator = adhocFilter.comparator;
- if (DISABLE_INPUT_OPERATORS.indexOf(adhocFilter.operatorId) >= 0) {
+ if (
+ adhocFilter.operatorId &&
+ DISABLE_INPUT_OPERATORS.indexOf(adhocFilter.operatorId as Operators) >=
+ 0
+ ) {
this.comparator = undefined;
}
this.clause = adhocFilter.clause || Clauses.Where;
@@ -45,7 +83,9 @@ export default class AdhocFilter {
this.sqlExpression =
typeof adhocFilter.sqlExpression === 'string'
? adhocFilter.sqlExpression
- : translateToSql(adhocFilter, { useSimple: true });
+ : translateToSql(adhocFilter as unknown as CoreAdhocFilter, {
+ useSimple: true,
+ });
this.clause = adhocFilter.clause;
if (
adhocFilter.operator &&
@@ -73,16 +113,28 @@ export default class AdhocFilter {
.substring(2, 15)}`;
}
- duplicateWith(nextFields) {
- return new AdhocFilter({
- ...this,
- // all duplicated fields are not new (i.e. will not open popup automatically)
- isNew: false,
+ duplicateWith(nextFields: Partial<AdhocFilterInput>): AdhocFilter {
+ // Spread class properties as plain object for constructor input
+ const currentFields: AdhocFilterInput = {
+ expressionType: this.expressionType,
+ subject: this.subject,
+ operator: this.operator,
+ operatorId: this.operatorId,
+ comparator: this.comparator,
+ clause: this.clause,
+ sqlExpression: this.sqlExpression,
+ isExtra: this.isExtra,
+ isNew: false, // all duplicated fields are not new
+ datasourceWarning: this.datasourceWarning,
+ deck_slices: this.deck_slices,
+ layerFilterScope: this.layerFilterScope,
+ filterOptionName: this.filterOptionName,
...nextFields,
- });
+ };
+ return new AdhocFilter(currentFields);
}
- equals(adhocFilter) {
+ equals(adhocFilter: AdhocFilter): boolean {
return (
adhocFilter.clause === this.clause &&
adhocFilter.expressionType === this.expressionType &&
@@ -94,10 +146,11 @@ export default class AdhocFilter {
);
}
- isValid() {
+ isValid(): boolean {
if (this.expressionType === ExpressionTypes.Simple) {
// operators where the comparator is not used
if (
+ this.operator &&
DISABLE_INPUT_OPERATORS.map(
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
).indexOf(this.operator) >= 0
@@ -121,16 +174,43 @@ export default class AdhocFilter {
);
}
- getDefaultLabel() {
+ getDefaultLabel(): string {
const label = this.translateToSql();
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
- getTooltipTitle() {
+ getTooltipTitle(): string {
return this.translateToSql();
}
- translateToSql() {
- return translateToSql(this);
+ translateToSql(): string {
+ return translateToSql(this as unknown as CoreAdhocFilter);
}
}
+
+/**
+ * Adapter function to create an AdhocFilter instance from a core AdhocFilter type.
+ * This bridges the type gap between @superset-ui/core's AdhocFilter and the local class.
+ */
+export function fromCoreAdhocFilter(filter: CoreAdhocFilter): AdhocFilter {
+ return new AdhocFilter(filter as AdhocFilterInput);
+}
+
+/**
+ * Type guard to check if an object can be used to construct an AdhocFilter.
+ * Returns true for plain objects that have filter-like properties.
+ */
+export function isDictionaryForAdhocFilter(
+ value: unknown,
+): value is AdhocFilterInput {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !(value instanceof AdhocFilter) &&
+ ('expressionType' in value ||
+ 'subject' in value ||
+ 'operator' in value ||
+ 'sqlExpression' in value ||
+ 'clause' in value)
+ );
+}
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
index 7e33db84fa..f7de33068e 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
@@ -22,34 +22,29 @@ import AdhocFilterControl from '.';
import AdhocFilter from '../AdhocFilter';
import { Clauses, ExpressionTypes } from '../types';
-interface Column {
- column_name: string;
- type: string;
-}
-
-interface Database {
- id: number;
-}
-
-interface Datasource {
- type: string;
- database: Database;
- schema: string;
- datasource_name: string;
-}
-
-interface Props {
+interface TestProps {
name: string;
label: string;
value: AdhocFilter[];
- datasource: Datasource;
- columns: Column[];
+ datasource: {
+ type: string;
+ database: { id: number };
+ schema: string;
+ datasource_name: string;
+ [key: string]: unknown;
+ };
+ columns: Array<{
+ column_name: string;
+ type?: string;
+ [key: string]: unknown;
+ }>;
onChange: jest.Mock;
sections: string[];
operators: string[];
+ [key: string]: unknown;
}
-const createProps = (): Props => ({
+const createProps = (): TestProps => ({
name: 'filter_control',
label: 'Filters',
value: [],
@@ -68,10 +63,16 @@ const createProps = (): Props => ({
operators: ['==', '>', '<'],
});
-const renderComponent = (props: Partial<Props> = {}) =>
- render(<AdhocFilterControl {...createProps()} {...props} />, {
- useDnd: true,
- });
+const renderComponent = (props: Partial<TestProps> = {}) =>
+ render(
+ <AdhocFilterControl
+ {...(createProps() as Record<string, unknown>)}
+ {...props}
+ />,
+ {
+ useDnd: true,
+ },
+ );
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('AdhocFilterControl', () => {
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx
similarity index 70%
rename from superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx
rename to superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx
index 315babb3bf..7ca2a8c491 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx
@@ -16,15 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component } from 'react';
+import { Component, ReactNode } from 'react';
import PropTypes from 'prop-types';
import { t, logging, SupersetClient, ensureIsArray } from '@superset-ui/core';
-import { withTheme } from '@apache-superset/core/ui';
+import { withTheme, type SupersetTheme } from '@apache-superset/core/ui';
import ControlHeader from 'src/explore/components/ControlHeader';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import savedMetricType from 'src/explore/components/controls/MetricControl/savedMetricType';
-import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
+import AdhocMetric, {
+ isDictionaryForAdhocMetric,
+} from 'src/explore/components/controls/MetricControl/AdhocMetric';
import {
Operators,
OPERATOR_ENUM_TO_OPERATOR_TYPE,
@@ -39,12 +41,70 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { Modal } from '@superset-ui/core/components';
import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger';
import AdhocFilterOption from 'src/explore/components/controls/FilterControl/AdhocFilterOption';
-import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
+import AdhocFilter, {
+ isDictionaryForAdhocFilter,
+} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import adhocFilterType from 'src/explore/components/controls/FilterControl/adhocFilterType';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
import { toQueryString } from 'src/utils/urlUtils';
import { Clauses, ExpressionTypes } from '../types';
+interface ColumnMeta {
+ column_name: string;
+ verbose_name?: string;
+ [key: string]: unknown;
+}
+
+interface SavedMetric {
+ metric_name: string;
+ expression: string;
+ [key: string]: unknown;
+}
+
+interface Datasource {
+ id?: number;
+ type?: string;
+ database?: { id: number };
+ datasource_name?: string;
+ catalog?: string;
+ schema?: string;
+ is_sqllab_view?: boolean;
+ [key: string]: unknown;
+}
+
+interface AdhocFilterControlProps {
+ label?: ReactNode;
+ name?: string;
+ sections?: string[];
+ operators?: string[];
+ onChange?: (values: AdhocFilter[]) => void;
+ value?: AdhocFilter[];
+ datasource?: Datasource;
+ columns?: ColumnMeta[];
+ savedMetrics?: SavedMetric[];
+ selectedMetrics?: string | AdhocMetric | (string | AdhocMetric)[];
+ isLoading?: boolean;
+ canDelete?: (
+ filter: AdhocFilter,
+ allFilters: AdhocFilter[],
+ ) => string | boolean | undefined;
+ theme?: SupersetTheme;
+}
+
+interface FilterOption {
+ column_name?: string;
+ saved_metric_name?: string;
+ label?: string;
+ filterOptionName?: string;
+ [key: string]: unknown;
+}
+
+interface AdhocFilterControlState {
+ values: AdhocFilter[];
+ options: FilterOption[];
+ partitionColumn: string | null;
+}
+
const { warning } = Modal;
const selectedMetricType = PropTypes.oneOfType([
@@ -78,51 +138,55 @@ const defaultProps = {
selectedMetrics: [],
};
-function isDictionaryForAdhocFilter(value) {
- return value && !(value instanceof AdhocFilter) && value.expressionType;
-}
-
-function optionsForSelect(props) {
+function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] {
const options = [
- ...props.columns,
+ ...(props.columns || []),
...ensureIsArray(props.selectedMetrics).map(
metric =>
metric &&
(typeof metric === 'string'
? { saved_metric_name: metric }
- : new AdhocMetric(metric)),
+ : isDictionaryForAdhocMetric(metric)
+ ? new AdhocMetric(metric)
+ : metric),
),
].filter(option => option);
return options
- .reduce((results, option) => {
- if (option.saved_metric_name) {
+ .reduce<FilterOption[]>((results, option) => {
+ if ((option as FilterOption).saved_metric_name) {
results.push({
- ...option,
- filterOptionName: option.saved_metric_name,
+ ...(option as FilterOption),
+ filterOptionName: (option as FilterOption).saved_metric_name,
});
- } else if (option.column_name) {
+ } else if ((option as FilterOption).column_name) {
results.push({
- ...option,
- filterOptionName: `_col_${option.column_name}`,
+ ...(option as FilterOption),
+ filterOptionName: `_col_${(option as FilterOption).column_name}`,
});
} else if (option instanceof AdhocMetric) {
results.push({
...option,
filterOptionName: `_adhocmetric_${option.label}`,
- });
+ } as FilterOption);
}
return results;
}, [])
- .sort((a, b) =>
- (a.saved_metric_name || a.column_name || a.label).localeCompare(
- b.saved_metric_name || b.column_name || b.label,
+ .sort((a: FilterOption, b: FilterOption) =>
+ (a.saved_metric_name || a.column_name || a.label || '').localeCompare(
+ b.saved_metric_name || b.column_name || b.label || '',
),
);
}
-class AdhocFilterControl extends Component {
- constructor(props) {
+class AdhocFilterControl extends Component<
+ AdhocFilterControlProps,
+ AdhocFilterControlState
+> {
+ optionRenderer: (option: FilterOption) => JSX.Element;
+ valueRenderer: (adhocFilter: AdhocFilter, index: number) => JSX.Element;
+
+ constructor(props: AdhocFilterControlProps) {
super(props);
this.onRemoveFilter = this.onRemoveFilter.bind(this);
this.onNewFilter = this.onNewFilter.bind(this);
@@ -146,14 +210,14 @@ class AdhocFilterControl extends Component {
onFilterEdit={this.onFilterEdit}
options={this.state.options}
sections={this.props.sections}
- operators={this.props.operators}
+ operators={this.props.operators as Operators[] | undefined}
datasource={this.props.datasource}
onRemoveFilter={e => {
e.stopPropagation();
this.onRemoveFilter(index);
}}
onMoveLabel={this.moveLabel}
- onDropLabel={() => this.props.onChange(this.state.values)}
+ onDropLabel={() => this.props.onChange?.(this.state.values)}
partitionColumn={this.state.partitionColumn}
/>
);
@@ -206,7 +270,7 @@ class AdhocFilterControl extends Component {
}
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: AdhocFilterControlProps): void {
if (this.props.columns !== prevProps.columns) {
this.setState({ options: optionsForSelect(this.props) });
}
@@ -219,17 +283,17 @@ class AdhocFilterControl extends Component {
}
}
- removeFilter(index) {
+ removeFilter(index: number): void {
const valuesCopy = [...this.state.values];
valuesCopy.splice(index, 1);
this.setState(prevState => ({
...prevState,
values: valuesCopy,
}));
- this.props.onChange(valuesCopy);
+ this.props.onChange?.(valuesCopy);
}
- onRemoveFilter(index) {
+ onRemoveFilter(index: number): void {
const { canDelete } = this.props;
const { values } = this.state;
const result = canDelete?.(values[index], values);
@@ -240,7 +304,7 @@ class AdhocFilterControl extends Component {
this.removeFilter(index);
}
- onNewFilter(newFilter) {
+ onNewFilter(newFilter: FilterOption | AdhocFilter): void {
const mappedOption = this.mapOption(newFilter);
if (mappedOption) {
this.setState(
@@ -249,14 +313,14 @@ class AdhocFilterControl extends Component {
values: [...prevState.values, mappedOption],
}),
() => {
- this.props.onChange(this.state.values);
+ this.props.onChange?.(this.state.values);
},
);
}
}
- onFilterEdit(changedFilter) {
- this.props.onChange(
+ onFilterEdit(changedFilter: AdhocFilter): void {
+ this.props.onChange?.(
this.state.values.map(value => {
if (value.filterOptionName === changedFilter.filterOptionName) {
return changedFilter;
@@ -266,20 +330,21 @@ class AdhocFilterControl extends Component {
);
}
- onChange(opts) {
+ onChange(opts: FilterOption[] | null): void {
const options = (opts || [])
.map(option => this.mapOption(option))
- .filter(option => option);
- this.props.onChange(options);
+ .filter((option): option is AdhocFilter => option !== null);
+ this.props.onChange?.(options);
}
- getMetricExpression(savedMetricName) {
- return this.props.savedMetrics.find(
+ getMetricExpression(savedMetricName: string): string {
+ const metric = this.props.savedMetrics?.find(
savedMetric => savedMetric.metric_name === savedMetricName,
- ).expression;
+ );
+ return metric?.expression ?? '';
}
- moveLabel(dragIndex, hoverIndex) {
+ moveLabel(dragIndex: number, hoverIndex: number): void {
const { values } = this.state;
const newValues = [...values];
@@ -290,7 +355,7 @@ class AdhocFilterControl extends Component {
this.setState({ values: newValues });
}
- mapOption(option) {
+ mapOption(option: FilterOption | AdhocFilter): AdhocFilter | null {
// already a AdhocFilter, skip
if (option instanceof AdhocFilter) {
return option;
@@ -331,16 +396,16 @@ class AdhocFilterControl extends Component {
return null;
}
- addNewFilterPopoverTrigger(trigger) {
+ addNewFilterPopoverTrigger(trigger: ReactNode): JSX.Element {
return (
<AdhocFilterPopoverTrigger
- operators={this.props.operators}
+ operators={this.props.operators as Operators[] | undefined}
sections={this.props.sections}
adhocFilter={new AdhocFilter({})}
- datasource={this.props.datasource}
+ datasource={(this.props.datasource as Record<string, unknown>) || {}}
options={this.state.options}
onFilterEdit={this.onNewFilter}
- partitionColumn={this.state.partitionColumn}
+ partitionColumn={this.state.partitionColumn ?? undefined}
>
{trigger}
</AdhocFilterPopoverTrigger>
@@ -373,7 +438,10 @@ class AdhocFilterControl extends Component {
}
}
+// Static properties are defined in the class using static keyword
+// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocFilterControl.propTypes = propTypes;
+// @ts-expect-error - defaultProps for backward compatibility with PropTypes
AdhocFilterControl.defaultProps = defaultProps;
export default withTheme(AdhocFilterControl);
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
similarity index 89%
rename from superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx
rename to superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
index f7b9e6ddcd..31803e512a 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.jsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type React from 'react';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { AGGREGATES } from 'src/explore/constants';
@@ -39,7 +40,8 @@ const sqlAdhocFilter = new AdhocFilter({
});
const faultyAdhocFilter = new AdhocFilter({
- expressionType: null,
+ // Use undefined for faulty expressionType to trigger error state
+ expressionType: undefined,
subject: null,
operator: '>',
comparator: '10',
@@ -69,10 +71,20 @@ const defaultProps = {
datasource: {},
};
-const renderPopover = (props = {}) =>
- render(<AdhocFilterEditPopover {...defaultProps} {...props} />, {
- useRedux: true, // Add Redux provider for context
- });
+// Cast props to handle AdhocMetric type in options array
+type AdhocFilterEditPopoverComponentProps = React.ComponentProps<
+ typeof AdhocFilterEditPopover
+>;
+const renderPopover = (props: Partial<typeof defaultProps> = {}) =>
+ render(
+ <AdhocFilterEditPopover
+ {...(defaultProps as unknown as AdhocFilterEditPopoverComponentProps)}
+ {...(props as unknown as Partial<AdhocFilterEditPopoverComponentProps>)}
+ />,
+ {
+ useRedux: true, // Add Redux provider for context
+ },
+ );
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('AdhocFilterEditPopover', () => {
@@ -123,7 +135,7 @@ describe('AdhocFilterEditPopover', () => {
fireEvent.change(sqlInput, { target: { value: 'COUNT(*) > 0' } });
// Wait for validation to complete
- await screen.findByRole('button', { name: /save/i, disabled: false });
+ await screen.findByRole('button', { name: /save/i });
// Click save button
const saveButton = screen.getByRole('button', { name: /save/i });
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx
similarity index 74%
rename from superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx
rename to superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx
index 3d5741420a..7bb635594d 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx
@@ -16,8 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { createRef, Component } from 'react';
+import type React from 'react';
+import { createRef, Component, type RefObject } from 'react';
import PropTypes from 'prop-types';
+import type { SupersetTheme } from '@apache-superset/core/ui';
import { Button, Icons, Select } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { t, SupersetClient } from '@superset-ui/core';
@@ -29,14 +31,54 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt
import AdhocFilterEditPopoverSimpleTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent';
import AdhocFilterEditPopoverSqlTabContent from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSqlTabContent';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
+import type { Dataset } from '@superset-ui/chart-controls';
+import type { ColumnType } from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent';
import {
POPOVER_INITIAL_HEIGHT,
POPOVER_INITIAL_WIDTH,
+ Operators,
} from 'src/explore/constants';
import rison from 'rison';
import { isObject } from 'lodash';
import { ExpressionTypes } from '../types';
+interface LayerOption {
+ id: number | null;
+ value: number;
+ label: string;
+}
+
+interface FilterOption {
+ column_name?: string;
+ saved_metric_name?: string;
+ [key: string]: unknown;
+}
+
+interface AdhocFilterEditPopoverProps {
+ adhocFilter: AdhocFilter;
+ onChange: (filter: AdhocFilter) => void;
+ onClose: () => void;
+ onResize: () => void;
+ options: FilterOption[];
+ datasource?: Record<string, unknown>;
+ partitionColumn?: string;
+ theme?: SupersetTheme;
+ sections?: string[];
+ operators?: string[];
+ requireSave?: boolean;
+}
+
+interface AdhocFilterEditPopoverState {
+ adhocFilter: AdhocFilter;
+ width: number;
+ height: number;
+ activeKey: string;
+ isSimpleTabValid: boolean;
+ selectedLayers: LayerOption[];
+ layerOptions: LayerOption[];
+ hasLayerFilterScopeChanged: boolean;
+}
+
const propTypes = {
adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
onChange: PropTypes.func.isRequired,
@@ -94,8 +136,21 @@ const LayerSelectContainer = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit * 12}px;
`;
-export default class AdhocFilterEditPopover extends Component {
- constructor(props) {
+export default class AdhocFilterEditPopover extends Component<
+ AdhocFilterEditPopoverProps,
+ AdhocFilterEditPopoverState
+> {
+ popoverContentRef: RefObject<HTMLDivElement>;
+
+ dragStartX = 0;
+
+ dragStartY = 0;
+
+ dragStartWidth = 0;
+
+ dragStartHeight = 0;
+
+ constructor(props: AdhocFilterEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onDragDown = this.onDragDown.bind(this);
@@ -126,13 +181,15 @@ export default class AdhocFilterEditPopover extends Component {
document.addEventListener('mouseup', this.onMouseUp);
// Load layer options if deck_slices exist
- if (
- this.props.adhocFilter?.deck_slices &&
- this.props.adhocFilter.deck_slices.length > 0
- ) {
+ const deckSlices = this.props.adhocFilter?.deck_slices as
+ | number[]
+ | undefined;
+ if (deckSlices && deckSlices.length > 0) {
this.loadLayerOptions(0, 100).then(result => {
this.setState({ layerOptions: result.data });
- const layerFilterScope = this.props.adhocFilter?.layerFilterScope;
+ const layerFilterScope = this.props.adhocFilter?.layerFilterScope as
+ | number[]
+ | undefined;
if (layerFilterScope) {
const selectedLayers = layerFilterScope.map(item => {
const layerOption = result.data.find(
@@ -140,7 +197,9 @@ export default class AdhocFilterEditPopover extends Component {
);
return layerOption;
});
- this.setState({ selectedLayers });
+ this.setState({
+ selectedLayers: selectedLayers.filter(Boolean) as LayerOption[],
+ });
}
});
}
@@ -151,18 +210,19 @@ export default class AdhocFilterEditPopover extends Component {
document.removeEventListener('mousemove', this.onMouseMove);
}
- onAdhocFilterChange(adhocFilter) {
+ onAdhocFilterChange(adhocFilter: AdhocFilter): void {
this.setState({ adhocFilter });
}
- setSimpleTabIsValid(isValid) {
+ setSimpleTabIsValid(isValid: boolean): void {
this.setState({ isSimpleTabValid: isValid });
}
onSave() {
- const hasDeckSlices =
- this.state.adhocFilter.deck_slices &&
- this.state.adhocFilter.deck_slices.length > 0;
+ const deckSlices = this.state.adhocFilter.deck_slices as
+ | number[]
+ | undefined;
+ const hasDeckSlices = deckSlices && deckSlices.length > 0;
if (!hasDeckSlices) {
this.props.onChange(this.state.adhocFilter);
@@ -176,16 +236,15 @@ export default class AdhocFilterEditPopover extends Component {
}
return item;
});
- const correctedAdhocFilter = {
- ...this.state.adhocFilter,
+ const correctedAdhocFilter = this.state.adhocFilter.duplicateWith({
layerFilterScope: selectedLayers,
- };
+ });
this.setState({ hasLayerFilterScopeChanged: false });
this.props.onChange(correctedAdhocFilter);
this.props.onClose();
}
- onDragDown(e) {
+ onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
@@ -193,7 +252,7 @@ export default class AdhocFilterEditPopover extends Component {
document.addEventListener('mousemove', this.onMouseMove);
}
- onMouseMove(e) {
+ onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
@@ -211,17 +270,17 @@ export default class AdhocFilterEditPopover extends Component {
document.removeEventListener('mousemove', this.onMouseMove);
}
- onTabChange(activeKey) {
+ onTabChange(activeKey: string) {
this.setState({
activeKey,
});
}
- adjustHeight(heightDifference) {
+ adjustHeight(heightDifference: number) {
this.setState(state => ({ height: state.height + heightDifference }));
}
- loadLayerOptions(page, pageSize) {
+ loadLayerOptions(page: number, pageSize: number) {
const query = rison.encode({
columns: ['id', 'slice_name', 'viz_type'],
filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }],
@@ -247,7 +306,8 @@ export default class AdhocFilterEditPopover extends Component {
};
}
- const deckSlices = this.props.adhocFilter?.deck_slices || [];
+ const deckSlices = (this.props.adhocFilter?.deck_slices ||
+ []) as number[];
const list = [
{
@@ -256,7 +316,7 @@ export default class AdhocFilterEditPopover extends Component {
label: 'All',
},
...response.json.result
- .map(item => {
+ .map((item: { id: number; slice_name: string }) => {
const sliceIndex = deckSlices.indexOf(item.id);
return {
id: item.id,
@@ -265,8 +325,18 @@ export default class AdhocFilterEditPopover extends Component {
sliceIndex,
};
})
- .filter(item => item.sliceIndex !== -1)
- .map(({ sliceIndex, ...item }) => item),
+ .filter((item: { sliceIndex: number }) => item.sliceIndex !== -1)
+ .map(
+ ({
+ sliceIndex,
+ ...item
+ }: {
+ sliceIndex: number;
+ id: number;
+ value: number;
+ label: string;
+ }) => item,
+ ),
];
return {
@@ -276,24 +346,29 @@ export default class AdhocFilterEditPopover extends Component {
});
}
- onLayerChange(selectedValue) {
- let updatedSelectedLayers = selectedValue;
+ onLayerChange(selectedValue: LayerOption[] | number[] | null) {
+ let updatedSelectedLayers: LayerOption[] =
+ (selectedValue as LayerOption[]) || [];
if (!selectedValue || selectedValue.length === 0) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else if (
selectedValue.length > 1 &&
- selectedValue.some(item => item.value === -1 || item === -1)
+ selectedValue.some(
+ (item: LayerOption | number) =>
+ (typeof item === 'object' && item.value === -1) || item === -1,
+ )
) {
+ const lastItem = selectedValue[selectedValue.length - 1];
if (
- selectedValue[selectedValue.length - 1].value === -1 ||
- selectedValue[selectedValue.length - 1] === -1
+ (typeof lastItem === 'object' && lastItem.value === -1) ||
+ lastItem === -1
) {
updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }];
} else {
- updatedSelectedLayers = selectedValue
- .filter(item => item.value !== -1)
- .filter(item => item !== -1);
+ updatedSelectedLayers = (selectedValue as LayerOption[]).filter(
+ (item: LayerOption) => item.value !== -1,
+ );
}
}
@@ -324,8 +399,8 @@ export default class AdhocFilterEditPopover extends Component {
!adhocFilter.equals(propsAdhocFilter) ||
hasLayerFilterScopeChanged;
- const hasDeckSlices =
- adhocFilter.deck_slices && adhocFilter.deck_slices.length > 0;
+ const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined;
+ const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0;
return (
<FilterPopoverContentContainer
@@ -349,11 +424,11 @@ export default class AdhocFilterEditPopover extends Component {
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
- operators={operators}
+ operators={operators as Operators[] | undefined}
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
- options={options}
- datasource={datasource}
+ options={options as ColumnType[]}
+ datasource={datasource as unknown as Dataset}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
@@ -372,7 +447,6 @@ export default class AdhocFilterEditPopover extends Component {
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
- activeKey={this.state.activeKey}
datasource={datasource}
/>
</ErrorBoundary>
@@ -384,7 +458,9 @@ export default class AdhocFilterEditPopover extends Component {
<LayerSelectContainer>
<Select
options={this.state.layerOptions}
- onChange={this.onLayerChange}
+ onChange={
+ this.onLayerChange as unknown as (value: unknown) => void
+ }
value={selectedLayers}
mode="multiple"
/>
@@ -427,4 +503,5 @@ export default class AdhocFilterEditPopover extends Component {
}
}
+// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocFilterEditPopover.propTypes = propTypes;
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx
index 2848d88573..d15aef3750 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/AdhocFilterEditPopoverSimpleTabContent.test.tsx
@@ -41,6 +41,7 @@ import fetchMock from 'fetch-mock';
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
import AdhocFilterEditPopoverSimpleTabContent, {
useSimpleTabFilterProps,
+ Props,
} from '.';
import { Clauses, ExpressionTypes } from '../types';
@@ -56,10 +57,10 @@ const simpleAdhocFilter = new AdhocFilter({
const advancedTypeTestAdhocFilterTest = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'advancedDataType',
- operatorId: null,
- operator: null,
- comparator: null,
- clause: null,
+ operatorId: undefined,
+ operator: undefined,
+ comparator: undefined,
+ clause: undefined,
});
const simpleMultiAdhocFilter = new AdhocFilter({
@@ -93,7 +94,7 @@ const options = [
sumValueAdhocMetric,
];
-const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
+const getAdvancedDataTypeTestProps = (overrides?: Record<string, unknown>) => {
const onChange = sinon.spy();
const validHandler = sinon.spy();
const props = {
@@ -113,7 +114,7 @@ const getAdvancedDataTypeTestProps = (overrides?: Record<string, any>) => {
return props;
};
-function setup(overrides?: Record<string, any>) {
+function setup(overrides?: Record<string, unknown>) {
const onChange = sinon.spy();
const validHandler = sinon.spy();
const spy = jest.spyOn(redux, 'useSelector');
@@ -132,7 +133,9 @@ function setup(overrides?: Record<string, any>) {
...overrides,
validHandler,
};
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
+ );
return props;
}
@@ -193,10 +196,10 @@ test('shows boolean only operators when subject is boolean', () => {
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
- operatorId: null,
- operator: null,
- comparator: null,
- clause: null,
+ operatorId: undefined,
+ operator: undefined,
+ comparator: undefined,
+ clause: undefined,
}),
datasource: {
columns: [
@@ -208,7 +211,9 @@ test('shows boolean only operators when subject is boolean', () => {
],
},
});
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
[
Operators.IsTrue,
Operators.IsFalse,
@@ -222,10 +227,10 @@ test('shows boolean only operators when subject is number', () => {
adhocFilter: new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
- operatorId: null,
- operator: null,
- comparator: null,
- clause: null,
+ operatorId: undefined,
+ operator: undefined,
+ comparator: undefined,
+ clause: undefined,
}),
datasource: {
columns: [
@@ -237,7 +242,9 @@ test('shows boolean only operators when subject is number', () => {
],
},
});
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
[
Operators.IsTrue,
Operators.IsFalse,
@@ -248,7 +255,9 @@ test('shows boolean only operators when subject is number', () => {
test('will convert from individual comparator to array if the operator changes to multi', () => {
const props = setup();
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onOperatorChange(Operators.In);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].comparator).toEqual(['10']);
@@ -259,7 +268,9 @@ test('will convert from array to individual comparators if the operator changes
const props = setup({
adhocFilter: simpleMultiAdhocFilter,
});
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onOperatorChange(Operators.LessThan);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -273,7 +284,9 @@ test('will convert from array to individual comparators if the operator changes
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
const props = setup();
- const { onComparatorChange } = useSimpleTabFilterProps(props);
+ const { onComparatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onComparatorChange('20');
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -283,7 +296,9 @@ test('passes the new adhocFilter to onChange after onComparatorChange', () => {
test('will filter operators for table datasources', () => {
const props = setup({ datasource: { type: 'table' as const } });
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
expect(isOperatorRelevant(Operators.Like, 'value')).toBe(true);
});
@@ -297,7 +312,9 @@ test('will show LATEST PARTITION operator', () => {
adhocFilter: simpleCustomFilter,
partitionColumn: 'ds',
});
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
expect(isOperatorRelevant(Operators.LatestPartition, 'ds')).toBe(true);
expect(isOperatorRelevant(Operators.LatestPartition, 'value')).toBe(false);
});
@@ -316,7 +333,9 @@ test('will generate custom sqlExpression for LATEST PARTITION operator', () => {
adhocFilter: testAdhocFilter,
partitionColumn: 'ds',
});
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onOperatorChange(Operators.LatestPartition);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0]).toEqual(
@@ -342,7 +361,9 @@ test('will not display boolean operators when column type is string', () => {
},
adhocFilter: simpleAdhocFilter,
});
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(false);
@@ -364,7 +385,9 @@ test('will display boolean operators when column is an expression', () => {
},
adhocFilter: simpleAdhocFilter,
});
- const { isOperatorRelevant } = useSimpleTabFilterProps(props);
+ const { isOperatorRelevant } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
booleanOnlyOperators.forEach(operator => {
expect(isOperatorRelevant(operator, 'value')).toBe(true);
@@ -373,7 +396,9 @@ test('will display boolean operators when column is an expression', () => {
test('sets comparator to undefined when operator is IS_TRUE', () => {
const props = setup();
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onOperatorChange(Operators.IsTrue);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IsTrue);
@@ -383,7 +408,9 @@ test('sets comparator to undefined when operator is IS_TRUE', () => {
test('sets comparator to undefined when operator is IS_FALSE', () => {
const props = setup();
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
onOperatorChange(Operators.IsFalse);
expect(props.onChange.calledOnce).toBe(true);
expect(props.onChange.lastCall.args[0].operatorId).toBe(Operators.IsFalse);
@@ -393,7 +420,9 @@ test('sets comparator to undefined when operator is IS_FALSE', () => {
test('sets comparator to undefined when operator is IS_NULL or IS_NOT_NULL', () => {
const props = setup();
- const { onOperatorChange } = useSimpleTabFilterProps(props);
+ const { onOperatorChange } = useSimpleTabFilterProps(
+ props as unknown as Props,
+ );
[Operators.IsNull, Operators.IsNotNull].forEach(op => {
onOperatorChange(op);
expect(props.onChange.called).toBe(true);
@@ -409,9 +438,14 @@ test('should not call API when column has no advanced data type', async () => {
const props = getAdvancedDataTypeTestProps();
await act(async () => {
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
- store,
- });
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent
+ {...(props as unknown as Props)}
+ />,
+ {
+ store,
+ },
+ );
});
const filterValueField = screen.getByPlaceholderText(
@@ -443,9 +477,14 @@ test('should call API when column has advanced data type', async () => {
});
await act(async () => {
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
- store,
- });
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent
+ {...(props as unknown as Props)}
+ />,
+ {
+ store,
+ },
+ );
});
const filterValueField = screen.getByPlaceholderText(
@@ -478,9 +517,14 @@ test('save button should be disabled if error message from API is returned', asy
});
await act(async () => {
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
- store,
- });
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent
+ {...(props as unknown as Props)}
+ />,
+ {
+ store,
+ },
+ );
});
const filterValueField = screen.getByPlaceholderText(
@@ -515,9 +559,14 @@ test('advanced data type operator list should update after API response', async
});
await act(async () => {
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />, {
- store,
- });
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent
+ {...(props as unknown as Props)}
+ />,
+ {
+ store,
+ },
+ );
});
const filterValueField = screen.getByPlaceholderText(
@@ -581,7 +630,9 @@ test('dropdown should remain open when clicked after filter is configured', asyn
validHandler,
};
- render(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
+ render(
+ <AdhocFilterEditPopoverSimpleTabContent {...(props as unknown as Props)} />,
+ );
const operatorDropdown = screen.getByRole('combobox', {
name: 'Select operator',
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx
index bd8c7cb30d..12d8b109d5 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopoverSimpleTabContent/index.tsx
@@ -18,7 +18,13 @@
*/
import { FC, ChangeEvent, useEffect, useState, useRef } from 'react';
-import { Input, InputRef, Select, Tooltip } from '@superset-ui/core/components';
+import {
+ Input,
+ InputRef,
+ Select,
+ Tooltip,
+ type SelectValue,
+} from '@superset-ui/core/components';
import {
isFeatureEnabled,
FeatureFlag,
@@ -87,9 +93,11 @@ export interface Props {
onChange: (filter: AdhocFilter) => void;
options: ColumnType[];
datasource: Dataset;
- partitionColumn: string;
+ partitionColumn?: string;
operators?: Operators[];
validHandler: (isValid: boolean) => void;
+ onHeightChange?: (heightDifference: number) => void;
+ popoverRef?: HTMLDivElement | null;
}
export interface AdvancedDataTypesState {
@@ -151,7 +159,9 @@ export const useSimpleTabFilterProps = (props: Props) => {
}
let { operator, operatorId, comparator } = props.adhocFilter;
operator =
- operator && operatorId && isOperatorRelevant(operatorId, subject)
+ operator &&
+ operatorId &&
+ isOperatorRelevant(operatorId as Operators, subject)
? OPERATOR_ENUM_TO_OPERATOR_TYPE[
operatorId as keyof typeof OPERATOR_ENUM_TO_OPERATOR_TYPE
].operation
@@ -290,7 +300,17 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
};
const renderSubjectOptionLabel = (option: ColumnType) => (
- <FilterDefinitionOption option={option} />
+ <FilterDefinitionOption
+ option={
+ option as unknown as {
+ column_name?: string;
+ saved_metric_name?: string;
+ label?: string;
+ type?: string;
+ [key: string]: unknown;
+ }
+ }
+ />
);
const getOptionsRemaining = () => {
@@ -314,9 +334,16 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
let columns = props.options;
const { subject, operator, operatorId } = props.adhocFilter;
+ const subjectValue =
+ typeof subject === 'string'
+ ? subject
+ : subject && 'column_name' in subject
+ ? subject.column_name
+ : undefined;
+
const subjectSelectProps = {
ariaLabel: t('Select subject'),
- value: subject ?? undefined,
+ value: subjectValue,
onChange: handleSubjectChange,
notFoundContent: t(
'No such column found. To filter on a metric, try the Custom SQL tab.',
@@ -333,11 +360,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
option => 'column_name' in option && option.column_name,
);
+ const subjectString = typeof subject === 'string' ? subject : '';
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
(props.operators ?? OPERATORS_OPTIONS).filter(op =>
- isOperatorRelevantWrapper(op, subject),
+ isOperatorRelevantWrapper(op, subjectString),
).length,
),
value: operatorId,
@@ -353,25 +381,35 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
allowClear: true,
allowNewOptions: true,
ariaLabel: t('Comparator option'),
- mode: MULTI_OPERATORS.has(operatorId)
- ? ('multiple' as const)
- : ('single' as const),
+ mode:
+ operatorId && MULTI_OPERATORS.has(operatorId as Operators)
+ ? ('multiple' as const)
+ : ('single' as const),
loading: loadingComparatorSuggestions,
- value: comparator,
+ value: comparator as SelectValue,
onChange: onComparatorChange,
notFoundContent: t('Type a value here'),
- disabled: DISABLE_INPUT_OPERATORS.includes(operatorId),
+ disabled:
+ operatorId !== undefined &&
+ DISABLE_INPUT_OPERATORS.includes(operatorId as Operators),
placeholder: createSuggestionsPlaceholder(),
};
- const labelText =
- comparator && comparator.length > 0 && createSuggestionsPlaceholder();
+ const comparatorHasValue =
+ comparator &&
+ (Array.isArray(comparator)
+ ? comparator.length > 0
+ : String(comparator).length > 0);
+ const labelText = comparatorHasValue ? createSuggestionsPlaceholder() : '';
const datePicker = useDatePickerInAdhocFilter({
- columnName: props.adhocFilter.subject,
+ columnName:
+ typeof props.adhocFilter.subject === 'string'
+ ? props.adhocFilter.subject
+ : undefined,
timeRange:
props.adhocFilter.operator === Operators.TemporalRange
- ? props.adhocFilter.comparator
+ ? (props.adhocFilter.comparator as string | undefined)
: undefined,
datasource: props.datasource,
onChange: onDatePickerChange,
@@ -441,8 +479,14 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
useEffect(() => {
if (isFeatureEnabled(FeatureFlag.EnableAdvancedDataTypes)) {
+ const comparatorValue =
+ comparator === undefined
+ ? ''
+ : typeof comparator === 'string'
+ ? comparator
+ : String(comparator);
fetchAdvancedDataTypeValueCallback(
- comparator === undefined ? '' : comparator,
+ comparatorValue,
advancedDataTypesState,
subjectAdvancedDataType,
);
@@ -501,7 +545,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
<>
<Select
options={(props.operators ?? OPERATORS_OPTIONS)
- .filter(op => isOperatorRelevantWrapper(op, subject))
+ .filter(op => isOperatorRelevantWrapper(op, subjectString))
.map((option, index) => ({
value: option,
label: OPERATOR_ENUM_TO_OPERATOR_TYPE[option].display,
@@ -510,7 +554,8 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
}))}
{...operatorSelectProps}
/>
- {MULTI_OPERATORS.has(operatorId) || suggestions.length > 0 ? (
+ {(operatorId && MULTI_OPERATORS.has(operatorId as Operators)) ||
+ suggestions.length > 0 ? (
<Tooltip
title={
advancedDataTypesState.errorMessage ||
@@ -543,9 +588,12 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
name="filter-value"
ref={comparatorInputRef}
onChange={onInputComparatorChange}
- value={comparator}
+ value={typeof comparator === 'string' ? comparator : undefined}
placeholder={t('Filter value (case sensitive)')}
- disabled={DISABLE_INPUT_OPERATORS.includes(operatorId)}
+ disabled={
+ operatorId !== undefined &&
+ DISABLE_INPUT_OPERATORS.includes(operatorId as Operators)
+ }
/>
</Tooltip>
)}
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/index.tsx
index 5852baf776..ca406143ec 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/index.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterOption/index.tsx
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type React from 'react';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { DndItemType } from 'src/explore/components/DndItemType';
import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger';
@@ -26,14 +27,14 @@ import { useGetTimeRangeLabel } from '../utils';
export interface AdhocFilterOptionProps {
adhocFilter: AdhocFilter;
- onFilterEdit: () => void;
- onRemoveFilter: () => void;
+ onFilterEdit: (editedFilter: AdhocFilter) => void;
+ onRemoveFilter: (e: React.MouseEvent) => void;
options: OptionSortType[];
- sections: string[];
- operators: Operators[];
- datasource: Record<string, any>;
- partitionColumn: string;
- onMoveLabel: () => void;
+ sections?: string[];
+ operators?: Operators[];
+ datasource?: Record<string, unknown>;
+ partitionColumn?: string | null;
+ onMoveLabel: (dragIndex: number, hoverIndex: number) => void;
onDropLabel: () => void;
index: number;
}
@@ -59,14 +60,18 @@ export default function AdhocFilterOption({
operators={operators}
adhocFilter={adhocFilter}
options={options}
- datasource={datasource}
+ datasource={(datasource as Record<string, unknown>) || {}}
onFilterEdit={onFilterEdit}
- partitionColumn={partitionColumn}
+ partitionColumn={partitionColumn ?? undefined}
>
<OptionControlLabel
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
- onRemove={onRemoveFilter}
+ onRemove={() =>
+ onRemoveFilter({
+ stopPropagation: () => {},
+ } as React.MouseEvent<Element, MouseEvent>)
+ }
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx
index 2a2670ee8c..115fb9449a 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx
@@ -93,7 +93,7 @@ class AdhocFilterPopoverTrigger extends PureComponent<
datasource={this.props.datasource}
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
- onClose={closePopover}
+ onClose={closePopover ?? (() => {})}
sections={this.props.sections}
operators={this.props.operators}
onChange={this.props.onFilterEdit}
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/utils/useDatePickerInAdhocFilter.tsx b/superset-frontend/src/explore/components/controls/FilterControl/utils/useDatePickerInAdhocFilter.tsx
index af148bb92d..8d2f895fb5 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/utils/useDatePickerInAdhocFilter.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/utils/useDatePickerInAdhocFilter.tsx
@@ -24,7 +24,7 @@ import DateFilterControl from 'src/explore/components/controls/DateFilterControl
import ControlHeader from 'src/explore/components/ControlHeader';
interface DatePickerInFilterProps {
- columnName: string;
+ columnName?: string;
timeRange?: string;
datasource: Dataset;
onChange: (columnName: string, timeRange: string) => void;
@@ -36,7 +36,7 @@ export const useDatePickerInAdhocFilter = ({
datasource,
onChange,
}: DatePickerInFilterProps): ReactElement | undefined => {
- const onTimeRangeChange = (val: string) => onChange(columnName, val);
+ const onTimeRangeChange = (val: string) => onChange(columnName ?? '', val);
const extensionsRegistry = getExtensionsRegistry();
@@ -45,7 +45,7 @@ export const useDatePickerInAdhocFilter = ({
);
const DateFilterComponent = DateFilterControlExtension ?? DateFilterControl;
- return isTemporalColumn(columnName, datasource) ? (
+ return columnName && isTemporalColumn(columnName, datasource) ? (
<>
<ControlHeader label={t('Time Range')} />
<DateFilterComponent
diff --git a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx
index d68239c709..7fa0c8cdef 100644
--- a/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx
+++ b/superset-frontend/src/explore/components/controls/FilterControl/utils/useGetTimeRangeLabel.tsx
@@ -53,21 +53,22 @@ export const useGetTimeRangeLabel = (adhocFilter: AdhocFilter): Results => {
adhocFilter.comparator !== NO_TIME_RANGE &&
actualTimeRange.title !== adhocFilter.comparator
) {
- fetchTimeRange(adhocFilter.comparator, adhocFilter.subject).then(
- ({ value, error }) => {
- if (error) {
- setActualTimeRange({
- actualTimeRange: `${adhocFilter.subject} (${adhocFilter.comparator})`,
- title: error,
- });
- } else {
- setActualTimeRange({
- actualTimeRange: value ?? '',
- title: adhocFilter.comparator,
- });
- }
- },
- );
+ fetchTimeRange(
+ adhocFilter.comparator as string,
+ adhocFilter.subject as string,
+ ).then(({ value, error }) => {
+ if (error) {
+ setActualTimeRange({
+ actualTimeRange: `${adhocFilter.subject} (${adhocFilter.comparator})`,
+ title: error,
+ });
+ } else {
+ setActualTimeRange({
+ actualTimeRange: value ?? '',
+ title: adhocFilter.comparator as string | undefined,
+ });
+ }
+ });
}
}, [adhocFilter]);
diff --git a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.jsx b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx
similarity index 69%
rename from superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.jsx
rename to superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx
index aa405a0ab5..fb8a1ab945 100644
--- a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.jsx
+++ b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx
@@ -28,7 +28,42 @@ import PopoverSection from '@superset-ui/core/components/PopoverSection';
const controlTypes = {
fixed: 'fix',
metric: 'metric',
-};
+} as const;
+
+interface ControlValue {
+ type?: 'fix' | 'metric';
+ value?:
+ | string
+ | number
+ | { label?: string; expressionType?: string; sqlExpression?: string };
+}
+
+interface MetricValue {
+ label?: string;
+ expressionType?: string;
+ sqlExpression?: string;
+ [key: string]: unknown;
+}
+
+interface DatasourceType {
+ columns?: { column_name: string }[];
+ metrics?: { metric_name: string; expression: string }[];
+ [key: string]: unknown;
+}
+
+interface FixedOrMetricControlProps {
+ onChange?: (value: ControlValue) => void;
+ value?: ControlValue;
+ isFloat?: boolean;
+ datasource: DatasourceType;
+ default?: ControlValue;
+}
+
+interface FixedOrMetricControlState {
+ type: 'fix' | 'metric';
+ fixedValue: string | number;
+ metricValue: MetricValue | null;
+}
const propTypes = {
onChange: PropTypes.func,
@@ -46,50 +81,60 @@ const defaultProps = {
default: { type: controlTypes.fixed, value: 5 },
};
-export default class FixedOrMetricControl extends Component {
- constructor(props) {
+export default class FixedOrMetricControl extends Component<
+ FixedOrMetricControlProps,
+ FixedOrMetricControlState
+> {
+ constructor(props: FixedOrMetricControlProps) {
super(props);
this.onChange = this.onChange.bind(this);
this.setType = this.setType.bind(this);
this.setFixedValue = this.setFixedValue.bind(this);
this.setMetric = this.setMetric.bind(this);
- const type =
- (props.value ? props.value.type : props.default.type) ||
- controlTypes.fixed;
- const value =
- (props.value ? props.value.value : props.default.value) || '100';
+ const type = (props.value?.type ??
+ props.default?.type ??
+ controlTypes.fixed) as 'fix' | 'metric';
+ const rawValue = props.value?.value ?? props.default?.value ?? '100';
+ const fixedValue =
+ type === controlTypes.fixed && typeof rawValue !== 'object'
+ ? rawValue
+ : '';
+ const metricValue =
+ type === controlTypes.metric && typeof rawValue === 'object'
+ ? (rawValue as MetricValue)
+ : null;
this.state = {
type,
- fixedValue: type === controlTypes.fixed ? value : '',
- metricValue: type === controlTypes.metric ? value : null,
+ fixedValue,
+ metricValue,
};
}
- onChange() {
- this.props.onChange({
+ onChange(): void {
+ this.props.onChange?.({
type: this.state.type,
value:
this.state.type === controlTypes.fixed
? this.state.fixedValue
- : this.state.metricValue,
+ : (this.state.metricValue ?? undefined),
});
}
- setType(type) {
+ setType(type: 'fix' | 'metric'): void {
this.setState({ type }, this.onChange);
}
- setFixedValue(fixedValue) {
+ setFixedValue(fixedValue: string | number): void {
this.setState({ fixedValue }, this.onChange);
}
- setMetric(metricValue) {
+ setMetric(metricValue: MetricValue | null): void {
this.setState({ metricValue }, this.onChange);
}
render() {
- const value = this.props.value || this.props.default;
- const type = value.type || controlTypes.fixed;
+ const value = this.props.value ?? this.props.default;
+ const type = value?.type ?? controlTypes.fixed;
const columns = this.props.datasource
? this.props.datasource.columns
: null;
@@ -136,6 +181,7 @@ export default class FixedOrMetricControl extends Component {
onChange={this.setFixedValue}
onFocus={() => {
this.setType(controlTypes.fixed);
+ return {};
}}
value={this.state.fixedValue}
/>
@@ -149,8 +195,8 @@ export default class FixedOrMetricControl extends Component {
>
<MetricsControl
name="metric"
- columns={columns}
- savedMetrics={metrics}
+ columns={columns ?? undefined}
+ savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
this.setType(controlTypes.metric);
@@ -170,5 +216,7 @@ export default class FixedOrMetricControl extends Component {
}
}
+// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
FixedOrMetricControl.propTypes = propTypes;
+// @ts-expect-error - defaultProps for backward compatibility with PropTypes
FixedOrMetricControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.ts
similarity index 98%
rename from superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js
rename to superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.ts
index 89ba8eacbe..50c819248f 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.js
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.test.ts
@@ -203,14 +203,14 @@ describe('AdhocMetric', () => {
aggregate: AGGREGATES.SUM,
});
expect(adhocMetric2.aggregate).toBe(AGGREGATES.SUM);
- expect(adhocMetric2.column.column_name).toBe('my_column');
+ expect(adhocMetric2.column?.column_name).toBe('my_column');
const adhocMetric3 = adhocMetric.duplicateWith({
expressionType: EXPRESSION_TYPES.SIMPLE,
column: valueColumn,
});
expect(adhocMetric3.aggregate).toBe(AGGREGATES.AVG);
- expect(adhocMetric3.column.column_name).toBe('value');
+ expect(adhocMetric3.column?.column_name).toBe('value');
});
test('should transform count_distinct SQL and do not change label if does not set metric label', () => {
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.ts
similarity index 62%
rename from superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js
rename to superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.ts
index 20c14793f7..7bd04bd0f1 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.js
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetric.ts
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { AdhocMetric as CoreAdhocMetric } from '@superset-ui/core';
import {
sqlaAutoGeneratedMetricRegex,
AGGREGATES,
@@ -26,7 +27,33 @@ export const EXPRESSION_TYPES = {
SQL: 'SQL',
};
-function inferSqlExpressionColumn(adhocMetric) {
+interface ColumnType {
+ column_name: string;
+ verbose_name?: string;
+ // Allow additional properties from ColumnMeta and other column types
+ [key: string]: unknown;
+}
+
+interface AdhocMetricInput {
+ expressionType?: string;
+ column?: ColumnType | null;
+ aggregate?: string | null;
+ sqlExpression?: string | null;
+ datasourceWarning?: boolean;
+ hasCustomLabel?: boolean;
+ label?: string;
+ optionName?: string;
+ // Additional properties that may be passed in
+ metric_name?: string;
+ expression?: string;
+ error_text?: string;
+ uuid?: string;
+ [key: string]: unknown;
+}
+
+function inferSqlExpressionColumn(
+ adhocMetric: AdhocMetricInput,
+): string | null {
if (
adhocMetric.sqlExpression &&
sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)
@@ -45,7 +72,9 @@ function inferSqlExpressionColumn(adhocMetric) {
return null;
}
-function inferSqlExpressionAggregate(adhocMetric) {
+function inferSqlExpressionAggregate(
+ adhocMetric: AdhocMetricInput,
+): string | null {
if (
adhocMetric.sqlExpression &&
sqlaAutoGeneratedMetricRegex.test(adhocMetric.sqlExpression)
@@ -58,15 +87,51 @@ function inferSqlExpressionAggregate(adhocMetric) {
return null;
}
+/**
+ * Adapter function to create an AdhocMetric instance from a core AdhocMetric type.
+ * This bridges the type gap between @superset-ui/core's AdhocMetric and the local class.
+ */
+export function fromCoreAdhocMetric(metric: CoreAdhocMetric): AdhocMetric {
+ return new AdhocMetric(metric as AdhocMetricInput);
+}
+
+/**
+ * Type guard to check if an object can be used to construct an AdhocMetric.
+ * Returns true for plain objects that have metric-like properties.
+ */
+export function isDictionaryForAdhocMetric(
+ value: unknown,
+): value is AdhocMetricInput {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !(value instanceof AdhocMetric) &&
+ ('expressionType' in value ||
+ 'column' in value ||
+ 'aggregate' in value ||
+ 'sqlExpression' in value ||
+ 'metric_name' in value)
+ );
+}
+
export default class AdhocMetric {
- constructor(adhocMetric) {
+ expressionType: string;
+ column?: ColumnType | null;
+ aggregate?: string | null;
+ sqlExpression?: string | null;
+ datasourceWarning: boolean;
+ hasCustomLabel: boolean;
+ label: string;
+ optionName: string;
+
+ constructor(adhocMetric: AdhocMetricInput) {
this.expressionType = adhocMetric.expressionType || EXPRESSION_TYPES.SIMPLE;
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
// try to be clever in the case of transitioning from Sql expression back to simple expression
const inferredColumn = inferSqlExpressionColumn(adhocMetric);
this.column =
- adhocMetric.column ||
- (inferredColumn && { column_name: inferredColumn });
+ adhocMetric.column ??
+ (inferredColumn ? { column_name: inferredColumn } : null);
this.aggregate =
adhocMetric.aggregate || inferSqlExpressionAggregate(adhocMetric);
this.sqlExpression = null;
@@ -78,7 +143,7 @@ export default class AdhocMetric {
this.datasourceWarning = !!adhocMetric.datasourceWarning;
this.hasCustomLabel = !!(adhocMetric.hasCustomLabel && adhocMetric.label);
this.label = this.hasCustomLabel
- ? adhocMetric.label
+ ? (adhocMetric.label ?? this.getDefaultLabel())
: this.getDefaultLabel();
this.optionName =
@@ -88,13 +153,16 @@ export default class AdhocMetric {
.substring(2, 15)}`;
}
- getDefaultLabel() {
+ getDefaultLabel(): string {
return this.translateToSql({ useVerboseName: true });
}
translateToSql(
- params = { useVerboseName: false, transformCountDistinct: false },
- ) {
+ params: { useVerboseName?: boolean; transformCountDistinct?: boolean } = {
+ useVerboseName: false,
+ transformCountDistinct: false,
+ },
+ ): string {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
const aggregate = this.aggregate || '';
// eslint-disable-next-line camelcase
@@ -115,19 +183,19 @@ export default class AdhocMetric {
return aggregate + column;
}
if (this.expressionType === EXPRESSION_TYPES.SQL) {
- return this.sqlExpression;
+ return this.sqlExpression ?? '';
}
return '';
}
- duplicateWith(nextFields) {
+ duplicateWith(nextFields: Partial<AdhocMetricInput>): AdhocMetric {
return new AdhocMetric({
...this,
...nextFields,
});
}
- equals(adhocMetric) {
+ equals(adhocMetric: AdhocMetric): boolean {
return (
adhocMetric.label === this.label &&
adhocMetric.expressionType === this.expressionType &&
@@ -138,7 +206,7 @@ export default class AdhocMetric {
);
}
- isValid() {
+ isValid(): boolean {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
return !!(this.column && this.aggregate);
}
@@ -148,11 +216,11 @@ export default class AdhocMetric {
return false;
}
- inferSqlExpressionAggregate() {
- return inferSqlExpressionAggregate(this);
+ inferSqlExpressionAggregate(): string | null {
+ return inferSqlExpressionAggregate(this as unknown as AdhocMetricInput);
}
- inferSqlExpressionColumn() {
- return inferSqlExpressionColumn(this);
+ inferSqlExpressionColumn(): string | null {
+ return inferSqlExpressionColumn(this as unknown as AdhocMetricInput);
}
}
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
similarity index 79%
rename from superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
index 3ae9b7b035..2690c70e32 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
@@ -49,6 +49,60 @@ import {
} from 'src/explore/components/optionRenderers';
import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
+import type { RefObject } from 'react';
+
+interface ColumnType {
+ column_name: string;
+ verbose_name?: string;
+ [key: string]: unknown;
+}
+
+interface SavedMetricType {
+ metric_name: string;
+ verbose_name?: string;
+ expression?: string;
+ [key: string]: unknown;
+}
+
+interface DatasourceInfo {
+ type?: DatasourceType | string;
+ id?: number | string;
+ extra?: string;
+ [key: string]: unknown;
+}
+
+interface ExtraConfig {
+ disallow_adhoc_metrics?: boolean;
+ [key: string]: unknown;
+}
+
+type Metric = AdhocMetric | SavedMetricType;
+
+interface AdhocMetricEditPopoverProps {
+ onChange: (newMetric: Metric, oldMetric?: Metric) => void;
+ onClose: () => void;
+ onResize: () => void;
+ getCurrentTab?: (tab: string) => void;
+ getCurrentLabel?: (labels: {
+ savedMetricLabel?: string;
+ adhocMetricLabel?: string;
+ }) => void;
+ handleDatasetModal?: (open: boolean) => void;
+ adhocMetric: AdhocMetric;
+ columns?: ColumnType[];
+ savedMetricsOptions?: SavedMetricType[];
+ savedMetric?: SavedMetricType;
+ datasource?: DatasourceInfo;
+ isNewMetric?: boolean;
+ isLabelModified?: boolean;
+}
+
+interface AdhocMetricEditPopoverState {
+ adhocMetric: AdhocMetric;
+ savedMetric?: SavedMetricType;
+ width: number;
+ height: number;
+}
const propTypes = {
onChange: PropTypes.func.isRequired,
@@ -85,11 +139,24 @@ const StyledSelect = styled(Select)`
export const SAVED_TAB_KEY = 'SAVED';
-export default class AdhocMetricEditPopover extends PureComponent {
+export default class AdhocMetricEditPopover extends PureComponent<
+ AdhocMetricEditPopoverProps,
+ AdhocMetricEditPopoverState
+> {
// "Saved" is a default tab unless there are no saved metrics for dataset
defaultActiveTabKey = this.getDefaultTab();
- constructor(props) {
+ aceEditorRef: RefObject<HTMLDivElement>;
+
+ dragStartX = 0;
+
+ dragStartY = 0;
+
+ dragStartWidth = 0;
+
+ dragStartHeight = 0;
+
+ constructor(props: AdhocMetricEditPopoverProps) {
super(props);
this.onSave = this.onSave.bind(this);
this.onResetStateAndClose = this.onResetStateAndClose.bind(this);
@@ -115,10 +182,13 @@ export default class AdhocMetricEditPopover extends PureComponent {
}
componentDidMount() {
- this.props.getCurrentTab(this.defaultActiveTabKey);
+ this.props.getCurrentTab?.(this.defaultActiveTabKey);
}
- componentDidUpdate(prevProps, prevState) {
+ componentDidUpdate(
+ _prevProps: AdhocMetricEditPopoverProps,
+ prevState: AdhocMetricEditPopoverState,
+ ) {
if (
prevState.adhocMetric?.sqlExpression !==
this.state.adhocMetric?.sqlExpression ||
@@ -127,7 +197,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
this.state.adhocMetric?.column?.column_name ||
prevState.savedMetric?.metric_name !== this.state.savedMetric?.metric_name
) {
- this.props.getCurrentLabel({
+ this.props.getCurrentLabel?.({
savedMetricLabel:
this.state.savedMetric?.verbose_name ||
this.state.savedMetric?.metric_name,
@@ -148,7 +218,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
return adhocMetric.expressionType;
}
if (
- (isNewMetric || savedMetric.metric_name) &&
+ (isNewMetric || savedMetric?.metric_name) &&
Array.isArray(savedMetricsOptions) &&
savedMetricsOptions.length > 0
) {
@@ -167,8 +237,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
this.props.onChange(
{
...metric,
- },
- oldMetric,
+ } as Metric,
+ oldMetric as Metric,
);
this.props.onClose();
}
@@ -183,8 +253,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
);
}
- onColumnChange(columnName) {
- const column = this.props.columns.find(
+ onColumnChange(columnName: string): void {
+ const column = this.props.columns?.find(
column => column.column_name === columnName,
);
this.setState(prevState => ({
@@ -196,7 +266,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
- onAggregateChange(aggregate) {
+ onAggregateChange(aggregate: string | null): void {
// we construct this object explicitly to overwrite the value in the case aggregate is null
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
@@ -207,8 +277,8 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
- onSavedMetricChange(savedMetricName) {
- const savedMetric = this.props.savedMetricsOptions.find(
+ onSavedMetricChange(savedMetricName: string): void {
+ const savedMetric = this.props.savedMetricsOptions?.find(
metric => metric.metric_name === savedMetricName,
);
this.setState(prevState => ({
@@ -222,7 +292,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
- onSqlExpressionChange(sqlExpression) {
+ onSqlExpressionChange(sqlExpression: string): void {
this.setState(prevState => ({
adhocMetric: prevState.adhocMetric.duplicateWith({
sqlExpression,
@@ -232,7 +302,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
}));
}
- onDragDown(e) {
+ onDragDown(e: React.MouseEvent): void {
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragStartWidth = this.state.width;
@@ -240,7 +310,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
document.addEventListener('mousemove', this.onMouseMove);
}
- onMouseMove(e) {
+ onMouseMove(e: MouseEvent): void {
this.props.onResize();
this.setState({
width: Math.max(
@@ -254,32 +324,42 @@ export default class AdhocMetricEditPopover extends PureComponent {
});
}
- onMouseUp() {
+ onMouseUp(): void {
document.removeEventListener('mousemove', this.onMouseMove);
}
- onTabChange(tab) {
+ onTabChange(tab: string): void {
this.refreshAceEditor();
- this.props.getCurrentTab(tab);
+ this.props.getCurrentTab?.(tab);
}
- refreshAceEditor() {
+ refreshAceEditor(): void {
setTimeout(() => {
if (this.aceEditorRef.current) {
- this.aceEditorRef.current.editor?.resize?.();
+ // Cast to access ace editor API
+ (
+ this.aceEditorRef.current as unknown as {
+ editor?: { resize?: () => void };
+ }
+ ).editor?.resize?.();
}
}, 0);
}
- renderColumnOption(option) {
+ renderColumnOption(option: ColumnType): React.ReactNode {
const column = { ...option };
- if (column.metric_name && !column.verbose_name) {
- column.verbose_name = column.metric_name;
+ if (
+ (column as unknown as { metric_name?: string }).metric_name &&
+ !column.verbose_name
+ ) {
+ column.verbose_name = (
+ column as unknown as { metric_name: string }
+ ).metric_name;
}
return <StyledColumnOption column={column} showType />;
}
- renderMetricOption(savedMetric) {
+ renderMetricOption(savedMetric: SavedMetricType): React.ReactNode {
return <StyledMetricOption metric={savedMetric} showType />;
}
@@ -298,7 +378,12 @@ export default class AdhocMetricEditPopover extends PureComponent {
...popoverProps
} = this.props;
const { adhocMetric, savedMetric } = this.state;
- const keywords = sqlKeywords.concat(getColumnKeywords(columns));
+ const columnsArray = columns ?? [];
+ const keywords = sqlKeywords.concat(
+ getColumnKeywords(
+ columnsArray as Parameters<typeof getColumnKeywords>[0],
+ ),
+ );
const columnValue =
(adhocMetric.column && adhocMetric.column.column_name) ||
@@ -307,7 +392,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
// autofocus on column if there's no value in column; otherwise autofocus on aggregate
const columnSelectProps = {
ariaLabel: t('Select column'),
- placeholder: t('%s column(s)', columns.length),
+ placeholder: t('%s column(s)', columnsArray.length),
value: columnValue,
onChange: this.onColumnChange,
allowClear: true,
@@ -317,8 +402,11 @@ export default class AdhocMetricEditPopover extends PureComponent {
const aggregateSelectProps = {
ariaLabel: t('Select aggregate options'),
placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length),
- value: adhocMetric.aggregate || adhocMetric.inferSqlExpressionAggregate(),
- onChange: this.onAggregateChange,
+ value:
+ adhocMetric.aggregate ??
+ adhocMetric.inferSqlExpressionAggregate() ??
+ undefined,
+ onChange: this.onAggregateChange as (value: unknown) => void,
allowClear: true,
autoFocus: !!columnValue,
};
@@ -343,10 +431,10 @@ export default class AdhocMetricEditPopover extends PureComponent {
) &&
savedMetric?.metric_name !== propsSavedMetric?.metric_name);
- let extra = {};
- if (datasource?.extra) {
+ let extra: ExtraConfig = {};
+ if (datasource?.extra && typeof datasource.extra === 'string') {
try {
- extra = JSON.parse(datasource.extra);
+ extra = JSON.parse(datasource.extra) as ExtraConfig;
} catch {} // eslint-disable-line no-empty
}
@@ -383,7 +471,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
{...savedSelectProps}
/>
</FormItem>
- ) : datasource.type === DatasourceType.Table ? (
+ ) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
@@ -403,7 +491,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
tabIndex={0}
role="button"
onClick={() => {
- this.props.handleDatasetModal(true);
+ this.props.handleDatasetModal?.(true);
this.props.onClose();
}}
>
@@ -433,9 +521,9 @@ export default class AdhocMetricEditPopover extends PureComponent {
<>
<FormItem label={t('column')}>
<Select
- options={columns.map(column => ({
+ options={columnsArray.map(column => ({
value: column.column_name,
- key: column.id,
+ key: (column as { id?: unknown }).id,
label: this.renderColumnOption(column),
}))}
{...columnSelectProps}
@@ -527,5 +615,7 @@ export default class AdhocMetricEditPopover extends PureComponent {
);
}
}
+// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocMetricEditPopover.propTypes = propTypes;
+// @ts-expect-error - defaultProps for backward compatibility with PropTypes
AdhocMetricEditPopover.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx
similarity index 90%
rename from superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx
index e30d0c0b81..72a7c975b9 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.test.tsx
@@ -37,41 +37,40 @@ const sumValueAdhocMetric = new AdhocMetric({
aggregate: AGGREGATES.SUM,
});
-const datasource = {
- type: 'table',
- id: 1,
- uid: '1__table',
- columnFormats: {},
- verboseMap: {},
-};
-
const defaultProps = {
adhocMetric: sumValueAdhocMetric,
savedMetric: {},
savedMetricsOptions: [],
onMetricEdit: jest.fn(),
columns,
- datasource,
+ datasource: {
+ type: 'table',
+ id: 1,
+ uid: '1__table',
+ columnFormats: {},
+ verboseMap: {},
+ },
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
index: 0,
};
-function setup(overrides) {
+function setup(overrides: Record<string, unknown> = {}) {
const props = {
...defaultProps,
...overrides,
};
- return render(<AdhocMetricOption {...props} />, { useDnd: true });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return render(<AdhocMetricOption {...(props as any)} />, { useDnd: true });
}
test('renders an overlay trigger wrapper for the label', () => {
- setup();
+ setup({});
expect(screen.getByText('SUM(value)')).toBeInTheDocument();
});
test('overwrites the adhocMetric in state with onLabelChange', async () => {
- setup();
+ setup({});
userEvent.click(screen.getByText('SUM(value)'));
userEvent.click(screen.getByTestId(/AdhocMetricEditTitle#trigger/i));
const labelInput = await screen.findByTestId(/AdhocMetricEditTitle#input/i);
@@ -86,7 +85,7 @@ test('overwrites the adhocMetric in state with onLabelChange', async () => {
});
test('returns to default labels when the custom label is cleared', async () => {
- setup();
+ setup({});
userEvent.click(screen.getByText('SUM(value)'));
userEvent.click(screen.getByTestId(/AdhocMetricEditTitle#trigger/i));
const labelInput = await screen.findByTestId(/AdhocMetricEditTitle#input/i);
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.jsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx
similarity index 63%
rename from superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx
index 7ca0f7065f..34a016e986 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx
@@ -18,12 +18,32 @@
*/
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
+import { Metric } from '@superset-ui/core';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { DndItemType } from 'src/explore/components/DndItemType';
+import { Datasource } from 'src/explore/types';
+import { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
import columnType from './columnType';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
import AdhocMetricPopoverTrigger from './AdhocMetricPopoverTrigger';
+import { savedMetricType as SavedMetricTypeDef } from './types';
+
+interface AdhocMetricOptionProps {
+ adhocMetric: AdhocMetric;
+ onMetricEdit: (newMetric: Metric, oldMetric: Metric) => void;
+ onRemoveMetric?: (index: number) => void;
+ columns?: { column_name: string; type: string }[];
+ savedMetricsOptions?: SavedMetricTypeDef[];
+ savedMetric?: SavedMetricTypeDef | Record<string, never>;
+ datasource?: Datasource & ISaveableDatasource;
+ onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
+ onDropLabel?: () => void;
+ index?: number;
+ type?: string;
+ multi?: boolean;
+ datasourceWarningMessage?: string;
+}
const propTypes = {
adhocMetric: PropTypes.instanceOf(AdhocMetric),
@@ -41,15 +61,15 @@ const propTypes = {
datasourceWarningMessage: PropTypes.string,
};
-class AdhocMetricOption extends PureComponent {
- constructor(props) {
+class AdhocMetricOption extends PureComponent<AdhocMetricOptionProps> {
+ constructor(props: AdhocMetricOptionProps) {
super(props);
this.onRemoveMetric = this.onRemoveMetric.bind(this);
}
- onRemoveMetric(e) {
+ onRemoveMetric(e?: React.MouseEvent): void {
e?.stopPropagation();
- this.props.onRemoveMetric(this.props.index);
+ this.props.onRemoveMetric?.(this.props.index ?? 0);
}
render() {
@@ -58,7 +78,7 @@ class AdhocMetricOption extends PureComponent {
onMetricEdit,
columns,
savedMetricsOptions,
- savedMetric,
+ savedMetric = {} as SavedMetricTypeDef,
datasource,
onMoveLabel,
onDropLabel,
@@ -67,25 +87,26 @@ class AdhocMetricOption extends PureComponent {
multi,
datasourceWarningMessage,
} = this.props;
- const withCaret = !savedMetric.error_text;
+ const withCaret = !(savedMetric as SavedMetricTypeDef).error_text;
return (
<AdhocMetricPopoverTrigger
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
- columns={columns}
- savedMetricsOptions={savedMetricsOptions}
+ columns={columns ?? []}
+ savedMetricsOptions={savedMetricsOptions ?? []}
savedMetric={savedMetric}
- datasource={datasource}
+ datasource={datasource!}
>
<OptionControlLabel
- savedMetric={savedMetric}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
label={adhocMetric.label}
- onRemove={this.onRemoveMetric}
+ onRemove={() => this.onRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
- index={index}
+ index={index ?? 0}
type={type ?? DndItemType.AdhocMetricOption}
withCaret={withCaret}
isFunction
@@ -99,4 +120,5 @@ class AdhocMetricOption extends PureComponent {
export default AdhocMetricOption;
+// @ts-expect-error - propTypes are defined for runtime validation but TypeScript handles type checking
AdhocMetricOption.propTypes = propTypes;
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx
index 5e59e4a5c3..95580a742f 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx
@@ -37,7 +37,7 @@ export type AdhocMetricPopoverTriggerProps = {
onMetricEdit(newMetric: Metric, oldMetric: Metric): void;
columns: { column_name: string; type: string }[];
savedMetricsOptions: savedMetricType[];
- savedMetric: savedMetricType;
+ savedMetric: savedMetricType | Record<string, never>;
datasource: Datasource & ISaveableDatasource;
children: ReactNode;
isControlledComponent?: boolean;
@@ -201,8 +201,8 @@ class AdhocMetricPopoverTrigger extends PureComponent<
const { visible, togglePopover, closePopover } = isControlledComponent
? {
visible: this.props.visible,
- togglePopover: this.props.togglePopover,
- closePopover: this.props.closePopover,
+ togglePopover: this.props.togglePopover ?? this.togglePopover,
+ closePopover: this.props.closePopover ?? this.closePopover,
}
: {
visible: this.state.popoverVisible,
@@ -216,12 +216,20 @@ class AdhocMetricPopoverTrigger extends PureComponent<
adhocMetric={adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
- savedMetric={savedMetric}
- datasource={datasource}
+ savedMetric={savedMetric as savedMetricType}
+ datasource={
+ datasource as unknown as {
+ type?: string;
+ id?: number | string;
+ extra?: string;
+ }
+ }
handleDatasetModal={this.handleDatasetModal}
onResize={this.onPopoverResize}
onClose={closePopover}
- onChange={this.onChange}
+ onChange={
+ this.onChange as (newMetric: unknown, oldMetric?: unknown) => void
+ }
getCurrentTab={this.getCurrentTab}
getCurrentLabel={this.getCurrentLabel}
isNewMetric={this.props.isNew}
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.jsx b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.tsx
similarity index 94%
rename from superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.tsx
index 96eb9d46eb..dee87d5a2a 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.test.tsx
@@ -44,7 +44,11 @@ describe('FilterDefinitionOption', () => {
});
test('renders a StyledColumnOption given an adhoc metric', async () => {
- render(<FilterDefinitionOption option={sumValueAdhocMetric} />);
+ render(
+ <FilterDefinitionOption
+ option={sumValueAdhocMetric as unknown as { label: string }}
+ />,
+ );
await expect(screen.getByText('SUM(source)')).toBeVisible();
});
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.jsx b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx
similarity index 81%
rename from superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx
index f310d212e6..c8aaf48855 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/FilterDefinitionOption.tsx
@@ -22,6 +22,14 @@ import columnType from './columnType';
import adhocMetricType from './adhocMetricType';
import { StyledColumnOption } from '../../optionRenderers';
+interface OptionType {
+ saved_metric_name?: string;
+ column_name?: string;
+ label?: string;
+ type?: string;
+ [key: string]: unknown;
+}
+
const propTypes = {
option: PropTypes.oneOfType([
columnType,
@@ -30,7 +38,11 @@ const propTypes = {
]).isRequired,
};
-export default function FilterDefinitionOption({ option }) {
+export default function FilterDefinitionOption({
+ option,
+}: {
+ option: OptionType;
+}) {
if (option.saved_metric_name) {
return (
<StyledColumnOption
@@ -40,7 +52,12 @@ export default function FilterDefinitionOption({ option }) {
);
}
if (option.column_name) {
- return <StyledColumnOption column={option} showType />;
+ return (
+ <StyledColumnOption
+ column={option as { column_name: string; type?: string }}
+ showType
+ />
+ );
}
if (option.label) {
return (
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.tsx
similarity index 83%
rename from superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.tsx
index d55bfa1d83..51717d0d57 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.test.tsx
@@ -26,17 +26,21 @@ const sumValueAdhocMetric = new AdhocMetric({
aggregate: AGGREGATES.SUM,
});
-const setup = propOverrides => {
+const defaultProps = {
+ onMetricEdit: jest.fn(),
+ option: sumValueAdhocMetric as AdhocMetric,
+ index: 1,
+ columns: [],
+ savedMetrics: [],
+ savedMetricsOptions: [],
+ datasource: undefined,
+ onMoveLabel: jest.fn(),
+ onDropLabel: jest.fn(),
+};
+
+const setup = (propOverrides: Record<string, unknown> = {}) => {
const props = {
- onMetricEdit: jest.fn(),
- option: sumValueAdhocMetric,
- index: 1,
- columns: [],
- savedMetrics: [],
- savedMetricsOptions: [],
- datasource: {},
- onMoveLabel: jest.fn(),
- onDropLabel: jest.fn(),
+ ...defaultProps,
...propOverrides,
};
return render(<MetricDefinitionValue {...props} />, { useDnd: true });
@@ -50,6 +54,6 @@ test('renders a MetricOption given a saved metric', () => {
});
test('renders an AdhocMetricOption given an adhoc metric', () => {
- setup();
+ setup({});
expect(screen.getByText('SUM(value)')).toBeInTheDocument();
});
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.tsx
similarity index 67%
rename from superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.tsx
index c300252dd2..4ad35b8d67 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricDefinitionValue.tsx
@@ -17,10 +17,30 @@
* under the License.
*/
import PropTypes from 'prop-types';
+import { Metric } from '@superset-ui/core';
+import { Datasource } from 'src/explore/types';
+import { ISaveableDatasource } from 'src/SqlLab/components/SaveDatasetModal';
import columnType from './columnType';
import AdhocMetricOption from './AdhocMetricOption';
import AdhocMetric from './AdhocMetric';
import savedMetricType from './savedMetricType';
+import { savedMetricType as SavedMetricTypeDef } from './types';
+
+interface MetricDefinitionValueProps {
+ option: AdhocMetric | SavedMetricTypeDef | string;
+ index: number;
+ onMetricEdit?: (newMetric: Metric, oldMetric: Metric) => void;
+ onRemoveMetric?: (index: number) => void;
+ onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
+ onDropLabel?: () => void;
+ columns?: { column_name: string; type: string }[];
+ savedMetrics?: SavedMetricTypeDef[];
+ savedMetricsOptions?: SavedMetricTypeDef[];
+ multi?: boolean;
+ datasource?: Datasource & ISaveableDatasource;
+ datasourceWarningMessage?: string;
+ type?: string;
+}
const propTypes = {
option: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired,
@@ -51,14 +71,14 @@ export default function MetricDefinitionValue({
type,
multi,
datasourceWarningMessage,
-}) {
- const getSavedMetricByName = metricName =>
- savedMetrics.find(metric => metric.metric_name === metricName);
+}: MetricDefinitionValueProps) {
+ const getSavedMetricByName = (metricName: string) =>
+ savedMetrics?.find(metric => metric.metric_name === metricName);
let savedMetric;
if (typeof option === 'string') {
savedMetric = getSavedMetricByName(option);
- } else if (option.metric_name) {
+ } else if ((option as SavedMetricTypeDef).metric_name) {
savedMetric = option;
}
@@ -82,7 +102,8 @@ export default function MetricDefinitionValue({
datasourceWarningMessage,
};
- return <AdhocMetricOption {...metricOptionProps} />;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return <AdhocMetricOption {...(metricOptionProps as any)} />;
}
return null;
}
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx
similarity index 92%
rename from superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx
index e54ff242da..234b5068c3 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.test.tsx
@@ -51,10 +51,11 @@ const defaultProps = {
{ metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
],
+ datasource: undefined,
datasourceType: 'sqla',
};
-function setup(overrides) {
+function setup(overrides: Record<string, unknown> = {}) {
const onChange = jest.fn();
const props = {
onChange,
@@ -92,7 +93,7 @@ test('handles creating a new metric', async () => {
const { onChange } = setup();
userEvent.click(screen.getByText(/add metric/i));
- await selectOption('sum__value', /select saved metrics/i);
+ await selectOption('sum__value', 'Select saved metrics');
userEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onChange).toHaveBeenCalledWith(['sum__value']);
});
@@ -106,7 +107,7 @@ test('accepts an edited metric from an AdhocMetricEditPopover', async () => {
userEvent.click(metricLabel);
await screen.findByText('aggregate');
- selectOption('AVG', /select aggregate options/i);
+ selectOption('AVG', 'Select aggregate options');
await screen.findByText('AVG(value)');
@@ -130,7 +131,7 @@ test('removes metrics if savedMetrics changes', async () => {
const savedTab = screen.getByRole('tab', { name: /saved/i });
userEvent.click(savedTab);
- await selectOption('avg__value', /select saved metrics/i);
+ await selectOption('avg__value', 'Select saved metrics');
const simpleTab = screen.getByRole('tab', { name: /simple/i });
userEvent.click(simpleTab);
@@ -143,6 +144,9 @@ test('removes metrics if savedMetrics changes', async () => {
test('does not remove custom SQL metric if savedMetrics changes', async () => {
const { rerender } = render(
<MetricsControl
+ name="metrics"
+ onChange={jest.fn()}
+ multi
value={[
{
expressionType: EXPRESSION_TYPES.SQL,
@@ -160,6 +164,7 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
{ metric_name: 'sum__value', expression: 'SUM(energy_usage.value)' },
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' },
]}
+ datasource={undefined}
/>,
{ useDnd: true },
);
@@ -169,6 +174,9 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
// Simulate removing columns
rerender(
<MetricsControl
+ name="metrics"
+ onChange={jest.fn()}
+ multi
value={[
{
expressionType: EXPRESSION_TYPES.SQL,
@@ -179,6 +187,7 @@ test('does not remove custom SQL metric if savedMetrics changes', async () => {
]}
columns={[]}
savedMetrics={[]}
+ datasource={undefined}
/>,
);
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.tsx
similarity index 76%
rename from superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx
rename to superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.tsx
index 082befda40..c9c3fd6b29 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.jsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/MetricsControl.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { ensureIsArray, t, usePrevious } from '@superset-ui/core';
import { isEqual } from 'lodash';
@@ -57,13 +57,14 @@ const defaultProps = {
columns: [],
};
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getOptionsForSavedMetrics(
- savedMetrics,
- currentMetricValues,
- currentMetric,
+ savedMetrics: any,
+ currentMetricValues: any,
+ currentMetric: any,
) {
return (
- savedMetrics?.filter(savedMetric =>
+ savedMetrics?.filter((savedMetric: { metric_name: string }) =>
Array.isArray(currentMetricValues)
? !currentMetricValues.includes(savedMetric.metric_name) ||
savedMetric.metric_name === currentMetric
@@ -72,13 +73,15 @@ function getOptionsForSavedMetrics(
);
}
-function isDictionaryForAdhocMetric(value) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function isDictionaryForAdhocMetric(value: any) {
return value && !(value instanceof AdhocMetric) && value.expressionType;
}
// adhoc metrics are stored as dictionaries in URL params. We convert them back into the
// AdhocMetric class for typechecking, consistency and instance method access.
-function coerceAdhocMetrics(value) {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function coerceAdhocMetrics(value: any) {
if (!value) {
return [];
}
@@ -88,7 +91,8 @@ function coerceAdhocMetrics(value) {
}
return [value];
}
- return value.map(val => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return value.map((val: any) => {
if (isDictionaryForAdhocMetric(val)) {
return new AdhocMetric(val);
}
@@ -99,21 +103,42 @@ function coerceAdhocMetrics(value) {
const emptySavedMetric = { metric_name: '', expression: '' };
// TODO: use typeguards to distinguish saved metrics from adhoc metrics
-const getMetricsMatchingCurrentDataset = (value, columns, savedMetrics) =>
- ensureIsArray(value).filter(metric => {
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const getMetricsMatchingCurrentDataset = (
+ value: any,
+ columns: any,
+ savedMetrics: any,
+) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ensureIsArray(value).filter((metric: any) => {
if (typeof metric === 'string' || metric.metric_name) {
return savedMetrics?.some(
- savedMetric =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (savedMetric: any) =>
savedMetric.metric_name === metric ||
savedMetric.metric_name === metric.metric_name,
);
}
return columns?.some(
- column =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (column: any) =>
!metric.column || metric.column.column_name === column.column_name,
);
});
+interface MetricsControlProps {
+ name: string;
+ onChange: (value: unknown) => void;
+ multi?: boolean;
+ value?: unknown;
+ columns?: unknown[];
+ savedMetrics?: unknown[];
+ datasource?: unknown;
+ clearable?: boolean;
+ isLoading?: boolean;
+ [key: string]: unknown;
+}
+
const MetricsControl = ({
onChange,
multi,
@@ -122,13 +147,14 @@ const MetricsControl = ({
savedMetrics,
datasource,
...props
-}) => {
+}: MetricsControlProps) => {
const [value, setValue] = useState(coerceAdhocMetrics(propsValue));
const prevColumns = usePrevious(columns);
const prevSavedMetrics = usePrevious(savedMetrics);
const handleChange = useCallback(
- opts => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (opts: any) => {
// if clear out options
if (opts === null) {
onChange(null);
@@ -137,21 +163,22 @@ const MetricsControl = ({
const transformedOpts = ensureIsArray(opts);
const optionValues = transformedOpts
- .map(option => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .map((option: any) => {
// pre-defined metric
if (option.metric_name) {
return option.metric_name;
}
return option;
})
- .filter(option => option);
+ .filter((option: unknown) => option);
onChange(multi ? optionValues : optionValues[0]);
},
[multi, onChange],
);
const onNewMetric = useCallback(
- newMetric => {
+ (newMetric: unknown) => {
const newValue = [...value, newMetric];
setValue(newValue);
handleChange(newValue);
@@ -160,8 +187,10 @@ const MetricsControl = ({
);
const onMetricEdit = useCallback(
- (changedMetric, oldMetric) => {
- const newValue = value.map(val => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (changedMetric: any, oldMetric: any) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const newValue = value.map((val: any) => {
if (
// compare saved metrics
val === oldMetric.metric_name ||
@@ -181,7 +210,7 @@ const MetricsControl = ({
);
const onRemoveMetric = useCallback(
- index => {
+ (index: number) => {
if (!Array.isArray(value)) {
return;
}
@@ -194,7 +223,7 @@ const MetricsControl = ({
);
const moveLabel = useCallback(
- (dragIndex, hoverIndex) => {
+ (dragIndex: number, hoverIndex: number) => {
const newValues = [...value];
[newValues[hoverIndex], newValues[dragIndex]] = [
newValues[dragIndex],
@@ -217,7 +246,7 @@ const MetricsControl = ({
const newAdhocMetric = useMemo(() => new AdhocMetric({}), [value]);
const addNewMetricPopoverTrigger = useCallback(
- trigger => {
+ (trigger: React.ReactNode) => {
if (isAddNewMetricDisabled()) {
return trigger;
}
@@ -225,10 +254,12 @@ const MetricsControl = ({
<AdhocMetricPopoverTrigger
adhocMetric={newAdhocMetric}
onMetricEdit={onNewMetric}
- columns={columns}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns={columns as any}
savedMetricsOptions={savedMetricOptions}
savedMetric={emptySavedMetric}
- datasource={datasource}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ datasource={datasource as any}
isNew
>
{trigger}
@@ -274,16 +305,20 @@ const MetricsControl = ({
);
const valueRenderer = useCallback(
- (option, index) => (
+ (option: unknown, index: number) => (
<MetricDefinitionValue
key={index}
index={index}
- option={option}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ option={option as any}
onMetricEdit={onMetricEdit}
onRemoveMetric={onRemoveMetric}
- columns={columns}
- datasource={datasource}
- savedMetrics={savedMetrics}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns={columns as any}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ datasource={datasource as any}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ savedMetrics={savedMetrics as any}
savedMetricsOptions={getOptionsForSavedMetrics(
savedMetrics,
value,
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/adhocMetricType.js b/superset-frontend/src/explore/components/controls/MetricControl/adhocMetricType.ts
similarity index 100%
rename from superset-frontend/src/explore/components/controls/MetricControl/adhocMetricType.js
rename to superset-frontend/src/explore/components/controls/MetricControl/adhocMetricType.ts
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/types.ts b/superset-frontend/src/explore/components/controls/MetricControl/types.ts
index 7e16810641..e1f9d7f347 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/types.ts
+++ b/superset-frontend/src/explore/components/controls/MetricControl/types.ts
@@ -20,6 +20,8 @@ export type savedMetricType = {
metric_name: string;
verbose_name?: string;
expression: string;
+ error_text?: string;
+ id?: number | string;
};
export interface AggregateOption {
diff --git a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
index 55771bf567..91b574a201 100644
--- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
+++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx
@@ -259,8 +259,8 @@ export const OptionControlLabel = ({
savedMetric?: savedMetricType;
adhocMetric?: AdhocMetric;
onRemove: () => void;
- onMoveLabel: (dragIndex: number, hoverIndex: number) => void;
- onDropLabel: () => void;
+ onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
+ onDropLabel?: () => void;
withCaret?: boolean;
isFunction?: boolean;
isDraggable?: boolean;
diff --git a/superset-frontend/src/explore/components/controls/SelectControl.test.jsx b/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
similarity index 94%
rename from superset-frontend/src/explore/components/controls/SelectControl.test.jsx
rename to superset-frontend/src/explore/components/controls/SelectControl.test.tsx
index 662895e4af..ce9f2ad28c 100644
--- a/superset-frontend/src/explore/components/controls/SelectControl.test.jsx
+++ b/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
@@ -31,7 +31,13 @@ import SelectControl, {
getSortComparator,
} from 'src/explore/components/controls/SelectControl';
-const defaultProps = {
+const defaultProps: {
+ choices: [string | number, string][];
+ name: string;
+ label: string;
+ valueKey: string;
+ onChange: jest.Mock;
+} = {
choices: [
['1 year ago', '1 year ago'],
['1 week ago', '1 week ago'],
@@ -306,14 +312,19 @@ describe('SelectControl', () => {
test('returns false for empty items', () => {
expect(areAllValuesNumbers([])).toBe(false);
+ // @ts-expect-error testing invalid input
expect(areAllValuesNumbers(null)).toBe(false);
+ // @ts-expect-error testing invalid input
expect(areAllValuesNumbers(undefined)).toBe(false);
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('getSortComparator', () => {
- const mockExplicitComparator = (a, b) => a.label.localeCompare(b.label);
+ const mockExplicitComparator = (
+ a: { label: string },
+ b: { label: string },
+ ) => a.label.localeCompare(b.label);
test('returns explicit comparator when provided', () => {
const choices = [
@@ -322,7 +333,7 @@ describe('SelectControl', () => {
];
const result = getSortComparator(
choices,
- null,
+ undefined,
'value',
mockExplicitComparator,
);
@@ -334,7 +345,7 @@ describe('SelectControl', () => {
[1, 'One'],
[2, 'Two'],
];
- const result = getSortComparator(choices, null, 'value', null);
+ const result = getSortComparator(choices, undefined, 'value', undefined);
expect(typeof result).toBe('function');
expect(result).not.toBe(mockExplicitComparator);
});
@@ -344,7 +355,7 @@ describe('SelectControl', () => {
{ value: 1, label: 'One' },
{ value: 2, label: 'Two' },
];
- const result = getSortComparator(null, options, 'value', null);
+ const result = getSortComparator(undefined, options, 'value', undefined);
expect(typeof result).toBe('function');
expect(result).not.toBe(mockExplicitComparator);
});
@@ -358,7 +369,7 @@ describe('SelectControl', () => {
{ value: 3, label: 'Three' },
{ value: 4, label: 'Four' },
];
- const result = getSortComparator(choices, options, 'value', null);
+ const result = getSortComparator(choices, options, 'value', undefined);
expect(typeof result).toBe('function');
});
@@ -367,7 +378,7 @@ describe('SelectControl', () => {
['one', 'One'],
['two', 'Two'],
];
- const result = getSortComparator(choices, null, 'value', null);
+ const result = getSortComparator(choices, undefined, 'value', undefined);
expect(result).toBeUndefined();
});
@@ -376,12 +387,17 @@ describe('SelectControl', () => {
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
];
- const result = getSortComparator(null, options, 'value', null);
+ const result = getSortComparator(undefined, options, 'value', undefined);
expect(result).toBeUndefined();
});
test('returns undefined when no choices or options provided', () => {
- const result = getSortComparator(null, null, 'value', null);
+ const result = getSortComparator(
+ undefined,
+ undefined,
+ 'value',
+ undefined,
+ );
expect(result).toBeUndefined();
});
});
diff --git a/superset-frontend/src/explore/components/controls/SelectControl.jsx b/superset-frontend/src/explore/components/controls/SelectControl.tsx
similarity index 65%
rename from superset-frontend/src/explore/components/controls/SelectControl.jsx
rename to superset-frontend/src/explore/components/controls/SelectControl.tsx
index 5ac1166847..932538ceff 100644
--- a/superset-frontend/src/explore/components/controls/SelectControl.jsx
+++ b/superset-frontend/src/explore/components/controls/SelectControl.tsx
@@ -16,13 +16,61 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import { PureComponent, type ReactNode } from 'react';
import PropTypes from 'prop-types';
import { isEqualArray, t } from '@superset-ui/core';
import { css } from '@apache-superset/core/ui';
import { Select } from '@superset-ui/core/components';
import ControlHeader from 'src/explore/components/ControlHeader';
+type SelectValue = string | number | (string | number)[] | null | undefined;
+
+interface SelectOption {
+ value: string | number;
+ label: string;
+ [key: string]: unknown;
+}
+
+export interface SelectControlProps {
+ ariaLabel?: string;
+ autoFocus?: boolean;
+ choices?: [string | number, string][];
+ clearable?: boolean;
+ description?: string | ReactNode;
+ disabled?: boolean;
+ freeForm?: boolean;
+ isLoading?: boolean;
+ mode?: string;
+ multi?: boolean;
+ isMulti?: boolean;
+ name: string;
+ onChange?: (value: SelectValue, options?: unknown[]) => void;
+ onFocus?: () => void;
+ onSelect?: (value: SelectValue) => void;
+ onDeselect?: (value: SelectValue) => void;
+ value?: SelectValue;
+ default?: SelectValue;
+ showHeader?: boolean;
+ optionRenderer?: (option: unknown) => ReactNode;
+ valueKey?: string;
+ options?: { value: string | number; label: string; [key: string]: unknown }[];
+ placeholder?: string;
+ filterOption?: (input: unknown, option: unknown) => boolean;
+ tokenSeparators?: string[];
+ notFoundContent?: ReactNode;
+ label?: string;
+ renderTrigger?: boolean;
+ validationErrors?: string[];
+ rightNode?: ReactNode;
+ leftNode?: ReactNode;
+ onClick?: () => void;
+ hovered?: boolean;
+ tooltipOnClick?: () => void;
+ warning?: string;
+ danger?: string;
+ sortComparator?: (a: SelectOption, b: SelectOption) => number;
+}
+
const propTypes = {
ariaLabel: PropTypes.string,
autoFocus: PropTypes.bool,
@@ -88,9 +136,17 @@ const defaultProps = {
valueKey: 'value',
};
-const numberComparator = (a, b) => a.value - b.value;
+interface SelectControlState {
+ options: SelectOption[];
+}
+
+const numberComparator = (a: SelectOption, b: SelectOption): number =>
+ (a.value as number) - (b.value as number);
-export const areAllValuesNumbers = (items, valueKey = 'value') => {
+export const areAllValuesNumbers = (
+ items: unknown[],
+ valueKey = 'value',
+): boolean => {
if (!items || items.length === 0) {
return false;
}
@@ -100,18 +156,22 @@ export const areAllValuesNumbers = (items, valueKey = 'value') => {
return typeof value === 'number';
}
if (typeof item === 'object' && item !== null) {
- return typeof item[valueKey] === 'number';
+ return typeof (item as Record<string, unknown>)[valueKey] === 'number';
}
return typeof item === 'number';
});
};
+type SortComparator =
+ | ((a: SelectOption, b: SelectOption) => number)
+ | undefined;
+
export const getSortComparator = (
- choices,
- options,
- valueKey,
- explicitComparator,
-) => {
+ choices: unknown[] | undefined,
+ options: unknown[] | undefined,
+ valueKey: string | undefined,
+ explicitComparator: SortComparator,
+): SortComparator => {
if (explicitComparator) {
return explicitComparator;
}
@@ -126,14 +186,16 @@ export const getSortComparator = (
return undefined;
};
-export const innerGetOptions = props => {
- const { choices, optionRenderer, valueKey } = props;
- let options = [];
+export const innerGetOptions = (props: SelectControlProps): SelectOption[] => {
+ const { choices, optionRenderer, valueKey = 'value' } = props;
+ let options: SelectOption[] = [];
if (props.options) {
options = props.options.map(o => ({
...o,
- value: o[valueKey],
- label: optionRenderer ? optionRenderer(o) : o.label || o[valueKey],
+ value: o[valueKey] as string | number,
+ label: optionRenderer
+ ? (optionRenderer(o) as string)
+ : ((o.label || o[valueKey]) as string),
}));
} else if (choices) {
// Accepts different formats of input
@@ -142,24 +204,25 @@ export const innerGetOptions = props => {
const [value, label] = c.length > 1 ? c : [c[0], c[0]];
return {
value,
- label,
+ label: String(label),
};
}
- if (Object.is(c)) {
- return {
- ...c,
- value: c[valueKey],
- label: c.label || c[valueKey],
- };
- }
- return { value: c, label: c };
+ // This branch handles object-like choices, but choices are typed as tuples
+ return { value: c as unknown as string | number, label: String(c) };
});
}
return options;
};
-export default class SelectControl extends PureComponent {
- constructor(props) {
+export default class SelectControl extends PureComponent<
+ SelectControlProps,
+ SelectControlState
+> {
+ static propTypes = propTypes;
+
+ static defaultProps = defaultProps;
+
+ constructor(props: SelectControlProps) {
super(props);
this.state = {
options: this.getOptions(props),
@@ -168,7 +231,7 @@ export default class SelectControl extends PureComponent {
this.handleFilterOptions = this.handleFilterOptions.bind(this);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: SelectControlProps) {
if (
!isEqualArray(this.props.choices, prevProps.choices) ||
!isEqualArray(this.props.options, prevProps.options)
@@ -180,30 +243,39 @@ export default class SelectControl extends PureComponent {
// Beware: This is acting like an on-click instead of an on-change
// (firing every time user chooses vs firing only if a new option is chosen).
- onChange(val) {
+ onChange(val: SelectValue | SelectOption | SelectOption[]) {
// will eventually call `exploreReducer`: SET_FIELD_VALUE
- const { valueKey } = this.props;
- let onChangeVal = val;
+ const { valueKey = 'value' } = this.props;
+ let onChangeVal: SelectValue = val as SelectValue;
if (Array.isArray(val)) {
const values = val.map(v =>
- v?.[valueKey] !== undefined ? v[valueKey] : v,
+ typeof v === 'object' &&
+ v !== null &&
+ (v as SelectOption)[valueKey] !== undefined
+ ? (v as SelectOption)[valueKey]
+ : v,
);
- onChangeVal = values;
+ onChangeVal = values as (string | number)[];
}
- if (typeof val === 'object' && val?.[valueKey] !== undefined) {
- onChangeVal = val[valueKey];
+ if (
+ typeof val === 'object' &&
+ val !== null &&
+ !Array.isArray(val) &&
+ (val as SelectOption)[valueKey] !== undefined
+ ) {
+ onChangeVal = (val as SelectOption)[valueKey] as string | number;
}
- this.props.onChange(onChangeVal, []);
+ this.props.onChange?.(onChangeVal, []);
}
- getOptions(props) {
+ getOptions(props: SelectControlProps) {
return innerGetOptions(props);
}
- handleFilterOptions(text, option) {
+ handleFilterOptions(text: string, option: SelectOption) {
const { filterOption } = this.props;
- return filterOption({ data: option }, text);
+ return filterOption?.({ data: option }, text) ?? true;
}
render() {
@@ -316,11 +388,9 @@ export default class SelectControl extends PureComponent {
}
`}
>
- <Select {...selectProps} />
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ <Select {...(selectProps as any)} />
</div>
);
}
}
-
-SelectControl.propTypes = propTypes;
-SelectControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/SpatialControl.jsx b/superset-frontend/src/explore/components/controls/SpatialControl.tsx
similarity index 72%
rename from superset-frontend/src/explore/components/controls/SpatialControl.jsx
rename to superset-frontend/src/explore/components/controls/SpatialControl.tsx
index 52575341cb..3ec41e37ff 100644
--- a/superset-frontend/src/explore/components/controls/SpatialControl.jsx
+++ b/superset-frontend/src/explore/components/controls/SpatialControl.tsx
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component } from 'react';
-import PropTypes from 'prop-types';
+import { Component, type ReactNode } from 'react';
import {
Row,
Col,
@@ -35,27 +34,54 @@ const spatialTypes = {
latlong: 'latlong',
delimited: 'delimited',
geohash: 'geohash',
-};
-
-const propTypes = {
- onChange: PropTypes.func,
- value: PropTypes.object,
- animation: PropTypes.bool,
- choices: PropTypes.array,
-};
-
-const defaultProps = {
- onChange: () => {},
- animation: true,
- choices: [],
-};
-
-export default class SpatialControl extends Component {
- constructor(props) {
+} as const;
+
+type SpatialType = (typeof spatialTypes)[keyof typeof spatialTypes];
+
+interface SpatialValue {
+ type: SpatialType;
+ latCol?: string;
+ lonCol?: string;
+ lonlatCol?: string;
+ delimiter?: string;
+ reverseCheckbox?: boolean;
+ geohashCol?: string;
+}
+
+interface SpatialControlProps {
+ onChange?: (value: SpatialValue, errors: string[]) => void;
+ value?: SpatialValue;
+ animation?: boolean;
+ choices?: [string, string][];
+}
+
+interface SpatialControlState {
+ type: SpatialType;
+ delimiter: string;
+ latCol: string | undefined;
+ lonCol: string | undefined;
+ lonlatCol: string | undefined;
+ reverseCheckbox: boolean;
+ geohashCol: string | undefined;
+ value: SpatialValue | null;
+ errors: string[];
+}
+
+export default class SpatialControl extends Component<
+ SpatialControlProps,
+ SpatialControlState
+> {
+ static defaultProps = {
+ onChange: () => {},
+ animation: true,
+ choices: [],
+ };
+
+ constructor(props: SpatialControlProps) {
super(props);
- const v = props.value || {};
- let defaultCol;
- if (props.choices.length > 0) {
+ const v = props.value || ({} as SpatialValue);
+ let defaultCol: string | undefined;
+ if (props.choices && props.choices.length > 0) {
defaultCol = props.choices[0][0];
}
this.state = {
@@ -69,19 +95,16 @@ export default class SpatialControl extends Component {
value: null,
errors: [],
};
- this.toggleCheckbox = this.toggleCheckbox.bind(this);
- this.onChange = this.onChange.bind(this);
- this.renderReverseCheckbox = this.renderReverseCheckbox.bind(this);
}
- componentDidMount() {
+ componentDidMount(): void {
this.onChange();
}
- onChange() {
+ onChange = (): void => {
const { type } = this.state;
- const value = { type };
- const errors = [];
+ const value: SpatialValue = { type };
+ const errors: string[] = [];
const errMsg = t('Invalid lat/long configuration.');
if (type === spatialTypes.latlong) {
value.latCol = this.state.latCol;
@@ -104,21 +127,21 @@ export default class SpatialControl extends Component {
}
}
this.setState({ value, errors });
- this.props.onChange(value, errors);
- }
+ this.props.onChange?.(value, errors);
+ };
- setType(type) {
+ setType = (type: SpatialType): void => {
this.setState({ type }, this.onChange);
- }
+ };
- toggleCheckbox() {
+ toggleCheckbox = (): void => {
this.setState(
prevState => ({ reverseCheckbox: !prevState.reverseCheckbox }),
this.onChange,
);
- }
+ };
- renderLabelContent() {
+ renderLabelContent(): string | null {
if (this.state.errors.length > 0) {
return 'N/A';
}
@@ -134,25 +157,28 @@ export default class SpatialControl extends Component {
return null;
}
- renderSelect(name, type) {
+ renderSelect(name: keyof SpatialControlState, type: SpatialType): ReactNode {
return (
<SelectControl
ariaLabel={name}
name={name}
choices={this.props.choices}
- value={this.state[name]}
+ value={this.state[name] as string}
clearable={false}
onFocus={() => {
this.setType(type);
}}
- onChange={value => {
- this.setState({ [name]: value }, this.onChange);
+ onChange={(value: string) => {
+ this.setState(
+ { [name]: value } as unknown as SpatialControlState,
+ this.onChange,
+ );
}}
/>
);
}
- renderReverseCheckbox() {
+ renderReverseCheckbox(): ReactNode {
return (
<span>
{t('Reverse lat/long ')}
@@ -164,13 +190,13 @@ export default class SpatialControl extends Component {
);
}
- renderPopoverContent() {
+ renderPopoverContent(): ReactNode {
return (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={this.state.type === spatialTypes.latlong}
- onSelect={this.setType.bind(this, spatialTypes.latlong)}
+ onSelect={() => this.setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -190,7 +216,7 @@ export default class SpatialControl extends Component {
'Python library for more details',
)}
isSelected={this.state.type === spatialTypes.delimited}
- onSelect={this.setType.bind(this, spatialTypes.delimited)}
+ onSelect={() => this.setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -205,7 +231,7 @@ export default class SpatialControl extends Component {
<PopoverSection
title={t('Geohash')}
isSelected={this.state.type === spatialTypes.geohash}
- onSelect={this.setType.bind(this, spatialTypes.geohash)}
+ onSelect={() => this.setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
@@ -221,13 +247,13 @@ export default class SpatialControl extends Component {
);
}
- render() {
+ render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
content={this.renderPopoverContent()}
- placement="topLeft" // so that popover doesn't move when label changes
+ placement="topLeft"
trigger="click"
>
<Label className="pointer">{this.renderLabelContent()}</Label>
@@ -236,6 +262,3 @@ export default class SpatialControl extends Component {
);
}
}
-
-SpatialControl.propTypes = propTypes;
-SpatialControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/TextAreaControl.test.jsx b/superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx
similarity index 100%
rename from superset-frontend/src/explore/components/controls/TextAreaControl.test.jsx
rename to superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx
diff --git a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx b/superset-frontend/src/explore/components/controls/TextAreaControl.tsx
similarity index 69%
rename from superset-frontend/src/explore/components/controls/TextAreaControl.jsx
rename to superset-frontend/src/explore/components/controls/TextAreaControl.tsx
index 076fa1174e..90b2c352c3 100644
--- a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx
+++ b/superset-frontend/src/explore/components/controls/TextAreaControl.tsx
@@ -33,6 +33,54 @@ import 'ace-builds/src-min-noconflict/mode-handlebars';
import ControlHeader from 'src/explore/components/ControlHeader';
+interface HotkeyConfig {
+ name: string;
+ key: string;
+ func: () => void;
+}
+
+interface ThemeType {
+ colorBorder: string;
+ colorBgMask: string;
+ sizeUnit: number;
+}
+
+interface TextAreaControlProps {
+ name?: string;
+ onChange?: (value: string) => void;
+ initialValue?: string;
+ height?: number;
+ minLines?: number;
+ maxLines?: number;
+ offerEditInModal?: boolean;
+ language?:
+ | 'json'
+ | 'html'
+ | 'sql'
+ | 'markdown'
+ | 'javascript'
+ | 'handlebars'
+ | null;
+ aboveEditorSection?: React.ReactNode;
+ readOnly?: boolean;
+ resize?:
+ | 'block'
+ | 'both'
+ | 'horizontal'
+ | 'inline'
+ | 'none'
+ | 'vertical'
+ | null;
+ textAreaStyles?: React.CSSProperties;
+ tooltipOptions?: Record<string, unknown>;
+ hotkeys?: HotkeyConfig[];
+ debounceDelay?: number | null;
+ theme?: ThemeType;
+ 'aria-required'?: boolean;
+ value?: string;
+ [key: string]: unknown;
+}
+
const propTypes = {
name: PropTypes.string,
onChange: PropTypes.func,
@@ -82,18 +130,27 @@ const defaultProps = {
debounceDelay: null,
};
-class TextAreaControl extends Component {
- constructor(props) {
+class TextAreaControl extends Component<TextAreaControlProps> {
+ static propTypes = propTypes;
+
+ static defaultProps = defaultProps;
+
+ debouncedOnChange:
+ | ReturnType<typeof debounce<(value: string) => void>>
+ | undefined;
+
+ constructor(props: TextAreaControlProps) {
super(props);
- if (props.debounceDelay) {
+ if (props.debounceDelay && props.onChange) {
this.debouncedOnChange = debounce(props.onChange, props.debounceDelay);
}
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: TextAreaControlProps) {
if (
this.props.onChange !== prevProps.onChange &&
- this.props.debounceDelay
+ this.props.debounceDelay &&
+ this.props.onChange
) {
if (this.debouncedOnChange) {
this.debouncedOnChange.cancel();
@@ -105,12 +162,12 @@ class TextAreaControl extends Component {
}
}
- handleChange(value) {
+ handleChange(value: string | { target: { value: string } }) {
const finalValue = typeof value === 'object' ? value.target.value : value;
if (this.debouncedOnChange) {
this.debouncedOnChange(finalValue);
} else {
- this.props.onChange(finalValue);
+ this.props.onChange?.(finalValue);
}
}
@@ -123,8 +180,10 @@ class TextAreaControl extends Component {
renderEditor(inModal = false) {
const minLines = inModal ? 40 : this.props.minLines || 12;
if (this.props.language) {
- const style = {
- border: `1px solid ${this.props.theme.colorBorder}`,
+ const style: React.CSSProperties = {
+ border: this.props.theme?.colorBorder
+ ? `1px solid ${this.props.theme.colorBorder}`
+ : undefined,
minHeight: `${minLines}em`,
width: 'auto',
...this.props.textAreaStyles,
@@ -133,10 +192,18 @@ class TextAreaControl extends Component {
style.resize = this.props.resize;
}
if (this.props.readOnly) {
- style.backgroundColor = this.props.theme.colorBgMask;
+ style.backgroundColor = this.props.theme?.colorBgMask;
}
- const onEditorLoad = editor => {
- this.props.hotkeys.forEach(keyConfig => {
+ const onEditorLoad = (editor: {
+ commands: {
+ addCommand: (cmd: {
+ name: string;
+ bindKey: { win: string; mac: string };
+ exec: () => void;
+ }) => void;
+ };
+ }) => {
+ this.props.hotkeys?.forEach(keyConfig => {
editor.commands.addCommand({
name: keyConfig.name,
bindKey: { win: keyConfig.key, mac: keyConfig.key },
@@ -203,16 +270,17 @@ class TextAreaControl extends Component {
{this.renderEditor()}
{this.props.offerEditInModal && (
<ModalTrigger
- modalTitle={controlHeader}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ modalTitle={controlHeader as any}
triggerNode={
<Button
buttonSize="small"
- style={{ marginTop: this.props.theme.sizeUnit }}
+ style={{ marginTop: this.props.theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', this.props.language)}
</Button>
}
- modalBody={this.renderModalBody(true)}
+ modalBody={this.renderModalBody()}
responsive
/>
)}
@@ -221,7 +289,5 @@ class TextAreaControl extends Component {
}
}
-TextAreaControl.propTypes = propTypes;
-TextAreaControl.defaultProps = defaultProps;
-
-export default withTheme(TextAreaControl);
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export default withTheme(TextAreaControl as any);
diff --git a/superset-frontend/src/explore/components/controls/TextControl/index.tsx b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
index d55d47860a..805d3d90eb 100644
--- a/superset-frontend/src/explore/components/controls/TextControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/TextControl/index.tsx
@@ -25,7 +25,9 @@ import { Constants, Input } from '@superset-ui/core/components';
type InputValueType = string | number;
export interface TextControlProps<T extends InputValueType = InputValueType> {
+ name?: string;
label?: string;
+ description?: string;
disabled?: boolean;
isFloat?: boolean;
isInt?: boolean;
@@ -36,6 +38,8 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
controlId?: string;
renderTrigger?: boolean;
validationErrors?: string[];
+ hovered?: boolean;
+ showHeader?: boolean;
}
export interface TextControlState {
diff --git a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx
similarity index 81%
rename from superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx
rename to superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx
index c10922b0e2..d614c4d563 100644
--- a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.jsx
+++ b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx
@@ -34,6 +34,42 @@ import BoundsControl from '../BoundsControl';
import CheckboxControl from '../CheckboxControl';
import ControlPopover from '../ControlPopover/ControlPopover';
+interface TimeSeriesColumnControlProps {
+ label?: string;
+ tooltip?: string;
+ colType?: string;
+ width?: string;
+ height?: string;
+ timeLag?: string | number;
+ timeRatio?: string;
+ comparisonType?: string;
+ showYAxis?: boolean;
+ yAxisBounds?: (number | null)[];
+ bounds?: (number | null)[];
+ d3format?: string;
+ dateFormat?: string;
+ sparkType?: string;
+ onChange?: (state: TimeSeriesColumnControlState) => void;
+}
+
+interface TimeSeriesColumnControlState {
+ label: string;
+ tooltip: string;
+ colType: string;
+ width: string;
+ height: string;
+ timeLag: string | number;
+ timeRatio: string;
+ comparisonType: string;
+ showYAxis: boolean;
+ yAxisBounds: (number | null)[];
+ bounds: (number | null)[];
+ d3format: string;
+ dateFormat: string;
+ sparkType: string;
+ popoverVisible: boolean;
+}
+
const propTypes = {
label: PropTypes.string,
tooltip: PropTypes.string,
@@ -111,8 +147,15 @@ const ButtonBar = styled.div`
justify-content: center;
`;
-export default class TimeSeriesColumnControl extends Component {
- constructor(props) {
+export default class TimeSeriesColumnControl extends Component<
+ TimeSeriesColumnControlProps,
+ TimeSeriesColumnControlState
+> {
+ static propTypes = propTypes;
+
+ static defaultProps = defaultProps;
+
+ constructor(props: TimeSeriesColumnControlProps) {
super(props);
this.onSave = this.onSave.bind(this);
@@ -124,22 +167,22 @@ export default class TimeSeriesColumnControl extends Component {
this.state = this.initialState();
}
- initialState() {
+ initialState(): TimeSeriesColumnControlState {
return {
- label: this.props.label,
- tooltip: this.props.tooltip,
- colType: this.props.colType,
- width: this.props.width,
- height: this.props.height,
- timeLag: this.props.timeLag || 0,
- timeRatio: this.props.timeRatio,
- comparisonType: this.props.comparisonType,
- showYAxis: this.props.showYAxis,
- yAxisBounds: this.props.yAxisBounds,
- bounds: this.props.bounds,
- d3format: this.props.d3format,
- dateFormat: this.props.dateFormat,
- sparkType: this.props.sparkType,
+ label: this.props.label ?? t('Time series columns'),
+ tooltip: this.props.tooltip ?? '',
+ colType: this.props.colType ?? '',
+ width: this.props.width ?? '',
+ height: this.props.height ?? '',
+ timeLag: this.props.timeLag ?? 0,
+ timeRatio: this.props.timeRatio ?? '',
+ comparisonType: this.props.comparisonType ?? '',
+ showYAxis: this.props.showYAxis ?? false,
+ yAxisBounds: this.props.yAxisBounds ?? [null, null],
+ bounds: this.props.bounds ?? [null, null],
+ d3format: this.props.d3format ?? '',
+ dateFormat: this.props.dateFormat ?? '',
+ sparkType: this.props.sparkType ?? 'line',
popoverVisible: false,
};
}
@@ -150,7 +193,7 @@ export default class TimeSeriesColumnControl extends Component {
}
onSave() {
- this.props.onChange(this.state);
+ this.props.onChange?.(this.state);
this.setState({ popoverVisible: false });
}
@@ -158,23 +201,23 @@ export default class TimeSeriesColumnControl extends Component {
this.resetState();
}
- onSelectChange(attr, opt) {
- this.setState({ [attr]: opt });
+ onSelectChange(attr: string, opt: string) {
+ this.setState(prevState => ({ ...prevState, [attr]: opt }));
}
- onTextInputChange(attr, event) {
- this.setState({ [attr]: event.target.value });
+ onTextInputChange(attr: string, event: React.ChangeEvent<HTMLInputElement>) {
+ this.setState(prevState => ({ ...prevState, [attr]: event.target.value }));
}
- onCheckboxChange(attr, value) {
- this.setState({ [attr]: value });
+ onCheckboxChange(attr: string, value: boolean) {
+ this.setState(prevState => ({ ...prevState, [attr]: value }));
}
- onBoundsChange(bounds) {
+ onBoundsChange(bounds: (number | null)[]) {
this.setState({ bounds });
}
- onPopoverVisibleChange(popoverVisible) {
+ onPopoverVisibleChange(popoverVisible: boolean) {
if (popoverVisible) {
this.setState({ popoverVisible });
} else {
@@ -182,15 +225,20 @@ export default class TimeSeriesColumnControl extends Component {
}
}
- onYAxisBoundsChange(yAxisBounds) {
+ onYAxisBoundsChange(yAxisBounds: (number | null)[]) {
this.setState({ yAxisBounds });
}
textSummary() {
- return `${this.props.label}`;
+ return `${this.props.label ?? ''}`;
}
- formRow(label, tooltip, ttLabel, control) {
+ formRow(
+ label: string,
+ tooltip: string,
+ ttLabel: string,
+ control: React.ReactNode,
+ ) {
return (
<StyledRow>
<StyledCol xs={24} md={11}>
@@ -412,6 +460,3 @@ export default class TimeSeriesColumnControl extends Component {
);
}
}
-
-TimeSeriesColumnControl.propTypes = propTypes;
-TimeSeriesColumnControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/ViewportControl.test.jsx b/superset-frontend/src/explore/components/controls/ViewportControl.test.tsx
similarity index 100%
rename from superset-frontend/src/explore/components/controls/ViewportControl.test.jsx
rename to superset-frontend/src/explore/components/controls/ViewportControl.test.tsx
diff --git a/superset-frontend/src/explore/components/controls/ViewportControl.jsx b/superset-frontend/src/explore/components/controls/ViewportControl.tsx
similarity index 62%
rename from superset-frontend/src/explore/components/controls/ViewportControl.jsx
rename to superset-frontend/src/explore/components/controls/ViewportControl.tsx
index daf82d14f0..431a2505e2 100644
--- a/superset-frontend/src/explore/components/controls/ViewportControl.jsx
+++ b/superset-frontend/src/explore/components/controls/ViewportControl.tsx
@@ -16,16 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component } from 'react';
+import { Component, type ReactNode } from 'react';
import { t } from '@superset-ui/core';
-import PropTypes from 'prop-types';
import { Popover, FormLabel, Label } from '@superset-ui/core/components';
import { decimal2sexagesimal } from 'geolib';
import TextControl from './TextControl';
import ControlHeader from '../ControlHeader';
-export const DEFAULT_VIEWPORT = {
+export interface Viewport {
+ longitude: number;
+ latitude: number;
+ zoom: number;
+ bearing: number;
+ pitch: number;
+}
+
+export const DEFAULT_VIEWPORT: Viewport = {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
zoom: 1,
@@ -33,54 +40,49 @@ export const DEFAULT_VIEWPORT = {
pitch: 0,
};
-const PARAMS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'];
+const PARAMS: (keyof Viewport)[] = [
+ 'longitude',
+ 'latitude',
+ 'zoom',
+ 'bearing',
+ 'pitch',
+];
-const propTypes = {
- onChange: PropTypes.func,
- value: PropTypes.shape({
- longitude: PropTypes.number,
- latitude: PropTypes.number,
- zoom: PropTypes.number,
- bearing: PropTypes.number,
- pitch: PropTypes.number,
- }),
- default: PropTypes.object,
- name: PropTypes.string.isRequired,
-};
-
-const defaultProps = {
- onChange: () => {},
- default: { type: 'fix', value: 5 },
- value: DEFAULT_VIEWPORT,
-};
+interface ViewportControlProps {
+ onChange?: (value: Viewport) => void;
+ value?: Viewport;
+ default?: Record<string, unknown>;
+ name: string;
+}
-export default class ViewportControl extends Component {
- constructor(props) {
- super(props);
- this.onChange = this.onChange.bind(this);
- }
+export default class ViewportControl extends Component<ViewportControlProps> {
+ static defaultProps = {
+ onChange: () => {},
+ default: { type: 'fix', value: 5 },
+ value: DEFAULT_VIEWPORT,
+ };
- onChange(ctrl, value) {
- this.props.onChange({
- ...this.props.value,
+ onChange = (ctrl: keyof Viewport, value: number): void => {
+ this.props.onChange?.({
+ ...this.props.value!,
[ctrl]: value,
});
- }
+ };
- renderTextControl(ctrl) {
+ renderTextControl(ctrl: keyof Viewport): ReactNode {
return (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
- value={this.props.value[ctrl]}
- onChange={this.onChange.bind(this, ctrl)}
+ value={this.props.value?.[ctrl]}
+ onChange={(value: number) => this.onChange(ctrl, value)}
isFloat
/>
</div>
);
}
- renderPopover() {
+ renderPopover(): ReactNode {
return (
<div id={`filter-popover-${this.props.name}`}>
{PARAMS.map(ctrl => this.renderTextControl(ctrl))}
@@ -88,8 +90,8 @@ export default class ViewportControl extends Component {
);
}
- renderLabel() {
- if (this.props.value.longitude && this.props.value.latitude) {
+ renderLabel(): string {
+ if (this.props.value?.longitude && this.props.value?.latitude) {
return `${decimal2sexagesimal(
this.props.value.longitude,
)} | ${decimal2sexagesimal(this.props.value.latitude)}`;
@@ -97,12 +99,11 @@ export default class ViewportControl extends Component {
return 'N/A';
}
- render() {
+ render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
- container={document.body}
trigger="click"
placement="right"
content={this.renderPopover()}
@@ -114,6 +115,3 @@ export default class ViewportControl extends Component {
);
}
}
-
-ViewportControl.propTypes = propTypes;
-ViewportControl.defaultProps = defaultProps;
diff --git a/superset-frontend/src/explore/components/controls/XAxisSortControl.tsx b/superset-frontend/src/explore/components/controls/XAxisSortControl.tsx
index a6ea631527..373e88f605 100644
--- a/superset-frontend/src/explore/components/controls/XAxisSortControl.tsx
+++ b/superset-frontend/src/explore/components/controls/XAxisSortControl.tsx
@@ -19,11 +19,15 @@
import { useEffect, useState } from 'react';
import SelectControl from './SelectControl';
-export default function XAxisSortControl(props: {
+interface XAxisSortControlProps {
onChange: (val: string | undefined) => void;
value: string | null;
shouldReset: boolean;
-}) {
+ name?: string;
+ [key: string]: unknown;
+}
+
+export default function XAxisSortControl(props: XAxisSortControlProps) {
const [value, setValue] = useState(props.value);
useEffect(() => {
if (props.shouldReset) {
@@ -32,5 +36,11 @@ export default function XAxisSortControl(props: {
}
}, [props.shouldReset, props.value]);
- return <SelectControl {...props} value={value} />;
+ return (
+ <SelectControl
+ {...props}
+ name={props.name ?? 'x_axis_sort'}
+ value={value}
+ />
+ );
}
diff --git a/superset-frontend/src/explore/controlUtils/standardizedFormData.ts b/superset-frontend/src/explore/controlUtils/standardizedFormData.ts
index 6bdf4a263b..0462b9e3a1 100644
--- a/superset-frontend/src/explore/controlUtils/standardizedFormData.ts
+++ b/superset-frontend/src/explore/controlUtils/standardizedFormData.ts
@@ -25,7 +25,6 @@ import {
QueryFormMetric,
} from '@superset-ui/core';
import {
- ControlStateMapping,
getStandardizedControls,
isStandardizedFormData,
StandardizedControls,
@@ -189,11 +188,10 @@ export class StandardizedFormData {
transform(
targetVizType: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
exploreState: Record<string, any>,
- ): {
- formData: QueryFormData;
- controlsState: ControlStateMapping;
- } {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ): any {
/*
* Transform form_data between different viz. Return new form_data and controlsState.
* 1. get memorized form_data by viz type or get previous form_data
@@ -211,13 +209,15 @@ export class StandardizedFormData {
publicFormData[key] = exploreState.form_data[key];
}
});
- const targetControlsState = getControlsState(exploreState, {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const targetControlsState = getControlsState(exploreState as any, {
...latestFormData,
...publicFormData,
viz_type: targetVizType,
});
const targetFormData = {
- ...getFormDataFromControls(targetControlsState),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...getFormDataFromControls(targetControlsState as any),
// Preserve dashboard context when switching viz types.
...(publicFormData.dashboardId && {
dashboardId: publicFormData.dashboardId,
@@ -243,14 +243,18 @@ export class StandardizedFormData {
getStandardizedControls().clear();
rv = {
formData: transformed,
- controlsState: getControlsState(exploreState, transformed),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ controlsState: getControlsState(exploreState as any, transformed),
};
}
// refresh validator message
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
rv.controlsState = getControlsState(
- { ...exploreState, controls: rv.controlsState },
- rv.formData,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { ...exploreState, controls: rv.controlsState } as any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ rv.formData as any,
);
return rv;
}
diff --git a/superset-frontend/src/explore/controls.jsx b/superset-frontend/src/explore/controls.tsx
similarity index 87%
rename from superset-frontend/src/explore/controls.jsx
rename to superset-frontend/src/explore/controls.tsx
index 6deea0274a..3decceb081 100644
--- a/superset-frontend/src/explore/controls.jsx
+++ b/superset-frontend/src/explore/controls.tsx
@@ -56,6 +56,7 @@
* in tandem with `controlPanels/index.js` that defines how controls are composed into sections for
* each and every visualization type.
*/
+import type { Column, SequentialScheme } from '@superset-ui/core';
import {
t,
getCategoricalSchemeRegistry,
@@ -67,6 +68,24 @@ import { formatSelectOptions } from 'src/explore/exploreUtils';
import { TIME_FILTER_LABELS } from './constants';
import { StyledColumnOption } from './components/optionRenderers';
+interface Datasource {
+ columns: Column[];
+ metrics: unknown[];
+ granularity_sqla?: Column[];
+ main_dttm_col?: string;
+ time_grain_sqla?: unknown[];
+}
+
+interface ControlState {
+ datasource?: Datasource;
+ controls?: Record<string, { value?: unknown }>;
+}
+
+interface ControlConfig {
+ includeTime?: boolean;
+ [key: string]: unknown;
+}
+
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
@@ -126,18 +145,20 @@ const groupByControl = {
'One or many columns to group by. High cardinality groupings should include a series limit ' +
'to limit the number of fetched and rendered series.',
),
- optionRenderer: c => <StyledColumnOption column={c} showType />,
+ optionRenderer: (c: Column) => <StyledColumnOption column={c} showType />,
valueKey: 'column_name',
- filterOption: ({ data: opt }, text) =>
+ filterOption: ({ data: opt }: { data: Column }, text: string) =>
(opt.column_name &&
opt.column_name.toLowerCase().indexOf(text.toLowerCase()) >= 0) ||
(opt.verbose_name &&
opt.verbose_name.toLowerCase().indexOf(text.toLowerCase()) >= 0),
- mapStateToProps: (state, control) => {
- const newState = {};
+ mapStateToProps: (state: ControlState, control: ControlConfig) => {
+ const newState: { options?: Column[] } = {};
if (state.datasource) {
- newState.options = state.datasource.columns.filter(c => c.groupby);
- if (control && control.includeTime) {
+ newState.options = state.datasource.columns.filter(
+ (c: Column) => c.groupby,
+ );
+ if (control?.includeTime) {
newState.options.push(timeColumnOption);
}
}
@@ -150,7 +171,7 @@ const metrics = {
multi: true,
label: t('Metrics'),
validators: [validateNonEmpty],
- mapStateToProps: state => {
+ mapStateToProps: (state: ControlState) => {
const { datasource } = state;
return {
columns: datasource ? datasource.columns : [],
@@ -167,10 +188,18 @@ const metric = {
description: t('Metric'),
};
-export function columnChoices(datasource) {
- if (datasource && datasource.columns) {
+export function columnChoices(
+ datasource: Datasource | null | undefined,
+): [string, string][] {
+ if (datasource?.columns) {
return datasource.columns
- .map(col => [col.column_name, col.verbose_name || col.column_name])
+ .map(
+ col =>
+ [col.column_name, col.verbose_name || col.column_name] as [
+ string,
+ string,
+ ],
+ )
.sort((opt1, opt2) =>
opt1[1].toLowerCase() > opt2[1].toLowerCase() ? 1 : -1,
);
@@ -188,7 +217,7 @@ export const controls = {
label: t('Dataset'),
default: null,
description: null,
- mapStateToProps: ({ datasource }) => ({
+ mapStateToProps: ({ datasource }: ControlState) => ({
datasource,
isEditable: !!datasource,
}),
@@ -219,7 +248,13 @@ export const controls = {
type: 'ColorSchemeControl',
label: t('Linear color scheme'),
choices: () =>
- sequentialSchemeRegistry.values().map(value => [value.id, value.label]),
+ sequentialSchemeRegistry
+ .values()
+ .filter(
+ (value): value is SequentialScheme =>
+ value !== undefined && 'id' in value && 'label' in value,
+ )
+ .map(value => [value.id, value.label]),
default: sequentialSchemeRegistry.getDefaultKey(),
clearable: false,
description: '',
@@ -285,10 +320,10 @@ export const controls = {
'expression',
),
clearable: false,
- optionRenderer: c => <StyledColumnOption column={c} showType />,
+ optionRenderer: (c: Column) => <StyledColumnOption column={c} showType />,
valueKey: 'column_name',
- mapStateToProps: state => {
- const props = {};
+ mapStateToProps: (state: ControlState) => {
+ const props: { choices?: Column[]; default?: string | null } = {};
if (state.datasource) {
props.choices = state.datasource.granularity_sqla;
props.default = null;
@@ -313,7 +348,7 @@ export const controls = {
'The options here are defined on a per database ' +
'engine basis in the Superset source code.',
),
- mapStateToProps: state => ({
+ mapStateToProps: (state: ControlState) => ({
choices: state.datasource ? state.datasource.time_grain_sqla : null,
}),
},
@@ -367,7 +402,7 @@ export const controls = {
'Metric used to define how the top series are sorted if a series or row limit is present. ' +
'If undefined reverts to the first metric (where appropriate).',
),
- mapStateToProps: state => ({
+ mapStateToProps: (state: ControlState) => ({
columns: state.datasource ? state.datasource.columns : [],
savedMetrics: state.datasource ? state.datasource.metrics : [],
datasource: state.datasource,
@@ -423,11 +458,9 @@ export const controls = {
default: 'SMART_NUMBER',
choices: D3_FORMAT_OPTIONS,
description: D3_FORMAT_DOCS,
- mapStateToProps: state => {
+ mapStateToProps: (state: ControlState) => {
const showWarning =
- state.controls &&
- state.controls.comparison_type &&
- state.controls.comparison_type.value === 'percentage';
+ state.controls?.comparison_type?.value === 'percentage';
return {
warning: showWarning
? t(
@@ -445,9 +478,9 @@ export const controls = {
label: t('Filters'),
default: null,
description: '',
- mapStateToProps: state => ({
+ mapStateToProps: (state: ControlState) => ({
columns: state.datasource
- ? state.datasource.columns.filter(c => c.filterable)
+ ? state.datasource.columns.filter((c: Column) => c.filterable)
: [],
savedMetrics: state.datasource ? state.datasource.metrics : [],
datasource: state.datasource,
diff --git a/superset-frontend/src/explore/store.test.jsx b/superset-frontend/src/explore/store.test.tsx
similarity index 95%
rename from superset-frontend/src/explore/store.test.jsx
rename to superset-frontend/src/explore/store.test.tsx
index aad4d1f8e4..602390a4d4 100644
--- a/superset-frontend/src/explore/store.test.jsx
+++ b/superset-frontend/src/explore/store.test.tsx
@@ -39,9 +39,8 @@ describe('store', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('applyDefaultFormData', () => {
- window.featureFlags = {
- SCOPED_FILTER: false,
- };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).featureFlags = {};
test('applies default to formData if the key is missing', () => {
const inputFormData = {
diff --git a/superset-frontend/src/explore/store.js b/superset-frontend/src/explore/store.ts
similarity index 61%
rename from superset-frontend/src/explore/store.js
rename to superset-frontend/src/explore/store.ts
index 9c2fa52127..b1189efff4 100644
--- a/superset-frontend/src/explore/store.js
+++ b/superset-frontend/src/explore/store.ts
@@ -17,11 +17,33 @@
* under the License.
*/
/* eslint camelcase: 0 */
-import { getChartControlPanelRegistry, VizType } from '@superset-ui/core';
+import {
+ DatasourceType,
+ getChartControlPanelRegistry,
+ VizType,
+} from '@superset-ui/core';
+import type { QueryFormData } from '@superset-ui/core';
import { getAllControlsState, getFormDataFromControls } from './controlUtils';
import { controls } from './controls';
-function handleDeprecatedControls(formData) {
+interface ExploreState {
+ common?: {
+ conf: {
+ DEFAULT_VIZ_TYPE?: string;
+ };
+ };
+ datasource: {
+ type: string;
+ };
+}
+
+type FormData = QueryFormData & {
+ y_axis_zero?: boolean;
+ y_axis_bounds?: [number | null, number | null];
+ datasource?: string;
+};
+
+function handleDeprecatedControls(formData: FormData): void {
// Reaffectation / handling of deprecated controls
/* eslint-disable no-param-reassign */
@@ -31,7 +53,10 @@ function handleDeprecatedControls(formData) {
}
}
-export function getControlsState(state, inputFormData) {
+export function getControlsState(
+ state: ExploreState,
+ inputFormData: FormData,
+): Record<string, unknown> {
/*
* Gets a new controls object to put in the state. The controls object
* is similar to the configuration control with only the controls
@@ -45,10 +70,11 @@ export function getControlsState(state, inputFormData) {
formData.viz_type || state.common?.conf.DEFAULT_VIZ_TYPE || VizType.Table;
handleDeprecatedControls(formData);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
const controlsState = getAllControlsState(
vizType,
- state.datasource.type,
- state,
+ state.datasource.type as DatasourceType,
+ state as any,
formData,
);
@@ -60,39 +86,45 @@ export function getControlsState(state, inputFormData) {
return controlsState;
}
-export function applyDefaultFormData(inputFormData) {
- const datasourceType = inputFormData.datasource.split('__')[1];
+export function applyDefaultFormData(
+ inputFormData: FormData,
+): Record<string, unknown> {
+ const datasourceType = inputFormData.datasource?.split('__')[1] ?? '';
const vizType = inputFormData.viz_type;
const controlsState = getAllControlsState(
vizType,
- datasourceType,
+ datasourceType as DatasourceType,
null,
inputFormData,
);
- const controlFormData = getFormDataFromControls(controlsState);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const controlFormData = getFormDataFromControls(controlsState as any);
- const formData = {};
+ const formData: Record<string, unknown> = {};
Object.keys(controlsState)
.concat(Object.keys(inputFormData))
.forEach(controlName => {
- if (inputFormData[controlName] === undefined) {
+ if (inputFormData[controlName as keyof FormData] === undefined) {
formData[controlName] = controlFormData[controlName];
} else {
- formData[controlName] = inputFormData[controlName];
+ formData[controlName] = inputFormData[controlName as keyof FormData];
}
});
return formData;
}
-const defaultControls = { ...controls };
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const defaultControls: Record<string, any> = { ...controls };
Object.keys(controls).forEach(f => {
- defaultControls[f].value = controls[f].default;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ defaultControls[f].value = (controls as any)[f].default;
});
const defaultState = {
controls: defaultControls,
- form_data: getFormDataFromControls(defaultControls),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ form_data: getFormDataFromControls(defaultControls as any),
};
export { defaultControls, defaultState };