mirror of
https://github.com/apache/superset.git
synced 2026-05-06 08:24:26 +00:00
Compare commits
36 Commits
impala-dia
...
codespaces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375fe42a68 | ||
|
|
e6e0c3c47e | ||
|
|
1d6617d809 | ||
|
|
4ff2a85b11 | ||
|
|
f1a3bdd878 | ||
|
|
4b5dbf3dcf | ||
|
|
458db68929 | ||
|
|
d4463078ad | ||
|
|
7ad10ac1a9 | ||
|
|
f580f6159e | ||
|
|
a26e0ea0fe | ||
|
|
4eef7a65c1 | ||
|
|
ba3388bf94 | ||
|
|
ca57bbc1e2 | ||
|
|
19f414b217 | ||
|
|
bc604d54e4 | ||
|
|
e922e51e6b | ||
|
|
8bf2e4ea3a | ||
|
|
cf8183b67e | ||
|
|
02f90f4321 | ||
|
|
a007b3020d | ||
|
|
26e5e637f9 | ||
|
|
8de420ec8e | ||
|
|
fd51cc65a2 | ||
|
|
16db999067 | ||
|
|
972be15dda | ||
|
|
c9e06714f8 | ||
|
|
32626ab707 | ||
|
|
a9cd58508b | ||
|
|
122bb68e5a | ||
|
|
914ce9aa4f | ||
|
|
bb572983cd | ||
|
|
ff76ab647f | ||
|
|
f554848c9f | ||
|
|
dc0c389488 | ||
|
|
22b3cc0480 |
5
.devcontainer/README.md
Normal file
5
.devcontainer/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Superset Development with GitHub Codespaces
|
||||
|
||||
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
|
||||
|
||||
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**
|
||||
52
.devcontainer/devcontainer.json
Normal file
52
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
|
||||
// Using the same base as Dockerfile, but non-slim for dev tools
|
||||
"image": "python:3.11.13-bookworm",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": true,
|
||||
"dockerDashComposeVersion": "v2"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "20"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||
"configureZshAsDefaultShell": true
|
||||
},
|
||||
"ghcr.io/devcontainers/features/sshd:1": {
|
||||
"version": "latest"
|
||||
}
|
||||
},
|
||||
|
||||
// Forward ports for development
|
||||
"forwardPorts": [9001],
|
||||
"portsAttributes": {
|
||||
"9001": {
|
||||
"label": "Superset (via Webpack Dev Server)",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "public"
|
||||
}
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.sh",
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
32
.devcontainer/setup-dev.sh
Executable file
32
.devcontainer/setup-dev.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Setup script for Superset Codespaces development environment
|
||||
|
||||
echo "🔧 Setting up Superset development environment..."
|
||||
|
||||
# The universal image has most tools, just need Superset-specific libs
|
||||
echo "📦 Installing Superset-specific dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh
|
||||
|
||||
# Install uv for fast Python package management
|
||||
echo "📦 Installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Add cargo/bin to PATH for uv
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
|
||||
|
||||
# Install Claude Code CLI via npm
|
||||
echo "🤖 Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Make the start script executable
|
||||
chmod +x .devcontainer/start-superset.sh
|
||||
|
||||
echo "✅ Development environment setup complete!"
|
||||
echo "🚀 Run '.devcontainer/start-superset.sh' to start Superset"
|
||||
59
.devcontainer/start-superset.sh
Executable file
59
.devcontainer/start-superset.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Startup script for Superset in Codespaces
|
||||
|
||||
echo "🚀 Starting Superset in Codespaces..."
|
||||
echo "🌐 Frontend will be available at port 9001"
|
||||
|
||||
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
|
||||
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||
if [ -n "$WORKSPACE_DIR" ]; then
|
||||
cd "$WORKSPACE_DIR"
|
||||
echo "📁 Working in: $WORKSPACE_DIR"
|
||||
else
|
||||
echo "📁 Using current directory: $(pwd)"
|
||||
fi
|
||||
|
||||
# Check if docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Clean up any existing containers
|
||||
echo "🧹 Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose-light.yml down
|
||||
|
||||
# Start services
|
||||
echo "🏗️ Building and starting services..."
|
||||
echo ""
|
||||
echo "📝 Once started, login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin"
|
||||
echo ""
|
||||
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
|
||||
|
||||
# Run docker-compose and capture exit code
|
||||
docker-compose -f docker-compose-light.yml up
|
||||
EXIT_CODE=$?
|
||||
|
||||
# If it failed, provide helpful instructions
|
||||
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
|
||||
echo ""
|
||||
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
|
||||
echo ""
|
||||
echo "🔄 To restart Superset, run:"
|
||||
echo " .devcontainer/start-superset.sh"
|
||||
echo ""
|
||||
echo "🔧 For troubleshooting:"
|
||||
echo " # View logs:"
|
||||
echo " docker-compose -f docker-compose-light.yml logs"
|
||||
echo ""
|
||||
echo " # Clean restart (removes volumes):"
|
||||
echo " docker-compose -f docker-compose-light.yml down -v"
|
||||
echo " .devcontainer/start-superset.sh"
|
||||
echo ""
|
||||
echo " # Common issues:"
|
||||
echo " - Network timeouts: Just retry, often transient"
|
||||
echo " - Port conflicts: Check 'docker ps'"
|
||||
echo " - Database issues: Try clean restart with -v"
|
||||
fi
|
||||
@@ -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 \
|
||||
|
||||
@@ -120,6 +120,78 @@ docker volume rm superset_db_home
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## GitHub Codespaces (Cloud Development)
|
||||
|
||||
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
|
||||
- Quick contributions without local setup
|
||||
- Consistent development environments across team members
|
||||
- Working from devices that can't run Docker locally
|
||||
- Safe experimentation in isolated environments
|
||||
|
||||
:::info
|
||||
We're grateful to GitHub for providing this excellent cloud development service that makes
|
||||
contributing to Apache Superset more accessible to developers worldwide.
|
||||
:::
|
||||
|
||||
### Getting Started with Codespaces
|
||||
|
||||
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
|
||||
|
||||
:::caution
|
||||
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||
Smaller instances will not have sufficient resources to run Superset effectively.
|
||||
:::
|
||||
|
||||
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
|
||||
- Build the development container
|
||||
- Install all dependencies
|
||||
- Start all required services (PostgreSQL, Redis, etc.)
|
||||
- Initialize the database with example data
|
||||
|
||||
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
|
||||
Click the globe icon to open Superset in your browser.
|
||||
- Default credentials: `admin` / `admin`
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
|
||||
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
|
||||
- **Multiple Instances**: Run multiple Codespaces for different branches/features
|
||||
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
|
||||
- **VS Code Integration**: Works seamlessly with VS Code desktop app
|
||||
|
||||
### Managing Codespaces
|
||||
|
||||
- **List active Codespaces**: `gh cs list`
|
||||
- **SSH into a Codespace**: `gh cs ssh`
|
||||
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
|
||||
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
|
||||
|
||||
### Debugging and Logs
|
||||
|
||||
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
|
||||
|
||||
```bash
|
||||
# Stream logs from all services
|
||||
docker compose -f docker-compose-light.yml logs -f
|
||||
|
||||
# Stream logs from a specific service
|
||||
docker compose -f docker-compose-light.yml logs -f superset
|
||||
|
||||
# View last 100 lines and follow
|
||||
docker compose -f docker-compose-light.yml logs --tail=100 -f
|
||||
|
||||
# List all running services
|
||||
docker compose -f docker-compose-light.yml ps
|
||||
```
|
||||
|
||||
:::tip
|
||||
Codespaces automatically stop after 30 minutes of inactivity to save resources.
|
||||
Your work is preserved and you can restart anytime.
|
||||
:::
|
||||
|
||||
## Installing Development Tools
|
||||
|
||||
:::note
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -795,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
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
|
||||
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
|
||||
|
||||
// Mock scrollIntoView to avoid errors in test environment
|
||||
jest.mock('scroll-into-view-if-needed', () => jest.fn());
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
class MainPreset extends Preset {
|
||||
@@ -256,4 +259,22 @@ describe('VizTypeControl', () => {
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
|
||||
});
|
||||
|
||||
it('Search input is focused when modal opens', async () => {
|
||||
// Mock the focus method to track if it was called
|
||||
const focusSpy = jest.fn();
|
||||
const originalFocus = HTMLInputElement.prototype.focus;
|
||||
HTMLInputElement.prototype.focus = focusSpy;
|
||||
|
||||
await waitForRenderWrapper();
|
||||
|
||||
const searchInput = screen.getByTestId(getTestId('search-input'));
|
||||
|
||||
// Verify that focus() was called on the search input
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
|
||||
// Restore the original focus method
|
||||
HTMLInputElement.prototype.focus = originalFocus;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -575,6 +575,13 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
|
||||
setIsSearchFocused(true);
|
||||
}, []);
|
||||
|
||||
// Auto-focus the search input when the modal opens
|
||||
useEffect(() => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
event => setSearchInputValue(event.target.value),
|
||||
[],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -199,6 +199,11 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
:raises DatasetUnAllowedDataURI: If a dataset is trying
|
||||
to load data from a URI that is not allowed.
|
||||
"""
|
||||
from superset.examples.helpers import normalize_example_data_url
|
||||
|
||||
# Convert example URLs to align with configuration
|
||||
data_uri = normalize_example_data_url(data_uri)
|
||||
|
||||
validate_data_uri(data_uri)
|
||||
logger.info("Downloading data from %s", data_uri)
|
||||
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
|
||||
@@ -190,6 +190,12 @@ def load_configs(
|
||||
db_ssh_tunnel_priv_key_passws[config["uuid"]]
|
||||
)
|
||||
|
||||
# Normalize example data URLs before schema validation
|
||||
if prefix == "datasets" and "data" in config:
|
||||
from superset.examples.helpers import normalize_example_data_url
|
||||
|
||||
config["data"] = normalize_example_data_url(config["data"])
|
||||
|
||||
schema.load(config)
|
||||
configs[file_name] = config
|
||||
except ValidationError as exc:
|
||||
|
||||
@@ -1368,10 +1368,23 @@ class SqlaTable(
|
||||
return get_template_processor(table=self, database=self.database, **kwargs)
|
||||
|
||||
def get_sqla_table(self) -> TableClause:
|
||||
tbl = table(self.table_name)
|
||||
# For databases that support cross-catalog queries (like BigQuery),
|
||||
# include the catalog in the table identifier to generate
|
||||
# project.dataset.table format
|
||||
if self.catalog and self.database.db_engine_spec.supports_cross_catalog_queries:
|
||||
# SQLAlchemy doesn't have built-in catalog support for TableClause,
|
||||
# so we need to construct the full identifier manually
|
||||
if self.schema:
|
||||
full_name = f"{self.catalog}.{self.schema}.{self.table_name}"
|
||||
else:
|
||||
full_name = f"{self.catalog}.{self.table_name}"
|
||||
|
||||
return table(full_name)
|
||||
|
||||
if self.schema:
|
||||
tbl.schema = self.schema
|
||||
return tbl
|
||||
return table(self.table_name, schema=self.schema)
|
||||
|
||||
return table(self.table_name)
|
||||
|
||||
def get_from_clause(
|
||||
self,
|
||||
@@ -1680,6 +1693,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 +1703,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)
|
||||
|
||||
@@ -38,7 +38,7 @@ def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None:
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
df = read_example_data(
|
||||
"bart-lines.json.gz", encoding="latin-1", compression="gzip"
|
||||
"examples://bart-lines.json.gz", encoding="latin-1", compression="gzip"
|
||||
)
|
||||
df["path_json"] = df.path.map(json.dumps)
|
||||
df["polyline"] = df.path.map(polyline.encode)
|
||||
|
||||
@@ -57,7 +57,7 @@ def gen_filter(
|
||||
|
||||
|
||||
def load_data(tbl_name: str, database: Database, sample: bool = False) -> None:
|
||||
pdf = read_example_data("birth_names2.json.gz", compression="gzip")
|
||||
pdf = read_example_data("examples://birth_names2.json.gz", compression="gzip")
|
||||
|
||||
# TODO(bkyryliuk): move load examples data into the pytest fixture
|
||||
if database.backend == "presto":
|
||||
@@ -584,8 +584,8 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
|
||||
}
|
||||
}"""
|
||||
)
|
||||
# pylint: disable=echarts_timeseries_line-too-long
|
||||
pos = json.loads(
|
||||
# pylint: disable=line-too-long
|
||||
pos = json.loads( # noqa: TID251
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
{
|
||||
@@ -859,11 +859,11 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
|
||||
""" # noqa: E501
|
||||
)
|
||||
)
|
||||
# pylint: enable=echarts_timeseries_line-too-long
|
||||
# pylint: enable=line-too-long
|
||||
# dashboard v2 doesn't allow add markup slice
|
||||
dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
|
||||
update_slice_ids(pos)
|
||||
dash.dashboard_title = "USA Births Names"
|
||||
dash.position_json = json.dumps(pos, indent=4)
|
||||
dash.position_json = json.dumps(pos, indent=4) # noqa: TID251
|
||||
dash.slug = "births"
|
||||
return dash
|
||||
|
||||
@@ -1490,4 +1490,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://github.com/apache-superset/examples-data/raw/master/datasets/examples/fcc_survey_2018.csv.gz
|
||||
data: examples://datasets/examples/fcc_survey_2018.csv.gz
|
||||
|
||||
@@ -60,4 +60,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channel_members.csv
|
||||
data: examples://datasets/examples/slack/channel_members.csv
|
||||
|
||||
@@ -360,4 +360,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channels.csv
|
||||
data: examples://datasets/examples/slack/channels.csv
|
||||
|
||||
@@ -344,4 +344,4 @@ columns:
|
||||
extra: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/sales.csv
|
||||
data: examples://datasets/examples/sales.csv
|
||||
|
||||
@@ -204,4 +204,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/covid_vaccines.csv
|
||||
data: examples://datasets/examples/covid_vaccines.csv
|
||||
|
||||
@@ -260,4 +260,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/exported_stats.csv
|
||||
data: examples://datasets/examples/slack/exported_stats.csv
|
||||
|
||||
@@ -480,4 +480,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/messages.csv
|
||||
data: examples://datasets/examples/slack/messages.csv
|
||||
|
||||
@@ -180,4 +180,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/threads.csv
|
||||
data: examples://datasets/examples/slack/threads.csv
|
||||
|
||||
@@ -90,4 +90,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/unicode_test.csv
|
||||
data: examples://datasets/examples/unicode_test.csv
|
||||
|
||||
@@ -220,4 +220,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users.csv
|
||||
data: examples://datasets/examples/slack/users.csv
|
||||
|
||||
@@ -60,4 +60,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users_channels.csv
|
||||
data: examples://datasets/examples/slack/users_channels.csv
|
||||
|
||||
@@ -153,4 +153,4 @@ columns:
|
||||
python_date_format: null
|
||||
version: 1.0.0
|
||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||
data: https://github.com/apache-superset/examples-data/raw/lowercase_columns_examples/datasets/examples/video_game_sales.csv
|
||||
data: examples://datasets/examples/video_game_sales.csv
|
||||
|
||||
@@ -49,7 +49,7 @@ def load_country_map_data(only_metadata: bool = False, force: bool = False) -> N
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
data = read_example_data(
|
||||
"birth_france_data_for_country_map.csv", encoding="utf-8"
|
||||
"examples://birth_france_data_for_country_map.csv", encoding="utf-8"
|
||||
)
|
||||
data["dttm"] = datetime.datetime.now().date()
|
||||
data.to_sql(
|
||||
|
||||
@@ -50,7 +50,7 @@ def load_energy(
|
||||
table_exists = database.has_table(Table(tbl_name, schema))
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data("energy.json.gz", compression="gzip")
|
||||
pdf = read_example_data("examples://energy.json.gz", compression="gzip")
|
||||
pdf = pdf.head(100) if sample else pdf
|
||||
pdf.to_sql(
|
||||
tbl_name,
|
||||
|
||||
@@ -38,12 +38,12 @@ def load_flights(only_metadata: bool = False, force: bool = False) -> None:
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data(
|
||||
"flight_data.csv.gz", encoding="latin-1", compression="gzip"
|
||||
"examples://flight_data.csv.gz", encoding="latin-1", compression="gzip"
|
||||
)
|
||||
|
||||
# Loading airports info to join and get lat/long
|
||||
airports = read_example_data(
|
||||
"airports.csv.gz", encoding="latin-1", compression="gzip"
|
||||
"examples://airports.csv.gz", encoding="latin-1", compression="gzip"
|
||||
)
|
||||
airports = airports.set_index("IATA_CODE")
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils import json
|
||||
|
||||
EXAMPLES_PROTOCOL = "examples://"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public sample‑data mirror configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,6 +127,20 @@ def get_example_url(filepath: str) -> str:
|
||||
return f"{BASE_URL}{filepath}"
|
||||
|
||||
|
||||
def normalize_example_data_url(url: str) -> str:
|
||||
"""Convert example data URLs to use the configured CDN.
|
||||
|
||||
Transforms examples:// URLs to the configured CDN URL.
|
||||
Non-example URLs are returned unchanged.
|
||||
"""
|
||||
if url.startswith(EXAMPLES_PROTOCOL):
|
||||
relative_path = url[len(EXAMPLES_PROTOCOL) :]
|
||||
return get_example_url(relative_path)
|
||||
|
||||
# Not an examples URL, return unchanged
|
||||
return url
|
||||
|
||||
|
||||
def read_example_data(
|
||||
filepath: str,
|
||||
max_attempts: int = 5,
|
||||
@@ -132,9 +148,7 @@ def read_example_data(
|
||||
**kwargs: Any,
|
||||
) -> pd.DataFrame:
|
||||
"""Load CSV or JSON from example data mirror with retry/backoff."""
|
||||
from superset.examples.helpers import get_example_url
|
||||
|
||||
url = get_example_url(filepath)
|
||||
url = normalize_example_data_url(filepath)
|
||||
is_json = filepath.endswith(".json") or filepath.endswith(".json.gz")
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
|
||||
@@ -48,7 +48,7 @@ def load_long_lat_data(only_metadata: bool = False, force: bool = False) -> None
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data(
|
||||
"san_francisco.csv.gz", encoding="utf-8", compression="gzip"
|
||||
"examples://san_francisco.csv.gz", encoding="utf-8", compression="gzip"
|
||||
)
|
||||
start = datetime.datetime.now().replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
|
||||
@@ -49,7 +49,7 @@ def load_multiformat_time_series( # pylint: disable=too-many-locals
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data(
|
||||
"multiformat_time_series.json.gz", compression="gzip"
|
||||
"examples://multiformat_time_series.json.gz", compression="gzip"
|
||||
)
|
||||
|
||||
# TODO(bkyryliuk): move load examples data into the pytest fixture
|
||||
|
||||
@@ -37,7 +37,7 @@ def load_paris_iris_geojson(only_metadata: bool = False, force: bool = False) ->
|
||||
table_exists = database.has_table(Table(tbl_name, schema))
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
df = read_example_data("paris_iris.json.gz", compression="gzip")
|
||||
df = read_example_data("examples://paris_iris.json.gz", compression="gzip")
|
||||
df["features"] = df.features.map(json.dumps)
|
||||
|
||||
df.to_sql(
|
||||
|
||||
@@ -46,7 +46,9 @@ def load_random_time_series_data(
|
||||
table_exists = database.has_table(Table(tbl_name, schema))
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data("random_time_series.json.gz", compression="gzip")
|
||||
pdf = read_example_data(
|
||||
"examples://random_time_series.json.gz", compression="gzip"
|
||||
)
|
||||
if database.backend == "presto":
|
||||
pdf.ds = pd.to_datetime(pdf.ds, unit="s")
|
||||
pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")
|
||||
|
||||
@@ -39,7 +39,9 @@ def load_sf_population_polygons(
|
||||
table_exists = database.has_table(Table(tbl_name, schema))
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
df = read_example_data("sf_population.json.gz", compression="gzip")
|
||||
df = read_example_data(
|
||||
"examples://sf_population.json.gz", compression="gzip"
|
||||
)
|
||||
df["contour"] = df.contour.map(json.dumps)
|
||||
|
||||
df.to_sql(
|
||||
|
||||
@@ -55,7 +55,7 @@ def load_world_bank_health_n_pop( # pylint: disable=too-many-locals
|
||||
table_exists = database.has_table(Table(tbl_name, schema))
|
||||
|
||||
if not only_metadata and (not table_exists or force):
|
||||
pdf = read_example_data("countries.json.gz", compression="gzip")
|
||||
pdf = read_example_data("examples://countries.json.gz", compression="gzip")
|
||||
pdf.columns = [col.replace(".", "_") for col in pdf.columns]
|
||||
if database.backend == "presto":
|
||||
pdf.year = pd.to_datetime(pdf.year)
|
||||
|
||||
@@ -34,6 +34,7 @@ from flask_appbuilder.utils.base import get_safe_redirect
|
||||
from flask_babel import lazy_gettext as _, refresh
|
||||
from flask_compress import Compress
|
||||
from flask_session import Session
|
||||
from sqlalchemy import inspect
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from superset.constants import CHANGE_ME_SECRET_KEY
|
||||
@@ -470,6 +471,31 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
icon="fa-lock",
|
||||
)
|
||||
|
||||
def _init_database_dependent_features(self) -> None:
|
||||
"""
|
||||
Initialize features that require database tables to exist.
|
||||
This is called during app initialization but checks table existence
|
||||
to handle cases where the app starts before database migration.
|
||||
"""
|
||||
inspector = inspect(db.engine)
|
||||
|
||||
# Check if core tables exist (use 'dashboards' as proxy for Superset tables)
|
||||
if not inspector.has_table("dashboards"):
|
||||
logger.debug(
|
||||
"Superset tables not yet created. Skipping database-dependent "
|
||||
"initialization. These features will be initialized after migration."
|
||||
)
|
||||
return
|
||||
|
||||
# Register SQLA event listeners for tagging system
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
register_sqla_event_listeners()
|
||||
|
||||
# Seed system themes from configuration
|
||||
from superset.commands.theme.seed import SeedSystemThemesCommand
|
||||
|
||||
SeedSystemThemesCommand().run()
|
||||
|
||||
def init_app_in_ctx(self) -> None:
|
||||
"""
|
||||
Runs init logic in the context of the app
|
||||
@@ -487,16 +513,8 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
|
||||
flask_app_mutator(self.superset_app)
|
||||
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
register_sqla_event_listeners()
|
||||
|
||||
# Seed system themes from configuration
|
||||
try:
|
||||
from superset.commands.theme.seed import SeedSystemThemesCommand
|
||||
|
||||
SeedSystemThemesCommand().run()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed system themes")
|
||||
# Initialize database-dependent features only if database is ready
|
||||
self._init_database_dependent_features()
|
||||
|
||||
self.init_views()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -287,3 +287,412 @@ 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"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"supports_cross_catalog,table_name,catalog,schema,expected_name,expected_schema",
|
||||
[
|
||||
# Database supports cross-catalog queries (like BigQuery)
|
||||
(
|
||||
True,
|
||||
"test_table",
|
||||
"test_project",
|
||||
"test_dataset",
|
||||
"test_project.test_dataset.test_table",
|
||||
None,
|
||||
),
|
||||
# Database supports cross-catalog queries, catalog only (no schema)
|
||||
(
|
||||
True,
|
||||
"test_table",
|
||||
"test_project",
|
||||
None,
|
||||
"test_project.test_table",
|
||||
None,
|
||||
),
|
||||
# Database supports cross-catalog queries, schema only (no catalog)
|
||||
(
|
||||
True,
|
||||
"test_table",
|
||||
None,
|
||||
"test_schema",
|
||||
"test_table",
|
||||
"test_schema",
|
||||
),
|
||||
# Database supports cross-catalog queries, no catalog or schema
|
||||
(
|
||||
True,
|
||||
"test_table",
|
||||
None,
|
||||
None,
|
||||
"test_table",
|
||||
None,
|
||||
),
|
||||
# Database doesn't support cross-catalog queries, catalog ignored
|
||||
(
|
||||
False,
|
||||
"test_table",
|
||||
"test_catalog",
|
||||
"test_schema",
|
||||
"test_table",
|
||||
"test_schema",
|
||||
),
|
||||
# Database doesn't support cross-catalog queries, no schema
|
||||
(
|
||||
False,
|
||||
"test_table",
|
||||
"test_catalog",
|
||||
None,
|
||||
"test_table",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_sqla_table_with_catalog(
|
||||
mocker: MockerFixture,
|
||||
supports_cross_catalog: bool,
|
||||
table_name: str,
|
||||
catalog: str | None,
|
||||
schema: str | None,
|
||||
expected_name: str,
|
||||
expected_schema: str | None,
|
||||
) -> None:
|
||||
"""Test that get_sqla_table handles catalog inclusion correctly based on
|
||||
database cross-catalog support
|
||||
"""
|
||||
# Mock database with specified cross-catalog support
|
||||
database = mocker.MagicMock()
|
||||
database.db_engine_spec.supports_cross_catalog_queries = supports_cross_catalog
|
||||
|
||||
# Create table with specified parameters
|
||||
table = SqlaTable(
|
||||
table_name=table_name,
|
||||
database=database,
|
||||
schema=schema,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
# Get the SQLAlchemy table representation
|
||||
sqla_table = table.get_sqla_table()
|
||||
|
||||
# Verify expected table name and schema
|
||||
assert sqla_table.name == expected_name
|
||||
assert sqla_table.schema == expected_schema
|
||||
|
||||
@@ -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