mirror of
https://github.com/apache/superset.git
synced 2026-07-18 20:55:47 +00:00
Compare commits
1 Commits
codespaces
...
db-upload-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b51ae8bdc |
@@ -1,5 +0,0 @@
|
|||||||
# 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)**
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/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
|
# 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
|
# 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.
|
# as the full content of these folders don't change, yielding a decent cache reuse rate.
|
||||||
# Note that it's not possible to selectively COPY or mount using blobs.
|
# Note that's it's not possible selectively COPY of mount using blobs.
|
||||||
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
|
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=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
|
||||||
--mount=type=cache,target=/root/.cache \
|
--mount=type=cache,target=/root/.cache \
|
||||||
|
|||||||
@@ -120,78 +120,6 @@ docker volume rm superset_db_home
|
|||||||
docker-compose up
|
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
|
## Installing Development Tools
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|||||||
32
superset-frontend/package-lock.json
generated
32
superset-frontend/package-lock.json
generated
@@ -53,8 +53,8 @@
|
|||||||
"@visx/scale": "^3.5.0",
|
"@visx/scale": "^3.5.0",
|
||||||
"@visx/tooltip": "^3.0.0",
|
"@visx/tooltip": "^3.0.0",
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "33.1.1",
|
||||||
"ag-grid-react": "34.0.2",
|
"ag-grid-react": "33.1.1",
|
||||||
"antd": "^5.24.6",
|
"antd": "^5.24.6",
|
||||||
"chrono-node": "^2.7.8",
|
"chrono-node": "^2.7.8",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
@@ -18747,27 +18747,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ag-charts-types": {
|
"node_modules/ag-charts-types": {
|
||||||
"version": "12.0.2",
|
"version": "11.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-11.1.1.tgz",
|
||||||
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
|
"integrity": "sha512-bRmUcf5VVhEEekhX8Vk0NSwa8Te8YM/zchjyYKR2CX4vDYiwoohM1Jg9RFvbIhVbLC1S6QrPEbx5v2C6RDfpSA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ag-grid-community": {
|
"node_modules/ag-grid-community": {
|
||||||
"version": "34.0.2",
|
"version": "33.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-33.1.1.tgz",
|
||||||
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
|
"integrity": "sha512-CNubIro0ipj4nfQ5WJPG9Isp7UI6MMDvNzrPdHNf3W+IoM8Uv3RUhjEn7xQqpQHuu6o/tMjrqpacipMUkhzqnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-charts-types": "12.0.2"
|
"ag-charts-types": "11.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ag-grid-react": {
|
"node_modules/ag-grid-react": {
|
||||||
"version": "34.0.2",
|
"version": "33.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.1.1.tgz",
|
||||||
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
|
"integrity": "sha512-xJ+t2gpqUUwpFqAeDvKz/GLVR4unkOghfQBr8iIY9RAdGFarYFClJavsOa8XPVVUqEB9OIuPVFnOdtocbX0jeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-grid-community": "34.0.2",
|
"ag-grid-community": "33.1.1",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -61168,8 +61168,8 @@
|
|||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@types/d3-array": "^2.9.0",
|
"@types/d3-array": "^2.9.0",
|
||||||
"@types/react-table": "^7.7.20",
|
"@types/react-table": "^7.7.20",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "^33.1.1",
|
||||||
"ag-grid-react": "^34.0.2",
|
"ag-grid-react": "^33.1.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"d3-array": "^2.4.0",
|
"d3-array": "^2.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@@ -61205,10 +61205,8 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"@reduxjs/toolkit": "*",
|
|
||||||
"@superset-ui/chart-controls": "*",
|
"@superset-ui/chart-controls": "*",
|
||||||
"@superset-ui/core": "*",
|
"@superset-ui/core": "*",
|
||||||
"@types/react-redux": "*",
|
|
||||||
"geostyler": "^14.1.3",
|
"geostyler": "^14.1.3",
|
||||||
"geostyler-data": "^1.0.0",
|
"geostyler-data": "^1.0.0",
|
||||||
"geostyler-openlayers-parser": "^4.0.0",
|
"geostyler-openlayers-parser": "^4.0.0",
|
||||||
|
|||||||
@@ -121,8 +121,8 @@
|
|||||||
"@visx/scale": "^3.5.0",
|
"@visx/scale": "^3.5.0",
|
||||||
"@visx/tooltip": "^3.0.0",
|
"@visx/tooltip": "^3.0.0",
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "33.1.1",
|
||||||
"ag-grid-react": "34.0.2",
|
"ag-grid-react": "33.1.1",
|
||||||
"antd": "^5.24.6",
|
"antd": "^5.24.6",
|
||||||
"chrono-node": "^2.7.8",
|
"chrono-node": "^2.7.8",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { render } from '@superset-ui/core/spec';
|
import { render } from '@superset-ui/core/spec';
|
||||||
import { TelemetryPixel } from '.';
|
import TelemetryPixel from '.';
|
||||||
|
|
||||||
const OLD_ENV = process.env;
|
const OLD_ENV = process.env;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
|
|||||||
|
|
||||||
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
||||||
|
|
||||||
export const TelemetryPixel = ({
|
const TelemetryPixel = ({
|
||||||
version = 'unknownVersion',
|
version = 'unknownVersion',
|
||||||
sha = 'unknownSHA',
|
sha = 'unknownSHA',
|
||||||
build = 'unknownBuild',
|
build = 'unknownBuild',
|
||||||
@@ -56,3 +56,4 @@ export const TelemetryPixel = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
export default TelemetryPixel;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -1,273 +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 {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,170 +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 { 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,8 +164,6 @@ export * from './Steps';
|
|||||||
export * from './Table';
|
export * from './Table';
|
||||||
export * from './TableView';
|
export * from './TableView';
|
||||||
export * from './Tag';
|
export * from './Tag';
|
||||||
export * from './TelemetryPixel';
|
|
||||||
export * from './ThemeSubMenu';
|
|
||||||
export * from './UnsavedChangesModal';
|
export * from './UnsavedChangesModal';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './Result';
|
export * from './Result';
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ import {
|
|||||||
type ThemeStorage,
|
type ThemeStorage,
|
||||||
type ThemeControllerOptions,
|
type ThemeControllerOptions,
|
||||||
type ThemeContextType,
|
type ThemeContextType,
|
||||||
type SupersetThemeConfig,
|
|
||||||
ThemeAlgorithm,
|
ThemeAlgorithm,
|
||||||
ThemeMode,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -68,16 +66,7 @@ const themeObject: Theme = Theme.fromConfig({
|
|||||||
const { theme } = themeObject;
|
const { theme } = themeObject;
|
||||||
const supersetTheme = theme;
|
const supersetTheme = theme;
|
||||||
|
|
||||||
export {
|
export { Theme, themeObject, styled, theme, supersetTheme };
|
||||||
Theme,
|
|
||||||
ThemeAlgorithm,
|
|
||||||
ThemeMode,
|
|
||||||
themeObject,
|
|
||||||
styled,
|
|
||||||
theme,
|
|
||||||
supersetTheme,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SupersetTheme,
|
SupersetTheme,
|
||||||
SerializableThemeConfig,
|
SerializableThemeConfig,
|
||||||
@@ -85,7 +74,6 @@ export type {
|
|||||||
ThemeStorage,
|
ThemeStorage,
|
||||||
ThemeControllerOptions,
|
ThemeControllerOptions,
|
||||||
ThemeContextType,
|
ThemeContextType,
|
||||||
SupersetThemeConfig,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export theme utility functions
|
// Export theme utility functions
|
||||||
|
|||||||
@@ -429,16 +429,3 @@ export interface ThemeContextType {
|
|||||||
canDetectOSPreference: () => boolean;
|
canDetectOSPreference: () => boolean;
|
||||||
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
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",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@types/d3-array": "^2.9.0",
|
"@types/d3-array": "^2.9.0",
|
||||||
"@types/react-table": "^7.7.20",
|
"@types/react-table": "^7.7.20",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "^33.1.1",
|
||||||
"ag-grid-react": "^34.0.2",
|
"ag-grid-react": "^33.1.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"d3-array": "^2.4.0",
|
"d3-array": "^2.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ const parseLabel = value => {
|
|||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
function displayCell(value, allowRenderHtml) {
|
|
||||||
if (allowRenderHtml && typeof value === 'string') {
|
|
||||||
return safeHtmlSpan(value);
|
|
||||||
}
|
|
||||||
return parseLabel(value);
|
|
||||||
}
|
|
||||||
function displayHeaderCell(
|
function displayHeaderCell(
|
||||||
needToggle,
|
needToggle,
|
||||||
ArrowIcon,
|
ArrowIcon,
|
||||||
@@ -748,7 +742,7 @@ export class TableRenderer extends Component {
|
|||||||
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
|
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
{agg.format(aggValue)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -765,7 +759,7 @@ export class TableRenderer extends Component {
|
|||||||
onClick={rowTotalCallbacks[flatRowKey]}
|
onClick={rowTotalCallbacks[flatRowKey]}
|
||||||
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
|
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
|
||||||
>
|
>
|
||||||
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
{agg.format(aggValue)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -829,7 +823,7 @@ export class TableRenderer extends Component {
|
|||||||
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
|
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
|
||||||
style={{ padding: '5px' }}
|
style={{ padding: '5px' }}
|
||||||
>
|
>
|
||||||
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
{agg.format(aggValue)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -846,7 +840,7 @@ export class TableRenderer extends Component {
|
|||||||
onClick={grandTotalCallback}
|
onClick={grandTotalCallback}
|
||||||
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
|
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
|
||||||
>
|
>
|
||||||
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
{agg.format(aggValue)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +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 { 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,27 +21,20 @@ import 'src/public-path';
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||||
import {
|
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
|
||||||
type SupersetThemeConfig,
|
|
||||||
makeApi,
|
|
||||||
t,
|
|
||||||
logging,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import Switchboard from '@superset-ui/switchboard';
|
import Switchboard from '@superset-ui/switchboard';
|
||||||
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
|
||||||
import setupClient from 'src/setup/setupClient';
|
import setupClient from 'src/setup/setupClient';
|
||||||
import setupPlugins from 'src/setup/setupPlugins';
|
import setupPlugins from 'src/setup/setupPlugins';
|
||||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||||
|
import { RootContextProviders } from 'src/views/RootContextProviders';
|
||||||
import { store, USER_LOADED } from 'src/views/store';
|
import { store, USER_LOADED } from 'src/views/store';
|
||||||
import { Loading } from '@superset-ui/core/components';
|
import { Loading } from '@superset-ui/core/components';
|
||||||
import { ErrorBoundary } from 'src/components';
|
import { ErrorBoundary } from 'src/components';
|
||||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||||
import {
|
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
|
||||||
EmbeddedContextProviders,
|
|
||||||
getThemeController,
|
|
||||||
} from './EmbeddedContextProviders';
|
|
||||||
import { embeddedApi } from './api';
|
import { embeddedApi } from './api';
|
||||||
import { getDataMaskChangeTrigger } from './utils';
|
import { getDataMaskChangeTrigger } from './utils';
|
||||||
|
|
||||||
@@ -51,7 +44,9 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
|
|||||||
const bootstrapData = getBootstrapData();
|
const bootstrapData = getBootstrapData();
|
||||||
|
|
||||||
function log(...info: unknown[]) {
|
function log(...info: unknown[]) {
|
||||||
if (debugMode) logging.debug(`[superset]`, ...info);
|
if (debugMode) {
|
||||||
|
logging.debug(`[superset]`, ...info);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LazyDashboardPage = lazy(
|
const LazyDashboardPage = lazy(
|
||||||
@@ -90,12 +85,12 @@ const EmbededLazyDashboardPage = () => {
|
|||||||
|
|
||||||
const EmbeddedRoute = () => (
|
const EmbeddedRoute = () => (
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<EmbeddedContextProviders>
|
<RootContextProviders>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<EmbededLazyDashboardPage />
|
<EmbededLazyDashboardPage />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<ToastContainer position="top" />
|
<ToastContainer position="top" />
|
||||||
</EmbeddedContextProviders>
|
</RootContextProviders>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -250,13 +245,12 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
|||||||
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
||||||
Switchboard.defineMethod(
|
Switchboard.defineMethod(
|
||||||
'setThemeConfig',
|
'setThemeConfig',
|
||||||
(payload: { themeConfig: SupersetThemeConfig }) => {
|
(payload: { themeConfig: AnyThemeConfig }) => {
|
||||||
const { themeConfig } = payload;
|
const { themeConfig } = payload;
|
||||||
log('Received setThemeConfig request:', themeConfig);
|
log('Received setThemeConfig request:', themeConfig);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const themeController = getThemeController();
|
themeObject.setConfig(themeConfig);
|
||||||
themeController.setThemeConfig(themeConfig);
|
|
||||||
return { success: true, message: 'Theme applied' };
|
return { success: true, message: 'Theme applied' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logging.error('Failed to apply theme config:', error);
|
logging.error('Failed to apply theme config:', error);
|
||||||
@@ -264,22 +258,8 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Switchboard.start();
|
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');
|
log('embed page is ready to receive messages');
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ import {
|
|||||||
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
|
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
|
||||||
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
|
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();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
class MainPreset extends Preset {
|
class MainPreset extends Preset {
|
||||||
@@ -259,22 +256,4 @@ describe('VizTypeControl', () => {
|
|||||||
|
|
||||||
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
|
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,13 +575,6 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
|
|||||||
setIsSearchFocused(true);
|
setIsSearchFocused(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-focus the search input when the modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchInputRef.current) {
|
|
||||||
searchInputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
|
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
event => setSearchInputValue(event.target.value),
|
event => setSearchInputValue(event.target.value),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const ExtraOptions = ({
|
|||||||
onExtraInputChange,
|
onExtraInputChange,
|
||||||
onExtraEditorChange,
|
onExtraEditorChange,
|
||||||
extraExtension,
|
extraExtension,
|
||||||
|
testedEngineInfo,
|
||||||
}: {
|
}: {
|
||||||
db: DatabaseObject | null;
|
db: DatabaseObject | null;
|
||||||
onInputChange: (
|
onInputChange: (
|
||||||
@@ -62,13 +63,14 @@ const ExtraOptions = ({
|
|||||||
) => void;
|
) => void;
|
||||||
onExtraEditorChange: Function;
|
onExtraEditorChange: Function;
|
||||||
extraExtension: DatabaseConnectionExtension | undefined;
|
extraExtension: DatabaseConnectionExtension | undefined;
|
||||||
|
testedEngineInfo?: any;
|
||||||
}) => {
|
}) => {
|
||||||
const expandableModalIsOpen = !!db?.expose_in_sqllab;
|
const expandableModalIsOpen = !!db?.expose_in_sqllab;
|
||||||
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
|
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
|
||||||
const isFileUploadSupportedByEngine =
|
// Use tested engine info if available, otherwise fall back to initial engine info
|
||||||
db?.engine_information?.supports_file_upload;
|
const engineInfo = testedEngineInfo || db?.engine_information;
|
||||||
const supportsDynamicCatalog =
|
const isFileUploadSupportedByEngine = engineInfo?.supports_file_upload;
|
||||||
db?.engine_information?.supports_dynamic_catalog;
|
const supportsDynamicCatalog = engineInfo?.supports_dynamic_catalog;
|
||||||
|
|
||||||
// JSON.parse will deep parse engine_params
|
// JSON.parse will deep parse engine_params
|
||||||
// if it's an object, and we want to keep it a string
|
// if it's an object, and we want to keep it a string
|
||||||
@@ -528,23 +530,29 @@ const ExtraOptions = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</StyledInputContainer>
|
</StyledInputContainer>
|
||||||
{isFileUploadSupportedByEngine && (
|
<StyledInputContainer
|
||||||
<StyledInputContainer
|
css={!db?.allow_file_upload ? no_margin_bottom : {}}
|
||||||
css={!db?.allow_file_upload ? no_margin_bottom : {}}
|
>
|
||||||
>
|
<div className="input-container">
|
||||||
<div className="input-container">
|
<Checkbox
|
||||||
<Checkbox
|
id="allow_file_upload"
|
||||||
id="allow_file_upload"
|
name="allow_file_upload"
|
||||||
name="allow_file_upload"
|
indeterminate={false}
|
||||||
indeterminate={false}
|
checked={!!db?.allow_file_upload}
|
||||||
checked={!!db?.allow_file_upload}
|
disabled={!isFileUploadSupportedByEngine}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
>
|
>
|
||||||
{t('Allow file uploads to database')}
|
{t('Allow file uploads to database')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
{!isFileUploadSupportedByEngine && (
|
||||||
</StyledInputContainer>
|
<InfoTooltip
|
||||||
)}
|
tooltip={t(
|
||||||
|
'File upload is not supported for this database engine'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</StyledInputContainer>
|
||||||
{isFileUploadSupportedByEngine && !!db?.allow_file_upload && (
|
{isFileUploadSupportedByEngine && !!db?.allow_file_upload && (
|
||||||
<StyledInputContainer css={no_margin_bottom}>
|
<StyledInputContainer css={no_margin_bottom}>
|
||||||
<div className="control-label">
|
<div className="control-label">
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|||||||
const [editNewDb, setEditNewDb] = useState<boolean>(false);
|
const [editNewDb, setEditNewDb] = useState<boolean>(false);
|
||||||
const [isLoading, setLoading] = useState<boolean>(false);
|
const [isLoading, setLoading] = useState<boolean>(false);
|
||||||
const [testInProgress, setTestInProgress] = useState<boolean>(false);
|
const [testInProgress, setTestInProgress] = useState<boolean>(false);
|
||||||
|
const [testedEngineInfo, setTestedEngineInfo] = useState<any>(null);
|
||||||
const [passwords, setPasswords] = useState<Record<string, string>>({});
|
const [passwords, setPasswords] = useState<Record<string, string>>({});
|
||||||
const [sshTunnelPasswords, setSSHTunnelPasswords] = useState<
|
const [sshTunnelPasswords, setSSHTunnelPasswords] = useState<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
@@ -735,6 +736,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|||||||
addSuccessToast(errorMsg);
|
addSuccessToast(errorMsg);
|
||||||
setHasValidated(true);
|
setHasValidated(true);
|
||||||
},
|
},
|
||||||
|
(engineInfo: any) => {
|
||||||
|
setTestedEngineInfo(engineInfo);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -797,6 +801,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|||||||
setSSHTunnelPrivateKeyPasswords({});
|
setSSHTunnelPrivateKeyPasswords({});
|
||||||
setConfirmedOverwrite(false);
|
setConfirmedOverwrite(false);
|
||||||
setUseSSHTunneling(undefined);
|
setUseSSHTunneling(undefined);
|
||||||
|
setTestedEngineInfo(null);
|
||||||
onHide();
|
onHide();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1771,6 +1776,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|||||||
<ExtraOptions
|
<ExtraOptions
|
||||||
extraExtension={dbConfigExtraExtension}
|
extraExtension={dbConfigExtraExtension}
|
||||||
db={db as DatabaseObject}
|
db={db as DatabaseObject}
|
||||||
|
testedEngineInfo={testedEngineInfo}
|
||||||
onInputChange={(
|
onInputChange={(
|
||||||
e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>,
|
e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -2020,6 +2026,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|||||||
<ExtraOptions
|
<ExtraOptions
|
||||||
extraExtension={dbConfigExtraExtension}
|
extraExtension={dbConfigExtraExtension}
|
||||||
db={db as DatabaseObject}
|
db={db as DatabaseObject}
|
||||||
|
testedEngineInfo={testedEngineInfo}
|
||||||
onInputChange={(e: CheckboxChangeEvent) => {
|
onInputChange={(e: CheckboxChangeEvent) => {
|
||||||
const { target } = e;
|
const { target } = e;
|
||||||
onChange(ActionType.InputChange, {
|
onChange(ActionType.InputChange, {
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { Fragment, useState, useEffect, FC, PureComponent } from 'react';
|
import { Fragment, useState, useEffect, FC, PureComponent } from 'react';
|
||||||
|
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQueryParams, BooleanParam } from 'use-query-params';
|
import { useQueryParams, BooleanParam } from 'use-query-params';
|
||||||
import { get, isEmpty } from 'lodash';
|
import { get, isEmpty } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
t,
|
t,
|
||||||
styled,
|
styled,
|
||||||
@@ -31,15 +33,10 @@ import {
|
|||||||
getExtensionsRegistry,
|
getExtensionsRegistry,
|
||||||
useTheme,
|
useTheme,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import {
|
import { Menu } from '@superset-ui/core/components/Menu';
|
||||||
Label,
|
import { Label, Tooltip } from '@superset-ui/core/components';
|
||||||
Tooltip,
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
ThemeSubMenu,
|
import { Typography } from '@superset-ui/core/components/Typography';
|
||||||
Menu,
|
|
||||||
Icons,
|
|
||||||
Typography,
|
|
||||||
TelemetryPixel,
|
|
||||||
} from '@superset-ui/core/components';
|
|
||||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||||
import { findPermission } from 'src/utils/findPermission';
|
import { findPermission } from 'src/utils/findPermission';
|
||||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||||
@@ -52,7 +49,9 @@ import { RootState } from 'src/dashboard/types';
|
|||||||
import DatabaseModal from 'src/features/databases/DatabaseModal';
|
import DatabaseModal from 'src/features/databases/DatabaseModal';
|
||||||
import UploadDataModal from 'src/features/databases/UploadDataModel';
|
import UploadDataModal from 'src/features/databases/UploadDataModel';
|
||||||
import { uploadUserPerms } from 'src/views/CRUD/utils';
|
import { uploadUserPerms } from 'src/views/CRUD/utils';
|
||||||
|
import TelemetryPixel from '@superset-ui/core/components/TelemetryPixel';
|
||||||
import { useThemeContext } from 'src/theme/ThemeProvider';
|
import { useThemeContext } from 'src/theme/ThemeProvider';
|
||||||
|
import ThemeSelect from '@superset-ui/core/components/ThemeSelect';
|
||||||
import LanguagePicker from './LanguagePicker';
|
import LanguagePicker from './LanguagePicker';
|
||||||
import {
|
import {
|
||||||
ExtensionConfigs,
|
ExtensionConfigs,
|
||||||
@@ -139,7 +138,6 @@ const RightMenu = ({
|
|||||||
datasetAdded?: boolean;
|
datasetAdded?: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
|
||||||
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
const user = useSelector<any, UserWithPermissionsAndRoles>(
|
||||||
state => state.user,
|
state => state.user,
|
||||||
);
|
);
|
||||||
@@ -373,6 +371,7 @@ const RightMenu = ({
|
|||||||
localStorage.removeItem('redux');
|
localStorage.removeItem('redux');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
<StyledDiv align={align}>
|
<StyledDiv align={align}>
|
||||||
{canDatabase && (
|
{canDatabase && (
|
||||||
@@ -494,15 +493,16 @@ const RightMenu = ({
|
|||||||
})}
|
})}
|
||||||
</StyledSubMenu>
|
</StyledSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canSetMode() && (
|
{canSetMode() && (
|
||||||
<ThemeSubMenu
|
<span>
|
||||||
setThemeMode={setThemeMode}
|
<ThemeSelect
|
||||||
themeMode={themeMode}
|
setThemeMode={setThemeMode}
|
||||||
hasLocalOverride={hasDevOverride()}
|
themeMode={themeMode}
|
||||||
onClearLocalSettings={clearLocalOverrides}
|
hasLocalOverride={hasDevOverride()}
|
||||||
allowOSPreference={canDetectOSPreference()}
|
onClearLocalSettings={clearLocalOverrides}
|
||||||
/>
|
allowOSPreference={canDetectOSPreference()}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<StyledSubMenu
|
<StyledSubMenu
|
||||||
|
|||||||
@@ -17,15 +17,13 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type AnyThemeConfig,
|
|
||||||
type SupersetTheme,
|
|
||||||
type SupersetThemeConfig,
|
|
||||||
type ThemeControllerOptions,
|
|
||||||
type ThemeStorage,
|
|
||||||
Theme,
|
Theme,
|
||||||
ThemeMode,
|
AnyThemeConfig,
|
||||||
|
ThemeStorage,
|
||||||
|
ThemeControllerOptions,
|
||||||
themeObject as supersetThemeObject,
|
themeObject as supersetThemeObject,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
|
||||||
import {
|
import {
|
||||||
getAntdConfig,
|
getAntdConfig,
|
||||||
normalizeThemeConfig,
|
normalizeThemeConfig,
|
||||||
@@ -96,7 +94,7 @@ export class ThemeController {
|
|||||||
|
|
||||||
private currentMode: ThemeMode;
|
private currentMode: ThemeMode;
|
||||||
|
|
||||||
private hasCustomThemes: boolean;
|
private readonly hasBootstrapThemes: boolean;
|
||||||
|
|
||||||
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
|
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
|
||||||
|
|
||||||
@@ -111,13 +109,15 @@ export class ThemeController {
|
|||||||
|
|
||||||
private dashboardCrudTheme: AnyThemeConfig | null = null;
|
private dashboardCrudTheme: AnyThemeConfig | null = null;
|
||||||
|
|
||||||
constructor({
|
constructor(options: ThemeControllerOptions = {}) {
|
||||||
storage = new LocalStorageAdapter(),
|
const {
|
||||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
storage = new LocalStorageAdapter(),
|
||||||
themeObject = supersetThemeObject,
|
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
themeObject = supersetThemeObject,
|
||||||
onChange = undefined,
|
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||||
}: ThemeControllerOptions = {}) {
|
onChange = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.modeStorageKey = modeStorageKey;
|
this.modeStorageKey = modeStorageKey;
|
||||||
|
|
||||||
@@ -129,14 +129,14 @@ export class ThemeController {
|
|||||||
bootstrapDefaultTheme,
|
bootstrapDefaultTheme,
|
||||||
bootstrapDarkTheme,
|
bootstrapDarkTheme,
|
||||||
bootstrapThemeSettings,
|
bootstrapThemeSettings,
|
||||||
hasCustomThemes,
|
hasBootstrapThemes,
|
||||||
}: BootstrapThemeData = this.loadBootstrapData();
|
}: BootstrapThemeData = this.loadBootstrapData();
|
||||||
|
|
||||||
this.hasCustomThemes = hasCustomThemes;
|
this.hasBootstrapThemes = hasBootstrapThemes;
|
||||||
this.themeSettings = bootstrapThemeSettings || {};
|
this.themeSettings = bootstrapThemeSettings || {};
|
||||||
|
|
||||||
// Set themes based on bootstrap data availability
|
// Set themes based on bootstrap data availability
|
||||||
if (this.hasCustomThemes) {
|
if (this.hasBootstrapThemes) {
|
||||||
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
|
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
|
||||||
this.defaultTheme =
|
this.defaultTheme =
|
||||||
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
|
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
|
||||||
@@ -424,42 +424,6 @@ export class ThemeController {
|
|||||||
return allowOSPreference === true;
|
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.
|
* Handles system theme changes with error recovery.
|
||||||
*/
|
*/
|
||||||
@@ -583,7 +547,7 @@ export class ThemeController {
|
|||||||
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
|
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
|
||||||
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
|
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
|
||||||
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
|
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
|
||||||
hasCustomThemes: hasValidDefault || hasValidDark,
|
hasBootstrapThemes: hasValidDefault || hasValidDark,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +607,7 @@ export class ThemeController {
|
|||||||
resolvedMode = ThemeController.getSystemPreferredMode();
|
resolvedMode = ThemeController.getSystemPreferredMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.hasCustomThemes) {
|
if (!this.hasBootstrapThemes) {
|
||||||
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
|
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
|
||||||
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
|
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,8 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import { Theme, AnyThemeConfig, ThemeContextType } from '@superset-ui/core';
|
||||||
type AnyThemeConfig,
|
import { ThemeMode } from '@superset-ui/core/theme/types';
|
||||||
type ThemeContextType,
|
|
||||||
Theme,
|
|
||||||
ThemeMode,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import { ThemeController } from './ThemeController';
|
import { ThemeController } from './ThemeController';
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||||
|
|||||||
@@ -17,17 +17,12 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { theme as antdThemeImport } from 'antd';
|
import { theme as antdThemeImport } from 'antd';
|
||||||
import {
|
import { Theme } from '@superset-ui/core';
|
||||||
type AnyThemeConfig,
|
|
||||||
type SupersetThemeConfig,
|
|
||||||
Theme,
|
|
||||||
ThemeAlgorithm,
|
|
||||||
ThemeMode,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import type {
|
import type {
|
||||||
BootstrapThemeDataConfig,
|
BootstrapThemeDataConfig,
|
||||||
CommonBootstrapData,
|
CommonBootstrapData,
|
||||||
} from 'src/types/bootstrapTypes';
|
} from 'src/types/bootstrapTypes';
|
||||||
|
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
|
||||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||||
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
|
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
|
||||||
|
|
||||||
@@ -48,7 +43,7 @@ const mockThemeFromConfig = jest.fn();
|
|||||||
const mockSetConfig = jest.fn();
|
const mockSetConfig = jest.fn();
|
||||||
|
|
||||||
// Mock data constants
|
// Mock data constants
|
||||||
const DEFAULT_THEME: AnyThemeConfig = {
|
const DEFAULT_THEME = {
|
||||||
token: {
|
token: {
|
||||||
colorBgBase: '#ededed',
|
colorBgBase: '#ededed',
|
||||||
colorTextBase: '#120f0f',
|
colorTextBase: '#120f0f',
|
||||||
@@ -60,7 +55,7 @@ const DEFAULT_THEME: AnyThemeConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DARK_THEME: AnyThemeConfig = {
|
const DARK_THEME = {
|
||||||
token: {
|
token: {
|
||||||
colorBgBase: '#141118',
|
colorBgBase: '#141118',
|
||||||
colorTextBase: '#fdc7c7',
|
colorTextBase: '#fdc7c7',
|
||||||
@@ -70,7 +65,7 @@ const DARK_THEME: AnyThemeConfig = {
|
|||||||
colorSuccess: '#3c7c1b',
|
colorSuccess: '#3c7c1b',
|
||||||
colorWarning: '#dc9811',
|
colorWarning: '#dc9811',
|
||||||
},
|
},
|
||||||
algorithm: ThemeAlgorithm.DARK,
|
algorithm: ThemeMode.DARK,
|
||||||
};
|
};
|
||||||
|
|
||||||
const THEME_SETTINGS = {
|
const THEME_SETTINGS = {
|
||||||
@@ -1054,298 +1049,4 @@ 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,7 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core';
|
import { Theme } from '@superset-ui/core';
|
||||||
|
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
|
||||||
import { act, render, screen } from '@superset-ui/core/spec';
|
import { act, render, screen } from '@superset-ui/core/spec';
|
||||||
import { renderHook } from '@testing-library/react-hooks';
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';
|
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import {
|
||||||
|
ColorSchemeConfig,
|
||||||
|
FeatureFlagMap,
|
||||||
|
JsonObject,
|
||||||
|
LanguagePack,
|
||||||
|
Locale,
|
||||||
|
SequentialSchemeConfig,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { FormatLocaleDefinition } from 'd3-format';
|
import { FormatLocaleDefinition } from 'd3-format';
|
||||||
import { TimeLocaleDefinition } from 'd3-time-format';
|
import { TimeLocaleDefinition } from 'd3-time-format';
|
||||||
import { isPlainObject } from 'lodash';
|
import { isPlainObject } from 'lodash';
|
||||||
@@ -23,14 +31,8 @@ import { Languages } from 'src/features/home/LanguagePicker';
|
|||||||
import type { FlashMessage } from 'src/components';
|
import type { FlashMessage } from 'src/components';
|
||||||
import type {
|
import type {
|
||||||
AnyThemeConfig,
|
AnyThemeConfig,
|
||||||
ColorSchemeConfig,
|
|
||||||
FeatureFlagMap,
|
|
||||||
JsonObject,
|
|
||||||
LanguagePack,
|
|
||||||
Locale,
|
|
||||||
SequentialSchemeConfig,
|
|
||||||
SerializableThemeConfig,
|
SerializableThemeConfig,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core/theme/types';
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
createdOn?: string;
|
createdOn?: string;
|
||||||
@@ -187,7 +189,7 @@ export interface BootstrapThemeData {
|
|||||||
bootstrapDefaultTheme: AnyThemeConfig | null;
|
bootstrapDefaultTheme: AnyThemeConfig | null;
|
||||||
bootstrapDarkTheme: AnyThemeConfig | null;
|
bootstrapDarkTheme: AnyThemeConfig | null;
|
||||||
bootstrapThemeSettings: SerializableThemeSettings | null;
|
bootstrapThemeSettings: SerializableThemeSettings | null;
|
||||||
hasCustomThemes: boolean;
|
hasBootstrapThemes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUser(user: any): user is User {
|
export function isUser(user: any): user is User {
|
||||||
|
|||||||
@@ -712,14 +712,18 @@ export const testDatabaseConnection = (
|
|||||||
connection: Partial<DatabaseObject>,
|
connection: Partial<DatabaseObject>,
|
||||||
handleErrorMsg: (errorMsg: string) => void,
|
handleErrorMsg: (errorMsg: string) => void,
|
||||||
addSuccessToast: (arg0: string) => void,
|
addSuccessToast: (arg0: string) => void,
|
||||||
|
onEngineInfo?: (engineInfo: any) => void,
|
||||||
) => {
|
) => {
|
||||||
SupersetClient.post({
|
SupersetClient.post({
|
||||||
endpoint: 'api/v1/database/test_connection/',
|
endpoint: 'api/v1/database/test_connection/',
|
||||||
body: JSON.stringify(connection),
|
body: JSON.stringify(connection),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}).then(
|
}).then(
|
||||||
() => {
|
(response) => {
|
||||||
addSuccessToast(t('Connection looks good!'));
|
addSuccessToast(t('Connection looks good!'));
|
||||||
|
if (onEngineInfo && response?.json?.engine_information) {
|
||||||
|
onEngineInfo(response.json.engine_information);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
createErrorHandler((errMsg: Record<string, string[] | string> | string) => {
|
createErrorHandler((errMsg: Record<string, string[] | string> | string) => {
|
||||||
handleErrorMsg(t('ERROR: %s', parsedErrorMessage(errMsg)));
|
handleErrorMsg(t('ERROR: %s', parsedErrorMessage(errMsg)));
|
||||||
|
|||||||
29
superset-websocket/package-lock.json
generated
29
superset-websocket/package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^0.7.0",
|
||||||
"hot-shots": "^11.1.0",
|
"hot-shots": "^11.1.0",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.1",
|
"@eslint/js": "^9.25.1",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/ioredis": "^4.27.8",
|
"@types/ioredis": "^4.27.8",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -1720,6 +1721,12 @@
|
|||||||
"@babel/types": "^7.3.0"
|
"@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": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -3038,11 +3045,11 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "1.0.2",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
|
||||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/create-jest": {
|
"node_modules/create-jest": {
|
||||||
@@ -8395,6 +8402,12 @@
|
|||||||
"@babel/types": "^7.3.0"
|
"@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": {
|
"@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -9304,9 +9317,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"cookie": {
|
"cookie": {
|
||||||
"version": "1.0.2",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
|
||||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
|
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ=="
|
||||||
},
|
},
|
||||||
"create-jest": {
|
"create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^0.7.0",
|
||||||
"hot-shots": "^11.1.0",
|
"hot-shots": "^11.1.0",
|
||||||
"ioredis": "^5.6.1",
|
"ioredis": "^5.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.1",
|
"@eslint/js": "^9.25.1",
|
||||||
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/eslint__js": "^8.42.3",
|
"@types/eslint__js": "^8.42.3",
|
||||||
"@types/ioredis": "^4.27.8",
|
"@types/ioredis": "^4.27.8",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
"typescript-eslint": "^8.19.0"
|
"typescript-eslint": "^8.19.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.4",
|
"node": "^16.9.1",
|
||||||
"npm": "^10.8.2"
|
"npm": "^7.5.4 || ^8.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { buildConfig } from '../src/config';
|
import { buildConfig } from '../src/config';
|
||||||
import { expect, test } from '@jest/globals';
|
|
||||||
|
|
||||||
test('buildConfig() builds configuration and applies env var overrides', () => {
|
test('buildConfig() builds configuration and applies env var overrides', () => {
|
||||||
let config = buildConfig();
|
let config = buildConfig();
|
||||||
|
|||||||
@@ -19,26 +19,15 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const config = require('../config.test.json');
|
const config = require('../config.test.json');
|
||||||
|
|
||||||
import {
|
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
test,
|
|
||||||
beforeEach,
|
|
||||||
afterEach,
|
|
||||||
jest,
|
|
||||||
} from '@jest/globals';
|
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
interface MockedRedisXrange {
|
|
||||||
(): Promise<server.StreamResult[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: these mock variables needs to start with "mock" due to
|
// NOTE: these mock variables needs to start with "mock" due to
|
||||||
// calls to `jest.mock` being hoisted to the top of the file.
|
// 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
|
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
|
||||||
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
|
const mockRedisXrange = jest.fn();
|
||||||
|
|
||||||
jest.mock('ws');
|
jest.mock('ws');
|
||||||
jest.mock('ioredis', () => {
|
jest.mock('ioredis', () => {
|
||||||
@@ -70,7 +59,7 @@ import * as server from '../src/index';
|
|||||||
import { statsd } from '../src/index';
|
import { statsd } from '../src/index';
|
||||||
|
|
||||||
describe('server', () => {
|
describe('server', () => {
|
||||||
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
|
let statsdIncrementMock: jest.SpyInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRedisXrange.mockClear();
|
mockRedisXrange.mockClear();
|
||||||
@@ -330,12 +319,10 @@ describe('server', () => {
|
|||||||
|
|
||||||
describe('wsConnection', () => {
|
describe('wsConnection', () => {
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
|
let wsEventMock: jest.SpyInstance;
|
||||||
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
|
let trackClientSpy: jest.SpyInstance;
|
||||||
let fetchRangeFromStreamSpy: jest.SpiedFunction<
|
let fetchRangeFromStreamSpy: jest.SpyInstance;
|
||||||
typeof server.fetchRangeFromStream
|
let dateNowSpy: jest.SpyInstance;
|
||||||
>;
|
|
||||||
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
|
|
||||||
let socketInstanceExpected: server.SocketInstance;
|
let socketInstanceExpected: server.SocketInstance;
|
||||||
|
|
||||||
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
||||||
@@ -444,8 +431,8 @@ describe('server', () => {
|
|||||||
|
|
||||||
describe('httpUpgrade', () => {
|
describe('httpUpgrade', () => {
|
||||||
let socket: net.Socket;
|
let socket: net.Socket;
|
||||||
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
|
let socketDestroySpy: jest.SpyInstance;
|
||||||
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
|
let wssUpgradeSpy: jest.SpyInstance;
|
||||||
|
|
||||||
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
const getRequest = (token: string, url: string): http.IncomingMessage => {
|
||||||
const request = new http.IncomingMessage(new net.Socket());
|
const request = new http.IncomingMessage(new net.Socket());
|
||||||
@@ -509,8 +496,8 @@ describe('server', () => {
|
|||||||
|
|
||||||
describe('checkSockets', () => {
|
describe('checkSockets', () => {
|
||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
|
let pingSpy: jest.SpyInstance;
|
||||||
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
|
let terminateSpy: jest.SpyInstance;
|
||||||
let socketInstance: server.SocketInstance;
|
let socketInstance: server.SocketInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import * as net from 'net';
|
|||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import jwt, { Algorithm } from 'jsonwebtoken';
|
import jwt, { Algorithm } from 'jsonwebtoken';
|
||||||
import { parse } from 'cookie';
|
import cookie from 'cookie';
|
||||||
import Redis, { RedisOptions } from 'ioredis';
|
import Redis, { RedisOptions } from 'ioredis';
|
||||||
import StatsD from 'hot-shots';
|
import StatsD from 'hot-shots';
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ export const processStreamResults = (results: StreamResult[]): void => {
|
|||||||
* configured via 'jwtCookieName' in the config.
|
* configured via 'jwtCookieName' in the config.
|
||||||
*/
|
*/
|
||||||
const readChannelId = (request: http.IncomingMessage): string => {
|
const readChannelId = (request: http.IncomingMessage): string => {
|
||||||
const cookies = parse(request.headers.cookie || '');
|
const cookies = cookie.parse(request.headers.cookie || '');
|
||||||
const token = cookies[opts.jwtCookieName];
|
const token = cookies[opts.jwtCookieName];
|
||||||
|
|
||||||
if (!token) throw new Error('JWT not present');
|
if (!token) throw new Error('JWT not present');
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
|||||||
|
|
||||||
def run( # noqa: C901
|
def run( # noqa: C901
|
||||||
self,
|
self,
|
||||||
) -> None: # pylint: disable=too-many-statements,too-many-branches
|
) -> Database: # pylint: disable=too-many-statements,too-many-branches
|
||||||
self.validate()
|
self.validate()
|
||||||
ex_str = ""
|
ex_str = ""
|
||||||
ssh_tunnel = self._properties.get("ssh_tunnel")
|
ssh_tunnel = self._properties.get("ssh_tunnel")
|
||||||
@@ -168,6 +168,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
|||||||
action=get_log_connection_action("test_connection_success", ssh_tunnel),
|
action=get_log_connection_action("test_connection_success", ssh_tunnel),
|
||||||
engine=database.db_engine_spec.__name__,
|
engine=database.db_engine_spec.__name__,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return database
|
||||||
|
|
||||||
except (NoSuchModuleError, ModuleNotFoundError) as ex:
|
except (NoSuchModuleError, ModuleNotFoundError) as ex:
|
||||||
event_logger.log_with_context(
|
event_logger.log_with_context(
|
||||||
|
|||||||
@@ -199,11 +199,6 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
|||||||
:raises DatasetUnAllowedDataURI: If a dataset is trying
|
:raises DatasetUnAllowedDataURI: If a dataset is trying
|
||||||
to load data from a URI that is not allowed.
|
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)
|
validate_data_uri(data_uri)
|
||||||
logger.info("Downloading data from %s", data_uri)
|
logger.info("Downloading data from %s", data_uri)
|
||||||
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||||
|
|||||||
@@ -190,12 +190,6 @@ def load_configs(
|
|||||||
db_ssh_tunnel_priv_key_passws[config["uuid"]]
|
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)
|
schema.load(config)
|
||||||
configs[file_name] = config
|
configs[file_name] = config
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
|
|||||||
@@ -1368,23 +1368,10 @@ class SqlaTable(
|
|||||||
return get_template_processor(table=self, database=self.database, **kwargs)
|
return get_template_processor(table=self, database=self.database, **kwargs)
|
||||||
|
|
||||||
def get_sqla_table(self) -> TableClause:
|
def get_sqla_table(self) -> TableClause:
|
||||||
# For databases that support cross-catalog queries (like BigQuery),
|
tbl = table(self.table_name)
|
||||||
# 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:
|
if self.schema:
|
||||||
return table(self.table_name, schema=self.schema)
|
tbl.schema = self.schema
|
||||||
|
return tbl
|
||||||
return table(self.table_name)
|
|
||||||
|
|
||||||
def get_from_clause(
|
def get_from_clause(
|
||||||
self,
|
self,
|
||||||
@@ -1693,9 +1680,6 @@ class SqlaTable(
|
|||||||
table=self,
|
table=self,
|
||||||
)
|
)
|
||||||
new_column.is_dttm = new_column.is_temporal
|
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)
|
db_engine_spec.alter_new_orm_column(new_column)
|
||||||
else:
|
else:
|
||||||
new_column = old_column
|
new_column = old_column
|
||||||
@@ -1703,9 +1687,6 @@ class SqlaTable(
|
|||||||
results.modified.append(col["column_name"])
|
results.modified.append(col["column_name"])
|
||||||
new_column.type = col["type"]
|
new_column.type = col["type"]
|
||||||
new_column.expression = ""
|
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.groupby = True
|
||||||
new_column.filterable = True
|
new_column.filterable = True
|
||||||
columns.append(new_column)
|
columns.append(new_column)
|
||||||
|
|||||||
@@ -1258,6 +1258,17 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
|||||||
properties:
|
properties:
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
engine_information:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
supports_file_upload:
|
||||||
|
type: boolean
|
||||||
|
disable_ssh_tunneling:
|
||||||
|
type: boolean
|
||||||
|
supports_dynamic_catalog:
|
||||||
|
type: boolean
|
||||||
|
supports_oauth2:
|
||||||
|
type: boolean
|
||||||
400:
|
400:
|
||||||
$ref: '#/components/responses/400'
|
$ref: '#/components/responses/400'
|
||||||
422:
|
422:
|
||||||
@@ -1271,8 +1282,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
|||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
return self.response_400(message=error.messages)
|
return self.response_400(message=error.messages)
|
||||||
try:
|
try:
|
||||||
TestConnectionDatabaseCommand(item).run()
|
database = TestConnectionDatabaseCommand(item).run()
|
||||||
return self.response(200, message="OK")
|
engine_information = database.db_engine_spec.get_public_information()
|
||||||
|
return self.response(200, message="OK", engine_information=engine_information)
|
||||||
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
|
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
|
||||||
return self.response_400(message=str(ex))
|
return self.response_400(message=str(ex))
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
if not only_metadata and (not table_exists or force):
|
||||||
df = read_example_data(
|
df = read_example_data(
|
||||||
"examples://bart-lines.json.gz", encoding="latin-1", compression="gzip"
|
"bart-lines.json.gz", encoding="latin-1", compression="gzip"
|
||||||
)
|
)
|
||||||
df["path_json"] = df.path.map(json.dumps)
|
df["path_json"] = df.path.map(json.dumps)
|
||||||
df["polyline"] = df.path.map(polyline.encode)
|
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:
|
def load_data(tbl_name: str, database: Database, sample: bool = False) -> None:
|
||||||
pdf = read_example_data("examples://birth_names2.json.gz", compression="gzip")
|
pdf = read_example_data("birth_names2.json.gz", compression="gzip")
|
||||||
|
|
||||||
# TODO(bkyryliuk): move load examples data into the pytest fixture
|
# TODO(bkyryliuk): move load examples data into the pytest fixture
|
||||||
if database.backend == "presto":
|
if database.backend == "presto":
|
||||||
@@ -584,8 +584,8 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
|
|||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
)
|
)
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=echarts_timeseries_line-too-long
|
||||||
pos = json.loads( # noqa: TID251
|
pos = json.loads(
|
||||||
textwrap.dedent(
|
textwrap.dedent(
|
||||||
"""\
|
"""\
|
||||||
{
|
{
|
||||||
@@ -859,11 +859,11 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
|
|||||||
""" # noqa: E501
|
""" # noqa: E501
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# pylint: enable=line-too-long
|
# pylint: enable=echarts_timeseries_line-too-long
|
||||||
# dashboard v2 doesn't allow add markup slice
|
# dashboard v2 doesn't allow add markup slice
|
||||||
dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
|
dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
|
||||||
update_slice_ids(pos)
|
update_slice_ids(pos)
|
||||||
dash.dashboard_title = "USA Births Names"
|
dash.dashboard_title = "USA Births Names"
|
||||||
dash.position_json = json.dumps(pos, indent=4) # noqa: TID251
|
dash.position_json = json.dumps(pos, indent=4)
|
||||||
dash.slug = "births"
|
dash.slug = "births"
|
||||||
return dash
|
return dash
|
||||||
|
|||||||
@@ -1490,4 +1490,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/fcc_survey_2018.csv.gz
|
data: https://github.com/apache-superset/examples-data/raw/master/datasets/examples/fcc_survey_2018.csv.gz
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/channel_members.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channel_members.csv
|
||||||
|
|||||||
@@ -360,4 +360,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/channels.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channels.csv
|
||||||
|
|||||||
@@ -344,4 +344,4 @@ columns:
|
|||||||
extra: null
|
extra: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/sales.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/sales.csv
|
||||||
|
|||||||
@@ -204,4 +204,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/covid_vaccines.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/covid_vaccines.csv
|
||||||
|
|||||||
@@ -260,4 +260,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/exported_stats.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/exported_stats.csv
|
||||||
|
|||||||
@@ -480,4 +480,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/messages.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/messages.csv
|
||||||
|
|||||||
@@ -180,4 +180,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/threads.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/threads.csv
|
||||||
|
|||||||
@@ -90,4 +90,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/unicode_test.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/unicode_test.csv
|
||||||
|
|||||||
@@ -220,4 +220,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/users.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users.csv
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/slack/users_channels.csv
|
data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users_channels.csv
|
||||||
|
|||||||
@@ -153,4 +153,4 @@ columns:
|
|||||||
python_date_format: null
|
python_date_format: null
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
|
||||||
data: examples://datasets/examples/video_game_sales.csv
|
data: https://github.com/apache-superset/examples-data/raw/lowercase_columns_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):
|
if not only_metadata and (not table_exists or force):
|
||||||
data = read_example_data(
|
data = read_example_data(
|
||||||
"examples://birth_france_data_for_country_map.csv", encoding="utf-8"
|
"birth_france_data_for_country_map.csv", encoding="utf-8"
|
||||||
)
|
)
|
||||||
data["dttm"] = datetime.datetime.now().date()
|
data["dttm"] = datetime.datetime.now().date()
|
||||||
data.to_sql(
|
data.to_sql(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def load_energy(
|
|||||||
table_exists = database.has_table(Table(tbl_name, schema))
|
table_exists = database.has_table(Table(tbl_name, schema))
|
||||||
|
|
||||||
if not only_metadata and (not table_exists or force):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data("examples://energy.json.gz", compression="gzip")
|
pdf = read_example_data("energy.json.gz", compression="gzip")
|
||||||
pdf = pdf.head(100) if sample else pdf
|
pdf = pdf.head(100) if sample else pdf
|
||||||
pdf.to_sql(
|
pdf.to_sql(
|
||||||
tbl_name,
|
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):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data(
|
pdf = read_example_data(
|
||||||
"examples://flight_data.csv.gz", encoding="latin-1", compression="gzip"
|
"flight_data.csv.gz", encoding="latin-1", compression="gzip"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Loading airports info to join and get lat/long
|
# Loading airports info to join and get lat/long
|
||||||
airports = read_example_data(
|
airports = read_example_data(
|
||||||
"examples://airports.csv.gz", encoding="latin-1", compression="gzip"
|
"airports.csv.gz", encoding="latin-1", compression="gzip"
|
||||||
)
|
)
|
||||||
airports = airports.set_index("IATA_CODE")
|
airports = airports.set_index("IATA_CODE")
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ from superset.connectors.sqla.models import SqlaTable
|
|||||||
from superset.models.slice import Slice
|
from superset.models.slice import Slice
|
||||||
from superset.utils import json
|
from superset.utils import json
|
||||||
|
|
||||||
EXAMPLES_PROTOCOL = "examples://"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public sample‑data mirror configuration
|
# Public sample‑data mirror configuration
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,20 +125,6 @@ def get_example_url(filepath: str) -> str:
|
|||||||
return f"{BASE_URL}{filepath}"
|
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(
|
def read_example_data(
|
||||||
filepath: str,
|
filepath: str,
|
||||||
max_attempts: int = 5,
|
max_attempts: int = 5,
|
||||||
@@ -148,7 +132,9 @@ def read_example_data(
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""Load CSV or JSON from example data mirror with retry/backoff."""
|
"""Load CSV or JSON from example data mirror with retry/backoff."""
|
||||||
url = normalize_example_data_url(filepath)
|
from superset.examples.helpers import get_example_url
|
||||||
|
|
||||||
|
url = get_example_url(filepath)
|
||||||
is_json = filepath.endswith(".json") or filepath.endswith(".json.gz")
|
is_json = filepath.endswith(".json") or filepath.endswith(".json.gz")
|
||||||
|
|
||||||
for attempt in range(1, max_attempts + 1):
|
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):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data(
|
pdf = read_example_data(
|
||||||
"examples://san_francisco.csv.gz", encoding="utf-8", compression="gzip"
|
"san_francisco.csv.gz", encoding="utf-8", compression="gzip"
|
||||||
)
|
)
|
||||||
start = datetime.datetime.now().replace(
|
start = datetime.datetime.now().replace(
|
||||||
hour=0, minute=0, second=0, microsecond=0
|
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):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data(
|
pdf = read_example_data(
|
||||||
"examples://multiformat_time_series.json.gz", compression="gzip"
|
"multiformat_time_series.json.gz", compression="gzip"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO(bkyryliuk): move load examples data into the pytest fixture
|
# 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))
|
table_exists = database.has_table(Table(tbl_name, schema))
|
||||||
|
|
||||||
if not only_metadata and (not table_exists or force):
|
if not only_metadata and (not table_exists or force):
|
||||||
df = read_example_data("examples://paris_iris.json.gz", compression="gzip")
|
df = read_example_data("paris_iris.json.gz", compression="gzip")
|
||||||
df["features"] = df.features.map(json.dumps)
|
df["features"] = df.features.map(json.dumps)
|
||||||
|
|
||||||
df.to_sql(
|
df.to_sql(
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ def load_random_time_series_data(
|
|||||||
table_exists = database.has_table(Table(tbl_name, schema))
|
table_exists = database.has_table(Table(tbl_name, schema))
|
||||||
|
|
||||||
if not only_metadata and (not table_exists or force):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data(
|
pdf = read_example_data("random_time_series.json.gz", compression="gzip")
|
||||||
"examples://random_time_series.json.gz", compression="gzip"
|
|
||||||
)
|
|
||||||
if database.backend == "presto":
|
if database.backend == "presto":
|
||||||
pdf.ds = pd.to_datetime(pdf.ds, unit="s")
|
pdf.ds = pd.to_datetime(pdf.ds, unit="s")
|
||||||
pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")
|
pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ def load_sf_population_polygons(
|
|||||||
table_exists = database.has_table(Table(tbl_name, schema))
|
table_exists = database.has_table(Table(tbl_name, schema))
|
||||||
|
|
||||||
if not only_metadata and (not table_exists or force):
|
if not only_metadata and (not table_exists or force):
|
||||||
df = read_example_data(
|
df = read_example_data("sf_population.json.gz", compression="gzip")
|
||||||
"examples://sf_population.json.gz", compression="gzip"
|
|
||||||
)
|
|
||||||
df["contour"] = df.contour.map(json.dumps)
|
df["contour"] = df.contour.map(json.dumps)
|
||||||
|
|
||||||
df.to_sql(
|
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))
|
table_exists = database.has_table(Table(tbl_name, schema))
|
||||||
|
|
||||||
if not only_metadata and (not table_exists or force):
|
if not only_metadata and (not table_exists or force):
|
||||||
pdf = read_example_data("examples://countries.json.gz", compression="gzip")
|
pdf = read_example_data("countries.json.gz", compression="gzip")
|
||||||
pdf.columns = [col.replace(".", "_") for col in pdf.columns]
|
pdf.columns = [col.replace(".", "_") for col in pdf.columns]
|
||||||
if database.backend == "presto":
|
if database.backend == "presto":
|
||||||
pdf.year = pd.to_datetime(pdf.year)
|
pdf.year = pd.to_datetime(pdf.year)
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ from flask_appbuilder.utils.base import get_safe_redirect
|
|||||||
from flask_babel import lazy_gettext as _, refresh
|
from flask_babel import lazy_gettext as _, refresh
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from flask_session import Session
|
from flask_session import Session
|
||||||
from sqlalchemy import inspect
|
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from superset.constants import CHANGE_ME_SECRET_KEY
|
from superset.constants import CHANGE_ME_SECRET_KEY
|
||||||
@@ -471,31 +470,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||||||
icon="fa-lock",
|
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:
|
def init_app_in_ctx(self) -> None:
|
||||||
"""
|
"""
|
||||||
Runs init logic in the context of the app
|
Runs init logic in the context of the app
|
||||||
@@ -513,8 +487,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||||||
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
|
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
|
||||||
flask_app_mutator(self.superset_app)
|
flask_app_mutator(self.superset_app)
|
||||||
|
|
||||||
# Initialize database-dependent features only if database is ready
|
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||||
self._init_database_dependent_features()
|
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")
|
||||||
|
|
||||||
self.init_views()
|
self.init_views()
|
||||||
|
|
||||||
|
|||||||
@@ -262,16 +262,8 @@ class RLSAsSubqueryTransformer(RLSTransformer):
|
|||||||
return node
|
return node
|
||||||
|
|
||||||
if predicate := self.get_predicate(node):
|
if predicate := self.get_predicate(node):
|
||||||
if node.alias:
|
# use alias or name
|
||||||
alias = node.alias
|
alias = node.alias or node.sql()
|
||||||
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.set("alias", None)
|
||||||
node = exp.Subquery(
|
node = exp.Subquery(
|
||||||
this=exp.Select(
|
this=exp.Select(
|
||||||
@@ -691,10 +683,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
eq.this.sql(
|
eq.this.sql(comments=False): eq.expression.sql(comments=False)
|
||||||
dialect=self._dialect,
|
|
||||||
comments=False,
|
|
||||||
): eq.expression.sql(comments=False)
|
|
||||||
for set_item in self._parsed.find_all(exp.SetItem)
|
for set_item in self._parsed.find_all(exp.SetItem)
|
||||||
for eq in set_item.find_all(exp.EQ)
|
for eq in set_item.find_all(exp.EQ)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,412 +287,3 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
|
|||||||
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
|
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
|
||||||
== "Car"
|
== "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
|
FROM some_table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "some_table"
|
) AS some_table
|
||||||
WHERE
|
WHERE
|
||||||
1 = 1
|
1 = 1
|
||||||
""".strip(),
|
""".strip(),
|
||||||
@@ -1868,7 +1868,7 @@ FROM (
|
|||||||
FROM table
|
FROM table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "table"
|
) AS table
|
||||||
WHERE
|
WHERE
|
||||||
1 = 1
|
1 = 1
|
||||||
""".strip(),
|
""".strip(),
|
||||||
@@ -1925,7 +1925,7 @@ JOIN (
|
|||||||
FROM other_table
|
FROM other_table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "other_table"
|
) AS other_table
|
||||||
ON table.id = other_table.id
|
ON table.id = other_table.id
|
||||||
""".strip(),
|
""".strip(),
|
||||||
),
|
),
|
||||||
@@ -1961,7 +1961,7 @@ FROM (
|
|||||||
FROM some_table
|
FROM some_table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "some_table"
|
) AS some_table
|
||||||
)
|
)
|
||||||
""".strip(),
|
""".strip(),
|
||||||
),
|
),
|
||||||
@@ -1977,7 +1977,7 @@ FROM (
|
|||||||
FROM table
|
FROM table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "table"
|
) AS table
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
@@ -2000,7 +2000,7 @@ FROM (
|
|||||||
FROM other_table
|
FROM other_table
|
||||||
WHERE
|
WHERE
|
||||||
id = 42
|
id = 42
|
||||||
) AS "other_table"
|
) AS other_table
|
||||||
""".strip(),
|
""".strip(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -2039,22 +2039,6 @@ INNER JOIN tbl_b AS b
|
|||||||
ON a.col = b.col
|
ON a.col = b.col
|
||||||
""".strip(),
|
""".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(
|
def test_rls_subquery_transformer(
|
||||||
|
|||||||
@@ -259,13 +259,13 @@ FROM (
|
|||||||
FROM t1
|
FROM t1
|
||||||
WHERE
|
WHERE
|
||||||
c1 = 1
|
c1 = 1
|
||||||
) AS "t1", (
|
) AS t1, (
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
FROM t2
|
FROM t2
|
||||||
WHERE
|
WHERE
|
||||||
c2 = 2
|
c2 = 2
|
||||||
) AS "t2"
|
) AS t2
|
||||||
""".strip()
|
""".strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user