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,
ISaveableDatasource,
} from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { QueryEditor } from 'src/SqlLab/types';
import useLogAction from 'src/logger/useLogAction';
@@ -215,7 +214,15 @@ const SaveQuery = ({
onHide={() => setShowSaveDatasetModal(false)}
buttonTextOnSave={t('Save & 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
className="save-query-modal"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ import {
getTimeFormatter,
TimeFormats,
ensureIsArray,
JsonObject,
} from '@superset-ui/core';
// 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);
export function storeQuery(query) {
export function storeQuery(query: JsonObject): Promise<string> {
return SupersetClient.post({
endpoint: '/kv/store/',
postPayload: { data: query },
@@ -47,7 +48,7 @@ export function storeQuery(query) {
});
}
export function optionLabel(opt) {
export function optionLabel(opt: any): string {
if (opt === null) {
return NULL_STRING;
}
@@ -66,28 +67,31 @@ export function optionLabel(opt) {
return opt;
}
export function optionValue(opt) {
export function optionValue(opt: any): any {
if (opt === null) {
return NULL_STRING;
}
return opt;
}
export function optionFromValue(opt) {
export function optionFromValue(opt: any): { value: any; label: string } {
// From a list of options, handles special values & labels
return { value: optionValue(opt), label: optionLabel(opt) };
}
function getColumnName(column) {
function getColumnName(column: any): string {
return column.name || column;
}
export function prepareCopyToClipboardTabularData(data, columns) {
export function prepareCopyToClipboardTabularData(
data: JsonObject[],
columns: any[],
): string {
let result = columns.length
? `${columns.map(getColumnName).join('\t')}\n`
: '';
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) {
// 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.
@@ -103,7 +107,10 @@ export function prepareCopyToClipboardTabularData(data, columns) {
return result;
}
export function applyFormattingToTabularData(data, timeFormattedColumns) {
export function applyFormattingToTabularData(
data: JsonObject[],
timeFormattedColumns: string[],
): JsonObject[] {
if (
!data ||
data.length === 0 ||
@@ -115,19 +122,22 @@ export function applyFormattingToTabularData(data, timeFormattedColumns) {
return data.map(row => ({
...row,
/* eslint-disable no-underscore-dangle */
...timeFormattedColumns.reduce((acc, colName) => {
if (row[colName] !== null && row[colName] !== undefined) {
acc[colName] = DATETIME_FORMATTER(row[colName]);
}
return acc;
}, {}),
...timeFormattedColumns.reduce(
(acc, colName) => {
if (row[colName] !== null && row[colName] !== undefined) {
acc[colName] = DATETIME_FORMATTER(row[colName]);
}
return acc;
},
{} as Record<string, any>,
),
}));
}
export const noOp = () => undefined;
export const noOp = (): undefined => undefined;
// Detects the user's OS through the browser
export const detectOS = () => {
export const detectOS = (): string => {
const { appVersion } = navigator;
// Leveraging this condition because of stackOverflow
@@ -140,8 +150,8 @@ export const detectOS = () => {
return 'Unknown OS';
};
export const isSafari = () => {
export const isSafari = (): boolean => {
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
* under the License.
*/
export const getDatasourceAsSaveableDataset = source => ({
columns: source.columns,
import { Dataset } from '@superset-ui/chart-controls';
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',
dbId: source?.database?.id || source?.dbId,
sql: source?.sql || '',
dbId: source?.database?.id || (source as any)?.dbId || 0,
sql: (source as any)?.sql || '',
catalog: source?.catalog,
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 getBootstrapData from './getBootstrapData';
function getDomainsConfig() {
function getDomainsConfig(): string[] {
const appContainer = document.getElementById('app');
if (!appContainer) {
return [];
@@ -42,13 +42,15 @@ function getDomainsConfig() {
initFeatureFlags(bootstrapData.common.feature_flags);
if (bootstrapData?.common?.conf?.SUPERSET_WEBSERVER_DOMAINS) {
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(hostName => {
availableDomains.add(hostName);
});
bootstrapData.common.conf.SUPERSET_WEBSERVER_DOMAINS.forEach(
(hostName: string) => {
availableDomains.add(hostName);
},
);
}
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';
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 copiedObject = { ...obj };
@@ -29,18 +38,28 @@ export function addToObject(state, arrKey, obj) {
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] };
newObject[obj.id] = { ...newObject[obj.id], ...alterations };
newObject[obj.id!] = { ...newObject[obj.id!], ...alterations };
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
// new object with an altered property
const idKey = 'id';
const newArr = [];
state[arrKey].forEach(arrItem => {
const newArr: any[] = [];
state[arrKey].forEach((arrItem: any) => {
if (obj[idKey] === arrItem[idKey]) {
newArr.push({ ...arrItem, ...alterations });
} else {
@@ -50,9 +69,14 @@ export function alterInArr(state, arrKey, obj, alterations) {
return { ...state, [arrKey]: newArr };
}
export function removeFromArr(state, arrKey, obj, idKey = 'id') {
const newArr = [];
state[arrKey].forEach(arrItem => {
export function removeFromArr<T extends Record<string, any>>(
state: T,
arrKey: keyof T,
obj: StateWithId,
idKey: string = 'id',
): T {
const newArr: any[] = [];
state[arrKey].forEach((arrItem: any) => {
if (!(obj[idKey] === arrItem[idKey])) {
newArr.push(arrItem);
}
@@ -60,12 +84,16 @@ export function removeFromArr(state, arrKey, obj, idKey = 'id') {
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 };
if (!newObj.id) {
newObj.id = nanoid();
}
const newState = {};
newState[arrKey] = [...state[arrKey], newObj];
const newState: Record<string, any> = {};
newState[arrKey as string] = [...state[arrKey], newObj];
return { ...state, ...newState };
}