mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
29 Commits
docs/testi
...
msyavuz/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f41b6b699 | ||
|
|
7609c33745 | ||
|
|
cd16218fbf | ||
|
|
bed45e42ac | ||
|
|
8286a1f2a5 | ||
|
|
f570786f44 | ||
|
|
f9b399328d | ||
|
|
7e222d54b6 | ||
|
|
f3da8510d0 | ||
|
|
0dc2a02d2e | ||
|
|
10055ed4c7 | ||
|
|
e9a2fa6c63 | ||
|
|
3e491be312 | ||
|
|
d1ee1307ff | ||
|
|
a3d28f6615 | ||
|
|
f95efae874 | ||
|
|
14e0d220e7 | ||
|
|
f802e3a454 | ||
|
|
58e493d471 | ||
|
|
c453757c48 | ||
|
|
e416fece27 | ||
|
|
6b65ab7a29 | ||
|
|
1f1b0389ce | ||
|
|
519835e1a4 | ||
|
|
8cbb61dd7e | ||
|
|
960a31f211 | ||
|
|
a1242bd80e | ||
|
|
291e07c345 | ||
|
|
7c745ac622 |
@@ -199,7 +199,7 @@ const AddSliceCard: FC<{
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={innerRef} style={style}>
|
||||
<div ref={innerRef as any} style={style}>
|
||||
<div
|
||||
data-test="chart-card"
|
||||
css={(theme: Theme) => css`
|
||||
|
||||
@@ -38,7 +38,71 @@ import {
|
||||
GRID_COLUMN_COUNT,
|
||||
} from './constants';
|
||||
|
||||
const typeToDefaultMetaData = {
|
||||
import type { ComponentType, LayoutItem } from '../types';
|
||||
|
||||
// Define interfaces for different component metadata types
|
||||
interface ChartMeta {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ColumnMeta {
|
||||
width: number;
|
||||
background: string;
|
||||
}
|
||||
|
||||
interface HeaderMeta {
|
||||
text: string;
|
||||
headerSize: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
interface MarkdownMeta {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RowMeta {
|
||||
background: string;
|
||||
}
|
||||
|
||||
interface TabMeta {
|
||||
text: string;
|
||||
defaultText: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
interface DynamicMeta {
|
||||
width: number;
|
||||
background: string;
|
||||
}
|
||||
|
||||
// Union type for all possible meta types
|
||||
type ComponentMeta =
|
||||
| ChartMeta
|
||||
| ColumnMeta
|
||||
| HeaderMeta
|
||||
| MarkdownMeta
|
||||
| RowMeta
|
||||
| TabMeta
|
||||
| DynamicMeta
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
|
||||
// Type mapping for component types to their default metadata
|
||||
type DefaultMetaDataMap = {
|
||||
[CHART_TYPE]: ChartMeta;
|
||||
[COLUMN_TYPE]: ColumnMeta;
|
||||
[DIVIDER_TYPE]: null;
|
||||
[HEADER_TYPE]: HeaderMeta;
|
||||
[MARKDOWN_TYPE]: MarkdownMeta;
|
||||
[ROW_TYPE]: RowMeta;
|
||||
[TABS_TYPE]: null;
|
||||
[TAB_TYPE]: TabMeta;
|
||||
[DYNAMIC_TYPE]: DynamicMeta;
|
||||
};
|
||||
|
||||
const typeToDefaultMetaData: DefaultMetaDataMap = {
|
||||
[CHART_TYPE]: { width: GRID_DEFAULT_CHART_WIDTH, height: 50 },
|
||||
[COLUMN_TYPE]: {
|
||||
width: GRID_DEFAULT_CHART_WIDTH,
|
||||
@@ -64,19 +128,25 @@ const typeToDefaultMetaData = {
|
||||
},
|
||||
};
|
||||
|
||||
function uuid(type) {
|
||||
function uuid(type: ComponentType): string {
|
||||
return `${type}-${nanoid()}`;
|
||||
}
|
||||
|
||||
export default function entityFactory(type, meta, parents = []) {
|
||||
function entityFactory(
|
||||
type: ComponentType,
|
||||
meta?: Partial<ComponentMeta>,
|
||||
parents: string[] = [],
|
||||
): LayoutItem {
|
||||
return {
|
||||
type,
|
||||
id: uuid(type),
|
||||
children: [],
|
||||
parents,
|
||||
meta: {
|
||||
...typeToDefaultMetaData[type],
|
||||
...(typeToDefaultMetaData[type as keyof DefaultMetaDataMap] || {}),
|
||||
...meta,
|
||||
},
|
||||
} as LayoutItem['meta'],
|
||||
};
|
||||
}
|
||||
|
||||
export default entityFactory;
|
||||
@@ -113,7 +113,10 @@ export const hydrateExplore =
|
||||
datasource: initialDatasource,
|
||||
};
|
||||
const initialControls = getControlsState(
|
||||
initialExploreState,
|
||||
{
|
||||
...getState(),
|
||||
explore: { ...getState().explore, ...initialExploreState },
|
||||
},
|
||||
initialFormData,
|
||||
) as ControlStateMapping;
|
||||
const colorSchemeKey = initialControls.color_scheme && 'color_scheme';
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { CopyToClipboardButton } from '.';
|
||||
|
||||
test('Render a button', () => {
|
||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
||||
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
@@ -39,7 +39,7 @@ test('Should copy to clipboard', async () => {
|
||||
// @ts-ignore
|
||||
global.navigator.clipboard = { write: callback, writeText: callback };
|
||||
|
||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
||||
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,10 @@ import {
|
||||
Radio,
|
||||
} from '@superset-ui/core/components';
|
||||
import { CopyToClipboard } from 'src/components';
|
||||
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
||||
import {
|
||||
prepareCopyToClipboardTabularData,
|
||||
TabularData,
|
||||
} from 'src/utils/common';
|
||||
import { getTimeColumns, setTimeColumns } from './utils';
|
||||
|
||||
export const CellNull = styled('span')`
|
||||
@@ -62,7 +65,7 @@ export const CopyToClipboardButton = ({
|
||||
data,
|
||||
columns,
|
||||
}: {
|
||||
data?: Record<string, any>;
|
||||
data?: TabularData;
|
||||
columns?: string[];
|
||||
}) => (
|
||||
<CopyToClipboard
|
||||
@@ -230,11 +233,11 @@ const DataTableTemporalHeaderCell = ({
|
||||
|
||||
export const useFilteredTableData = (
|
||||
filterText: string,
|
||||
data?: Record<string, any>[],
|
||||
data?: TabularData,
|
||||
) => {
|
||||
const rowsAsStrings = useMemo(
|
||||
() =>
|
||||
data?.map((row: Record<string, any>) =>
|
||||
data?.map(row =>
|
||||
Object.values(row).map(value =>
|
||||
value ? value.toString().toLowerCase() : t('N/A'),
|
||||
),
|
||||
@@ -259,7 +262,7 @@ const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
export const useTableColumns = (
|
||||
colnames?: string[],
|
||||
coltypes?: GenericDataType[],
|
||||
data?: Record<string, any>[],
|
||||
data?: TabularData,
|
||||
datasourceId?: string,
|
||||
isVisible?: boolean,
|
||||
moreConfigs?: { [key: string]: Partial<Column> },
|
||||
@@ -317,7 +320,7 @@ export const useTableColumns = (
|
||||
return {
|
||||
// react-table requires a non-empty id, therefore we introduce a fallback value in case the key is empty
|
||||
id: key || index,
|
||||
accessor: (row: Record<string, any>) => row[key],
|
||||
accessor: (row: any) => row[key],
|
||||
Header:
|
||||
colType === GenericDataType.Temporal &&
|
||||
typeof firstValue !== 'string' ? (
|
||||
|
||||
@@ -62,7 +62,8 @@ export const TableControls = ({
|
||||
name &&
|
||||
!originalTimeColumns.includes(name),
|
||||
)
|
||||
.map(([colname]) => colname);
|
||||
.map(([colname]) => colname)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
const formattedData = useMemo(
|
||||
() => applyFormattingToTabularData(data, formattedTimeColumns),
|
||||
[data, formattedTimeColumns],
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { TabularData } from 'src/utils/common';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
@@ -55,6 +56,21 @@ export const SamplesPane = ({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [rowcount, setRowCount] = useState<number>(0);
|
||||
const [responseError, setResponseError] = useState<string>('');
|
||||
|
||||
// Convert nested array data to TabularData format
|
||||
const convertToTabularData = (
|
||||
rows: Record<string, any>[][],
|
||||
columnNames: string[],
|
||||
): TabularData =>
|
||||
rows.map(row =>
|
||||
columnNames.reduce(
|
||||
(obj, colName, index) => ({
|
||||
...obj,
|
||||
[colName]: row[index],
|
||||
}),
|
||||
{} as any,
|
||||
),
|
||||
);
|
||||
const datasourceId = useMemo(
|
||||
() => `${datasource.id}__${datasource.type}`,
|
||||
[datasource],
|
||||
@@ -92,17 +108,23 @@ export const SamplesPane = ({
|
||||
}, [datasource, isRequest, queryForce]);
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// Convert data to TabularData format
|
||||
const tabularData = useMemo(
|
||||
() => convertToTabularData(data, colnames),
|
||||
[data, colnames],
|
||||
);
|
||||
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
tabularData,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const filteredData = useFilteredTableData(filterText, tabularData);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import {
|
||||
TableView,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useFilteredTableData,
|
||||
useTableColumns,
|
||||
} from 'src/explore/components/DataTableControl';
|
||||
import { TabularData } from 'src/utils/common';
|
||||
import { TableControls } from './DataTableControls';
|
||||
import { SingleQueryResultPaneProp } from '../types';
|
||||
|
||||
@@ -42,18 +43,39 @@ export const SingleQueryResultPane = ({
|
||||
}: SingleQueryResultPaneProp) => {
|
||||
const [filterText, setFilterText] = useState('');
|
||||
|
||||
// Convert nested array data to TabularData format
|
||||
const convertToTabularData = (
|
||||
rows: Record<string, any>[][],
|
||||
columnNames: string[],
|
||||
): TabularData =>
|
||||
rows.map(row =>
|
||||
columnNames.reduce(
|
||||
(obj, colName, index) => ({
|
||||
...obj,
|
||||
[colName]: row[index],
|
||||
}),
|
||||
{} as any,
|
||||
),
|
||||
);
|
||||
|
||||
// Convert data to TabularData format
|
||||
const tabularData = useMemo(
|
||||
() => convertToTabularData(data, colnames),
|
||||
[data, colnames],
|
||||
);
|
||||
|
||||
// this is to preserve the order of the columns, even if there are integer values,
|
||||
// while also only grabbing the first column's keys
|
||||
const columns = useTableColumns(
|
||||
colnames,
|
||||
coltypes,
|
||||
data,
|
||||
tabularData,
|
||||
datasourceId,
|
||||
isVisible,
|
||||
{}, // moreConfig
|
||||
true, // allowHTML
|
||||
);
|
||||
const filteredData = useFilteredTableData(filterText, data);
|
||||
const filteredData = useFilteredTableData(filterText, tabularData);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Column } from '@superset-ui/core';
|
||||
export { savedMetricType } from './types';
|
||||
|
||||
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||
|
||||
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||
const columnType = {} as any;
|
||||
export default columnType;
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { savedMetricType as default } from './types';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export type { AggregateOption } from './types';
|
||||
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { AggregateOption as default } from './types';
|
||||
// Core Playwright Components for Superset
|
||||
export { Button } from './Button';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
|
||||
@@ -16,10 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Column } from '@superset-ui/core';
|
||||
|
||||
export type ColumnType = Pick<Column, 'column_name' | 'type'>;
|
||||
export type { AggregateOption } from './types';
|
||||
|
||||
// For backward compatibility with PropTypes usage - create a placeholder object
|
||||
const columnType = {} as any;
|
||||
export default columnType;
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { AggregateOption as default } from './types';
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { savedMetricType } from './types';
|
||||
|
||||
// For backward compatibility with PropTypes usage
|
||||
export { savedMetricType as default } from './types';
|
||||
export const URL = {
|
||||
LOGIN: 'login/',
|
||||
WELCOME: 'superset/welcome/',
|
||||
} as const;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
StandardizedFormDataInterface,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { getControlsState } from 'src/explore/store';
|
||||
import type { ExplorePageState } from 'src/explore/types';
|
||||
import { getFormDataFromControls } from './getFormDataFromControls';
|
||||
|
||||
export const sharedMetricsKey = [
|
||||
@@ -187,7 +188,9 @@ export class StandardizedFormData {
|
||||
|
||||
transform(
|
||||
targetVizType: string,
|
||||
exploreState: Record<string, any>,
|
||||
exploreState: Record<string, any> & {
|
||||
form_data: QueryFormData;
|
||||
},
|
||||
): {
|
||||
formData: QueryFormData;
|
||||
controlsState: ControlStateMapping;
|
||||
@@ -209,11 +212,14 @@ export class StandardizedFormData {
|
||||
publicFormData[key] = exploreState.form_data[key];
|
||||
}
|
||||
});
|
||||
const targetControlsState = getControlsState(exploreState, {
|
||||
...latestFormData,
|
||||
...publicFormData,
|
||||
viz_type: targetVizType,
|
||||
});
|
||||
const targetControlsState = getControlsState(
|
||||
exploreState as Partial<ExplorePageState>,
|
||||
{
|
||||
...latestFormData,
|
||||
...publicFormData,
|
||||
viz_type: targetVizType,
|
||||
},
|
||||
);
|
||||
const targetFormData = {
|
||||
...getFormDataFromControls(targetControlsState),
|
||||
standardizedFormData: this.serialize(),
|
||||
@@ -237,13 +243,19 @@ export class StandardizedFormData {
|
||||
getStandardizedControls().clear();
|
||||
rv = {
|
||||
formData: transformed,
|
||||
controlsState: getControlsState(exploreState, transformed),
|
||||
controlsState: getControlsState(
|
||||
exploreState as Partial<ExplorePageState>,
|
||||
transformed,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// refresh validator message
|
||||
rv.controlsState = getControlsState(
|
||||
{ ...exploreState, controls: rv.controlsState },
|
||||
{
|
||||
...exploreState,
|
||||
controls: rv.controlsState,
|
||||
} as Partial<ExplorePageState>,
|
||||
rv.formData,
|
||||
);
|
||||
return rv;
|
||||
|
||||
@@ -17,11 +17,21 @@
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint camelcase: 0 */
|
||||
import { getChartControlPanelRegistry, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
getChartControlPanelRegistry,
|
||||
VizType,
|
||||
QueryFormData,
|
||||
DatasourceType,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
ControlStateMapping,
|
||||
ControlPanelState,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { getAllControlsState, getFormDataFromControls } from './controlUtils';
|
||||
import { controls } from './controls';
|
||||
import { ExplorePageState } from './types';
|
||||
|
||||
function handleDeprecatedControls(formData) {
|
||||
function handleDeprecatedControls(formData: QueryFormData): void {
|
||||
// Reaffectation / handling of deprecated controls
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
@@ -31,7 +41,10 @@ function handleDeprecatedControls(formData) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getControlsState(state, inputFormData) {
|
||||
export function getControlsState(
|
||||
state: Partial<ExplorePageState>,
|
||||
inputFormData: QueryFormData,
|
||||
): ControlStateMapping {
|
||||
/*
|
||||
* Gets a new controls object to put in the state. The controls object
|
||||
* is similar to the configuration control with only the controls
|
||||
@@ -45,33 +58,70 @@ export function getControlsState(state, inputFormData) {
|
||||
formData.viz_type || state.common?.conf.DEFAULT_VIZ_TYPE || VizType.Table;
|
||||
|
||||
handleDeprecatedControls(formData);
|
||||
|
||||
// Create a proper ControlPanelState from the partial state
|
||||
const controlPanelState: ControlPanelState = {
|
||||
slice: state.explore?.slice || { slice_id: -1 },
|
||||
form_data: formData,
|
||||
datasource: state.explore?.datasource || null,
|
||||
controls: state.explore?.controls || {},
|
||||
common: state.common || {},
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
const controlsState = getAllControlsState(
|
||||
vizType,
|
||||
state.datasource.type,
|
||||
state,
|
||||
state.explore?.datasource?.type as DatasourceType,
|
||||
controlPanelState,
|
||||
formData,
|
||||
);
|
||||
|
||||
// Filter out null values to match ControlStateMapping type
|
||||
const filteredControlsState: ControlStateMapping = {};
|
||||
Object.keys(controlsState).forEach(key => {
|
||||
const control = controlsState[key];
|
||||
if (control !== null) {
|
||||
filteredControlsState[key] = control;
|
||||
}
|
||||
});
|
||||
|
||||
const controlPanelConfig = getChartControlPanelRegistry().get(vizType) || {};
|
||||
if (controlPanelConfig.onInit) {
|
||||
return controlPanelConfig.onInit(controlsState);
|
||||
return controlPanelConfig.onInit(filteredControlsState);
|
||||
}
|
||||
|
||||
return controlsState;
|
||||
return filteredControlsState;
|
||||
}
|
||||
|
||||
export function applyDefaultFormData(inputFormData) {
|
||||
const datasourceType = inputFormData.datasource.split('__')[1];
|
||||
export function applyDefaultFormData(
|
||||
inputFormData: QueryFormData,
|
||||
): QueryFormData {
|
||||
const datasourceType = inputFormData.datasource.split(
|
||||
'__',
|
||||
)[1] as DatasourceType;
|
||||
const vizType = inputFormData.viz_type;
|
||||
const controlsState = getAllControlsState(
|
||||
const rawControlsState = getAllControlsState(
|
||||
vizType,
|
||||
datasourceType,
|
||||
null,
|
||||
inputFormData,
|
||||
);
|
||||
|
||||
// Filter out null values to match ControlStateMapping type
|
||||
const controlsState: ControlStateMapping = {};
|
||||
Object.keys(rawControlsState).forEach(key => {
|
||||
const control = rawControlsState[key];
|
||||
if (control !== null) {
|
||||
controlsState[key] = control;
|
||||
}
|
||||
});
|
||||
|
||||
const controlFormData = getFormDataFromControls(controlsState);
|
||||
|
||||
const formData = {};
|
||||
const formData: QueryFormData = {
|
||||
datasource: inputFormData.datasource,
|
||||
viz_type: inputFormData.viz_type,
|
||||
};
|
||||
Object.keys(controlsState)
|
||||
.concat(Object.keys(inputFormData))
|
||||
.forEach(controlName => {
|
||||
@@ -85,12 +135,22 @@ export function applyDefaultFormData(inputFormData) {
|
||||
return formData;
|
||||
}
|
||||
|
||||
const defaultControls = { ...controls };
|
||||
const defaultControls: ControlStateMapping = {
|
||||
...controls,
|
||||
} as ControlStateMapping;
|
||||
Object.keys(controls).forEach(f => {
|
||||
defaultControls[f].value = controls[f].default;
|
||||
if (defaultControls[f]) {
|
||||
defaultControls[f] = {
|
||||
...defaultControls[f],
|
||||
value: (controls as Record<string, any>)[f].default,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const defaultState = {
|
||||
const defaultState: {
|
||||
controls: ControlStateMapping;
|
||||
form_data: QueryFormData;
|
||||
} = {
|
||||
controls: defaultControls,
|
||||
form_data: getFormDataFromControls(defaultControls),
|
||||
};
|
||||
@@ -1,157 +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.
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint prefer-const: 2 */
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
import { safeStringify } from '../utils/safeStringify';
|
||||
import { LOG_EVENT } from '../logger/actions';
|
||||
import {
|
||||
LOG_EVENT_TYPE_TIMING,
|
||||
LOG_ACTIONS_SPA_NAVIGATION,
|
||||
} from '../logger/LogUtils';
|
||||
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
||||
import { ensureAppRoot } from '../utils/pathUtils';
|
||||
|
||||
const LOG_ENDPOINT = '/superset/log/?explode=events';
|
||||
const sendBeacon = events => {
|
||||
if (events.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = LOG_ENDPOINT;
|
||||
const { source, source_id } = events[0];
|
||||
// backend logs treat these request params as first-class citizens
|
||||
if (source === 'dashboard') {
|
||||
endpoint += `&dashboard_id=${source_id}`;
|
||||
} else if (source === 'slice') {
|
||||
endpoint += `&slice_id=${source_id}`;
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
const formData = new FormData();
|
||||
formData.append('events', safeStringify(events));
|
||||
if (SupersetClient.getGuestToken()) {
|
||||
// if we have a guest token, we need to send it for auth via the form
|
||||
formData.append('guest_token', SupersetClient.getGuestToken());
|
||||
}
|
||||
navigator.sendBeacon(ensureAppRoot(endpoint), formData);
|
||||
} else {
|
||||
SupersetClient.post({
|
||||
endpoint,
|
||||
postPayload: { events },
|
||||
parseMethod: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// beacon API has data size limit = 2^16.
|
||||
// assume avg each log entry has 2^6 characters
|
||||
const MAX_EVENTS_PER_REQUEST = 1024;
|
||||
const logMessageQueue = new DebouncedMessageQueue({
|
||||
callback: sendBeacon,
|
||||
sizeThreshold: MAX_EVENTS_PER_REQUEST,
|
||||
delayThreshold: 1000,
|
||||
});
|
||||
let lastEventId = 0;
|
||||
const loggerMiddleware = store => next => {
|
||||
let navPath;
|
||||
return action => {
|
||||
if (action.type !== LOG_EVENT) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } =
|
||||
store.getState();
|
||||
let logMetadata = {
|
||||
impression_id: impressionId,
|
||||
version: 'v2',
|
||||
};
|
||||
const { eventName } = action.payload;
|
||||
let { eventData = {} } = action.payload;
|
||||
|
||||
if (eventName === LOG_ACTIONS_SPA_NAVIGATION) {
|
||||
navPath = eventData.path;
|
||||
}
|
||||
const path = navPath || window?.location?.href;
|
||||
|
||||
if (dashboardInfo?.id && path?.includes('/dashboard/')) {
|
||||
logMetadata = {
|
||||
source: 'dashboard',
|
||||
source_id: dashboardInfo.id,
|
||||
dashboard_id: dashboardInfo.id,
|
||||
...logMetadata,
|
||||
};
|
||||
} else if (explore?.slice) {
|
||||
logMetadata = {
|
||||
source: 'explore',
|
||||
source_id: explore.slice ? explore.slice.slice_id : 0,
|
||||
...(explore.slice.slice_id && { slice_id: explore.slice.slice_id }),
|
||||
...logMetadata,
|
||||
};
|
||||
} else if (path?.includes('/sqllab/')) {
|
||||
const editor = sqlLab.queryEditors.find(
|
||||
({ id }) => id === sqlLab.tabHistory.slice(-1)[0],
|
||||
);
|
||||
logMetadata = {
|
||||
source: 'sqlLab',
|
||||
source_id: editor?.id,
|
||||
db_id: editor?.dbId,
|
||||
schema: editor?.schema,
|
||||
};
|
||||
}
|
||||
|
||||
eventData = {
|
||||
...logMetadata,
|
||||
ts: new Date().getTime(),
|
||||
event_name: eventName,
|
||||
...eventData,
|
||||
};
|
||||
if (LOG_EVENT_TYPE_TIMING.has(eventName)) {
|
||||
eventData = {
|
||||
...eventData,
|
||||
event_type: 'timing',
|
||||
trigger_event: lastEventId,
|
||||
};
|
||||
} else {
|
||||
lastEventId = nanoid();
|
||||
eventData = {
|
||||
...eventData,
|
||||
event_type: 'user',
|
||||
event_id: lastEventId,
|
||||
visibility: document.visibilityState,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
eventData.target_id &&
|
||||
dashboardLayout?.present?.[eventData.target_id]
|
||||
) {
|
||||
const { meta } = dashboardLayout.present[eventData.target_id];
|
||||
// chart name or tab/header text
|
||||
eventData.target_name = meta.chartId ? meta.sliceName : meta.text;
|
||||
}
|
||||
|
||||
logMessageQueue.append(eventData);
|
||||
return eventData;
|
||||
};
|
||||
};
|
||||
|
||||
export default loggerMiddleware;
|
||||
199
superset-frontend/src/middleware/loggerMiddleware.ts
Normal file
199
superset-frontend/src/middleware/loggerMiddleware.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable camelcase */
|
||||
/* eslint prefer-const: 2 */
|
||||
|
||||
import { Dispatch, Middleware, MiddlewareAPI, AnyAction } from 'redux';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
import { safeStringify } from '../utils/safeStringify';
|
||||
import { LOG_EVENT } from '../logger/actions';
|
||||
import {
|
||||
LOG_EVENT_TYPE_TIMING,
|
||||
LOG_ACTIONS_SPA_NAVIGATION,
|
||||
} from '../logger/LogUtils';
|
||||
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
||||
import { ensureAppRoot } from '../utils/pathUtils';
|
||||
import type { RootState } from '../views/store';
|
||||
import type { QueryEditor } from '../SqlLab/types';
|
||||
|
||||
// Types for log events
|
||||
interface LogAction extends AnyAction {
|
||||
type: typeof LOG_EVENT;
|
||||
payload: {
|
||||
eventName: string;
|
||||
eventData?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface LogEventData {
|
||||
impression_id?: string;
|
||||
version?: string;
|
||||
source?: 'dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||
source_id?: string | number;
|
||||
dashboard_id?: string | number;
|
||||
slice_id?: number;
|
||||
db_id?: number;
|
||||
schema?: string;
|
||||
ts: number;
|
||||
event_name: string;
|
||||
event_type: 'timing' | 'user';
|
||||
trigger_event?: string | number;
|
||||
event_id?: string;
|
||||
visibility?: DocumentVisibilityState;
|
||||
target_id?: string;
|
||||
target_name?: string;
|
||||
path?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LOG_ENDPOINT = '/superset/log/?explode=events';
|
||||
const sendBeacon = (events: LogEventData[]): void => {
|
||||
if (events.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = LOG_ENDPOINT;
|
||||
const { source, source_id } = events[0];
|
||||
// backend logs treat these request params as first-class citizens
|
||||
if (source === 'dashboard') {
|
||||
endpoint += `&dashboard_id=${source_id}`;
|
||||
} else if (source === 'slice') {
|
||||
endpoint += `&slice_id=${source_id}`;
|
||||
}
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
const formData = new FormData();
|
||||
formData.append('events', safeStringify(events));
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
// if we have a guest token, we need to send it for auth via the form
|
||||
formData.append('guest_token', guestToken);
|
||||
}
|
||||
navigator.sendBeacon(ensureAppRoot(endpoint), formData);
|
||||
} else {
|
||||
SupersetClient.post({
|
||||
endpoint,
|
||||
postPayload: { events },
|
||||
parseMethod: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// beacon API has data size limit = 2^16.
|
||||
// assume avg each log entry has 2^6 characters
|
||||
const MAX_EVENTS_PER_REQUEST = 1024;
|
||||
const logMessageQueue = new DebouncedMessageQueue<LogEventData>({
|
||||
callback: sendBeacon,
|
||||
sizeThreshold: MAX_EVENTS_PER_REQUEST,
|
||||
delayThreshold: 1000,
|
||||
});
|
||||
let lastEventId: string | number = 0;
|
||||
|
||||
const loggerMiddleware: Middleware<{}, RootState, Dispatch<AnyAction>> =
|
||||
(store: MiddlewareAPI<Dispatch<AnyAction>, RootState>) =>
|
||||
(next: Dispatch<AnyAction>) => {
|
||||
let navPath: string | undefined;
|
||||
return (action: AnyAction): unknown => {
|
||||
if (action.type !== LOG_EVENT) {
|
||||
return next(action);
|
||||
}
|
||||
|
||||
const logAction = action as LogAction;
|
||||
const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } =
|
||||
store.getState();
|
||||
let logMetadata: Partial<LogEventData> = {
|
||||
impression_id: impressionId,
|
||||
version: 'v2',
|
||||
};
|
||||
const { eventName } = logAction.payload;
|
||||
const { eventData = {} } = logAction.payload;
|
||||
|
||||
if (eventName === LOG_ACTIONS_SPA_NAVIGATION) {
|
||||
navPath = eventData.path as string;
|
||||
}
|
||||
const path = navPath || window?.location?.href;
|
||||
|
||||
if (dashboardInfo?.id && path?.includes('/dashboard/')) {
|
||||
logMetadata = {
|
||||
source: 'dashboard',
|
||||
source_id: dashboardInfo.id,
|
||||
dashboard_id: dashboardInfo.id,
|
||||
...logMetadata,
|
||||
};
|
||||
} else if (explore?.slice) {
|
||||
logMetadata = {
|
||||
source: 'explore',
|
||||
source_id: explore.slice ? explore.slice.slice_id : 0,
|
||||
...(explore.slice.slice_id && { slice_id: explore.slice.slice_id }),
|
||||
...logMetadata,
|
||||
};
|
||||
} else if (path?.includes('/sqllab/')) {
|
||||
const editor = sqlLab.queryEditors.find(
|
||||
({ id }: QueryEditor) => id === sqlLab.tabHistory.slice(-1)[0],
|
||||
);
|
||||
logMetadata = {
|
||||
source: 'sqlLab',
|
||||
source_id: editor?.id,
|
||||
db_id: editor?.dbId,
|
||||
schema: editor?.schema,
|
||||
...logMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
let finalEventData: LogEventData = {
|
||||
...logMetadata,
|
||||
ts: new Date().getTime(),
|
||||
event_name: eventName,
|
||||
...eventData,
|
||||
} as LogEventData;
|
||||
|
||||
if (LOG_EVENT_TYPE_TIMING.has(eventName)) {
|
||||
finalEventData = {
|
||||
...finalEventData,
|
||||
event_type: 'timing',
|
||||
trigger_event: lastEventId,
|
||||
};
|
||||
} else {
|
||||
lastEventId = nanoid();
|
||||
finalEventData = {
|
||||
...finalEventData,
|
||||
event_type: 'user',
|
||||
event_id: lastEventId,
|
||||
visibility: document.visibilityState,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
finalEventData.target_id &&
|
||||
dashboardLayout?.present?.[finalEventData.target_id as string]
|
||||
) {
|
||||
const { meta } =
|
||||
dashboardLayout.present[finalEventData.target_id as string];
|
||||
// chart name or tab/header text
|
||||
finalEventData.target_name = meta.chartId ? meta.sliceName : meta.text;
|
||||
}
|
||||
|
||||
logMessageQueue.append(finalEventData);
|
||||
return finalEventData;
|
||||
};
|
||||
};
|
||||
|
||||
export default loggerMiddleware;
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getTimeFormatter,
|
||||
TimeFormats,
|
||||
ensureIsArray,
|
||||
JsonResponse,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
// ATTENTION: If you change any constants, make sure to also change constants.py
|
||||
@@ -36,18 +37,41 @@ export const SHORT_TIME = 'h:m a';
|
||||
|
||||
const DATETIME_FORMATTER = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
|
||||
export function storeQuery(query) {
|
||||
export interface OptionType {
|
||||
value: string | number | boolean | null;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ColumnDefinition {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type ColumnType = string | ColumnDefinition;
|
||||
|
||||
export interface TabularDataRow {
|
||||
[key: string]: unknown;
|
||||
[key: number]: unknown;
|
||||
}
|
||||
|
||||
export type TabularData = TabularDataRow[];
|
||||
|
||||
export interface StoreQueryResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function storeQuery(query: Record<string, unknown>): Promise<string> {
|
||||
return SupersetClient.post({
|
||||
endpoint: '/kv/store/',
|
||||
postPayload: { data: query },
|
||||
}).then(response => {
|
||||
}).then((response: JsonResponse) => {
|
||||
const responseData = response.json as StoreQueryResponse;
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
const url = `${baseUrl}?id=${response.json.id}`;
|
||||
const url = `${baseUrl}?id=${responseData.id}`;
|
||||
return url;
|
||||
});
|
||||
}
|
||||
|
||||
export function optionLabel(opt) {
|
||||
export function optionLabel(opt: unknown): string {
|
||||
if (opt === null) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
@@ -60,34 +84,47 @@ export function optionLabel(opt) {
|
||||
if (opt === false) {
|
||||
return FALSE_STRING;
|
||||
}
|
||||
if (typeof opt !== 'string' && opt.toString) {
|
||||
return opt.toString();
|
||||
if (
|
||||
typeof opt !== 'string' &&
|
||||
opt &&
|
||||
typeof (opt as { toString?: () => string }).toString === 'function'
|
||||
) {
|
||||
return (opt as { toString: () => string }).toString();
|
||||
}
|
||||
return opt;
|
||||
return String(opt);
|
||||
}
|
||||
|
||||
export function optionValue(opt) {
|
||||
export function optionValue(opt: unknown): string | unknown {
|
||||
if (opt === null) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
|
||||
export function optionFromValue(opt) {
|
||||
export function optionFromValue(opt: unknown): OptionType {
|
||||
// From a list of options, handles special values & labels
|
||||
return { value: optionValue(opt), label: optionLabel(opt) };
|
||||
return {
|
||||
value: optionValue(opt) as string | number | boolean | null,
|
||||
label: optionLabel(opt),
|
||||
};
|
||||
}
|
||||
|
||||
function getColumnName(column) {
|
||||
return column.name || column;
|
||||
function getColumnName(column: ColumnType): string {
|
||||
if (typeof column === 'object' && column?.name) {
|
||||
return column.name;
|
||||
}
|
||||
return String(column);
|
||||
}
|
||||
|
||||
export function prepareCopyToClipboardTabularData(data, columns) {
|
||||
export function prepareCopyToClipboardTabularData(
|
||||
data: TabularData,
|
||||
columns: ColumnType[],
|
||||
): string {
|
||||
let result = columns.length
|
||||
? `${columns.map(getColumnName).join('\t')}\n`
|
||||
: '';
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
const row = {};
|
||||
const row: { [key: number]: unknown } = {};
|
||||
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,31 +140,47 @@ export function prepareCopyToClipboardTabularData(data, columns) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
||||
export function applyFormattingToTabularData(
|
||||
data: TabularData | null | undefined,
|
||||
timeFormattedColumns: string | string[],
|
||||
): TabularData {
|
||||
if (
|
||||
!data ||
|
||||
data.length === 0 ||
|
||||
ensureIsArray(timeFormattedColumns).length === 0
|
||||
) {
|
||||
return data;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
const formattedColumns = ensureIsArray(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;
|
||||
}, {}),
|
||||
...formattedColumns.reduce(
|
||||
(acc: Record<string, unknown>, colName: string) => {
|
||||
if (row[colName] !== null && row[colName] !== undefined) {
|
||||
const cellValue = row[colName];
|
||||
// Convert string to Date if needed for time formatter
|
||||
const timeValue =
|
||||
typeof cellValue === 'string'
|
||||
? new Date(cellValue)
|
||||
: (cellValue as number | Date);
|
||||
acc[colName] = DATETIME_FORMATTER(timeValue);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export const noOp = () => undefined;
|
||||
export const noOp = (): undefined => undefined;
|
||||
|
||||
export type OSType = 'Windows' | 'MacOS' | 'UNIX' | 'Linux' | 'Unknown OS';
|
||||
|
||||
// Detects the user's OS through the browser
|
||||
export const detectOS = () => {
|
||||
export const detectOS = (): OSType => {
|
||||
const { appVersion } = navigator;
|
||||
|
||||
// Leveraging this condition because of stackOverflow
|
||||
@@ -140,8 +193,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));
|
||||
};
|
||||
@@ -1,71 +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 { nanoid } from 'nanoid';
|
||||
|
||||
export function addToObject(state, arrKey, obj) {
|
||||
const newObject = { ...state[arrKey] };
|
||||
const copiedObject = { ...obj };
|
||||
|
||||
if (!copiedObject.id) {
|
||||
copiedObject.id = nanoid();
|
||||
}
|
||||
newObject[copiedObject.id] = copiedObject;
|
||||
return { ...state, [arrKey]: newObject };
|
||||
}
|
||||
|
||||
export function alterInObject(state, arrKey, obj, alterations) {
|
||||
const newObject = { ...state[arrKey] };
|
||||
newObject[obj.id] = { ...newObject[obj.id], ...alterations };
|
||||
return { ...state, [arrKey]: newObject };
|
||||
}
|
||||
|
||||
export function alterInArr(state, arrKey, obj, alterations) {
|
||||
// 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 => {
|
||||
if (obj[idKey] === arrItem[idKey]) {
|
||||
newArr.push({ ...arrItem, ...alterations });
|
||||
} else {
|
||||
newArr.push(arrItem);
|
||||
}
|
||||
});
|
||||
return { ...state, [arrKey]: newArr };
|
||||
}
|
||||
|
||||
export function removeFromArr(state, arrKey, obj, idKey = 'id') {
|
||||
const newArr = [];
|
||||
state[arrKey].forEach(arrItem => {
|
||||
if (!(obj[idKey] === arrItem[idKey])) {
|
||||
newArr.push(arrItem);
|
||||
}
|
||||
});
|
||||
return { ...state, [arrKey]: newArr };
|
||||
}
|
||||
|
||||
export function addToArr(state, arrKey, obj) {
|
||||
const newObj = { ...obj };
|
||||
if (!newObj.id) {
|
||||
newObj.id = nanoid();
|
||||
}
|
||||
const newState = {};
|
||||
newState[arrKey] = [...state[arrKey], newObj];
|
||||
return { ...state, ...newState };
|
||||
}
|
||||
Reference in New Issue
Block a user