mirror of
https://github.com/apache/superset.git
synced 2026-05-13 03:45:12 +00:00
Compare commits
69 Commits
feat/plugi
...
chore/ts-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc8b2cef9 | ||
|
|
0881cf8b84 | ||
|
|
e5fc8c46ef | ||
|
|
f068039db8 | ||
|
|
0fc9f19eed | ||
|
|
277ebe5fe2 | ||
|
|
f34aac822c | ||
|
|
e7aaf2f1e3 | ||
|
|
4b0a9c33e6 | ||
|
|
fce1496d8b | ||
|
|
d97262c506 | ||
|
|
f6f0e7c042 | ||
|
|
c5c1c20fe8 | ||
|
|
7d9e43418f | ||
|
|
28d39599a6 | ||
|
|
5fcfc36ce4 | ||
|
|
9f22ea5c57 | ||
|
|
3466b741e7 | ||
|
|
d9e29b802f | ||
|
|
168c668dbc | ||
|
|
dd090691e4 | ||
|
|
721bdef5e2 | ||
|
|
c753706d3d | ||
|
|
6c0e5d5bd7 | ||
|
|
56404a009e | ||
|
|
9dd4d6ed53 | ||
|
|
38f3eeeab8 | ||
|
|
7b74aa0f88 | ||
|
|
ab6d0b9ff5 | ||
|
|
1fb9ec632c | ||
|
|
1ceebddcb5 | ||
|
|
6f0f7d4341 | ||
|
|
dc5ee217ca | ||
|
|
23da4d7693 | ||
|
|
7400fb199b | ||
|
|
c2187aa1d5 | ||
|
|
5015c5d942 | ||
|
|
2b2e3bbdff | ||
|
|
de9dc4fa32 | ||
|
|
53a584d527 | ||
|
|
dac6aa7490 | ||
|
|
7a37bf7883 | ||
|
|
f98c7dbd05 | ||
|
|
949a882173 | ||
|
|
98314909a2 | ||
|
|
59f9fe431f | ||
|
|
fc07c64ed7 | ||
|
|
9ace943ed6 | ||
|
|
befeaf6202 | ||
|
|
c6e1fe5f91 | ||
|
|
586e03cb80 | ||
|
|
6cd18d4d2e | ||
|
|
bd551be4b3 | ||
|
|
666c8ce085 | ||
|
|
919de23304 | ||
|
|
6878882d9f | ||
|
|
419e505d36 | ||
|
|
8e1fe0be7a | ||
|
|
edc82b09e3 | ||
|
|
f182d0fd43 | ||
|
|
a737894d47 | ||
|
|
9dd441fb10 | ||
|
|
362634c61a | ||
|
|
6dc5277120 | ||
|
|
ffc3af0d34 | ||
|
|
e7129b4600 | ||
|
|
c9b0d8d5af | ||
|
|
6a4608d3a9 | ||
|
|
66e8795bf7 |
@@ -36,7 +36,13 @@ module.exports = {
|
||||
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
|
||||
},
|
||||
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
|
||||
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],
|
||||
modulePathIgnorePatterns: [
|
||||
'<rootDir>/packages/generator-superset',
|
||||
'<rootDir>/packages/.*/esm',
|
||||
'<rootDir>/packages/.*/lib',
|
||||
'<rootDir>/plugins/.*/esm',
|
||||
'<rootDir>/plugins/.*/lib',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
|
||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||
testEnvironmentOptions: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ function QueryAutoRefresh({
|
||||
dispatch(
|
||||
queryFailed(
|
||||
query,
|
||||
query.errorMessage,
|
||||
query.errorMessage ?? '',
|
||||
query.extra?.errors?.[0]?.extra?.link,
|
||||
query.extra?.errors,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
|
||||
// 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);
|
||||
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 should still be disabled because name is not filled
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,60 +54,81 @@ import {
|
||||
ANNOTATION_SOURCE_TYPES_METADATA,
|
||||
} from './AnnotationTypes';
|
||||
|
||||
interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
viz_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SliceData {
|
||||
data: {
|
||||
groupby?: string[];
|
||||
all_columns?: string[];
|
||||
include_time?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnnotationOverrides {
|
||||
time_range?: string | null;
|
||||
time_grain_sqla?: string | null;
|
||||
granularity?: string | null;
|
||||
time_shift?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const AUTOMATIC_COLOR = '';
|
||||
|
||||
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,
|
||||
|
||||
error: PropTypes.string,
|
||||
colorScheme: PropTypes.string,
|
||||
|
||||
addAnnotationLayer: PropTypes.func,
|
||||
removeAnnotationLayer: PropTypes.func,
|
||||
close: PropTypes.func,
|
||||
};
|
||||
|
||||
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: '',
|
||||
|
||||
addAnnotationLayer: () => {},
|
||||
removeAnnotationLayer: () => {},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const NotFoundContentWrapper = styled.div`
|
||||
&& > div:first-child {
|
||||
padding-left: 0;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(): ReactNode {
|
||||
return <Checkbox onChange={this.onChange} checked={!!this.props.value} />;
|
||||
}
|
||||
|
||||
renderCheckbox() {
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={this.onChange.bind(this)}
|
||||
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;
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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'>
|
||||
>,
|
||||
);
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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', () => {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -20,6 +20,8 @@ export type savedMetricType = {
|
||||
metric_name: string;
|
||||
verbose_name?: string;
|
||||
expression: string;
|
||||
error_text?: string;
|
||||
id?: number | string;
|
||||
};
|
||||
|
||||
export interface AggregateOption {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
export const areAllValuesNumbers = (items, valueKey = 'value') => {
|
||||
const numberComparator = (a: SelectOption, b: SelectOption): number =>
|
||||
(a.value as number) - (b.value as number);
|
||||
|
||||
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;
|
||||
@@ -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',
|
||||
};
|
||||
} as const;
|
||||
|
||||
const propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.object,
|
||||
animation: PropTypes.bool,
|
||||
choices: PropTypes.array,
|
||||
};
|
||||
type SpatialType = (typeof spatialTypes)[keyof typeof spatialTypes];
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => {},
|
||||
animation: true,
|
||||
choices: [],
|
||||
};
|
||||
interface SpatialValue {
|
||||
type: SpatialType;
|
||||
latCol?: string;
|
||||
lonCol?: string;
|
||||
lonlatCol?: string;
|
||||
delimiter?: string;
|
||||
reverseCheckbox?: boolean;
|
||||
geohashCol?: string;
|
||||
}
|
||||
|
||||
export default class SpatialControl extends Component {
|
||||
constructor(props) {
|
||||
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;
|
||||
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
interface ViewportControlProps {
|
||||
onChange?: (value: Viewport) => void;
|
||||
value?: Viewport;
|
||||
default?: Record<string, unknown>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
onChange: () => {},
|
||||
default: { type: 'fix', value: 5 },
|
||||
value: DEFAULT_VIEWPORT,
|
||||
};
|
||||
export default class ViewportControl extends Component<ViewportControlProps> {
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
default: { type: 'fix', value: 5 },
|
||||
value: DEFAULT_VIEWPORT,
|
||||
};
|
||||
|
||||
export default class ViewportControl extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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 = {
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user