Compare commits

...

2 Commits

Author SHA1 Message Date
Maxime Beauchemin
c92a78ab35 fix tsc errors 2025-07-14 11:01:56 -07:00
Maxime Beauchemin
04ba0a00b3 src/utils 2025-07-13 11:22:22 -07:00
13 changed files with 194 additions and 106 deletions

View File

@@ -35,7 +35,6 @@ import {
SaveDatasetModal, SaveDatasetModal,
ISaveableDatasource, ISaveableDatasource,
} from 'src/SqlLab/components/SaveDatasetModal'; } from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { QueryEditor } from 'src/SqlLab/types'; import { QueryEditor } from 'src/SqlLab/types';
import useLogAction from 'src/logger/useLogAction'; import useLogAction from 'src/logger/useLogAction';
@@ -215,7 +214,15 @@ const SaveQuery = ({
onHide={() => setShowSaveDatasetModal(false)} onHide={() => setShowSaveDatasetModal(false)}
buttonTextOnSave={t('Save & Explore')} buttonTextOnSave={t('Save & Explore')}
buttonTextOnOverwrite={t('Overwrite & Explore')} buttonTextOnOverwrite={t('Overwrite & Explore')}
datasource={getDatasourceAsSaveableDataset(query)} datasource={{
columns,
name: query.name || query.description || t('Undefined'),
dbId: query.dbId || 0,
sql: query.sql || '',
catalog: query.catalog,
schema: query.schema,
templateParams: query.templateParams,
}}
/> />
<Modal <Modal
className="save-query-modal" className="save-query-modal"

View File

@@ -92,7 +92,7 @@ export function MarshmallowErrorMessage({
children: ( children: (
<JSONTree <JSONTree
data={extra.messages} data={extra.messages}
shouldExpandNodeInitially={() => true} shouldExpandNode={() => true}
hideRoot hideRoot
theme={jsonTreeTheme} theme={jsonTreeTheme}
/> />

View File

@@ -67,7 +67,7 @@ export const CopyToClipboardButton = ({
}) => ( }) => (
<CopyToClipboard <CopyToClipboard
text={ text={
data && columns ? prepareCopyToClipboardTabularData(data, columns) : '' data && columns ? prepareCopyToClipboardTabularData([data], columns) : ''
} }
wrapped={false} wrapped={false}
copyNode={ copyNode={

View File

@@ -62,7 +62,8 @@ export const TableControls = ({
name && name &&
!originalTimeColumns.includes(name), !originalTimeColumns.includes(name),
) )
.map(([colname]) => colname); .map(([colname]) => colname)
.filter((colname): colname is string => colname !== undefined);
const formattedData = useMemo( const formattedData = useMemo(
() => applyFormattingToTabularData(data, formattedTimeColumns), () => applyFormattingToTabularData(data, formattedTimeColumns),
[data, formattedTimeColumns], [data, formattedTimeColumns],

View File

@@ -18,33 +18,49 @@
*/ */
import { debounce } from 'lodash'; import { debounce } from 'lodash';
interface DebouncedMessageQueueConfig {
callback?: (events: any[]) => void;
sizeThreshold?: number;
delayThreshold?: number;
}
class DebouncedMessageQueue { class DebouncedMessageQueue {
private queue: any[];
private sizeThreshold: number;
private delayThreshold: number;
private callback: (events: any[]) => void;
private trigger: () => void;
constructor({ constructor({
callback = () => {}, callback = () => {},
sizeThreshold = 1000, sizeThreshold = 1000,
delayThreshold = 1000, delayThreshold = 1000,
}) { }: DebouncedMessageQueueConfig = {}) {
this.queue = []; this.queue = [];
this.sizeThreshold = sizeThreshold; this.sizeThreshold = sizeThreshold;
this.delayThreshold = delayThreshold; this.delayThreshold = delayThreshold;
this.trigger = debounce(this.trigger.bind(this), this.delayThreshold); this.trigger = debounce(this.triggerQueue.bind(this), this.delayThreshold);
this.callback = callback; this.callback = callback;
} }
append(eventData) { append(eventData: any): void {
this.queue.push(eventData); this.queue.push(eventData);
this.trigger(); this.trigger();
} }
trigger() { private triggerQueue(): void {
if (this.queue.length > 0) { if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold); const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events); this.callback.call(null, events);
// If there are remaining items, call it again. // If there are remaining items, call it again.
if (this.queue.length > 0) { if (this.queue.length > 0) {
this.trigger(); this.triggerQueue();
} }
} }
} }

View File

@@ -50,8 +50,8 @@ describe('utils/common', () => {
}); });
describe('prepareCopyToClipboardTabularData', () => { describe('prepareCopyToClipboardTabularData', () => {
it('converts empty array', () => { it('converts empty array', () => {
const data = []; const data: any[] = [];
const columns = []; const columns: any[] = [];
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(''); expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
}); });
it('converts non empty array', () => { it('converts non empty array', () => {
@@ -77,7 +77,7 @@ describe('utils/common', () => {
}); });
describe('applyFormattingToTabularData', () => { describe('applyFormattingToTabularData', () => {
it('does not mutate empty array', () => { it('does not mutate empty array', () => {
const data = []; const data: any[] = [];
expect(applyFormattingToTabularData(data, [])).toEqual(data); expect(applyFormattingToTabularData(data, [])).toEqual(data);
}); });
it('does not mutate array without temporal column', () => { it('does not mutate array without temporal column', () => {

View File

@@ -21,6 +21,7 @@ import {
getTimeFormatter, getTimeFormatter,
TimeFormats, TimeFormats,
ensureIsArray, ensureIsArray,
JsonObject,
} from '@superset-ui/core'; } from '@superset-ui/core';
// ATTENTION: If you change any constants, make sure to also change constants.py // ATTENTION: If you change any constants, make sure to also change constants.py
@@ -36,7 +37,7 @@ export const SHORT_TIME = 'h:m a';
const DATETIME_FORMATTER = getTimeFormatter(TimeFormats.DATABASE_DATETIME); const DATETIME_FORMATTER = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
export function storeQuery(query) { export function storeQuery(query: JsonObject): Promise<string> {
return SupersetClient.post({ return SupersetClient.post({
endpoint: '/kv/store/', endpoint: '/kv/store/',
postPayload: { data: query }, postPayload: { data: query },
@@ -47,7 +48,7 @@ export function storeQuery(query) {
}); });
} }
export function optionLabel(opt) { export function optionLabel(opt: any): string {
if (opt === null) { if (opt === null) {
return NULL_STRING; return NULL_STRING;
} }
@@ -66,28 +67,31 @@ export function optionLabel(opt) {
return opt; return opt;
} }
export function optionValue(opt) { export function optionValue(opt: any): any {
if (opt === null) { if (opt === null) {
return NULL_STRING; return NULL_STRING;
} }
return opt; return opt;
} }
export function optionFromValue(opt) { export function optionFromValue(opt: any): { value: any; label: string } {
// From a list of options, handles special values & labels // From a list of options, handles special values & labels
return { value: optionValue(opt), label: optionLabel(opt) }; return { value: optionValue(opt), label: optionLabel(opt) };
} }
function getColumnName(column) { function getColumnName(column: any): string {
return column.name || column; return column.name || column;
} }
export function prepareCopyToClipboardTabularData(data, columns) { export function prepareCopyToClipboardTabularData(
data: JsonObject[],
columns: any[],
): string {
let result = columns.length let result = columns.length
? `${columns.map(getColumnName).join('\t')}\n` ? `${columns.map(getColumnName).join('\t')}\n`
: ''; : '';
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i += 1) {
const row = {}; const row: Record<number, any> = {};
for (let j = 0; j < columns.length; j += 1) { for (let j = 0; j < columns.length; j += 1) {
// JavaScript does not maintain the order of a mixed set of keys (i.e integers and strings) // JavaScript does not maintain the order of a mixed set of keys (i.e integers and strings)
// the below function orders the keys based on the column names. // the below function orders the keys based on the column names.
@@ -103,7 +107,10 @@ export function prepareCopyToClipboardTabularData(data, columns) {
return result; return result;
} }
export function applyFormattingToTabularData(data, timeFormattedColumns) { export function applyFormattingToTabularData(
data: JsonObject[],
timeFormattedColumns: string[],
): JsonObject[] {
if ( if (
!data || !data ||
data.length === 0 || data.length === 0 ||
@@ -115,19 +122,22 @@ export function applyFormattingToTabularData(data, timeFormattedColumns) {
return data.map(row => ({ return data.map(row => ({
...row, ...row,
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
...timeFormattedColumns.reduce((acc, colName) => { ...timeFormattedColumns.reduce(
if (row[colName] !== null && row[colName] !== undefined) { (acc, colName) => {
acc[colName] = DATETIME_FORMATTER(row[colName]); if (row[colName] !== null && row[colName] !== undefined) {
} acc[colName] = DATETIME_FORMATTER(row[colName]);
return acc; }
}, {}), return acc;
},
{} as Record<string, any>,
),
})); }));
} }
export const noOp = () => undefined; export const noOp = (): undefined => undefined;
// Detects the user's OS through the browser // Detects the user's OS through the browser
export const detectOS = () => { export const detectOS = (): string => {
const { appVersion } = navigator; const { appVersion } = navigator;
// Leveraging this condition because of stackOverflow // Leveraging this condition because of stackOverflow
@@ -140,8 +150,8 @@ export const detectOS = () => {
return 'Unknown OS'; return 'Unknown OS';
}; };
export const isSafari = () => { export const isSafari = (): boolean => {
const { userAgent } = navigator; const { userAgent } = navigator;
return userAgent && /^((?!chrome|android).)*safari/i.test(userAgent); return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
}; };

View File

@@ -16,12 +16,22 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
export const getDatasourceAsSaveableDataset = source => ({ import { Dataset } from '@superset-ui/chart-controls';
columns: source.columns, import { ISaveableDatasource } from '../SqlLab/components/SaveDatasetModal';
export const getDatasourceAsSaveableDataset = (
source: Partial<Dataset> | any,
): ISaveableDatasource => ({
columns: (source.columns || []).map((col: any) => ({
column_name: col.column_name || col.name || '',
name: col.name || col.column_name || '',
type: col.type || null,
is_dttm: col.is_dttm || null,
})),
name: source?.datasource_name || source?.name || 'Untitled', name: source?.datasource_name || source?.name || 'Untitled',
dbId: source?.database?.id || source?.dbId, dbId: source?.database?.id || (source as any)?.dbId || 0,
sql: source?.sql || '', sql: (source as any)?.sql || '',
catalog: source?.catalog, catalog: source?.catalog,
schema: source?.schema, schema: source?.schema,
templateParams: source?.templateParams, templateParams: (source as any)?.templateParams,
}); });

View File

@@ -1,52 +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 memoizeOne from 'memoize-one';
import { isControlPanelSectionConfig } from '@superset-ui/chart-controls';
import { getChartControlPanelRegistry } from '@superset-ui/core';
import { controls } from '../explore/controls';
const memoizedControls = memoizeOne((vizType, controlPanel) => {
const controlsMap = {};
(controlPanel?.controlPanelSections || [])
.filter(isControlPanelSectionConfig)
.forEach(section => {
section.controlSetRows.forEach(row => {
row.forEach(control => {
if (!control) return;
if (typeof control === 'string') {
// For now, we have to look in controls.jsx to get the config for some controls.
// Once everything is migrated out, delete this if statement.
controlsMap[control] = controls[control];
} else if (control.name && control.config) {
// condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
controlsMap[control.name] = control.config;
}
});
});
});
return controlsMap;
});
const getControlsForVizType = vizType => {
const controlPanel = getChartControlPanelRegistry().get(vizType);
return memoizedControls(vizType, controlPanel);
};
export default getControlsForVizType;

View File

@@ -0,0 +1,66 @@
/**
* 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 memoizeOne from 'memoize-one';
import {
isControlPanelSectionConfig,
ControlStateMapping,
ControlPanelConfig,
} from '@superset-ui/chart-controls';
import { getChartControlPanelRegistry } from '@superset-ui/core';
import { controls } from '../explore/controls';
const memoizedControls = memoizeOne(
(vizType: string, controlPanel: ControlPanelConfig): ControlStateMapping => {
const controlsMap: ControlStateMapping = {};
(controlPanel?.controlPanelSections || [])
.filter(isControlPanelSectionConfig)
.forEach(section => {
section.controlSetRows.forEach(row => {
row.forEach(control => {
if (!control) return;
if (typeof control === 'string') {
// For now, we have to look in controls.jsx to get the config for some controls.
// Once everything is migrated out, delete this if statement.
controlsMap[control] = (controls as any)[control];
} else if (
control &&
typeof control === 'object' &&
'name' in control &&
'config' in control
) {
// condition needed because there are elements, e.g. <hr /> in some control configs (I'm looking at you, FilterBox!)
controlsMap[control.name] = control.config;
}
});
});
});
return controlsMap;
},
);
const getControlsForVizType = (vizType: string): ControlStateMapping => {
const controlPanel = getChartControlPanelRegistry().get(vizType);
return memoizedControls(
vizType,
(controlPanel as ControlPanelConfig) || { controlPanelSections: [] },
);
};
export default getControlsForVizType;

View File

@@ -19,7 +19,7 @@
import { initFeatureFlags } from '@superset-ui/core'; import { initFeatureFlags } from '@superset-ui/core';
import getBootstrapData from './getBootstrapData'; import getBootstrapData from './getBootstrapData';
function getDomainsConfig() { function getDomainsConfig(): string[] {
const appContainer = document.getElementById('app'); const appContainer = document.getElementById('app');
if (!appContainer) { if (!appContainer) {
return []; return [];
@@ -42,13 +42,15 @@ function getDomainsConfig() {
initFeatureFlags(bootstrapData.common.feature_flags); initFeatureFlags(bootstrapData.common.feature_flags);
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) { if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => { bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(
availableDomains.add(hostName); (hostName: string) => {
}); availableDomains.add(hostName);
},
);
} }
return Array.from(availableDomains); return Array.from(availableDomains);
} }
export const availableDomains = getDomainsConfig(); export const availableDomains: string[] = getDomainsConfig();
export const allowCrossDomain = availableDomains.length > 1; export const allowCrossDomain: boolean = availableDomains.length > 1;

View File

@@ -18,7 +18,16 @@
*/ */
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
export function addToObject(state, arrKey, obj) { interface StateWithId {
id?: string;
[key: string]: any;
}
export function addToObject<T extends Record<string, any>>(
state: T,
arrKey: keyof T,
obj: StateWithId,
): T {
const newObject = { ...state[arrKey] }; const newObject = { ...state[arrKey] };
const copiedObject = { ...obj }; const copiedObject = { ...obj };
@@ -29,18 +38,28 @@ export function addToObject(state, arrKey, obj) {
return { ...state, [arrKey]: newObject }; return { ...state, [arrKey]: newObject };
} }
export function alterInObject(state, arrKey, obj, alterations) { export function alterInObject<T extends Record<string, any>>(
state: T,
arrKey: keyof T,
obj: StateWithId,
alterations: Record<string, any>,
): T {
const newObject = { ...state[arrKey] }; const newObject = { ...state[arrKey] };
newObject[obj.id] = { ...newObject[obj.id], ...alterations }; newObject[obj.id!] = { ...newObject[obj.id!], ...alterations };
return { ...state, [arrKey]: newObject }; return { ...state, [arrKey]: newObject };
} }
export function alterInArr(state, arrKey, obj, alterations) { export function alterInArr<T extends Record<string, any>>(
state: T,
arrKey: keyof T,
obj: StateWithId,
alterations: Record<string, any>,
): T {
// Finds an item in an array in the state and replaces it with a // Finds an item in an array in the state and replaces it with a
// new object with an altered property // new object with an altered property
const idKey = 'id'; const idKey = 'id';
const newArr = []; const newArr: any[] = [];
state[arrKey].forEach(arrItem => { state[arrKey].forEach((arrItem: any) => {
if (obj[idKey] === arrItem[idKey]) { if (obj[idKey] === arrItem[idKey]) {
newArr.push({ ...arrItem, ...alterations }); newArr.push({ ...arrItem, ...alterations });
} else { } else {
@@ -50,9 +69,14 @@ export function alterInArr(state, arrKey, obj, alterations) {
return { ...state, [arrKey]: newArr }; return { ...state, [arrKey]: newArr };
} }
export function removeFromArr(state, arrKey, obj, idKey = 'id') { export function removeFromArr<T extends Record<string, any>>(
const newArr = []; state: T,
state[arrKey].forEach(arrItem => { arrKey: keyof T,
obj: StateWithId,
idKey: string = 'id',
): T {
const newArr: any[] = [];
state[arrKey].forEach((arrItem: any) => {
if (!(obj[idKey] === arrItem[idKey])) { if (!(obj[idKey] === arrItem[idKey])) {
newArr.push(arrItem); newArr.push(arrItem);
} }
@@ -60,12 +84,16 @@ export function removeFromArr(state, arrKey, obj, idKey = 'id') {
return { ...state, [arrKey]: newArr }; return { ...state, [arrKey]: newArr };
} }
export function addToArr(state, arrKey, obj) { export function addToArr<T extends Record<string, any>>(
state: T,
arrKey: keyof T,
obj: StateWithId,
): T {
const newObj = { ...obj }; const newObj = { ...obj };
if (!newObj.id) { if (!newObj.id) {
newObj.id = nanoid(); newObj.id = nanoid();
} }
const newState = {}; const newState: Record<string, any> = {};
newState[arrKey] = [...state[arrKey], newObj]; newState[arrKey as string] = [...state[arrKey], newObj];
return { ...state, ...newState }; return { ...state, ...newState };
} }