mirror of
https://github.com/apache/superset.git
synced 2026-06-23 00:19:22 +00:00
Compare commits
9 Commits
supersetbo
...
pre-cost-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992226527f | ||
|
|
a9cd58508b | ||
|
|
122bb68e5a | ||
|
|
914ce9aa4f | ||
|
|
bb572983cd | ||
|
|
ff76ab647f | ||
|
|
f554848c9f | ||
|
|
dc0c389488 | ||
|
|
22b3cc0480 |
@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
|
||||
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
|
||||
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
|
||||
# as the full content of these folders don't change, yielding a decent cache reuse rate.
|
||||
# Note that's it's not possible selectively COPY of mount using blobs.
|
||||
# Note that it's not possible to selectively COPY or mount using blobs.
|
||||
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
|
||||
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
|
||||
--mount=type=cache,target=/root/.cache \
|
||||
|
||||
@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.19.1",
|
||||
"sqlalchemy-bigquery>=1.6.1",
|
||||
"sqlalchemy-bigquery>=1.15.0",
|
||||
"google-cloud-bigquery>=3.10.0",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
|
||||
|
||||
@@ -11,9 +11,7 @@ apispec==6.6.1
|
||||
apsw==3.50.1.0
|
||||
# via shillelagh
|
||||
async-timeout==4.0.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# redis
|
||||
# via -r requirements/base.in
|
||||
attrs==25.3.0
|
||||
# via
|
||||
# cattrs
|
||||
@@ -99,11 +97,6 @@ email-validator==2.2.0
|
||||
# via flask-appbuilder
|
||||
et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# cattrs
|
||||
# trio
|
||||
# trio-websocket
|
||||
flask==2.3.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -161,7 +154,6 @@ greenlet==3.1.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.16.0
|
||||
@@ -318,7 +310,7 @@ python-dateutil==2.9.0.post0
|
||||
# holidays
|
||||
# pandas
|
||||
# shillelagh
|
||||
python-dotenv==1.1.1
|
||||
python-dotenv==1.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
python-geohash==0.8.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -403,11 +395,9 @@ typing-extensions==4.14.0
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.2
|
||||
|
||||
@@ -20,10 +20,6 @@ apsw==3.50.1.0
|
||||
# shillelagh
|
||||
astroid==3.3.10
|
||||
# via pylint
|
||||
async-timeout==4.0.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# redis
|
||||
attrs==25.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -180,13 +176,6 @@ et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cattrs
|
||||
# pytest
|
||||
# trio
|
||||
# trio-websocket
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
flask==2.3.3
|
||||
@@ -324,7 +313,6 @@ greenlet==3.1.1
|
||||
# apache-superset
|
||||
# gevent
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
grpcio==1.71.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -682,7 +670,7 @@ python-dateutil==2.9.0.post0
|
||||
# pyhive
|
||||
# shillelagh
|
||||
# trino
|
||||
python-dotenv==1.1.1
|
||||
python-dotenv==1.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -807,7 +795,7 @@ sqlalchemy==1.4.54
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.12.0
|
||||
sqlalchemy-bigquery==1.15.0
|
||||
# via apache-superset
|
||||
sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
@@ -830,11 +818,6 @@ tabulate==0.9.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
tomli==2.2.1
|
||||
# via
|
||||
# coverage
|
||||
# pylint
|
||||
# pytest
|
||||
tomlkit==0.13.3
|
||||
# via pylint
|
||||
tqdm==4.67.1
|
||||
@@ -857,13 +840,10 @@ typing-extensions==4.14.0
|
||||
# -c requirements/base.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
# astroid
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.2
|
||||
|
||||
32
superset-frontend/package-lock.json
generated
32
superset-frontend/package-lock.json
generated
@@ -53,8 +53,8 @@
|
||||
"@visx/scale": "^3.5.0",
|
||||
"@visx/tooltip": "^3.0.0",
|
||||
"@visx/xychart": "^3.5.1",
|
||||
"ag-grid-community": "33.1.1",
|
||||
"ag-grid-react": "33.1.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "34.0.2",
|
||||
"antd": "^5.24.6",
|
||||
"chrono-node": "^2.7.8",
|
||||
"classnames": "^2.2.5",
|
||||
@@ -18747,27 +18747,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-11.1.1.tgz",
|
||||
"integrity": "sha512-bRmUcf5VVhEEekhX8Vk0NSwa8Te8YM/zchjyYKR2CX4vDYiwoohM1Jg9RFvbIhVbLC1S6QrPEbx5v2C6RDfpSA==",
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
|
||||
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "33.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-33.1.1.tgz",
|
||||
"integrity": "sha512-CNubIro0ipj4nfQ5WJPG9Isp7UI6MMDvNzrPdHNf3W+IoM8Uv3RUhjEn7xQqpQHuu6o/tMjrqpacipMUkhzqnw==",
|
||||
"version": "34.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
|
||||
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "11.1.1"
|
||||
"ag-charts-types": "12.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "33.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.1.1.tgz",
|
||||
"integrity": "sha512-xJ+t2gpqUUwpFqAeDvKz/GLVR4unkOghfQBr8iIY9RAdGFarYFClJavsOa8XPVVUqEB9OIuPVFnOdtocbX0jeA==",
|
||||
"version": "34.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
|
||||
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "33.1.1",
|
||||
"ag-grid-community": "34.0.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -61168,8 +61168,8 @@
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/d3-array": "^2.9.0",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"ag-grid-community": "^33.1.1",
|
||||
"ag-grid-react": "^33.1.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "^34.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^2.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -61205,8 +61205,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@reduxjs/toolkit": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@types/react-redux": "*",
|
||||
"geostyler": "^14.1.3",
|
||||
"geostyler-data": "^1.0.0",
|
||||
"geostyler-openlayers-parser": "^4.0.0",
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
"@visx/scale": "^3.5.0",
|
||||
"@visx/tooltip": "^3.0.0",
|
||||
"@visx/xychart": "^3.5.1",
|
||||
"ag-grid-community": "33.1.1",
|
||||
"ag-grid-react": "33.1.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "34.0.2",
|
||||
"antd": "^5.24.6",
|
||||
"chrono-node": "^2.7.8",
|
||||
"classnames": "^2.2.5",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@superset-ui/core/spec';
|
||||
import TelemetryPixel from '.';
|
||||
import { TelemetryPixel } from '.';
|
||||
|
||||
const OLD_ENV = process.env;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
|
||||
|
||||
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
||||
|
||||
const TelemetryPixel = ({
|
||||
export const TelemetryPixel = ({
|
||||
version = 'unknownVersion',
|
||||
sha = 'unknownSHA',
|
||||
build = 'unknownBuild',
|
||||
@@ -56,4 +56,3 @@ const TelemetryPixel = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default TelemetryPixel;
|
||||
|
||||
@@ -1,116 +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 { Dropdown, Icons } from '@superset-ui/core/components';
|
||||
import type { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { t, useTheme } from '@superset-ui/core';
|
||||
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
|
||||
|
||||
export interface ThemeSelectProps {
|
||||
setThemeMode: (newMode: ThemeMode) => void;
|
||||
tooltipTitle?: string;
|
||||
themeMode: ThemeMode;
|
||||
hasLocalOverride?: boolean;
|
||||
onClearLocalSettings?: () => void;
|
||||
allowOSPreference?: boolean;
|
||||
}
|
||||
|
||||
const ThemeSelect: React.FC<ThemeSelectProps> = ({
|
||||
setThemeMode,
|
||||
tooltipTitle = 'Select theme',
|
||||
themeMode,
|
||||
hasLocalOverride = false,
|
||||
onClearLocalSettings,
|
||||
allowOSPreference = true,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleSelect = (mode: ThemeMode) => {
|
||||
setThemeMode(mode);
|
||||
};
|
||||
|
||||
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
|
||||
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
|
||||
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
|
||||
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
|
||||
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
|
||||
};
|
||||
|
||||
// Use different icon when local theme is active
|
||||
const triggerIcon = hasLocalOverride ? (
|
||||
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
|
||||
) : (
|
||||
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
|
||||
);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
type: 'group',
|
||||
label: t('Theme'),
|
||||
},
|
||||
{
|
||||
key: ThemeMode.DEFAULT,
|
||||
label: t('Light'),
|
||||
icon: <Icons.SunOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.DEFAULT),
|
||||
},
|
||||
{
|
||||
key: ThemeMode.DARK,
|
||||
label: t('Dark'),
|
||||
icon: <Icons.MoonOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.DARK),
|
||||
},
|
||||
...(allowOSPreference
|
||||
? [
|
||||
{
|
||||
key: ThemeMode.SYSTEM,
|
||||
label: t('Match system'),
|
||||
icon: <Icons.FormatPainterOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.SYSTEM),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Add clear settings option only when there's a local theme active
|
||||
if (onClearLocalSettings && hasLocalOverride) {
|
||||
menuItems.push(
|
||||
{ type: 'divider' } as MenuItem,
|
||||
{
|
||||
key: 'clear-local',
|
||||
label: t('Clear local theme'),
|
||||
icon: <Icons.ClearOutlined />,
|
||||
onClick: onClearLocalSettings,
|
||||
} as MenuItem,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
selectedKeys: [themeMode],
|
||||
}}
|
||||
trigger={['hover']}
|
||||
>
|
||||
{triggerIcon}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSelect;
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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 {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@superset-ui/core/spec';
|
||||
import { ThemeMode } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components';
|
||||
import { ThemeSubMenu } from '.';
|
||||
|
||||
// Mock the translation function
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
describe('ThemeSubMenu', () => {
|
||||
const defaultProps = {
|
||||
allowOSPreference: true,
|
||||
setThemeMode: jest.fn(),
|
||||
themeMode: ThemeMode.DEFAULT,
|
||||
hasLocalOverride: false,
|
||||
onClearLocalSettings: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThemeSubMenu = (props = defaultProps) =>
|
||||
render(
|
||||
<Menu>
|
||||
<ThemeSubMenu {...props} />
|
||||
</Menu>,
|
||||
);
|
||||
|
||||
const findMenuWithText = async (text: string) => {
|
||||
await waitFor(() => {
|
||||
const found = screen
|
||||
.getAllByRole('menu')
|
||||
.some(m => within(m).queryByText(text));
|
||||
|
||||
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
|
||||
});
|
||||
|
||||
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders Light and Dark theme options by default', async () => {
|
||||
renderThemeSubMenu();
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Light');
|
||||
|
||||
expect(within(menu!).getByText('Light')).toBeInTheDocument();
|
||||
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Match system option when allowOSPreference is false', async () => {
|
||||
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with allowOSPreference as true by default', async () => {
|
||||
renderThemeSubMenu();
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Match system');
|
||||
|
||||
expect(within(menu).getByText('Match system')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
|
||||
const mockClear = jest.fn();
|
||||
renderThemeSubMenu({
|
||||
...defaultProps,
|
||||
hasLocalOverride: true,
|
||||
onClearLocalSettings: mockClear,
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Clear local theme');
|
||||
|
||||
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render clear option when hasLocalOverride is false', async () => {
|
||||
const mockClear = jest.fn();
|
||||
renderThemeSubMenu({
|
||||
...defaultProps,
|
||||
hasLocalOverride: false,
|
||||
onClearLocalSettings: mockClear,
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
|
||||
const mockSet = jest.fn();
|
||||
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Light');
|
||||
userEvent.click(within(menu).getByText('Light'));
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('calls setThemeMode with DARK when Dark is clicked', async () => {
|
||||
const mockSet = jest.fn();
|
||||
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Dark');
|
||||
userEvent.click(within(menu).getByText('Dark'));
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
|
||||
});
|
||||
|
||||
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
|
||||
const mockSet = jest.fn();
|
||||
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Match system');
|
||||
userEvent.click(within(menu).getByText('Match system'));
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
|
||||
});
|
||||
|
||||
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
|
||||
const mockClear = jest.fn();
|
||||
renderThemeSubMenu({
|
||||
...defaultProps,
|
||||
hasLocalOverride: true,
|
||||
onClearLocalSettings: mockClear,
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Clear local theme');
|
||||
userEvent.click(within(menu).getByText('Clear local theme'));
|
||||
|
||||
expect(mockClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('displays sun icon for DEFAULT theme', () => {
|
||||
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
|
||||
expect(screen.getByTestId('sun')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays moon icon for DARK theme', () => {
|
||||
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
|
||||
expect(screen.getByTestId('moon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays format-painter icon for SYSTEM theme', () => {
|
||||
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
|
||||
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays override icon when hasLocalOverride is true', () => {
|
||||
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
|
||||
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Theme group header', async () => {
|
||||
renderThemeSubMenu();
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Theme');
|
||||
|
||||
expect(within(menu).getByText('Theme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sun icon for Light theme option', async () => {
|
||||
renderThemeSubMenu();
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Light');
|
||||
const lightOption = within(menu).getByText('Light').closest('li');
|
||||
|
||||
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders moon icon for Dark theme option', async () => {
|
||||
renderThemeSubMenu();
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Dark');
|
||||
const darkOption = within(menu).getByText('Dark').closest('li');
|
||||
|
||||
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders format-painter icon for Match system option', async () => {
|
||||
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Match system');
|
||||
const matchOption = within(menu).getByText('Match system').closest('li');
|
||||
|
||||
expect(
|
||||
within(matchOption!).getByTestId('format-painter'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders clear icon for Clear local theme option', async () => {
|
||||
renderThemeSubMenu({
|
||||
...defaultProps,
|
||||
hasLocalOverride: true,
|
||||
onClearLocalSettings: jest.fn(),
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const menu = await findMenuWithText('Clear local theme');
|
||||
const clearOption = within(menu)
|
||||
.getByText('Clear local theme')
|
||||
.closest('li');
|
||||
|
||||
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders divider before clear option when clear option is present', async () => {
|
||||
renderThemeSubMenu({
|
||||
...defaultProps,
|
||||
hasLocalOverride: true,
|
||||
onClearLocalSettings: jest.fn(),
|
||||
});
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
|
||||
const menu = await findMenuWithText('Clear local theme');
|
||||
const divider = within(menu).queryByRole('separator');
|
||||
|
||||
expect(divider).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render divider when clear option is not present', async () => {
|
||||
renderThemeSubMenu({ ...defaultProps });
|
||||
|
||||
userEvent.hover(await screen.findByRole('menuitem'));
|
||||
const divider = document.querySelector('.ant-menu-item-divider');
|
||||
|
||||
expect(divider).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 { useMemo } from 'react';
|
||||
import { Icons, Menu } from '@superset-ui/core/components';
|
||||
import {
|
||||
css,
|
||||
styled,
|
||||
t,
|
||||
ThemeMode,
|
||||
useTheme,
|
||||
ThemeAlgorithm,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
const StyledThemeSubMenu = styled(Menu.SubMenu)`
|
||||
${({ theme }) => css`
|
||||
[data-icon='caret-down'] {
|
||||
color: ${theme.colorIcon};
|
||||
font-size: ${theme.fontSizeXS}px;
|
||||
margin-left: ${theme.sizeUnit}px;
|
||||
}
|
||||
&.ant-menu-submenu-active {
|
||||
.ant-menu-title-content {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
|
||||
${({ theme, selected }) => css`
|
||||
&:hover {
|
||||
color: ${theme.colorPrimary} !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
${selected &&
|
||||
css`
|
||||
background-color: ${theme.colors.primary.light4} !important;
|
||||
color: ${theme.colors.primary.dark1} !important;
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface ThemeSubMenuOption {
|
||||
key: ThemeMode;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface ThemeSubMenuProps {
|
||||
setThemeMode: (newMode: ThemeMode) => void;
|
||||
themeMode: ThemeMode;
|
||||
hasLocalOverride?: boolean;
|
||||
onClearLocalSettings?: () => void;
|
||||
allowOSPreference?: boolean;
|
||||
}
|
||||
|
||||
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
|
||||
setThemeMode,
|
||||
themeMode,
|
||||
hasLocalOverride = false,
|
||||
onClearLocalSettings,
|
||||
allowOSPreference = true,
|
||||
}: ThemeSubMenuProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleSelect = (mode: ThemeMode) => {
|
||||
setThemeMode(mode);
|
||||
};
|
||||
|
||||
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
|
||||
useMemo(
|
||||
() => ({
|
||||
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
|
||||
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
|
||||
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
|
||||
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedThemeModeIcon = useMemo(
|
||||
() =>
|
||||
hasLocalOverride ? (
|
||||
<Icons.FormatPainterOutlined
|
||||
style={{ color: theme.colors.error.base }}
|
||||
/>
|
||||
) : (
|
||||
themeIconMap[themeMode]
|
||||
),
|
||||
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
|
||||
);
|
||||
|
||||
const themeOptions: ThemeSubMenuOption[] = [
|
||||
{
|
||||
key: ThemeMode.DEFAULT,
|
||||
label: t('Light'),
|
||||
icon: <Icons.SunOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.DEFAULT),
|
||||
},
|
||||
{
|
||||
key: ThemeMode.DARK,
|
||||
label: t('Dark'),
|
||||
icon: <Icons.MoonOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.DARK),
|
||||
},
|
||||
...(allowOSPreference
|
||||
? [
|
||||
{
|
||||
key: ThemeMode.SYSTEM,
|
||||
label: t('Match system'),
|
||||
icon: <Icons.FormatPainterOutlined />,
|
||||
onClick: () => handleSelect(ThemeMode.SYSTEM),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Add clear settings option only when there's a local theme active
|
||||
const clearOption =
|
||||
onClearLocalSettings && hasLocalOverride
|
||||
? {
|
||||
key: 'clear-local',
|
||||
label: t('Clear local theme'),
|
||||
icon: <Icons.ClearOutlined />,
|
||||
onClick: onClearLocalSettings,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledThemeSubMenu
|
||||
key="theme-sub-menu"
|
||||
title={selectedThemeModeIcon}
|
||||
icon={<Icons.CaretDownOutlined iconSize="xs" />}
|
||||
>
|
||||
<Menu.ItemGroup title={t('Theme')} />
|
||||
{themeOptions.map(option => (
|
||||
<StyledThemeSubMenuItem
|
||||
key={option.key}
|
||||
onClick={option.onClick}
|
||||
selected={option.key === themeMode}
|
||||
>
|
||||
{option.icon} {option.label}
|
||||
</StyledThemeSubMenuItem>
|
||||
))}
|
||||
{clearOption && [
|
||||
<Menu.Divider key="theme-divider" />,
|
||||
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
|
||||
{clearOption.icon} {clearOption.label}
|
||||
</Menu.Item>,
|
||||
]}
|
||||
</StyledThemeSubMenu>
|
||||
);
|
||||
};
|
||||
@@ -164,6 +164,8 @@ export * from './Steps';
|
||||
export * from './Table';
|
||||
export * from './TableView';
|
||||
export * from './Tag';
|
||||
export * from './TelemetryPixel';
|
||||
export * from './ThemeSubMenu';
|
||||
export * from './UnsavedChangesModal';
|
||||
export * from './constants';
|
||||
export * from './Result';
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
type ThemeStorage,
|
||||
type ThemeControllerOptions,
|
||||
type ThemeContextType,
|
||||
type SupersetThemeConfig,
|
||||
ThemeAlgorithm,
|
||||
ThemeMode,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
@@ -66,7 +68,16 @@ const themeObject: Theme = Theme.fromConfig({
|
||||
const { theme } = themeObject;
|
||||
const supersetTheme = theme;
|
||||
|
||||
export { Theme, themeObject, styled, theme, supersetTheme };
|
||||
export {
|
||||
Theme,
|
||||
ThemeAlgorithm,
|
||||
ThemeMode,
|
||||
themeObject,
|
||||
styled,
|
||||
theme,
|
||||
supersetTheme,
|
||||
};
|
||||
|
||||
export type {
|
||||
SupersetTheme,
|
||||
SerializableThemeConfig,
|
||||
@@ -74,6 +85,7 @@ export type {
|
||||
ThemeStorage,
|
||||
ThemeControllerOptions,
|
||||
ThemeContextType,
|
||||
SupersetThemeConfig,
|
||||
};
|
||||
|
||||
// Export theme utility functions
|
||||
|
||||
@@ -429,3 +429,16 @@ export interface ThemeContextType {
|
||||
canDetectOSPreference: () => boolean;
|
||||
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration object for complete theme setup including default, dark themes and settings
|
||||
*/
|
||||
export interface SupersetThemeConfig {
|
||||
theme_default: AnyThemeConfig;
|
||||
theme_dark?: AnyThemeConfig;
|
||||
theme_settings?: {
|
||||
enforced?: boolean;
|
||||
allowSwitching?: boolean;
|
||||
allowOSPreference?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/d3-array": "^2.9.0",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"ag-grid-community": "^33.1.1",
|
||||
"ag-grid-react": "^33.1.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "^34.0.2",
|
||||
"classnames": "^2.5.1",
|
||||
"d3-array": "^2.4.0",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -34,6 +34,12 @@ const parseLabel = value => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
function displayCell(value, allowRenderHtml) {
|
||||
if (allowRenderHtml && typeof value === 'string') {
|
||||
return safeHtmlSpan(value);
|
||||
}
|
||||
return parseLabel(value);
|
||||
}
|
||||
function displayHeaderCell(
|
||||
needToggle,
|
||||
ArrowIcon,
|
||||
@@ -742,7 +748,7 @@ export class TableRenderer extends Component {
|
||||
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
|
||||
style={style}
|
||||
>
|
||||
{agg.format(aggValue)}
|
||||
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
@@ -759,7 +765,7 @@ export class TableRenderer extends Component {
|
||||
onClick={rowTotalCallbacks[flatRowKey]}
|
||||
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
|
||||
>
|
||||
{agg.format(aggValue)}
|
||||
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -823,7 +829,7 @@ export class TableRenderer extends Component {
|
||||
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
|
||||
style={{ padding: '5px' }}
|
||||
>
|
||||
{agg.format(aggValue)}
|
||||
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
@@ -840,7 +846,7 @@ export class TableRenderer extends Component {
|
||||
onClick={grandTotalCallback}
|
||||
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
|
||||
>
|
||||
{agg.format(aggValue)}
|
||||
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ export const COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED';
|
||||
export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED';
|
||||
export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED';
|
||||
|
||||
export const COST_THRESHOLD_CHECK_STARTED = 'COST_THRESHOLD_CHECK_STARTED';
|
||||
export const COST_THRESHOLD_CHECK_RETURNED = 'COST_THRESHOLD_CHECK_RETURNED';
|
||||
export const COST_THRESHOLD_CHECK_FAILED = 'COST_THRESHOLD_CHECK_FAILED';
|
||||
|
||||
export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED';
|
||||
export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS';
|
||||
export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED';
|
||||
@@ -233,6 +237,45 @@ export function estimateQueryCost(queryEditor) {
|
||||
};
|
||||
}
|
||||
|
||||
export function checkCostThreshold(queryEditor) {
|
||||
return (dispatch, getState) => {
|
||||
const { dbId, catalog, schema, sql, selectedText, templateParams } =
|
||||
getUpToDateQuery(getState(), queryEditor);
|
||||
const requestSql = selectedText || sql;
|
||||
const postPayload = {
|
||||
database_id: dbId,
|
||||
catalog,
|
||||
schema,
|
||||
sql: requestSql,
|
||||
template_params: JSON.parse(templateParams || '{}'),
|
||||
};
|
||||
return Promise.all([
|
||||
dispatch({ type: COST_THRESHOLD_CHECK_STARTED, query: queryEditor }),
|
||||
SupersetClient.post({
|
||||
endpoint: '/api/v1/sqllab/check_cost_threshold/',
|
||||
body: JSON.stringify(postPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
.then(({ json }) =>
|
||||
dispatch({ type: COST_THRESHOLD_CHECK_RETURNED, query: queryEditor, json }),
|
||||
)
|
||||
.catch(response =>
|
||||
getClientErrorObject(response).then(error => {
|
||||
const message =
|
||||
error.error ||
|
||||
error.statusText ||
|
||||
t('Failed at checking cost threshold');
|
||||
return dispatch({
|
||||
type: COST_THRESHOLD_CHECK_FAILED,
|
||||
query: queryEditor,
|
||||
error: message,
|
||||
});
|
||||
}),
|
||||
),
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
export function clearInactiveQueries(interval) {
|
||||
return { type: CLEAR_INACTIVE_QUERIES, interval };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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 { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@superset-ui/core';
|
||||
import { theme } from 'src/preamble';
|
||||
import CostWarningModal from './index';
|
||||
|
||||
const mockProps = {
|
||||
visible: true,
|
||||
onHide: jest.fn(),
|
||||
onProceed: jest.fn(),
|
||||
warningMessage: 'This query will scan 10 GB of data, which exceeds the threshold of 5 GB.',
|
||||
thresholdInfo: {
|
||||
bytes_threshold: 5 * 1024 ** 3, // 5 GB
|
||||
estimated_bytes: 10 * 1024 ** 3, // 10 GB
|
||||
},
|
||||
};
|
||||
|
||||
const renderWithTheme = (ui: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
||||
|
||||
describe('CostWarningModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with warning message', () => {
|
||||
renderWithTheme(<CostWarningModal {...mockProps} />);
|
||||
|
||||
expect(screen.getByText('Query Cost Warning')).toBeInTheDocument();
|
||||
expect(screen.getByText(mockProps.warningMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows threshold details when provided', () => {
|
||||
renderWithTheme(<CostWarningModal {...mockProps} />);
|
||||
|
||||
expect(screen.getByText('Threshold Details:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data to scan:')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.0 GB')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.0 GB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables proceed button until checkbox is checked', () => {
|
||||
renderWithTheme(<CostWarningModal {...mockProps} />);
|
||||
|
||||
const proceedButton = screen.getByText('Run Query Anyway');
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
|
||||
expect(proceedButton).toBeDisabled();
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
expect(proceedButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onProceed when proceed button is clicked with checkbox checked', () => {
|
||||
renderWithTheme(<CostWarningModal {...mockProps} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
const proceedButton = screen.getByText('Run Query Anyway');
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.click(proceedButton);
|
||||
|
||||
expect(mockProps.onProceed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onHide when cancel button is clicked', () => {
|
||||
renderWithTheme(<CostWarningModal {...mockProps} />);
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(mockProps.onHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders without threshold details when not provided', () => {
|
||||
const propsWithoutThreshold = {
|
||||
...mockProps,
|
||||
thresholdInfo: undefined,
|
||||
};
|
||||
|
||||
renderWithTheme(<CostWarningModal {...propsWithoutThreshold} />);
|
||||
|
||||
expect(screen.queryByText('Threshold Details:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default message when warningMessage is null', () => {
|
||||
const propsWithNoMessage = {
|
||||
...mockProps,
|
||||
warningMessage: null,
|
||||
};
|
||||
|
||||
renderWithTheme(<CostWarningModal {...propsWithNoMessage} />);
|
||||
|
||||
expect(screen.getByText('This query may be expensive to run.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles cost threshold details', () => {
|
||||
const propsWithCostThreshold = {
|
||||
...mockProps,
|
||||
thresholdInfo: {
|
||||
cost_threshold: 100,
|
||||
estimated_cost: 250,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithTheme(<CostWarningModal {...propsWithCostThreshold} />);
|
||||
|
||||
expect(screen.getByText('Estimated cost:')).toBeInTheDocument();
|
||||
expect(screen.getByText('250')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost threshold:')).toBeInTheDocument();
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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 { useState } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { Button, Modal, Checkbox } from '@superset-ui/core/components';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
.ant-modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
const WarningContent = styled.div`
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const DetailsSection = styled.div`
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light4};
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
const CheckboxWrapper = styled.div`
|
||||
margin: 16px 0;
|
||||
`;
|
||||
|
||||
interface CostWarningModalProps {
|
||||
visible: boolean;
|
||||
onHide: () => void;
|
||||
onProceed: () => void;
|
||||
warningMessage: string | null;
|
||||
thresholdInfo?: {
|
||||
bytes_threshold?: number;
|
||||
estimated_bytes?: number;
|
||||
cost_threshold?: number;
|
||||
estimated_cost?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CostWarningModal({
|
||||
visible,
|
||||
onHide,
|
||||
onProceed,
|
||||
warningMessage,
|
||||
thresholdInfo,
|
||||
}: CostWarningModalProps) {
|
||||
const [proceedAnyway, setProceedAnyway] = useState(false);
|
||||
|
||||
const handleProceed = () => {
|
||||
if (proceedAnyway) {
|
||||
onProceed();
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
if (bytes < 1024 ** 5) return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
|
||||
return `${(bytes / 1024 ** 5).toFixed(1)} PB`;
|
||||
};
|
||||
|
||||
const renderThresholdDetails = () => {
|
||||
if (!thresholdInfo) return null;
|
||||
|
||||
const details = [];
|
||||
|
||||
if (thresholdInfo.bytes_threshold && thresholdInfo.estimated_bytes) {
|
||||
details.push(
|
||||
<div key="bytes">
|
||||
<strong>{t('Data to scan:')}</strong> {formatBytes(thresholdInfo.estimated_bytes)}
|
||||
<br />
|
||||
<strong>{t('Threshold:')}</strong> {formatBytes(thresholdInfo.bytes_threshold)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (thresholdInfo.cost_threshold && thresholdInfo.estimated_cost) {
|
||||
details.push(
|
||||
<div key="cost">
|
||||
<strong>{t('Estimated cost:')}</strong> {thresholdInfo.estimated_cost}
|
||||
<br />
|
||||
<strong>{t('Cost threshold:')}</strong> {thresholdInfo.cost_threshold}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return details.length > 0 ? (
|
||||
<DetailsSection>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<strong>{t('Threshold Details:')}</strong>
|
||||
</div>
|
||||
{details.map((detail, index) => (
|
||||
<div key={index} style={{ marginBottom: index < details.length - 1 ? '8px' : '0' }}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</DetailsSection>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
show={visible}
|
||||
onHide={onHide}
|
||||
title={
|
||||
<ModalTitleWithIcon
|
||||
icon="exclamation-triangle"
|
||||
title={t('Query Cost Warning')}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onHide}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={handleProceed}
|
||||
disabled={!proceedAnyway}
|
||||
>
|
||||
{t('Run Query Anyway')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<WarningContent>
|
||||
{warningMessage || t('This query may be expensive to run.')}
|
||||
</WarningContent>
|
||||
|
||||
{renderThresholdDetails()}
|
||||
|
||||
<CheckboxWrapper>
|
||||
<Checkbox
|
||||
checked={proceedAnyway}
|
||||
onChange={(e) => setProceedAnyway(e.target.checked)}
|
||||
>
|
||||
{t('I understand the cost implications and want to proceed anyway')}
|
||||
</Checkbox>
|
||||
</CheckboxWrapper>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
addNewQueryEditor,
|
||||
CtasEnum,
|
||||
estimateQueryCost,
|
||||
checkCostThreshold,
|
||||
persistEditorHeight,
|
||||
postStopQuery,
|
||||
queryEditorSetAutorun,
|
||||
@@ -123,6 +124,7 @@ import SouthPane from '../SouthPane';
|
||||
import SaveQuery, { QueryPayload } from '../SaveQuery';
|
||||
import ScheduleQueryButton from '../ScheduleQueryButton';
|
||||
import EstimateQueryCostButton from '../EstimateQueryCostButton';
|
||||
import CostWarningModal from '../CostWarningModal';
|
||||
import ShareSqlLabQuery from '../ShareSqlLabQuery';
|
||||
import SqlEditorLeftBar from '../SqlEditorLeftBar';
|
||||
import AceEditorWrapper from '../AceEditorWrapper';
|
||||
@@ -270,6 +272,7 @@ const SqlEditor: FC<Props> = ({
|
||||
hideLeftBar,
|
||||
currentQueryEditorId,
|
||||
hasSqlStatement,
|
||||
costThresholdData,
|
||||
} = useSelector<
|
||||
SqlLabRootState,
|
||||
{
|
||||
@@ -278,8 +281,9 @@ const SqlEditor: FC<Props> = ({
|
||||
hideLeftBar?: boolean;
|
||||
currentQueryEditorId: QueryEditor['id'];
|
||||
hasSqlStatement: boolean;
|
||||
costThresholdData?: any;
|
||||
}
|
||||
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
|
||||
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory, queryCostThresholds } }) => {
|
||||
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
|
||||
if (unsavedQueryEditor?.id === queryEditor.id) {
|
||||
dbId = unsavedQueryEditor.dbId || dbId;
|
||||
@@ -295,6 +299,7 @@ const SqlEditor: FC<Props> = ({
|
||||
latestQuery: queries[latestQueryId || ''],
|
||||
hideLeftBar,
|
||||
currentQueryEditorId: tabHistory.slice(-1)[0],
|
||||
costThresholdData: queryCostThresholds[queryEditor.id],
|
||||
};
|
||||
}, shallowEqual);
|
||||
|
||||
@@ -317,6 +322,11 @@ const SqlEditor: FC<Props> = ({
|
||||
);
|
||||
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
|
||||
const [createAs, setCreateAs] = useState('');
|
||||
const [showCostWarningModal, setShowCostWarningModal] = useState(false);
|
||||
const [costWarningData, setCostWarningData] = useState<{
|
||||
warningMessage: string | null;
|
||||
thresholdInfo?: any;
|
||||
} | null>(null);
|
||||
const currentSQL = useRef<string>(queryEditor.sql);
|
||||
const showEmptyState = useMemo(
|
||||
() => !database || isEmpty(database),
|
||||
@@ -330,7 +340,69 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
|
||||
|
||||
const checkCostThresholdAndRun = useCallback(
|
||||
(ctasArg = false, ctas_method = CtasEnum.Table) => {
|
||||
if (!database) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if cost threshold checking is enabled via feature flag or configuration
|
||||
// For now, we'll implement the logic directly
|
||||
dispatch(checkCostThreshold(queryEditor)).then(([_, response]) => {
|
||||
if (response && response.json) {
|
||||
const { exceeds_threshold, formatted_warning, threshold_info } = response.json;
|
||||
|
||||
if (exceeds_threshold && formatted_warning) {
|
||||
// Show warning modal
|
||||
setCostWarningData({
|
||||
warningMessage: formatted_warning,
|
||||
thresholdInfo: threshold_info,
|
||||
});
|
||||
setShowCostWarningModal(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If no threshold exceeded or checking failed, proceed with query
|
||||
dispatch(
|
||||
runQueryFromSqlEditor(
|
||||
database,
|
||||
queryEditor,
|
||||
defaultQueryLimit,
|
||||
ctasArg ? ctas : '',
|
||||
ctasArg,
|
||||
ctas_method,
|
||||
),
|
||||
);
|
||||
dispatch(setActiveSouthPaneTab('Results'));
|
||||
}).catch(() => {
|
||||
// If cost checking fails, proceed with query anyway
|
||||
dispatch(
|
||||
runQueryFromSqlEditor(
|
||||
database,
|
||||
queryEditor,
|
||||
defaultQueryLimit,
|
||||
ctasArg ? ctas : '',
|
||||
ctasArg,
|
||||
ctas_method,
|
||||
),
|
||||
);
|
||||
dispatch(setActiveSouthPaneTab('Results'));
|
||||
});
|
||||
},
|
||||
[ctas, database, defaultQueryLimit, dispatch, queryEditor],
|
||||
);
|
||||
|
||||
const startQuery = useCallback(
|
||||
(ctasArg = false, ctas_method = CtasEnum.Table) => {
|
||||
// Use cost threshold checking for regular queries
|
||||
checkCostThresholdAndRun(ctasArg, ctas_method);
|
||||
},
|
||||
[checkCostThresholdAndRun],
|
||||
);
|
||||
|
||||
// Direct query execution without cost checking (for modal "proceed anyway")
|
||||
const executeQueryDirectly = useCallback(
|
||||
(ctasArg = false, ctas_method = CtasEnum.Table) => {
|
||||
if (!database) {
|
||||
return;
|
||||
@@ -1121,6 +1193,20 @@ const SqlEditor: FC<Props> = ({
|
||||
<span>{t('Name')}</span>
|
||||
<Input placeholder={createModalPlaceHolder} onChange={ctasChanged} />
|
||||
</Modal>
|
||||
<CostWarningModal
|
||||
visible={showCostWarningModal}
|
||||
onHide={() => {
|
||||
setShowCostWarningModal(false);
|
||||
setCostWarningData(null);
|
||||
}}
|
||||
onProceed={() => {
|
||||
setShowCostWarningModal(false);
|
||||
setCostWarningData(null);
|
||||
executeQueryDirectly();
|
||||
}}
|
||||
warningMessage={costWarningData?.warningMessage || null}
|
||||
thresholdInfo={costWarningData?.thresholdInfo}
|
||||
/>
|
||||
</StyledSqlEditor>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -264,6 +264,7 @@ export default function getInitialState({
|
||||
queriesLastUpdate: Date.now(),
|
||||
editorTabLastUpdatedAt,
|
||||
queryCostEstimates: {},
|
||||
queryCostThresholds: {},
|
||||
unsavedQueryEditor,
|
||||
lastUpdatedActiveTab,
|
||||
destroyedQueryEditors,
|
||||
|
||||
@@ -315,6 +315,51 @@ export default function sqlLabReducer(state = {}, action) {
|
||||
},
|
||||
};
|
||||
},
|
||||
[actions.COST_THRESHOLD_CHECK_STARTED]() {
|
||||
return {
|
||||
...state,
|
||||
queryCostThresholds: {
|
||||
...state.queryCostThresholds,
|
||||
[action.query.id]: {
|
||||
completed: false,
|
||||
exceedsThreshold: false,
|
||||
thresholdInfo: null,
|
||||
formattedWarning: null,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[actions.COST_THRESHOLD_CHECK_RETURNED]() {
|
||||
return {
|
||||
...state,
|
||||
queryCostThresholds: {
|
||||
...state.queryCostThresholds,
|
||||
[action.query.id]: {
|
||||
completed: true,
|
||||
exceedsThreshold: action.json.exceeds_threshold,
|
||||
thresholdInfo: action.json.threshold_info,
|
||||
formattedWarning: action.json.formatted_warning,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[actions.COST_THRESHOLD_CHECK_FAILED]() {
|
||||
return {
|
||||
...state,
|
||||
queryCostThresholds: {
|
||||
...state.queryCostThresholds,
|
||||
[action.query.id]: {
|
||||
completed: false,
|
||||
exceedsThreshold: false,
|
||||
thresholdInfo: null,
|
||||
formattedWarning: null,
|
||||
error: action.error,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
[actions.START_QUERY]() {
|
||||
let newState = { ...state };
|
||||
if (action.query.sqlEditorId) {
|
||||
|
||||
93
superset-frontend/src/embedded/EmbeddedContextProviders.tsx
Normal file
93
superset-frontend/src/embedded/EmbeddedContextProviders.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 { Route } from 'react-router-dom';
|
||||
import { getExtensionsRegistry } from '@superset-ui/core';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { FlashProvider, DynamicPluginProvider } from 'src/components';
|
||||
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
|
||||
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
|
||||
import { ThemeController } from 'src/theme/ThemeController';
|
||||
import type { ThemeStorage } from '@superset-ui/core';
|
||||
import { store } from 'src/views/store';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
/**
|
||||
* In-memory implementation of ThemeStorage interface for embedded contexts.
|
||||
* Persistent storage is not required for embedded dashboards.
|
||||
*/
|
||||
class ThemeMemoryStorageAdapter implements ThemeStorage {
|
||||
private storage = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.storage.get(key) || null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.storage.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const themeController = new ThemeController({
|
||||
storage: new ThemeMemoryStorageAdapter(),
|
||||
});
|
||||
|
||||
export const getThemeController = (): ThemeController => themeController;
|
||||
|
||||
const { common } = getBootstrapData();
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
export const EmbeddedContextProviders: React.FC = ({ children }) => {
|
||||
const RootContextProviderExtension = extensionsRegistry.get(
|
||||
'root.context.provider',
|
||||
);
|
||||
|
||||
return (
|
||||
<SupersetThemeProvider themeController={themeController}>
|
||||
<ReduxProvider store={store}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<FlashProvider messages={common.flash_messages}>
|
||||
<EmbeddedUiConfigProvider>
|
||||
<DynamicPluginProvider>
|
||||
<QueryParamProvider
|
||||
ReactRouterRoute={Route}
|
||||
stringifyOptions={{ encode: false }}
|
||||
>
|
||||
{RootContextProviderExtension ? (
|
||||
<RootContextProviderExtension>
|
||||
{children}
|
||||
</RootContextProviderExtension>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</QueryParamProvider>
|
||||
</DynamicPluginProvider>
|
||||
</EmbeddedUiConfigProvider>
|
||||
</FlashProvider>
|
||||
</DndProvider>
|
||||
</ReduxProvider>
|
||||
</SupersetThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -21,20 +21,27 @@ import 'src/public-path';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
|
||||
import {
|
||||
type SupersetThemeConfig,
|
||||
makeApi,
|
||||
t,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import Switchboard from '@superset-ui/switchboard';
|
||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||
import setupClient from 'src/setup/setupClient';
|
||||
import setupPlugins from 'src/setup/setupPlugins';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { RootContextProviders } from 'src/views/RootContextProviders';
|
||||
import { store, USER_LOADED } from 'src/views/store';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
|
||||
import {
|
||||
EmbeddedContextProviders,
|
||||
getThemeController,
|
||||
} from './EmbeddedContextProviders';
|
||||
import { embeddedApi } from './api';
|
||||
import { getDataMaskChangeTrigger } from './utils';
|
||||
|
||||
@@ -44,9 +51,7 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
|
||||
const bootstrapData = getBootstrapData();
|
||||
|
||||
function log(...info: unknown[]) {
|
||||
if (debugMode) {
|
||||
logging.debug(`[superset]`, ...info);
|
||||
}
|
||||
if (debugMode) logging.debug(`[superset]`, ...info);
|
||||
}
|
||||
|
||||
const LazyDashboardPage = lazy(
|
||||
@@ -85,12 +90,12 @@ const EmbededLazyDashboardPage = () => {
|
||||
|
||||
const EmbeddedRoute = () => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<RootContextProviders>
|
||||
<EmbeddedContextProviders>
|
||||
<ErrorBoundary>
|
||||
<EmbededLazyDashboardPage />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</RootContextProviders>
|
||||
</EmbeddedContextProviders>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -245,12 +250,13 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
||||
Switchboard.defineMethod(
|
||||
'setThemeConfig',
|
||||
(payload: { themeConfig: AnyThemeConfig }) => {
|
||||
(payload: { themeConfig: SupersetThemeConfig }) => {
|
||||
const { themeConfig } = payload;
|
||||
log('Received setThemeConfig request:', themeConfig);
|
||||
|
||||
try {
|
||||
themeObject.setConfig(themeConfig);
|
||||
const themeController = getThemeController();
|
||||
themeController.setThemeConfig(themeConfig);
|
||||
return { success: true, message: 'Theme applied' };
|
||||
} catch (error) {
|
||||
logging.error('Failed to apply theme config:', error);
|
||||
@@ -258,8 +264,22 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Switchboard.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up theme controller on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
const controller = getThemeController();
|
||||
if (controller) {
|
||||
log('Destroying theme controller');
|
||||
controller.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
logging.warn('Failed to destroy theme controller:', error);
|
||||
}
|
||||
});
|
||||
|
||||
log('embed page is ready to receive messages');
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { Fragment, useState, useEffect, FC, PureComponent } from 'react';
|
||||
|
||||
import rison from 'rison';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryParams, BooleanParam } from 'use-query-params';
|
||||
import { get, isEmpty } from 'lodash';
|
||||
|
||||
import {
|
||||
t,
|
||||
styled,
|
||||
@@ -33,10 +31,15 @@ import {
|
||||
getExtensionsRegistry,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { Label, Tooltip } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
import {
|
||||
Label,
|
||||
Tooltip,
|
||||
ThemeSubMenu,
|
||||
Menu,
|
||||
Icons,
|
||||
Typography,
|
||||
TelemetryPixel,
|
||||
} from '@superset-ui/core/components';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
@@ -49,9 +52,7 @@ import { RootState } from 'src/dashboard/types';
|
||||
import DatabaseModal from 'src/features/databases/DatabaseModal';
|
||||
import UploadDataModal from 'src/features/databases/UploadDataModel';
|
||||
import { uploadUserPerms } from 'src/views/CRUD/utils';
|
||||
import TelemetryPixel from '@superset-ui/core/components/TelemetryPixel';
|
||||
import { useThemeContext } from 'src/theme/ThemeProvider';
|
||||
import ThemeSelect from '@superset-ui/core/components/ThemeSelect';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import {
|
||||
ExtensionConfigs,
|
||||
@@ -138,6 +139,7 @@ const RightMenu = ({
|
||||
datasetAdded?: boolean;
|
||||
}) => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||
state => state.user,
|
||||
);
|
||||
@@ -371,7 +373,6 @@ const RightMenu = ({
|
||||
localStorage.removeItem('redux');
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledDiv align={align}>
|
||||
{canDatabase && (
|
||||
@@ -493,16 +494,15 @@ const RightMenu = ({
|
||||
})}
|
||||
</StyledSubMenu>
|
||||
)}
|
||||
|
||||
{canSetMode() && (
|
||||
<span>
|
||||
<ThemeSelect
|
||||
setThemeMode={setThemeMode}
|
||||
themeMode={themeMode}
|
||||
hasLocalOverride={hasDevOverride()}
|
||||
onClearLocalSettings={clearLocalOverrides}
|
||||
allowOSPreference={canDetectOSPreference()}
|
||||
/>
|
||||
</span>
|
||||
<ThemeSubMenu
|
||||
setThemeMode={setThemeMode}
|
||||
themeMode={themeMode}
|
||||
hasLocalOverride={hasDevOverride()}
|
||||
onClearLocalSettings={clearLocalOverrides}
|
||||
allowOSPreference={canDetectOSPreference()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StyledSubMenu
|
||||
|
||||
@@ -17,13 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type SupersetTheme,
|
||||
type SupersetThemeConfig,
|
||||
type ThemeControllerOptions,
|
||||
type ThemeStorage,
|
||||
Theme,
|
||||
AnyThemeConfig,
|
||||
ThemeStorage,
|
||||
ThemeControllerOptions,
|
||||
ThemeMode,
|
||||
themeObject as supersetThemeObject,
|
||||
} from '@superset-ui/core';
|
||||
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import {
|
||||
getAntdConfig,
|
||||
normalizeThemeConfig,
|
||||
@@ -94,7 +96,7 @@ export class ThemeController {
|
||||
|
||||
private currentMode: ThemeMode;
|
||||
|
||||
private readonly hasBootstrapThemes: boolean;
|
||||
private hasCustomThemes: boolean;
|
||||
|
||||
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
|
||||
|
||||
@@ -109,15 +111,13 @@ export class ThemeController {
|
||||
|
||||
private dashboardCrudTheme: AnyThemeConfig | null = null;
|
||||
|
||||
constructor(options: ThemeControllerOptions = {}) {
|
||||
const {
|
||||
storage = new LocalStorageAdapter(),
|
||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||
themeObject = supersetThemeObject,
|
||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||
onChange = null,
|
||||
} = options;
|
||||
|
||||
constructor({
|
||||
storage = new LocalStorageAdapter(),
|
||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||
themeObject = supersetThemeObject,
|
||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||
onChange = undefined,
|
||||
}: ThemeControllerOptions = {}) {
|
||||
this.storage = storage;
|
||||
this.modeStorageKey = modeStorageKey;
|
||||
|
||||
@@ -129,14 +129,14 @@ export class ThemeController {
|
||||
bootstrapDefaultTheme,
|
||||
bootstrapDarkTheme,
|
||||
bootstrapThemeSettings,
|
||||
hasBootstrapThemes,
|
||||
hasCustomThemes,
|
||||
}: BootstrapThemeData = this.loadBootstrapData();
|
||||
|
||||
this.hasBootstrapThemes = hasBootstrapThemes;
|
||||
this.hasCustomThemes = hasCustomThemes;
|
||||
this.themeSettings = bootstrapThemeSettings || {};
|
||||
|
||||
// Set themes based on bootstrap data availability
|
||||
if (this.hasBootstrapThemes) {
|
||||
if (this.hasCustomThemes) {
|
||||
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
|
||||
this.defaultTheme =
|
||||
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
|
||||
@@ -424,6 +424,42 @@ export class ThemeController {
|
||||
return allowOSPreference === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an entire new theme configuration, replacing all existing theme data and settings.
|
||||
* This method is designed for use cases like embedded dashboards where themes are provided
|
||||
* dynamically from external sources.
|
||||
* @param config - The complete theme configuration object
|
||||
*/
|
||||
public setThemeConfig(config: SupersetThemeConfig): void {
|
||||
this.defaultTheme = config.theme_default;
|
||||
this.darkTheme = config.theme_dark || null;
|
||||
this.hasCustomThemes = true;
|
||||
|
||||
this.themeSettings = {
|
||||
enforced: config.theme_settings?.enforced ?? false,
|
||||
allowSwitching: config.theme_settings?.allowSwitching ?? true,
|
||||
allowOSPreference: config.theme_settings?.allowOSPreference ?? true,
|
||||
};
|
||||
|
||||
let newMode: ThemeMode;
|
||||
try {
|
||||
this.validateModeUpdatePermission(this.currentMode);
|
||||
const hasRequiredTheme = this.isValidThemeMode(this.currentMode);
|
||||
newMode = hasRequiredTheme
|
||||
? this.currentMode
|
||||
: this.determineInitialMode();
|
||||
} catch {
|
||||
newMode = this.determineInitialMode();
|
||||
}
|
||||
|
||||
this.currentMode = newMode;
|
||||
|
||||
const themeToApply =
|
||||
this.getThemeForMode(this.currentMode) || this.defaultTheme;
|
||||
|
||||
this.updateTheme(themeToApply);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles system theme changes with error recovery.
|
||||
*/
|
||||
@@ -547,7 +583,7 @@ export class ThemeController {
|
||||
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
|
||||
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
|
||||
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
|
||||
hasBootstrapThemes: hasValidDefault || hasValidDark,
|
||||
hasCustomThemes: hasValidDefault || hasValidDark,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -607,7 +643,7 @@ export class ThemeController {
|
||||
resolvedMode = ThemeController.getSystemPreferredMode();
|
||||
}
|
||||
|
||||
if (!this.hasBootstrapThemes) {
|
||||
if (!this.hasCustomThemes) {
|
||||
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
|
||||
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Theme, AnyThemeConfig, ThemeContextType } from '@superset-ui/core';
|
||||
import { ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type ThemeContextType,
|
||||
Theme,
|
||||
ThemeMode,
|
||||
} from '@superset-ui/core';
|
||||
import { ThemeController } from './ThemeController';
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
@@ -17,12 +17,17 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { Theme } from '@superset-ui/core';
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type SupersetThemeConfig,
|
||||
Theme,
|
||||
ThemeAlgorithm,
|
||||
ThemeMode,
|
||||
} from '@superset-ui/core';
|
||||
import type {
|
||||
BootstrapThemeDataConfig,
|
||||
CommonBootstrapData,
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
|
||||
|
||||
@@ -43,7 +48,7 @@ const mockThemeFromConfig = jest.fn();
|
||||
const mockSetConfig = jest.fn();
|
||||
|
||||
// Mock data constants
|
||||
const DEFAULT_THEME = {
|
||||
const DEFAULT_THEME: AnyThemeConfig = {
|
||||
token: {
|
||||
colorBgBase: '#ededed',
|
||||
colorTextBase: '#120f0f',
|
||||
@@ -55,7 +60,7 @@ const DEFAULT_THEME = {
|
||||
},
|
||||
};
|
||||
|
||||
const DARK_THEME = {
|
||||
const DARK_THEME: AnyThemeConfig = {
|
||||
token: {
|
||||
colorBgBase: '#141118',
|
||||
colorTextBase: '#fdc7c7',
|
||||
@@ -65,7 +70,7 @@ const DARK_THEME = {
|
||||
colorSuccess: '#3c7c1b',
|
||||
colorWarning: '#dc9811',
|
||||
},
|
||||
algorithm: ThemeMode.DARK,
|
||||
algorithm: ThemeAlgorithm.DARK,
|
||||
};
|
||||
|
||||
const THEME_SETTINGS = {
|
||||
@@ -1049,4 +1054,298 @@ describe('ThemeController', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setThemeConfig', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBootstrapData.mockReturnValue(
|
||||
createMockBootstrapData({
|
||||
default: {},
|
||||
dark: {},
|
||||
settings: {},
|
||||
}),
|
||||
);
|
||||
|
||||
controller = new ThemeController({
|
||||
themeObject: mockThemeObject,
|
||||
defaultTheme: { token: {} },
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set complete theme configuration', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: true,
|
||||
allowOSPreference: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
|
||||
expect(controller.canSetTheme()).toBe(true);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle theme_default only', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(true);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle theme_default and theme_dark without settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DARK_THEME.token),
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle enforced theme settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: true,
|
||||
allowSwitching: false,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(false);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
}).toThrow('User does not have permission to update the theme mode');
|
||||
});
|
||||
|
||||
it('should handle allowOSPreference: false setting', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: true,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeMode(ThemeMode.SYSTEM);
|
||||
}).toThrow('System theme mode is not allowed');
|
||||
});
|
||||
|
||||
it('should re-determine initial mode based on new settings', () => {
|
||||
mockMatchMedia.mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
});
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: false,
|
||||
allowOSPreference: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply appropriate theme after configuration', () => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
jest.clearAllMocks();
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: {
|
||||
token: {
|
||||
colorPrimary: '#00ff00',
|
||||
},
|
||||
},
|
||||
theme_dark: {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
colorBgBase: '#000000',
|
||||
},
|
||||
algorithm: 'dark',
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig as SupersetThemeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining({
|
||||
colorPrimary: '#ff0000',
|
||||
colorBgBase: '#000000',
|
||||
}),
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing theme_dark gracefully', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_settings: {
|
||||
allowSwitching: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
jest.clearAllMocks();
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing theme mode when possible', () => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
const initialMode = controller.getCurrentMode();
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
allowSwitching: true,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(initialMode);
|
||||
});
|
||||
|
||||
it('should trigger onChange callbacks', () => {
|
||||
const changeCallback = jest.fn();
|
||||
controller.onChange(changeCallback);
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(changeCallback).toHaveBeenCalledTimes(1);
|
||||
expect(changeCallback).toHaveBeenCalledWith(mockThemeObject);
|
||||
});
|
||||
|
||||
it('should handle partial theme_settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_settings: {
|
||||
enforced: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(false);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error in theme application', () => {
|
||||
mockSetConfig.mockImplementationOnce(() => {
|
||||
throw new Error('Theme application error');
|
||||
});
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeConfig(themeConfig);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to apply theme:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update stored theme mode', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'superset-theme-mode',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { Theme } from '@superset-ui/core';
|
||||
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core';
|
||||
import { act, render, screen } from '@superset-ui/core/spec';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';
|
||||
|
||||
@@ -16,14 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
ColorSchemeConfig,
|
||||
FeatureFlagMap,
|
||||
JsonObject,
|
||||
LanguagePack,
|
||||
Locale,
|
||||
SequentialSchemeConfig,
|
||||
} from '@superset-ui/core';
|
||||
import { FormatLocaleDefinition } from 'd3-format';
|
||||
import { TimeLocaleDefinition } from 'd3-time-format';
|
||||
import { isPlainObject } from 'lodash';
|
||||
@@ -31,8 +23,14 @@ import { Languages } from 'src/features/home/LanguagePicker';
|
||||
import type { FlashMessage } from 'src/components';
|
||||
import type {
|
||||
AnyThemeConfig,
|
||||
ColorSchemeConfig,
|
||||
FeatureFlagMap,
|
||||
JsonObject,
|
||||
LanguagePack,
|
||||
Locale,
|
||||
SequentialSchemeConfig,
|
||||
SerializableThemeConfig,
|
||||
} from '@superset-ui/core/theme/types';
|
||||
} from '@superset-ui/core';
|
||||
|
||||
export type User = {
|
||||
createdOn?: string;
|
||||
@@ -189,7 +187,7 @@ export interface BootstrapThemeData {
|
||||
bootstrapDefaultTheme: AnyThemeConfig | null;
|
||||
bootstrapDarkTheme: AnyThemeConfig | null;
|
||||
bootstrapThemeSettings: SerializableThemeSettings | null;
|
||||
hasBootstrapThemes: boolean;
|
||||
hasCustomThemes: boolean;
|
||||
}
|
||||
|
||||
export function isUser(user: any): user is User {
|
||||
|
||||
29
superset-websocket/package-lock.json
generated
29
superset-websocket/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cookie": "^0.7.0",
|
||||
"cookie": "^1.0.2",
|
||||
"hot-shots": "^11.1.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -20,7 +20,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/ioredis": "^4.27.8",
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -1721,12 +1720,6 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -3045,11 +3038,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
|
||||
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
@@ -8402,12 +8395,6 @@
|
||||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -9317,9 +9304,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
|
||||
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ=="
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
|
||||
},
|
||||
"create-jest": {
|
||||
"version": "29.7.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cookie": "^0.7.0",
|
||||
"cookie": "^1.0.2",
|
||||
"hot-shots": "^11.1.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -28,7 +28,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/ioredis": "^4.27.8",
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -52,7 +51,7 @@
|
||||
"typescript-eslint": "^8.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.9.1",
|
||||
"npm": "^7.5.4 || ^8.1.2"
|
||||
"node": "^20.19.4",
|
||||
"npm": "^10.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { buildConfig } from '../src/config';
|
||||
import { expect, test } from '@jest/globals';
|
||||
|
||||
test('buildConfig() builds configuration and applies env var overrides', () => {
|
||||
let config = buildConfig();
|
||||
|
||||
@@ -19,15 +19,26 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const config = require('../config.test.json');
|
||||
|
||||
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
jest,
|
||||
} from '@jest/globals';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
interface MockedRedisXrange {
|
||||
(): Promise<server.StreamResult[]>;
|
||||
}
|
||||
|
||||
// NOTE: these mock variables needs to start with "mock" due to
|
||||
// calls to `jest.mock` being hoisted to the top of the file.
|
||||
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
|
||||
const mockRedisXrange = jest.fn();
|
||||
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
|
||||
|
||||
jest.mock('ws');
|
||||
jest.mock('ioredis', () => {
|
||||
@@ -59,7 +70,7 @@ import * as server from '../src/index';
|
||||
import { statsd } from '../src/index';
|
||||
|
||||
describe('server', () => {
|
||||
let statsdIncrementMock: jest.SpyInstance;
|
||||
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedisXrange.mockClear();
|
||||
@@ -319,10 +330,12 @@ describe('server', () => {
|
||||
|
||||
describe('wsConnection', () => {
|
||||
let ws: WebSocket;
|
||||
let wsEventMock: jest.SpyInstance;
|
||||
let trackClientSpy: jest.SpyInstance;
|
||||
let fetchRangeFromStreamSpy: jest.SpyInstance;
|
||||
let dateNowSpy: jest.SpyInstance;
|
||||
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
|
||||
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
|
||||
let fetchRangeFromStreamSpy: jest.SpiedFunction<
|
||||
typeof server.fetchRangeFromStream
|
||||
>;
|
||||
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
|
||||
let socketInstanceExpected: server.SocketInstance;
|
||||
|
||||
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
||||
@@ -431,8 +444,8 @@ describe('server', () => {
|
||||
|
||||
describe('httpUpgrade', () => {
|
||||
let socket: net.Socket;
|
||||
let socketDestroySpy: jest.SpyInstance;
|
||||
let wssUpgradeSpy: jest.SpyInstance;
|
||||
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
|
||||
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
|
||||
|
||||
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
||||
const request = new http.IncomingMessage(new net.Socket());
|
||||
@@ -496,8 +509,8 @@ describe('server', () => {
|
||||
|
||||
describe('checkSockets', () => {
|
||||
let ws: WebSocket;
|
||||
let pingSpy: jest.SpyInstance;
|
||||
let terminateSpy: jest.SpyInstance;
|
||||
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
|
||||
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
|
||||
let socketInstance: server.SocketInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import * as net from 'net';
|
||||
import WebSocket from 'ws';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import jwt, { Algorithm } from 'jsonwebtoken';
|
||||
import cookie from 'cookie';
|
||||
import { parse } from 'cookie';
|
||||
import Redis, { RedisOptions } from 'ioredis';
|
||||
import StatsD from 'hot-shots';
|
||||
|
||||
@@ -285,7 +285,7 @@ export const processStreamResults = (results: StreamResult[]): void => {
|
||||
* configured via 'jwtCookieName' in the config.
|
||||
*/
|
||||
const readChannelId = (request: http.IncomingMessage): string => {
|
||||
const cookies = cookie.parse(request.headers.cookie || '');
|
||||
const cookies = parse(request.headers.cookie || '');
|
||||
const token = cookies[opts.jwtCookieName];
|
||||
|
||||
if (!token) throw new Error('JWT not present');
|
||||
|
||||
230
superset/commands/sql_lab/check_cost_threshold.py
Normal file
230
superset/commands/sql_lab/check_cost_threshold.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from superset import app
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.sql_lab.estimate import QueryEstimationCommand, EstimateQueryCostType
|
||||
|
||||
config = app.config
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CostThresholdResult(TypedDict):
|
||||
exceeds_threshold: bool
|
||||
estimated_cost: list[dict[str, Any]]
|
||||
threshold_info: dict[str, Any]
|
||||
formatted_warning: str | None
|
||||
|
||||
|
||||
class QueryCostThresholdCheckCommand(BaseCommand):
|
||||
"""
|
||||
Command to check if a query's estimated cost exceeds configured thresholds.
|
||||
"""
|
||||
|
||||
_estimation_command: QueryEstimationCommand
|
||||
|
||||
def __init__(self, estimation_params: EstimateQueryCostType) -> None:
|
||||
self._estimation_command = QueryEstimationCommand(estimation_params)
|
||||
|
||||
def validate(self) -> None:
|
||||
# Use the estimation command's validation
|
||||
self._estimation_command.validate()
|
||||
|
||||
def run(self) -> CostThresholdResult:
|
||||
"""
|
||||
Check if query cost exceeds thresholds.
|
||||
|
||||
Returns a result indicating whether the query exceeds cost thresholds
|
||||
and provides information for user warnings.
|
||||
"""
|
||||
self.validate()
|
||||
|
||||
# Check if cost checking is enabled
|
||||
if not config.get("SQLLAB_QUERY_COST_CHECKING_ENABLED", False):
|
||||
return self._create_empty_result()
|
||||
|
||||
estimated_cost = self._get_estimated_cost()
|
||||
if not estimated_cost:
|
||||
return self._create_empty_result()
|
||||
|
||||
thresholds = self._get_engine_thresholds()
|
||||
if not thresholds:
|
||||
return CostThresholdResult(
|
||||
exceeds_threshold=False,
|
||||
estimated_cost=estimated_cost,
|
||||
threshold_info={},
|
||||
formatted_warning=None,
|
||||
)
|
||||
|
||||
return self._check_thresholds(estimated_cost, thresholds)
|
||||
|
||||
def _create_empty_result(self) -> CostThresholdResult:
|
||||
"""Create an empty result when cost checking is disabled or fails."""
|
||||
return CostThresholdResult(
|
||||
exceeds_threshold=False,
|
||||
estimated_cost=[],
|
||||
threshold_info={},
|
||||
formatted_warning=None,
|
||||
)
|
||||
|
||||
def _get_estimated_cost(self) -> list[dict[str, Any]] | None:
|
||||
"""Get cost estimation, returning None if it fails."""
|
||||
try:
|
||||
return self._estimation_command.run()
|
||||
except Exception as ex:
|
||||
logger.warning("Cost estimation failed: %s", str(ex))
|
||||
return None
|
||||
|
||||
def _get_engine_thresholds(self) -> dict[str, Any]:
|
||||
"""Get thresholds for the current database engine."""
|
||||
database = self._estimation_command._database
|
||||
engine_name = database.db_engine_spec.engine_name
|
||||
if engine_name is None:
|
||||
return {}
|
||||
|
||||
engine_name = engine_name.lower()
|
||||
return config.get("SQLLAB_QUERY_COST_THRESHOLDS", {}).get(engine_name, {})
|
||||
|
||||
def _check_thresholds(
|
||||
self, estimated_cost: list[dict[str, Any]], thresholds: dict[str, Any]
|
||||
) -> CostThresholdResult:
|
||||
"""Check if estimated cost exceeds configured thresholds."""
|
||||
exceeds_threshold = False
|
||||
warning_messages = []
|
||||
threshold_info = {}
|
||||
|
||||
for cost_item in estimated_cost:
|
||||
if self._check_bytes_threshold(cost_item, thresholds, threshold_info, warning_messages):
|
||||
exceeds_threshold = True
|
||||
if self._check_cost_threshold(cost_item, thresholds, threshold_info, warning_messages):
|
||||
exceeds_threshold = True
|
||||
|
||||
formatted_warning = None
|
||||
if warning_messages:
|
||||
formatted_warning = (
|
||||
" ".join(warning_messages) + " Are you sure you want to continue?"
|
||||
)
|
||||
|
||||
return CostThresholdResult(
|
||||
exceeds_threshold=exceeds_threshold,
|
||||
estimated_cost=estimated_cost,
|
||||
threshold_info=threshold_info,
|
||||
formatted_warning=formatted_warning,
|
||||
)
|
||||
|
||||
def _check_bytes_threshold(
|
||||
self,
|
||||
cost_item: dict[str, Any],
|
||||
thresholds: dict[str, Any],
|
||||
threshold_info: dict[str, Any],
|
||||
warning_messages: list[str]
|
||||
) -> bool:
|
||||
"""Check bytes scanned threshold. Returns True if threshold exceeded."""
|
||||
if "bytes_scanned" not in thresholds or "Bytes Scanned" not in cost_item:
|
||||
return False
|
||||
|
||||
try:
|
||||
bytes_scanned = self._parse_bytes_from_cost_item(cost_item["Bytes Scanned"])
|
||||
threshold_bytes = thresholds["bytes_scanned"]
|
||||
threshold_info["bytes_threshold"] = threshold_bytes
|
||||
threshold_info["estimated_bytes"] = bytes_scanned
|
||||
|
||||
if bytes_scanned > threshold_bytes:
|
||||
warning_messages.append(
|
||||
f"This query will scan approximately {self._format_bytes(bytes_scanned)} "
|
||||
f"of data, which exceeds the threshold of {self._format_bytes(threshold_bytes)}."
|
||||
)
|
||||
return True
|
||||
except (ValueError, KeyError) as ex:
|
||||
logger.warning("Failed to parse bytes from cost estimation: %s", str(ex))
|
||||
|
||||
return False
|
||||
|
||||
def _check_cost_threshold(
|
||||
self,
|
||||
cost_item: dict[str, Any],
|
||||
thresholds: dict[str, Any],
|
||||
threshold_info: dict[str, Any],
|
||||
warning_messages: list[str]
|
||||
) -> bool:
|
||||
"""Check cost threshold. Returns True if threshold exceeded."""
|
||||
if "cost_threshold" not in thresholds or "Cost" not in cost_item:
|
||||
return False
|
||||
|
||||
try:
|
||||
cost_value = float(cost_item["Cost"])
|
||||
threshold_cost = thresholds["cost_threshold"]
|
||||
threshold_info["cost_threshold"] = threshold_cost
|
||||
threshold_info["estimated_cost"] = cost_value
|
||||
|
||||
if cost_value > threshold_cost:
|
||||
warning_messages.append(
|
||||
f"This query has an estimated cost of {cost_value}, "
|
||||
f"which exceeds the threshold of {threshold_cost}."
|
||||
)
|
||||
return True
|
||||
except (ValueError, KeyError) as ex:
|
||||
logger.warning("Failed to parse cost from cost estimation: %s", str(ex))
|
||||
|
||||
return False
|
||||
|
||||
def _parse_bytes_from_cost_item(self, bytes_str: str) -> int:
|
||||
"""Parse bytes from formatted string like '5.2 GB' or '1024 MB'."""
|
||||
if not isinstance(bytes_str, str):
|
||||
return int(bytes_str)
|
||||
|
||||
# Remove commas and split
|
||||
parts = bytes_str.replace(",", "").strip().split()
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Cannot parse bytes from: {bytes_str}")
|
||||
|
||||
value_str, unit = parts
|
||||
value = float(value_str)
|
||||
unit = unit.upper()
|
||||
|
||||
multipliers = {
|
||||
"B": 1,
|
||||
"KB": 1024,
|
||||
"MB": 1024**2,
|
||||
"GB": 1024**3,
|
||||
"TB": 1024**4,
|
||||
"PB": 1024**5,
|
||||
}
|
||||
|
||||
if unit not in multipliers:
|
||||
raise ValueError(f"Unknown unit: {unit}")
|
||||
|
||||
return int(value * multipliers[unit])
|
||||
|
||||
def _format_bytes(self, bytes_count: int) -> str:
|
||||
"""Format bytes into human-readable string."""
|
||||
if bytes_count < 1024:
|
||||
return f"{bytes_count} B"
|
||||
elif bytes_count < 1024**2:
|
||||
return f"{bytes_count / 1024:.1f} KB"
|
||||
elif bytes_count < 1024**3:
|
||||
return f"{bytes_count / (1024**2):.1f} MB"
|
||||
elif bytes_count < 1024**4:
|
||||
return f"{bytes_count / (1024**3):.1f} GB"
|
||||
elif bytes_count < 1024**5:
|
||||
return f"{bytes_count / (1024**4):.1f} TB"
|
||||
else:
|
||||
return f"{bytes_count / (1024**5):.1f} PB"
|
||||
@@ -1191,6 +1191,18 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
|
||||
# timeout.
|
||||
SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT = int(timedelta(seconds=10).total_seconds())
|
||||
|
||||
# Query cost governance configuration
|
||||
# Enable automatic cost checking before query execution
|
||||
SQLLAB_QUERY_COST_CHECKING_ENABLED = False
|
||||
|
||||
# Cost thresholds that trigger warnings before query execution
|
||||
# This is a dictionary where keys are database engine names and values are threshold configs
|
||||
# Each threshold config can contain:
|
||||
# - 'bytes_scanned': maximum bytes that can be scanned without warning
|
||||
# - 'cost_threshold': monetary cost threshold (engine-specific units)
|
||||
# Example: {'bigquery': {'bytes_scanned': 5 * 1024**4}, 'presto': {'cost_threshold': 1000}}
|
||||
SQLLAB_QUERY_COST_THRESHOLDS = {}
|
||||
|
||||
# Timeout duration for SQL Lab fetching query results by the resultsKey.
|
||||
# 0 means no timeout.
|
||||
SQLLAB_QUERY_RESULT_TIMEOUT = 0
|
||||
|
||||
@@ -1680,6 +1680,9 @@ class SqlaTable(
|
||||
table=self,
|
||||
)
|
||||
new_column.is_dttm = new_column.is_temporal
|
||||
# Set description from comment field if available
|
||||
if col.get("comment"):
|
||||
new_column.description = col["comment"]
|
||||
db_engine_spec.alter_new_orm_column(new_column)
|
||||
else:
|
||||
new_column = old_column
|
||||
@@ -1687,6 +1690,9 @@ class SqlaTable(
|
||||
results.modified.append(col["column_name"])
|
||||
new_column.type = col["type"]
|
||||
new_column.expression = ""
|
||||
# Set description from comment field if available
|
||||
if col.get("comment"):
|
||||
new_column.description = col["comment"]
|
||||
new_column.groupby = True
|
||||
new_column.filterable = True
|
||||
columns.append(new_column)
|
||||
|
||||
@@ -262,8 +262,16 @@ class RLSAsSubqueryTransformer(RLSTransformer):
|
||||
return node
|
||||
|
||||
if predicate := self.get_predicate(node):
|
||||
# use alias or name
|
||||
alias = node.alias or node.sql()
|
||||
if node.alias:
|
||||
alias = node.alias
|
||||
else:
|
||||
name = ".".join(
|
||||
part
|
||||
for part in (node.catalog or "", node.db or "", node.name)
|
||||
if part
|
||||
)
|
||||
alias = exp.TableAlias(this=exp.Identifier(this=name, quoted=True))
|
||||
|
||||
node.set("alias", None)
|
||||
node = exp.Subquery(
|
||||
this=exp.Select(
|
||||
@@ -683,7 +691,10 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
|
||||
"""
|
||||
return {
|
||||
eq.this.sql(comments=False): eq.expression.sql(comments=False)
|
||||
eq.this.sql(
|
||||
dialect=self._dialect,
|
||||
comments=False,
|
||||
): eq.expression.sql(comments=False)
|
||||
for set_item in self._parsed.find_all(exp.SetItem)
|
||||
for eq in set_item.find_all(exp.EQ)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import app, is_feature_enabled
|
||||
from superset.commands.sql_lab.check_cost_threshold import (
|
||||
QueryCostThresholdCheckCommand,
|
||||
)
|
||||
from superset.commands.sql_lab.estimate import QueryEstimationCommand
|
||||
from superset.commands.sql_lab.execute import CommandResult, ExecuteSqlCommand
|
||||
from superset.commands.sql_lab.export import SqlResultExportCommand
|
||||
@@ -188,6 +191,66 @@ class SqlLabRestApi(BaseSupersetApi):
|
||||
result = command.run()
|
||||
return self.response(200, result=result)
|
||||
|
||||
@expose("/check_cost_threshold/", methods=("POST",))
|
||||
@protect()
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
|
||||
f".check_cost_threshold",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def check_cost_threshold(self) -> Response:
|
||||
"""Check if query cost exceeds configured thresholds.
|
||||
---
|
||||
post:
|
||||
summary: Check if query cost exceeds thresholds
|
||||
requestBody:
|
||||
description: SQL query and params
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EstimateQueryCostSchema'
|
||||
responses:
|
||||
200:
|
||||
description: Cost threshold check result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
exceeds_threshold:
|
||||
type: boolean
|
||||
description: Whether query exceeds cost thresholds
|
||||
estimated_cost:
|
||||
type: array
|
||||
description: Detailed cost estimation
|
||||
threshold_info:
|
||||
type: object
|
||||
description: Information about thresholds and estimates
|
||||
formatted_warning:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Human-readable warning message
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
model = self.estimate_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
command = QueryCostThresholdCheckCommand(model)
|
||||
result = command.run()
|
||||
return self.response(200, **result)
|
||||
|
||||
@expose("/format_sql/", methods=("POST",))
|
||||
@statsd_metrics
|
||||
@protect()
|
||||
|
||||
@@ -287,3 +287,321 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
|
||||
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
|
||||
== "Car"
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_metadata_with_comment_field_new_columns(mocker: MockerFixture) -> None:
|
||||
"""Test that fetch_metadata correctly assigns comment field to description
|
||||
for new columns
|
||||
"""
|
||||
# Mock database
|
||||
database = mocker.MagicMock()
|
||||
database.get_metrics.return_value = []
|
||||
|
||||
# Mock db_engine_spec
|
||||
mock_db_engine_spec = mocker.MagicMock()
|
||||
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
|
||||
database.db_engine_spec = mock_db_engine_spec
|
||||
|
||||
# Create table
|
||||
table = SqlaTable(
|
||||
table_name="test_table",
|
||||
database=database,
|
||||
)
|
||||
|
||||
# Mock external_metadata to return columns with comment fields
|
||||
mock_columns = [
|
||||
{
|
||||
"column_name": "id",
|
||||
"type": "INTEGER",
|
||||
"comment": "Primary key identifier",
|
||||
},
|
||||
{
|
||||
"column_name": "name",
|
||||
"type": "VARCHAR",
|
||||
"comment": "Full name of the user",
|
||||
},
|
||||
{
|
||||
"column_name": "status",
|
||||
"type": "VARCHAR",
|
||||
# No comment field for this column
|
||||
},
|
||||
]
|
||||
|
||||
# Mock dependencies
|
||||
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
|
||||
mocker.patch("superset.connectors.sqla.models.db.session")
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
|
||||
)
|
||||
|
||||
# Execute fetch_metadata
|
||||
result = table.fetch_metadata()
|
||||
|
||||
# Verify results
|
||||
assert len(result.added) == 3
|
||||
assert set(result.added) == {"id", "name", "status"}
|
||||
|
||||
# Check that descriptions were set correctly from comments
|
||||
columns_by_name = {col.column_name: col for col in table.columns}
|
||||
|
||||
assert columns_by_name["id"].description == "Primary key identifier"
|
||||
assert columns_by_name["name"].description == "Full name of the user"
|
||||
# Column without comment should have None description
|
||||
assert columns_by_name["status"].description is None
|
||||
|
||||
|
||||
def test_fetch_metadata_with_comment_field_existing_columns(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that fetch_metadata correctly updates description for existing columns"""
|
||||
# Mock database
|
||||
database = mocker.MagicMock()
|
||||
database.get_metrics.return_value = []
|
||||
|
||||
# Mock db_engine_spec
|
||||
mock_db_engine_spec = mocker.MagicMock()
|
||||
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
|
||||
database.db_engine_spec = mock_db_engine_spec
|
||||
|
||||
# Create table with existing columns
|
||||
table = SqlaTable(
|
||||
table_name="test_table_existing",
|
||||
database=database,
|
||||
)
|
||||
table.id = 1 # Set ID so it's treated as existing table
|
||||
|
||||
# Create existing columns
|
||||
existing_col1 = TableColumn(
|
||||
column_name="id",
|
||||
type="INTEGER",
|
||||
table=table,
|
||||
description="Old description",
|
||||
)
|
||||
existing_col2 = TableColumn(
|
||||
column_name="name",
|
||||
type="VARCHAR",
|
||||
table=table,
|
||||
)
|
||||
table.columns = [existing_col1, existing_col2]
|
||||
|
||||
# Mock external_metadata to return updated columns with comments
|
||||
mock_columns = [
|
||||
{
|
||||
"column_name": "id",
|
||||
"type": "INTEGER",
|
||||
"comment": "Updated primary key description",
|
||||
},
|
||||
{
|
||||
"column_name": "name",
|
||||
"type": "VARCHAR",
|
||||
"comment": "Updated name description",
|
||||
},
|
||||
]
|
||||
|
||||
# Mock dependencies
|
||||
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = [
|
||||
existing_col1,
|
||||
existing_col2,
|
||||
]
|
||||
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
|
||||
)
|
||||
|
||||
# Execute fetch_metadata
|
||||
result = table.fetch_metadata()
|
||||
|
||||
# Verify no new columns were added
|
||||
assert len(result.added) == 0
|
||||
|
||||
# Check that descriptions were updated from comments
|
||||
columns_by_name = {col.column_name: col for col in table.columns}
|
||||
|
||||
assert columns_by_name["id"].description == "Updated primary key description"
|
||||
assert columns_by_name["name"].description == "Updated name description"
|
||||
|
||||
|
||||
def test_fetch_metadata_mixed_comment_scenarios(mocker: MockerFixture) -> None:
|
||||
"""Test fetch_metadata with mix of new/existing columns and with/without
|
||||
comments
|
||||
"""
|
||||
# Mock database
|
||||
database = mocker.MagicMock()
|
||||
database.get_metrics.return_value = []
|
||||
|
||||
# Mock db_engine_spec
|
||||
mock_db_engine_spec = mocker.MagicMock()
|
||||
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
|
||||
database.db_engine_spec = mock_db_engine_spec
|
||||
|
||||
# Create table with one existing column
|
||||
table = SqlaTable(
|
||||
table_name="test_table_mixed",
|
||||
database=database,
|
||||
)
|
||||
table.id = 1
|
||||
|
||||
existing_col = TableColumn(
|
||||
column_name="existing_col",
|
||||
type="INTEGER",
|
||||
table=table,
|
||||
description="Existing description",
|
||||
)
|
||||
table.columns = [existing_col]
|
||||
|
||||
# Mock external_metadata with mixed scenarios
|
||||
mock_columns = [
|
||||
{
|
||||
"column_name": "existing_col",
|
||||
"type": "INTEGER",
|
||||
"comment": "Updated existing column comment",
|
||||
},
|
||||
{
|
||||
"column_name": "new_with_comment",
|
||||
"type": "VARCHAR",
|
||||
"comment": "New column with comment",
|
||||
},
|
||||
{
|
||||
"column_name": "new_without_comment",
|
||||
"type": "VARCHAR",
|
||||
# No comment field
|
||||
},
|
||||
]
|
||||
|
||||
# Mock dependencies
|
||||
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = [
|
||||
existing_col
|
||||
]
|
||||
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
|
||||
)
|
||||
|
||||
# Execute fetch_metadata
|
||||
result = table.fetch_metadata()
|
||||
|
||||
# Check added columns
|
||||
assert len(result.added) == 2
|
||||
assert set(result.added) == {"new_with_comment", "new_without_comment"}
|
||||
|
||||
# Check all column descriptions
|
||||
columns_by_name = {col.column_name: col for col in table.columns}
|
||||
|
||||
# Existing column should have updated description
|
||||
assert (
|
||||
columns_by_name["existing_col"].description == "Updated existing column comment"
|
||||
)
|
||||
|
||||
# New column with comment should have description set
|
||||
assert columns_by_name["new_with_comment"].description == "New column with comment"
|
||||
|
||||
# New column without comment should have None description
|
||||
assert columns_by_name["new_without_comment"].description is None
|
||||
|
||||
|
||||
def test_fetch_metadata_no_comment_field_safe_handling(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Test that fetch_metadata safely handles columns with no comment field"""
|
||||
# Mock database
|
||||
database = mocker.MagicMock()
|
||||
database.get_metrics.return_value = []
|
||||
|
||||
# Mock db_engine_spec
|
||||
mock_db_engine_spec = mocker.MagicMock()
|
||||
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
|
||||
database.db_engine_spec = mock_db_engine_spec
|
||||
|
||||
# Create table
|
||||
table = SqlaTable(
|
||||
table_name="test_table_no_comments",
|
||||
database=database,
|
||||
)
|
||||
|
||||
# Mock external_metadata with columns that have no comment fields
|
||||
mock_columns = [
|
||||
{"column_name": "col1", "type": "INTEGER"},
|
||||
{"column_name": "col2", "type": "VARCHAR"},
|
||||
]
|
||||
|
||||
# Mock dependencies
|
||||
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
|
||||
mocker.patch("superset.connectors.sqla.models.db.session")
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
|
||||
)
|
||||
|
||||
# Execute fetch_metadata - should not raise any exceptions
|
||||
result = table.fetch_metadata()
|
||||
|
||||
# Check that columns were added successfully
|
||||
assert len(result.added) == 2
|
||||
assert set(result.added) == {"col1", "col2"}
|
||||
|
||||
# Check that descriptions are None (not set)
|
||||
columns_by_name = {col.column_name: col for col in table.columns}
|
||||
assert columns_by_name["col1"].description is None
|
||||
assert columns_by_name["col2"].description is None
|
||||
|
||||
|
||||
def test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> None:
|
||||
"""Test that fetch_metadata handles empty comment fields correctly"""
|
||||
# Mock database
|
||||
database = mocker.MagicMock()
|
||||
database.get_metrics.return_value = []
|
||||
|
||||
# Mock db_engine_spec
|
||||
mock_db_engine_spec = mocker.MagicMock()
|
||||
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
|
||||
database.db_engine_spec = mock_db_engine_spec
|
||||
|
||||
# Create table
|
||||
table = SqlaTable(
|
||||
table_name="test_table_empty_comments",
|
||||
database=database,
|
||||
)
|
||||
|
||||
# Mock external_metadata with empty comment fields
|
||||
mock_columns = [
|
||||
{
|
||||
"column_name": "col_with_empty_comment",
|
||||
"type": "INTEGER",
|
||||
"comment": "", # Empty string comment
|
||||
},
|
||||
{
|
||||
"column_name": "col_with_none_comment",
|
||||
"type": "VARCHAR",
|
||||
"comment": None, # None comment
|
||||
},
|
||||
{
|
||||
"column_name": "col_with_valid_comment",
|
||||
"type": "VARCHAR",
|
||||
"comment": "Valid comment",
|
||||
},
|
||||
]
|
||||
|
||||
# Mock dependencies
|
||||
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
|
||||
mocker.patch("superset.connectors.sqla.models.db.session")
|
||||
mocker.patch(
|
||||
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
|
||||
)
|
||||
|
||||
# Execute fetch_metadata
|
||||
result = table.fetch_metadata()
|
||||
|
||||
# Check that all columns were added
|
||||
assert len(result.added) == 3
|
||||
|
||||
columns_by_name = {col.column_name: col for col in table.columns}
|
||||
|
||||
# Empty string comment should not be set (falsy)
|
||||
assert columns_by_name["col_with_empty_comment"].description is None
|
||||
|
||||
# None comment should not be set
|
||||
assert columns_by_name["col_with_none_comment"].description is None
|
||||
|
||||
# Valid comment should be set
|
||||
assert columns_by_name["col_with_valid_comment"].description == "Valid comment"
|
||||
|
||||
@@ -1851,7 +1851,7 @@ FROM (
|
||||
FROM some_table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS some_table
|
||||
) AS "some_table"
|
||||
WHERE
|
||||
1 = 1
|
||||
""".strip(),
|
||||
@@ -1868,7 +1868,7 @@ FROM (
|
||||
FROM table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS table
|
||||
) AS "table"
|
||||
WHERE
|
||||
1 = 1
|
||||
""".strip(),
|
||||
@@ -1925,7 +1925,7 @@ JOIN (
|
||||
FROM other_table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS other_table
|
||||
) AS "other_table"
|
||||
ON table.id = other_table.id
|
||||
""".strip(),
|
||||
),
|
||||
@@ -1961,7 +1961,7 @@ FROM (
|
||||
FROM some_table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS some_table
|
||||
) AS "some_table"
|
||||
)
|
||||
""".strip(),
|
||||
),
|
||||
@@ -1977,7 +1977,7 @@ FROM (
|
||||
FROM table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS table
|
||||
) AS "table"
|
||||
UNION ALL
|
||||
SELECT
|
||||
*
|
||||
@@ -2000,7 +2000,7 @@ FROM (
|
||||
FROM other_table
|
||||
WHERE
|
||||
id = 42
|
||||
) AS other_table
|
||||
) AS "other_table"
|
||||
""".strip(),
|
||||
),
|
||||
(
|
||||
@@ -2039,6 +2039,22 @@ INNER JOIN tbl_b AS b
|
||||
ON a.col = b.col
|
||||
""".strip(),
|
||||
),
|
||||
(
|
||||
"SELECT * FROM public.flights LIMIT 100",
|
||||
{Table("flights", "public", "catalog1"): "\"AIRLINE\" like 'A%'"},
|
||||
"""
|
||||
SELECT
|
||||
*
|
||||
FROM (
|
||||
SELECT
|
||||
*
|
||||
FROM public.flights
|
||||
WHERE
|
||||
"AIRLINE" LIKE 'A%'
|
||||
) AS "public.flights"
|
||||
LIMIT 100
|
||||
""".strip(),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rls_subquery_transformer(
|
||||
|
||||
@@ -259,13 +259,13 @@ FROM (
|
||||
FROM t1
|
||||
WHERE
|
||||
c1 = 1
|
||||
) AS t1, (
|
||||
) AS "t1", (
|
||||
SELECT
|
||||
*
|
||||
FROM t2
|
||||
WHERE
|
||||
c2 = 2
|
||||
) AS t2
|
||||
) AS "t2"
|
||||
""".strip()
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user