Compare commits

...

51 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
47e6d85104 chore(HeaderReportDropdown): Remove leftover console log 2025-08-08 18:55:11 +03:00
Abhinav Kumar
ad592c717e fix: Reset description height to zero when chart is not expanded (#33843) 2025-08-07 12:51:46 -07:00
SBIN2010
38e15196f2 fix(Heatmap): addin x axis label rotation (#34239) 2025-08-07 12:27:35 -07:00
dependabot[bot]
8131c24acd chore(deps): bump ws and @types/ws in /superset-websocket (#34450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-07 12:16:26 -07:00
dependabot[bot]
952b620465 chore(deps-dev): bump @types/node from 22.10.3 to 24.1.0 in /superset-websocket (#34448)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-07 12:16:12 -07:00
Oliver Schlüter
f3e3bd0348 fix(db_engine_specs): generate correct boolean filter SQL syntax for Athena compatibility (#34598) 2025-08-07 18:39:31 +03:00
Brandon Kaplan
1e1310dbd8 chore(helm): bump app version to 5.0.0 (#33889) 2025-08-07 07:32:01 -07:00
Mehmet Salih Yavuz
adaae8ba15 fix(Timeshift): Determine temporal column correctly (#34582) 2025-08-07 15:20:34 +03:00
Joe Li
a66b7e98e0 feat: Add ESLint rule to enforce sentence case in button text (#34434)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-06 13:13:36 -07:00
JUST.in DO IT
3e12d97e8e fix(echart): broken aggregator due to bigint value (#34580) 2025-08-06 15:22:04 -03:00
dependabot[bot]
00304f77e1 chore(deps-dev): bump globals from 16.0.0 to 16.3.0 in /superset-websocket (#34452)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 09:21:18 -07:00
dependabot[bot]
e88db9f403 chore(deps): update re-resizable requirement from ^6.10.1 to ^6.11.2 in /superset-frontend/packages/superset-ui-core (#34453)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 09:12:41 -07:00
JUST.in DO IT
53e9cf6d17 fix: navigate to SQL Lab due to router api updates (#34569) 2025-08-06 11:54:10 -03:00
Damian Pendrak
5a004590e0 feat(deckgl): add selected cross-filter indication (#34322) 2025-08-06 17:53:54 +03:00
Levis Mbote
53503e32ae fix(Table chart): fix percentage metric column (#34175) 2025-08-06 17:51:02 +03:00
Maxime Beauchemin
246181a546 feat(docker): Add pytest support to docker-compose-light.yml (#34373)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-06 00:17:50 -04:00
JUST.in DO IT
6f5d9c989a fix(echarts): rename time series shifted without dimensions (#34541) 2025-08-05 18:29:55 -07:00
dependabot[bot]
8515792b04 chore(deps): update @deck.gl/aggregation-layers requirement from ^9.1.13 to ^9.1.14 in /superset-frontend/plugins/legacy-preset-chart-deckgl (#34468)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 17:51:03 -07:00
dependabot[bot]
923b2b1d77 chore(deps-dev): bump @babel/runtime-corejs3 from 7.26.7 to 7.28.2 in /superset-frontend (#34464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 17:46:27 -07:00
dependabot[bot]
486b0122d0 chore(deps-dev): update jest requirement from ^30.0.4 to ^30.0.5 in /superset-frontend/plugins/plugin-chart-pivot-table (#34462)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 17:46:01 -07:00
dependabot[bot]
ae090fa74c chore(deps-dev): update @types/prop-types requirement from ^15.7.2 to ^15.7.15 in /superset-frontend/packages/superset-ui-core (#34451)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 17:45:40 -07:00
dependabot[bot]
35ec6d308a chore(deps-dev): update jest requirement from ^30.0.4 to ^30.0.5 in /superset-frontend/packages/generator-superset (#34457)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:43:18 -07:00
dependabot[bot]
c62a6f5cee chore(deps): bump @deck.gl/react from 9.1.13 to 9.1.14 in /superset-frontend (#34461)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:42:48 -07:00
dependabot[bot]
cdd140b3cc chore(deps-dev): update jest requirement from ^30.0.4 to ^30.0.5 in /superset-frontend/plugins/plugin-chart-handlebars (#34501)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:31:23 -07:00
dependabot[bot]
09cf49c2ba chore(deps): bump @babel/runtime from 7.26.10 to 7.28.2 in /superset-frontend (#34472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:30:21 -07:00
dependabot[bot]
ac4b4c7646 chore(deps-dev): bump eslint-config-prettier from 10.1.5 to 10.1.8 in /superset-websocket (#34454)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:28:28 -07:00
dependabot[bot]
d0a6c78966 chore(deps): bump react-draggable from 4.4.6 to 4.5.0 in /superset-frontend (#34474)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:19:42 -07:00
dependabot[bot]
65f2071aa4 chore(deps): bump react-lines-ellipsis from 0.15.4 to 0.16.1 in /superset-frontend (#34483)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:09:17 -07:00
dependabot[bot]
e8f37a3f89 chore(deps-dev): bump eslint from 9.31.0 to 9.32.0 in /docs (#34492)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:04:26 -07:00
dependabot[bot]
19d229ea12 chore(deps-dev): bump typescript-eslint from 8.37.0 to 8.38.0 in /docs (#34493)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:03:50 -07:00
dependabot[bot]
622a62d7a1 chore(deps): update react requirement from ^19.1.0 to ^19.1.1 in /superset-frontend/plugins/legacy-plugin-chart-chord (#34502)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:01:19 -07:00
yousoph
4a556f4ac4 fix: update copy text for better capitalization and abbreviation standards (#34508)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-05 14:40:52 -07:00
dependabot[bot]
7a1839ba1b chore(deps): bump @rjsf/validator-ajv8 from 5.24.9 to 5.24.12 in /superset-frontend (#34487)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 14:05:42 -07:00
dependabot[bot]
8f2afb8f4d chore(deps-dev): bump @babel/preset-react from 7.26.3 to 7.27.1 in /superset-frontend (#34489)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 14:03:11 -07:00
dependabot[bot]
02586981da chore(deps-dev): bump eslint-plugin-prettier from 5.5.1 to 5.5.3 in /docs (#34496)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 13:59:30 -07:00
JUST.in DO IT
8700a0b939 fix(table chart): render bigint value in a raw mode (#34556) 2025-08-05 13:11:28 -07:00
Mehmet Salih Yavuz
d843fef2ce fix(theming): More theming bugs/regressions (#34507) 2025-08-05 23:07:35 +03:00
Vitor Avila
f45654c03c chore: Rename dataset creation buttons (#34544) 2025-08-05 15:24:51 -03:00
Mehmet Salih Yavuz
761daec53d feat(timeshift): Add support for date range timeshifts (#34375) 2025-08-05 19:31:40 +03:00
Vitor Avila
407fb67f1e fix: Avoid null scrollLeft in VirtualTable (#34545) 2025-08-05 09:25:47 -03:00
Vitor Avila
49689eec6c feat: Enable drilling in embedded (#34319) 2025-08-05 02:23:00 -03:00
Maxime Beauchemin
791ea9860d fix(explore): Fix missing await for async buildV1ChartDataPayload calls (#34528) 2025-08-04 15:08:34 -07:00
JUST.in DO IT
2f8939d229 fix(native filters): throws an error when a chart containing a bigint value (#34539) 2025-08-04 16:17:06 -03:00
JUST.in DO IT
ccf6290120 chore(core): Add drawer to core ui components (#34515) 2025-08-04 11:06:29 -07:00
dependabot[bot]
96a1aa60e8 chore(deps): update gh-pages requirement from ^6.2.0 to ^6.3.0 in /superset-frontend/packages/superset-ui-demo (#34444)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:38:12 -07:00
dependabot[bot]
2ea0368c2d chore(deps-dev): bump @types/classnames from 2.3.0 to 2.3.4 in /superset-frontend (#34478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:36:27 -07:00
dependabot[bot]
9e407e4e80 chore(deps): bump dom-to-image-more from 3.5.0 to 3.6.0 in /superset-frontend (#34482)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:11:29 -07:00
dependabot[bot]
360e58c181 chore(deps): bump @deck.gl/core from 9.1.13 to 9.1.14 in /superset-frontend (#34480)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:11:13 -07:00
dependabot[bot]
22d5eb7835 chore(deps-dev): bump tsx from 4.19.4 to 4.20.3 in /superset-frontend (#34484)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:10:12 -07:00
dependabot[bot]
7c4a77a909 chore(deps-dev): bump @babel/compat-data from 7.27.2 to 7.28.0 in /superset-frontend (#34485)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-03 00:09:55 -07:00
Evan Rusackas
4e209e51d0 fix(sqllab): prevent strings with angle brackets from being hidden (#34512)
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-02 22:53:17 -07:00
184 changed files with 5932 additions and 1964 deletions

1
.gitignore vendored
View File

@@ -131,3 +131,4 @@ superset/static/stats/statistics.html
# LLM-related
CLAUDE.local.md
.aider*
.claude_rc*

View File

@@ -28,6 +28,7 @@ These features are considered **unfinished** and should only be used on developm
[//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY"
- ALERT_REPORT_TABS
- DATE_RANGE_TIMESHIFTS_ENABLED
- ENABLE_ADVANCED_DATA_TYPES
- PRESTO_EXPAND_DATA
- SHARE_QUERIES_VIA_KV_STORE

View File

@@ -32,6 +32,7 @@ assists people when migrating to a new version.
- [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed.
- [31590](https://github.com/apache/superset/pull/31590) Marks the begining of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle.
- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
- [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions.
## 5.0.0

View File

@@ -17,16 +17,47 @@
# -----------------------------------------------------------------------
# Lightweight docker-compose for running multiple Superset instances
# This includes only essential services: database, Redis, and Superset app
# This includes only essential services: database and Superset app (no Redis)
#
# IMPORTANT: To run multiple instances in parallel:
# RUNNING SUPERSET:
# 1. Start services: docker-compose -f docker-compose-light.yml up
# 2. Access at: http://localhost:9001 (or NODE_PORT if specified)
#
# RUNNING MULTIPLE INSTANCES:
# - Use different project names: docker-compose -p project1 -f docker-compose-light.yml up
# - Use different NODE_PORT values: NODE_PORT=9002 docker-compose -p project2 -f docker-compose-light.yml up
# - Volumes are isolated by project name (e.g., project1_db_home_light, project2_db_home_light)
# - Database name is intentionally different (superset_light) to prevent accidental cross-connections
#
# For verbose logging during development:
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
# RUNNING TESTS WITH PYTEST:
# Tests run in an isolated environment with a separate test database.
# The pytest-runner service automatically creates and initializes the test database on first use.
#
# Basic usage:
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest tests/unit_tests/
#
# Run specific test file:
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest tests/unit_tests/test_foo.py
#
# Run with pytest options:
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest -v -s -x tests/
#
# Force reload test database and run tests (when tests are failing due to bad state):
# docker-compose -f docker-compose-light.yml run --rm -e FORCE_RELOAD=true pytest-runner pytest tests/
#
# Run any command in test environment:
# docker-compose -f docker-compose-light.yml run --rm pytest-runner bash
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest --collect-only
#
# For parallel test execution with different projects:
# docker-compose -p project1 -f docker-compose-light.yml run --rm pytest-runner pytest tests/
#
# DEVELOPMENT TIPS:
# - First test run takes ~20-30 seconds (database creation + initialization)
# - Subsequent runs are fast (~2-3 seconds startup)
# - Use FORCE_RELOAD=true when you need a clean test database
# - Tests use SimpleCache instead of Redis (no Redis required)
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed logs
# -----------------------------------------------------------------------
x-superset-user: &superset-user root
x-superset-volumes: &superset-volumes
@@ -56,13 +87,14 @@ services:
required: false
image: postgres:16
restart: unless-stopped
# No host port mapping - only accessible within Docker network
volumes:
- db_home_light:/var/lib/postgresql/data
- ./docker/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
environment:
# Override database name to avoid conflicts
POSTGRES_DB: superset_light
# Increase max connections for test runs
command: postgres -c max_connections=200
superset-light:
env_file:
@@ -150,6 +182,34 @@ services:
required: false
volumes: *superset-volumes
pytest-runner:
build:
<<: *common-build
entrypoint: ["/app/docker/docker-pytest-entrypoint.sh"]
env_file:
- path: docker/.env # default
required: true
- path: docker/.env-local # optional override
required: false
profiles:
- test # Only starts when --profile test is used
depends_on:
db-light:
condition: service_started
user: *superset-user
volumes: *superset-volumes
environment:
# Test-specific database configuration
DATABASE_HOST: db-light
DATABASE_DB: test
POSTGRES_DB: test
# Point to test database
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@db-light:5432/test
# Use the light test config that doesn't require Redis
SUPERSET_CONFIG: superset_test_config_light
# Python path includes test directory
PYTHONPATH: /app/pythonpath:/app/docker/pythonpath_dev:/app
volumes:
superset_home_light:
external: false

View File

@@ -0,0 +1,152 @@
#!/bin/bash
#
# 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.
#
set -e
# Wait for PostgreSQL to be ready
echo "Waiting for database to be ready..."
for i in {1..30}; do
if python3 -c "
import psycopg2
try:
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
conn.close()
print('Database is ready!')
except:
exit(1)
" 2>/dev/null; then
echo "Database connection established!"
break
fi
echo "Waiting for database... ($i/30)"
if [ $i -eq 30 ]; then
echo "Database connection timeout after 30 seconds"
exit 1
fi
sleep 1
done
# Handle database setup based on FORCE_RELOAD
if [ "${FORCE_RELOAD}" = "true" ]; then
echo "Force reload requested - resetting test database"
# Drop and recreate the test database using Python
python3 -c "
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Connect to default database
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
# Drop and recreate test database
try:
cur.execute('DROP DATABASE IF EXISTS test')
except:
pass
cur.execute('CREATE DATABASE test')
conn.close()
# Connect to test database to create schemas
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
cur.execute('CREATE SCHEMA sqllab_test_db')
cur.execute('CREATE SCHEMA admin_database')
cur.close()
conn.close()
print('Test database reset successfully')
"
# Use --no-reset-db since we already reset it
FLAGS="--no-reset-db"
else
echo "Using existing test database (set FORCE_RELOAD=true to reset)"
FLAGS="--no-reset-db"
# Ensure test database exists using Python
python3 -c "
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Check if test database exists
try:
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
conn.close()
print('Test database already exists')
except:
print('Creating test database...')
# Connect to default database to create test database
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
# Create test database
cur.execute('CREATE DATABASE test')
conn.close()
# Connect to test database to create schemas
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cur = conn.cursor()
cur.execute('CREATE SCHEMA IF NOT EXISTS sqllab_test_db')
cur.execute('CREATE SCHEMA IF NOT EXISTS admin_database')
cur.close()
conn.close()
print('Test database created successfully')
"
fi
# Always run database migrations to ensure schema is up to date
echo "Running database migrations..."
cd /app
superset db upgrade
# Initialize test environment if needed
if [ "${FORCE_RELOAD}" = "true" ] || [ ! -f "/app/superset_home/.test_initialized" ]; then
echo "Initializing test environment..."
# Run initialization commands
superset init
echo "Loading test users..."
superset load-test-users
# Mark as initialized
touch /app/superset_home/.test_initialized
else
echo "Test environment already initialized (skipping init and load-test-users)"
echo "Tip: Use FORCE_RELOAD=true to reinitialize the test database"
fi
# Create missing scripts needed for tests
if [ ! -f "/app/scripts/tag_latest_release.sh" ]; then
echo "Creating missing tag_latest_release.sh script for tests..."
cp /app/docker/tag_latest_release.sh /app/scripts/tag_latest_release.sh 2>/dev/null || true
fi
# Install pip module for Shillelagh compatibility (aligns with CI environment)
echo "Installing pip module for Shillelagh compatibility..."
uv pip install pip
# If arguments provided, execute them
if [ $# -gt 0 ]; then
exec "$@"
fi

View File

@@ -0,0 +1,55 @@
# 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.
#
# Test configuration for docker-compose-light.yml - uses SimpleCache instead of Redis
# Import all settings from the main test config first
import os
import sys
# Add the tests directory to the path to import the test config
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
from tests.integration_tests.superset_test_config import * # noqa: F403
# Override Redis-based caching to use simple in-memory cache
CACHE_CONFIG = {
"CACHE_TYPE": "SimpleCache",
"CACHE_DEFAULT_TIMEOUT": 300,
"CACHE_KEY_PREFIX": "superset_test_",
}
DATA_CACHE_CONFIG = {
**CACHE_CONFIG,
"CACHE_DEFAULT_TIMEOUT": 30,
"CACHE_KEY_PREFIX": "superset_test_data_",
}
# Keep SimpleCache for these as they're already using it
# FILTER_STATE_CACHE_CONFIG - already SimpleCache in parent
# EXPLORE_FORM_DATA_CACHE_CONFIG - already SimpleCache in parent
# Disable Celery for lightweight testing
CELERY_CONFIG = None
# Use FileSystemCache for SQL Lab results instead of Redis
from flask_caching.backends.filesystemcache import FileSystemCache # noqa: E402
RESULTS_BACKEND = FileSystemCache("/app/superset_home/sqllab_test")
# Override WEBDRIVER_BASEURL for tests to match expected values
WEBDRIVER_BASEURL = "http://0.0.0.0:8080/"
WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL

190
docker/tag_latest_release.sh Executable file
View File

@@ -0,0 +1,190 @@
#! /bin/bash
# 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.
run_git_tag () {
if [[ "$DRY_RUN" == "false" ]] && [[ "$SKIP_TAG" == "false" ]]
then
git tag -a -f latest "${GITHUB_TAG_NAME}" -m "latest tag"
echo "${GITHUB_TAG_NAME} has been tagged 'latest'"
fi
exit 0
}
###
# separating out git commands into functions so they can be mocked in unit tests
###
git_show_ref () {
if [[ "$TEST_ENV" == "true" ]]
then
if [[ "$GITHUB_TAG_NAME" == "does_not_exist" ]]
# mock return for testing only
then
echo ""
else
echo "2817aebd69dc7d199ec45d973a2079f35e5658b6 refs/tags/${GITHUB_TAG_NAME}"
fi
fi
result=$(git show-ref "${GITHUB_TAG_NAME}")
echo "${result}"
}
get_latest_tag_list () {
if [[ "$TEST_ENV" == "true" ]]
then
echo "(tag: 2.1.0, apache/2.1test)"
else
result=$(git show-ref --tags --dereference latest | awk '{print $2}' | xargs git show --pretty=tformat:%d -s | grep tag:)
echo "${result}"
fi
}
###
split_string () {
local version="$1"
local delimiter="$2"
local components=()
local tmp=""
for (( i=0; i<${#version}; i++ )); do
local char="${version:$i:1}"
if [[ "$char" != "$delimiter" ]]; then
tmp="$tmp$char"
elif [[ -n "$tmp" ]]; then
components+=("$tmp")
tmp=""
fi
done
if [[ -n "$tmp" ]]; then
components+=("$tmp")
fi
echo "${components[@]}"
}
DRY_RUN=false
# get params passed in with script when it was run
# --dry-run is optional and returns the value of SKIP_TAG, but does not run the git tag statement
# A tag name is required as a param. A SHA won't work. You must first tag a sha with a release number
# and then run this script
while [[ $# -gt 0 ]]
do
key="$1"
case $key in
--dry-run)
DRY_RUN=true
shift # past value
;;
*) # this should be the tag name
GITHUB_TAG_NAME=$key
shift # past value
;;
esac
done
if [ -z "${GITHUB_TAG_NAME}" ]; then
echo "Missing tag parameter, usage: ./scripts/tag_latest_release.sh <GITHUB_TAG_NAME>"
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
exit 1
fi
if [ -z "$(git_show_ref)" ]; then
echo "The tag ${GITHUB_TAG_NAME} does not exist. Please use a different tag."
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
exit 0
fi
# check that this tag only contains a proper semantic version
if ! [[ ${GITHUB_TAG_NAME} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
echo "This tag ${GITHUB_TAG_NAME} is not a valid release version. Not tagging."
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
exit 1
fi
## split the current GITHUB_TAG_NAME into an array at the dot
THIS_TAG_NAME=$(split_string "${GITHUB_TAG_NAME}" ".")
# look up the 'latest' tag on git
LATEST_TAG_LIST=$(get_latest_tag_list) || echo 'not found'
# if 'latest' tag doesn't exist, then set this commit to latest
if [[ -z "$LATEST_TAG_LIST" ]]
then
echo "there are no latest tags yet, so I'm going to start by tagging this sha as the latest"
run_git_tag
exit 0
fi
# remove parenthesis and tag: from the list of tags
LATEST_TAGS_STRINGS=$(echo "$LATEST_TAG_LIST" | sed 's/tag: \([^,]*\)/\1/g' | tr -d '()')
LATEST_TAGS=$(split_string "$LATEST_TAGS_STRINGS" ",")
TAGS=($(split_string "$LATEST_TAGS" " "))
# Initialize a flag for comparison result
compare_result=""
# Iterate through the tags of the latest release
for tag in $TAGS
do
if [[ $tag == "latest" ]]; then
continue
else
## extract just the version from this tag
LATEST_RELEASE_TAG="$tag"
echo "LATEST_RELEASE_TAG: ${LATEST_RELEASE_TAG}"
# check that this only contains a proper semantic version
if ! [[ ${LATEST_RELEASE_TAG} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
echo "'Latest' has been associated with tag ${LATEST_RELEASE_TAG} which is not a valid release version. Looking for another."
continue
fi
echo "The current release with the latest tag is version ${LATEST_RELEASE_TAG}"
# Split the version strings into arrays
THIS_TAG_NAME_ARRAY=($(split_string "$THIS_TAG_NAME" "."))
LATEST_RELEASE_TAG_ARRAY=($(split_string "$LATEST_RELEASE_TAG" "."))
# Iterate through the components of the version strings
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="greater"
break
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
compare_result="lesser"
break
fi
done
fi
done
# Determine the result based on the comparison
if [[ -z "$compare_result" ]]; then
echo "Versions are equal"
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
elif [[ "$compare_result" == "greater" ]]; then
echo "This release tag ${GITHUB_TAG_NAME} is newer than the latest."
echo "SKIP_TAG=false" >> $GITHUB_OUTPUT
# Add other actions you want to perform for a newer version
elif [[ "$compare_result" == "lesser" ]]; then
echo "This release tag ${GITHUB_TAG_NAME} is older than the latest."
echo "This release tag ${GITHUB_TAG_NAME} is not the latest. Not tagging."
# if you've gotten this far, then we don't want to run any tags in the next step
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
fi

View File

@@ -44,14 +44,14 @@
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.31.0",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"typescript-eslint": "^8.37.0",
"typescript-eslint": "^8.39.0",
"webpack": "^5.101.0"
},
"browserslist": {

View File

@@ -2150,14 +2150,7 @@
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz"
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
"@eslint-community/eslint-utils@^4.2.0":
version "4.4.1"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz"
integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/eslint-utils@^4.7.0":
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
@@ -2205,12 +2198,7 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.31.0":
version "9.31.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.31.0.tgz#adb1f39953d8c475c4384b67b67541b0d7206ed8"
integrity sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==
"@eslint/js@^9.32.0":
"@eslint/js@9.32.0", "@eslint/js@^9.32.0":
version "9.32.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.32.0.tgz#a02916f58bd587ea276876cb051b579a3d75d091"
integrity sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==
@@ -2220,10 +2208,10 @@
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
"@eslint/plugin-kit@^0.3.1":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz#32926b59bd407d58d817941e48b2a7049359b1fd"
integrity sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==
"@eslint/plugin-kit@^0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz#c6b9f165e94bf4d9fdd493f1c028a94aaf5fc1cc"
integrity sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==
dependencies:
"@eslint/core" "^0.15.1"
levn "^0.4.1"
@@ -3729,79 +3717,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.37.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz#332392883f936137cd6252c8eb236d298e514e70"
integrity sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==
"@typescript-eslint/eslint-plugin@8.39.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz#c9afec1866ee1a6ea3d768b5f8e92201efbbba06"
integrity sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.37.0"
"@typescript-eslint/type-utils" "8.37.0"
"@typescript-eslint/utils" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/type-utils" "8.39.0"
"@typescript-eslint/utils" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.37.0", "@typescript-eslint/parser@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.37.0.tgz#b87f6b61e25ad5cc5bbf8baf809b8da889c89804"
integrity sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==
"@typescript-eslint/parser@8.39.0", "@typescript-eslint/parser@^8.37.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.39.0.tgz#c4b895d7a47f4cd5ee6ee77ea30e61d58b802008"
integrity sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==
dependencies:
"@typescript-eslint/scope-manager" "8.37.0"
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.37.0.tgz#0594352e32a4ac9258591b88af77b5653800cdfe"
integrity sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==
"@typescript-eslint/project-service@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.39.0.tgz#71cb29c3f8139f99a905b8705127bffc2ae84759"
integrity sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.37.0"
"@typescript-eslint/types" "^8.37.0"
"@typescript-eslint/tsconfig-utils" "^8.39.0"
"@typescript-eslint/types" "^8.39.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz#a31a3c80ca2ef4ed58de13742debb692e7d4c0a4"
integrity sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==
"@typescript-eslint/scope-manager@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz#ba4bf6d8257bbc172c298febf16bc22df4856570"
integrity sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==
dependencies:
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
"@typescript-eslint/tsconfig-utils@8.37.0", "@typescript-eslint/tsconfig-utils@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz#47a2760d265c6125f8e7864bc5c8537cad2bd053"
integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
"@typescript-eslint/tsconfig-utils@8.39.0", "@typescript-eslint/tsconfig-utils@^8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz#b2e87fef41a3067c570533b722f6af47be213f13"
integrity sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==
"@typescript-eslint/type-utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz#2a682e4c6ff5886712dad57e9787b5e417124507"
integrity sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==
"@typescript-eslint/type-utils@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz#310ec781ae5e7bb0f5940bfd652573587f22786b"
integrity sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==
dependencies:
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/utils" "8.37.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/utils" "8.39.0"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.37.0.tgz#09517aa9625eb3c68941dde3ac8835740587b6ff"
integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
"@typescript-eslint/types@8.39.0", "@typescript-eslint/types@^8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.0.tgz#80f010b7169d434a91cd0529d70a528dbc9c99c6"
integrity sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==
"@typescript-eslint/typescript-estree@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz#a07e4574d8e6e4355a558f61323730c987f5fcbc"
integrity sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==
"@typescript-eslint/typescript-estree@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz#b9477a5c47a0feceffe91adf553ad9a3cd4cb3d6"
integrity sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==
dependencies:
"@typescript-eslint/project-service" "8.37.0"
"@typescript-eslint/tsconfig-utils" "8.37.0"
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/visitor-keys" "8.37.0"
"@typescript-eslint/project-service" "8.39.0"
"@typescript-eslint/tsconfig-utils" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/visitor-keys" "8.39.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -3809,22 +3797,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.37.0.tgz#189ea59b2709f5d898614611f091a776751ee335"
integrity sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==
"@typescript-eslint/utils@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.39.0.tgz#dfea42f3c7ec85f9f3e994ff0bba8f3b2f09e220"
integrity sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.37.0"
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/scope-manager" "8.39.0"
"@typescript-eslint/types" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/visitor-keys@8.37.0":
version "8.37.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz#cdb6a6bd3e8d6dd69bd70c1bdda36e2d18737455"
integrity sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==
"@typescript-eslint/visitor-keys@8.39.0":
version "8.39.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz#5d619a6e810cdd3fd1913632719cbccab08bf875"
integrity sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==
dependencies:
"@typescript-eslint/types" "8.37.0"
"@typescript-eslint/types" "8.39.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -5617,20 +5605,13 @@ debug@2.6.9:
dependencies:
ms "2.0.0"
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.0:
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
dependencies:
ms "^2.1.3"
debug@^4.3.2, debug@^4.3.4:
version "4.3.4"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
decode-named-character-reference@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz#5d6ce68792808901210dac42a8e9853511e2b8bf"
@@ -6156,10 +6137,10 @@ eslint-config-prettier@^10.1.8:
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
eslint-plugin-prettier@^5.5.1:
version "5.5.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz#470820964de9aedb37e9ce62c3266d2d26d08d15"
integrity sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==
eslint-plugin-prettier@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz#1f88e9220a72ac8be171eec5f9d4e4d529b5f4a0"
integrity sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==
dependencies:
prettier-linter-helpers "^1.0.0"
synckit "^0.11.7"
@@ -6214,10 +6195,10 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@^9.31.0:
version "9.31.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.31.0.tgz#9a488e6da75bbe05785cd62e43c5ea99356d21ba"
integrity sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==
eslint@^9.32.0:
version "9.32.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.32.0.tgz#4ea28df4a8dbc454e1251e0f3aed4bcf4ce50a47"
integrity sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/regexpp" "^4.12.1"
@@ -6225,8 +6206,8 @@ eslint@^9.31.0:
"@eslint/config-helpers" "^0.3.0"
"@eslint/core" "^0.15.0"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.31.0"
"@eslint/plugin-kit" "^0.3.1"
"@eslint/js" "9.32.0"
"@eslint/plugin-kit" "^0.3.4"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
@@ -9059,11 +9040,6 @@ ms@2.0.0:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@2.1.3, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -12372,15 +12348,15 @@ types-ramda@^0.30.0:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.37.0:
version "8.37.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.37.0.tgz#2235ddfa40cdbdadb1afb05f8bda688a2294b4c2"
integrity sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==
typescript-eslint@^8.39.0:
version "8.39.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.39.0.tgz#b19c1a925cf8566831ae3875d2881ee2349808a5"
integrity sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==
dependencies:
"@typescript-eslint/eslint-plugin" "8.37.0"
"@typescript-eslint/parser" "8.37.0"
"@typescript-eslint/typescript-estree" "8.37.0"
"@typescript-eslint/utils" "8.37.0"
"@typescript-eslint/eslint-plugin" "8.39.0"
"@typescript-eslint/parser" "8.39.0"
"@typescript-eslint/typescript-estree" "8.39.0"
"@typescript-eslint/utils" "8.39.0"
typescript@~5.8.3:
version "5.8.3"

View File

@@ -15,7 +15,7 @@
# limitations under the License.
#
apiVersion: v2
appVersion: "4.1.2"
appVersion: "5.0.0"
description: Apache Superset is a modern, enterprise-ready business intelligence web application
name: superset
icon: https://artifacthub.io/image/68c1d717-0e97-491f-b046-754e46f46922@2x
@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.14.3
version: 0.15.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 13.4.4

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.14.3](https://img.shields.io/badge/Version-0.14.3-informational?style=flat-square)
![Version: 0.15.0](https://img.shields.io/badge/Version-0.15.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -336,3 +336,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| supersetWorker.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to supersetWorker deployments |
| tolerations | list | `[]` | |
| topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to all deployments |
## Versioning
This chart follows [semantic versioning](https://semver.org/). The chart version is independent of the Superset version. The chart version is incremented when there are changes to the chart itself, such as new features, bug fixes, or changes in configuration options. In addition to semver, the chart version is also incremented in the minor version when there is a breaking change in the Superset appVersion itself. When there are non-breaking changes in the Superset appVersion, the chart version is incremented in the patch version.

View File

@@ -48,3 +48,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
{{ template "chart.requirementsSection" . }}
{{ template "chart.valuesSection" . }}
## Versioning
This chart follows [semantic versioning](https://semver.org/). The chart version is independent of the Superset version. The chart version is incremented when there are changes to the chart itself, such as new features, bug fixes, or changes in configuration options. In addition to semver, the chart version is also incremented in the minor version when there is a breaking change in the Superset appVersion itself. When there are non-breaking changes in the Superset appVersion, the chart version is incremented in the patch version.

View File

@@ -195,6 +195,7 @@ development = [
"grpcio>=1.55.3",
"openapi-spec-validator",
"parameterized",
"pip",
"pre-commit",
"progress>=1.5,<2",
"psutil",

View File

@@ -541,6 +541,8 @@ pillow==10.3.0
# via
# apache-superset
# matplotlib
pip==25.1.1
# via apache-superset
platformdirs==4.3.8
# via
# -c requirements/base.txt

View File

@@ -403,6 +403,7 @@ module.exports = {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/sentence-case-buttons': 'error',
camelcase: [
'error',
{

View File

@@ -31,6 +31,52 @@ import {
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
@@ -62,6 +108,7 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
@@ -235,12 +282,14 @@ describe('Drill by modal', () => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
@@ -389,6 +438,7 @@ describe('Drill by modal', () => {
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
@@ -552,6 +602,7 @@ describe('Drill by modal', () => {
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});

View File

@@ -155,7 +155,7 @@ describe('Horizontal FilterBar', () => {
]);
setFilterBarOrientation('horizontal');
cy.get('.filter-item-wrapper').should('have.length', 3);
cy.get('.filter-item-wrapper').should('have.length', 4);
openMoreFilters();
cy.getBySel('form-item-value').should('have.length', 12);
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');

View File

@@ -22,7 +22,6 @@ import {
dataTestChartName,
} from 'cypress/support/directories';
import { waitForChartLoad } from 'cypress/utils';
import {
addParentFilterWithValue,
applyNativeFilterValueWithIndex,
@@ -344,7 +343,7 @@ describe('Native filters', () => {
it('User can delete a native filter', () => {
enterNativeFilterEditModal(false);
cy.get(nativeFilters.filtersList.removeIcon).first().click();
cy.contains('Restore Filter').should('not.exist', { timeout: 10000 });
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
});
it('User can cancel creating a new filter', () => {

View File

@@ -41,7 +41,7 @@ module.exports = {
context.report({
node,
message:
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it cant handle strings that include variables",
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
});
}
}
@@ -52,5 +52,67 @@ module.exports = {
};
},
},
'sentence-case-buttons': {
create(context) {
function isTitleCase(str) {
// Match "Delete Dataset", "Create Chart", etc. (2+ title-cased words)
return /^[A-Z][a-z]+(\s+[A-Z][a-z]*)+$/.test(str);
}
function isButtonContext(node) {
const { parent } = node;
if (!parent) return false;
// Check for button-specific props
if (parent.type === 'Property') {
const key = parent.key.name;
return [
'primaryButtonName',
'secondaryButtonName',
'confirmButtonText',
'cancelButtonText',
].includes(key);
}
// Check for Button components
if (parent.type === 'JSXExpressionContainer') {
const jsx = parent.parent;
if (jsx?.type === 'JSXElement') {
const elementName = jsx.openingElement.name.name;
return elementName === 'Button';
}
}
return false;
}
function handler(node) {
if (node.arguments.length) {
const firstArg = node.arguments[0];
if (
firstArg.type === 'Literal' &&
typeof firstArg.value === 'string'
) {
const text = firstArg.value;
if (isButtonContext(node) && isTitleCase(text)) {
const sentenceCase = text
.toLowerCase()
.replace(/^\w/, c => c.toUpperCase());
context.report({
node: firstArg,
message: `Button text should use sentence case: "${text}" should be "${sentenceCase}"`,
});
}
}
}
}
return {
"CallExpression[callee.name='t']": handler,
"CallExpression[callee.name='tn']": handler,
};
},
},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -88,7 +88,7 @@
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^5.21.1",
"@rjsf/utils": "^5.24.3",
"@rjsf/validator-ajv8": "^5.24.9",
"@rjsf/validator-ajv8": "^5.24.12",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -129,7 +129,7 @@
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.2.0",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
@@ -176,7 +176,7 @@
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^9.16.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.15.4",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
"react-resize-detector": "^7.1.2",
@@ -208,7 +208,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.55.6",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.26.8",
"@babel/compat-data": "^7.28.0",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/node": "^7.22.6",
@@ -216,11 +216,11 @@
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-runtime": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.26.0",
"@babel/runtime-corejs3": "^7.26.0",
"@babel/runtime": "^7.28.2",
"@babel/runtime-corejs3": "^7.28.2",
"@babel/types": "^7.26.9",
"@cypress/react": "^8.0.2",
"@emotion/babel-plugin": "^11.13.5",
@@ -243,7 +243,7 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/classnames": "^2.2.10",
"@types/classnames": "^2.3.4",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
@@ -331,7 +331,7 @@
"ts-jest": "^29.4.0",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.19.2",
"tsx": "^4.20.3",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.99.9",

View File

@@ -36,7 +36,7 @@
"devDependencies": {
"cross-env": "^7.0.3",
"fs-extra": "^11.3.0",
"jest": "^30.0.4",
"jest": "^30.0.5",
"yeoman-test": "^10.1.1"
},
"engines": {

View File

@@ -36,18 +36,19 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
const columns = ensureIsArray(
queryObject.series_columns || queryObject.columns,
);
const timeOffsets = ensureIsArray(formData.time_compare);
const { truncate_metric } = formData;
const xAxisLabel = getXAxisLabel(formData);
const isTimeComparisonValue = isTimeComparison(formData, queryObject);
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) at least 1 metric
// 2) dimension exist
// 2) dimension exist or multiple time shift metrics exist
// 3) xAxis exist
// 4) truncate_metric in form_data and truncate_metric is true
if (
metrics.length > 0 &&
columns.length > 0 &&
(columns.length > 0 || timeOffsets.length > 1) &&
xAxisLabel &&
truncate_metric !== undefined &&
!!truncate_metric
@@ -84,7 +85,8 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
ComparisonType.Percentage,
ComparisonType.Ratio,
].includes(formData.comparison_type) &&
metrics.length === 1
metrics.length === 1 &&
renamePairs.length === 0
) {
renamePairs.push([getMetricLabel(metrics[0]), null]);
}

View File

@@ -29,3 +29,4 @@ export * from './getStandardizedControls';
export * from './getTemporalColumns';
export * from './displayTimeRelatedControls';
export * from './colorControls';
export * from './metricColumnFilter';

View File

@@ -0,0 +1,135 @@
/**
* 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 { QueryFormMetric, SqlaFormData } from '@superset-ui/core';
import {
shouldSkipMetricColumn,
isRegularMetric,
isPercentMetric,
} from './metricColumnFilter';
const createMetric = (label: string): QueryFormMetric =>
({
label,
expressionType: 'SIMPLE',
column: { column_name: label },
aggregate: 'SUM',
}) as QueryFormMetric;
describe('metricColumnFilter', () => {
const createFormData = (
metrics: string[],
percentMetrics: string[],
): SqlaFormData =>
({
datasource: 'test_datasource',
viz_type: 'table',
metrics: metrics.map(createMetric),
percent_metrics: percentMetrics.map(createMetric),
}) as SqlaFormData;
describe('shouldSkipMetricColumn', () => {
it('should skip unprefixed percent metric columns if prefixed version exists', () => {
const colnames = ['metric1', '%metric1'];
const formData = createFormData([], ['metric1']);
const result = shouldSkipMetricColumn({
colname: 'metric1',
colnames,
formData,
});
expect(result).toBe(true);
});
it('should not skip if column is also a regular metric', () => {
const colnames = ['metric1', '%metric1'];
const formData = createFormData(['metric1'], ['metric1']);
const result = shouldSkipMetricColumn({
colname: 'metric1',
colnames,
formData,
});
expect(result).toBe(false);
});
it('should not skip if column starts with %', () => {
const colnames = ['%metric1'];
const formData = createFormData(['metric1'], []);
const result = shouldSkipMetricColumn({
colname: '%metric1',
colnames,
formData,
});
expect(result).toBe(false);
});
it('should not skip if no prefixed version exists', () => {
const colnames = ['metric1'];
const formData = createFormData([], ['metric1']);
const result = shouldSkipMetricColumn({
colname: 'metric1',
colnames,
formData,
});
expect(result).toBe(false);
});
});
describe('isRegularMetric', () => {
it('should return true for regular metrics', () => {
const formData = createFormData(['metric1', 'metric2'], []);
expect(isRegularMetric('metric1', formData)).toBe(true);
expect(isRegularMetric('metric2', formData)).toBe(true);
});
it('should return false for non-metrics', () => {
const formData = createFormData(['metric1'], []);
expect(isRegularMetric('non_metric', formData)).toBe(false);
});
it('should return false for percentage metrics', () => {
const formData = createFormData([], ['percent_metric1']);
expect(isRegularMetric('percent_metric1', formData)).toBe(false);
});
});
describe('isPercentMetric', () => {
it('should return true for percentage metrics', () => {
const formData = createFormData([], ['percent_metric1']);
expect(isPercentMetric('%percent_metric1', formData)).toBe(true);
});
it('should return false for non-percentage metrics', () => {
const formData = createFormData(['regular_metric'], []);
expect(isPercentMetric('regular_metric', formData)).toBe(false);
});
it('should return false for regular metrics', () => {
const formData = createFormData(['metric1'], []);
expect(isPercentMetric('metric1', formData)).toBe(false);
});
});
});

View File

@@ -0,0 +1,95 @@
/**
* 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 {
QueryFormMetric,
getMetricLabel,
SqlaFormData,
} from '@superset-ui/core';
export interface MetricColumnFilterParams {
colname: string;
colnames: string[];
formData: SqlaFormData;
}
/**
* Determines if a column should be skipped based on metric filtering logic.
*
* This function implements the logic to skip unprefixed percent metric columns
* if a prefixed version exists, but doesn't skip if it's also a regular metric.
*
* @param params - The parameters for metric column filtering
* @returns true if the column should be skipped, false otherwise
*/
export function shouldSkipMetricColumn({
colname,
colnames,
formData,
}: MetricColumnFilterParams): boolean {
if (!colname) {
return false;
}
// Check if this column name exists as a percent metric in form data
const isPercentMetric = formData.percent_metrics?.some(
(metric: QueryFormMetric) => getMetricLabel(metric) === colname,
);
// Check if this column name exists as a regular metric in form data
const isRegularMetric = formData.metrics?.some(
(metric: QueryFormMetric) => getMetricLabel(metric) === colname,
);
// Check if there's a prefixed version of this column in the column list
const hasPrefixedVersion = colnames.includes(`%${colname}`);
// Skip if: has prefixed version AND is percent metric AND is NOT regular metric
return hasPrefixedVersion && isPercentMetric && !isRegularMetric;
}
/**
* Determines if a column is a regular metric.
*
* @param colname - The column name to check
* @param formData - The form data containing metrics
* @returns true if the column is a regular metric, false otherwise
*/
export function isRegularMetric(
colname: string,
formData: SqlaFormData,
): boolean {
return !!formData.metrics?.some(metric => getMetricLabel(metric) === colname);
}
/**
* Determines if a column is a percentage metric.
*
* @param colname: string,
* @param formData - The form data containing percent_metrics
* @returns true if the column is a percentage metric, false otherwise
*/
export function isPercentMetric(
colname: string,
formData: SqlaFormData,
): boolean {
return !!formData.percent_metrics?.some(
(metric: QueryFormMetric) => `%${getMetricLabel(metric)}` === colname,
);
}

View File

@@ -65,6 +65,20 @@ test('should skip renameOperator if series does not exist', () => {
).toEqual(undefined);
});
test('should skip renameOperator if series does not exist and a single time shift exists', () => {
expect(
renameOperator(
{ ...formData, ...{ time_compare: ['1 year ago'] } },
{
...queryObject,
...{
columns: [],
},
},
),
).toEqual(undefined);
});
test('should skip renameOperator if does not exist x_axis and is_timeseries', () => {
expect(
renameOperator(
@@ -93,6 +107,26 @@ test('should add renameOperator', () => {
});
});
test('should add renameOperator if a metric exists and multiple time shift', () => {
expect(
renameOperator(
{
...formData,
...{ time_compare: ['1 year ago', '2 years ago'] },
},
{
...queryObject,
...{
columns: [],
},
},
),
).toEqual({
operation: 'rename',
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
});
});
test('should add renameOperator if exists derived metrics', () => {
[
ComparisonType.Difference,
@@ -176,7 +210,6 @@ test('should add renameOperator if exist "actual value" time comparison', () =>
operation: 'rename',
options: {
columns: {
'count(*)': null,
'count(*)__1 year ago': '1 year ago',
'count(*)__1 year later': '1 year later',
},

View File

@@ -25,7 +25,7 @@
],
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.25.6",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
@@ -46,10 +46,10 @@
"lodash": "^4.17.21",
"math-expression-evaluator": "^2.0.6",
"pretty-ms": "^9.2.0",
"re-resizable": "^6.10.1",
"re-resizable": "^6.11.2",
"react-ace": "^10.1.0",
"react-js-cron": "^5.2.0",
"react-draggable": "^4.4.6",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^15.4.5",
"react-ultimate-pagination": "^1.3.2",
@@ -78,7 +78,7 @@
"@types/lodash": "^4.17.20",
"@types/math-expression-evaluator": "^1.3.3",
"@types/node": "^22.10.3",
"@types/prop-types": "^15.7.2",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^11.1.4",

View File

@@ -24,6 +24,7 @@ export const Badge = styled((props: BadgeProps) => <AntdBadge {...props} />)`
${({ theme, color, count }) => `
& > sup,
& > sup.ant-badge-count {
box-shadow: none;
${
count !== undefined ? `background: ${color || theme.colorPrimary};` : ''
}

View File

@@ -0,0 +1,22 @@
/**
* 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 type { DrawerProps } from './types';
export { Drawer } from 'antd';
export type { DrawerProps };

View File

@@ -0,0 +1,22 @@
/**
* 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 type { DrawerProps } from 'antd/es/drawer';
export { DrawerProps };

View File

@@ -17,10 +17,8 @@
* under the License.
*/
import {
CSSProperties,
cloneElement,
forwardRef,
ReactElement,
RefObject,
useEffect,
useImperativeHandle,
@@ -28,7 +26,6 @@ import {
useMemo,
useState,
useRef,
ReactNode,
useCallback,
} from 'react';
@@ -36,77 +33,7 @@ import { Global } from '@emotion/react';
import { css, t, useTheme, usePrevious } from '@superset-ui/core';
import { useResizeDetector } from 'react-resize-detector';
import { Badge, Icons, Button, Tooltip, Popover } from '..';
/**
* Container item.
*/
export interface DropdownItem {
/**
* String that uniquely identifies the item.
*/
id: string;
/**
* The element to be rendered.
*/
element: ReactElement;
}
/**
* Horizontal container that displays overflowed items in a dropdown.
* It shows an indicator of how many items are currently overflowing.
*/
export interface DropdownContainerProps {
/**
* Array of items. The id property is used to uniquely identify
* the elements when rendering or dealing with event handlers.
*/
items: DropdownItem[];
/**
* Event handler called every time an element moves between
* main container and dropdown.
*/
onOverflowingStateChange?: (overflowingState: {
notOverflowed: string[];
overflowed: string[];
}) => void;
/**
* Option to customize the content of the dropdown.
*/
dropdownContent?: (overflowedItems: DropdownItem[]) => ReactElement;
/**
* Dropdown ref.
*/
dropdownRef?: RefObject<HTMLDivElement>;
/**
* Dropdown additional style properties.
*/
dropdownStyle?: CSSProperties;
/**
* Displayed count in the dropdown trigger.
*/
dropdownTriggerCount?: number;
/**
* Icon of the dropdown trigger.
*/
dropdownTriggerIcon?: ReactElement;
/**
* Text of the dropdown trigger.
*/
dropdownTriggerText?: string;
/**
* Text of the dropdown trigger tooltip
*/
dropdownTriggerTooltip?: ReactNode | null;
/**
* Main container additional style properties.
*/
style?: CSSProperties;
/**
* Force render popover content before it's first opened
*/
forceRender?: boolean;
}
export type DropdownRef = HTMLDivElement & { open: () => void };
import { DropdownContainerProps, DropdownItem, DropdownRef } from './types';
const MAX_HEIGHT = 500;
@@ -428,8 +355,13 @@ export const DropdownContainer = forwardRef(
<Button
buttonStyle="secondary"
data-test="dropdown-container-btn"
icon={dropdownTriggerIcon}
css={css`
padding-left: ${theme.paddingXS}px;
padding-right: ${theme.paddingXXS}px;
gap: ${theme.sizeXXS}px;
`}
>
{dropdownTriggerIcon}
{dropdownTriggerText}
<Badge
count={dropdownTriggerCount ?? overflowingCount}
@@ -461,3 +393,5 @@ export const DropdownContainer = forwardRef(
);
},
);
export { DropdownItem, DropdownContainerProps, DropdownRef } from './types';

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import type { CSSProperties, ReactElement, RefObject, ReactNode } from 'react';
import { IconType } from '../Icons';
/**
* Container item.
@@ -69,7 +70,7 @@ export interface DropdownContainerProps {
/**
* Icon of the dropdown trigger.
*/
dropdownTriggerIcon?: ReactElement;
dropdownTriggerIcon?: IconType;
/**
* Text of the dropdown trigger.
*/

View File

@@ -32,8 +32,9 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
`;
export const StyledContainer = styled.div<{ headerPosition: string }>`
${({ headerPosition }) => `
${({ headerPosition, theme }) => `
display: flex;
gap: ${theme.sizeUnit}px;
flex-direction: ${headerPosition === 'top' ? 'column' : 'row'};
align-items: ${headerPosition === 'left' ? 'center' : undefined};
width: 100%;

View File

@@ -132,7 +132,7 @@ const VirtualTable = <RecordType extends object>(
if (gridRef.current) {
return gridRef.current?.state?.scrollLeft;
}
return null;
return 0;
},
set: (scrollLeft: number) => {
if (gridRef.current) {

View File

@@ -52,7 +52,9 @@ interface TableCollectionProps<T extends object> {
const StyledTable = styled(Table)`
${({ theme }) => `
th.ant-column-cell {
min-width: fit-content;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
@@ -83,7 +85,6 @@ const StyledTable = styled(Table)`
font-feature-settings: 'tnum' 1;
text-overflow: ellipsis;
overflow: hidden;
max-width: 320px;
line-height: 1;
vertical-align: middle;
padding-left: ${theme.sizeUnit * 4}px;
@@ -149,7 +150,7 @@ function TableCollection<T extends object>({
size={size}
data-test="listview-table"
pagination={false}
tableLayout="auto"
tableLayout="fixed"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}

View File

@@ -94,7 +94,7 @@ export function mapColumns<T extends object>(
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
hidden: column.hidden,
key: column.id,
minWidth: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
width: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
ellipsis: !columnsForWrapText?.includes(column.id),
defaultSortOrder: (isSorted
? isSortedDesc

View File

@@ -76,6 +76,7 @@ export { CronPicker, type CronError } from './CronPicker';
export * from './DatePicker';
export { DeleteModal, type DeleteModalProps } from './DeleteModal';
export { Divider, type DividerProps } from './Divider';
export { Drawer, type DrawerProps } from './Drawer';
export {
Dropdown,
MenuDotsDropdown,

View File

@@ -49,6 +49,21 @@ describe('isProbablyHTML', () => {
const trickyText = 'a <= 10 and b > 10';
expect(isProbablyHTML(trickyText)).toBe(false);
});
it('should return false for strings with angle brackets that are not HTML', () => {
// Test case from issue #25561
expect(isProbablyHTML('<abcdef:12345>')).toBe(false);
// Other similar cases
expect(isProbablyHTML('<foo:bar>')).toBe(false);
expect(isProbablyHTML('<123>')).toBe(false);
expect(isProbablyHTML('<test@example.com>')).toBe(false);
expect(isProbablyHTML('<custom-element>')).toBe(false);
// Mathematical expressions
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
expect(isProbablyHTML('price < $100')).toBe(false);
});
});
describe('sanitizeHtmlIfNeeded', () => {

View File

@@ -68,9 +68,87 @@ export function isProbablyHTML(text: string) {
return true;
}
// Check if the string contains common HTML patterns
if (!hasHtmlTagPattern(text)) {
return false;
}
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
// Check if parsing created actual HTML elements (not just text nodes)
const elements = Array.from(doc.body.childNodes).filter(
node => node.nodeType === 1,
) as Element[];
// If no elements were created, it's not HTML
if (elements.length === 0) {
return false;
}
// Check if the elements are known HTML tags (not custom/unknown tags)
// This prevents strings like "<abcdef:12345>" from being treated as HTML
return elements.some(element => {
const tagName = element.tagName.toLowerCase();
// List of common HTML tags we want to recognize
const knownHtmlTags = [
'div',
'span',
'p',
'a',
'b',
'i',
'u',
'em',
'strong',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'tr',
'td',
'th',
'tbody',
'thead',
'tfoot',
'ul',
'ol',
'li',
'img',
'br',
'hr',
'pre',
'code',
'blockquote',
'section',
'article',
'nav',
'header',
'footer',
'form',
'input',
'button',
'select',
'option',
'textarea',
'label',
'fieldset',
'legend',
'video',
'audio',
'canvas',
'iframe',
'script',
'style',
'link',
'meta',
'title',
];
return knownHtmlTags.includes(tagName);
});
}
export function sanitizeHtmlIfNeeded(htmlString: string) {

View File

@@ -43,7 +43,7 @@
"@storybook/types": "8.4.7",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.2.0",
"gh-pages": "^6.3.0",
"jquery": "^3.7.1",
"memoize-one": "^5.2.1",
"react": "^17.0.2",
@@ -54,7 +54,7 @@
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.26.3",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.2.9",
"babel-loader": "^10.0.0",

View File

@@ -31,7 +31,7 @@
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.1.0"
"react": "^19.1.1"
},
"peerDependencies": {
"@superset-ui/chart-controls": "*",

View File

@@ -24,11 +24,11 @@
"lib"
],
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/core": "^9.1.13",
"@deck.gl/aggregation-layers": "^9.1.14",
"@deck.gl/core": "^9.1.14",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.13",
"@deck.gl/react": "^9.1.14",
"@luma.gl/constants": "^9.1.9",
"@luma.gl/core": "^9.1.9",
"@luma.gl/engine": "^9.1.9",

View File

@@ -89,6 +89,7 @@ export type CategoricalDeckGLContainerProps = {
width: number;
viewport: Viewport;
getLayer: GetLayerType<unknown>;
getHighlightLayer?: GetLayerType<unknown>;
payload: JsonObject;
onAddFilter?: HandlerFunction;
setControlValue: (control: string, value: JsonValue) => void;
@@ -213,6 +214,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
const getLayers = useCallback(() => {
const {
getLayer,
getHighlightLayer,
payload,
formData: fd,
onAddFilter,
@@ -244,19 +246,27 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
data: { ...payload.data, features },
};
return [
getLayer({
formData: fd,
payload: filteredPayload,
onAddFilter,
setTooltip,
datasource: props.datasource,
onContextMenu,
filterState,
setDataMask,
emitCrossFilters,
}) as Layer,
];
const layerProps = {
formData: fd,
payload: filteredPayload,
onAddFilter,
setTooltip,
datasource: props.datasource,
onContextMenu,
filterState,
setDataMask,
emitCrossFilters,
};
const layer = getLayer(layerProps) as Layer;
if (emitCrossFilters && filterState?.value && getHighlightLayer) {
const highlightLayer = getHighlightLayer(layerProps) as Layer;
return [layer, highlightLayer];
}
return [layer];
}, [addColor, categories, props, setTooltip]);
const toggleCategory = useCallback(

View File

@@ -23,8 +23,11 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import {
AdhocFilter,
ContextMenuFilters,
DataMask,
Datasource,
ensureIsArray,
FilterState,
HandlerFunction,
isDefined,
JsonObject,
@@ -65,7 +68,15 @@ export type DeckMultiProps = {
height: number;
width: number;
datasource: Datasource;
setDataMask?: (dataMask: DataMask) => void;
onContextMenu?: (
clientX: number,
clientY: number,
filters?: ContextMenuFilters,
) => void;
onSelect: () => void;
filterState?: FilterState;
emitCrossFilters?: boolean;
};
const DeckMulti = (props: DeckMultiProps) => {
@@ -175,16 +186,14 @@ const DeckMulti = (props: DeckMultiProps) => {
const createLayerFromData = useCallback(
(subslice: JsonObject, json: JsonObject): Layer =>
// @ts-ignore TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
layerGenerators[subslice.form_data.viz_type](
subslice.form_data,
json,
props.onAddFilter,
layerGenerators[subslice.form_data.viz_type]({
formData: subslice.form_data,
payload: json,
setTooltip,
props.datasource,
[],
props.onSelect,
),
[props.onAddFilter, props.onSelect, props.datasource, setTooltip],
datasource: props.datasource,
onSelect: props.onSelect,
}),
[props.onSelect, props.datasource, setTooltip],
);
const loadSingleLayer = useCallback(

View File

@@ -87,6 +87,7 @@ interface GetPointsType {
export function createDeckGLComponent(
getLayer: GetLayerType<unknown>,
getPoints: GetPointsType,
getHighlightLayer?: GetLayerType<unknown>,
) {
// Higher order component
return memo((props: DeckGLComponentProps) => {
@@ -118,7 +119,7 @@ export function createDeckGLComponent(
}
}, []);
const computeLayer = useCallback(
const computeLayers = useCallback(
(props: DeckGLComponentProps) => {
const {
formData,
@@ -130,7 +131,7 @@ export function createDeckGLComponent(
emitCrossFilters,
} = props;
return getLayer({
const layerProps = {
formData,
payload,
onAddFilter,
@@ -139,7 +140,17 @@ export function createDeckGLComponent(
onContextMenu,
filterState,
emitCrossFilters,
}) as Layer;
};
const layer = getLayer(layerProps) as Layer;
if (emitCrossFilters && filterState?.value && getHighlightLayer) {
const highlightLayer = getHighlightLayer(layerProps) as Layer;
return [layer, highlightLayer];
}
return [layer];
},
[setTooltip],
);
@@ -152,7 +163,7 @@ export function createDeckGLComponent(
setCategories(categories);
}, [props]);
const [layer, setLayer] = useState(computeLayer(props));
const [layers, setLayers] = useState(computeLayers(props));
useEffect(() => {
// Only recompute the layer if anything BUT the viewport has changed
@@ -167,9 +178,9 @@ export function createDeckGLComponent(
viewport: null,
};
if (!isEqual(prevFdNoVP, currFdNoVP) || prevPayload !== props.payload) {
setLayer(computeLayer(props));
setLayers(computeLayers(props));
}
}, [computeLayer, prevFormData, prevFilterState, prevPayload, props]);
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
const { formData, payload, setControlValue, height, width } = props;
@@ -179,7 +190,7 @@ export function createDeckGLComponent(
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={[layer]}
layers={layers}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
@@ -200,6 +211,7 @@ export function createDeckGLComponent(
export function createCategoricalDeckGLComponent(
getLayer: GetLayerType<Layer>,
getPoints: GetPointsType,
getHighlightLayer?: GetLayerType<Layer>,
) {
return function Component(props: DeckGLComponentProps) {
const {
@@ -224,6 +236,7 @@ export function createCategoricalDeckGLComponent(
setControlValue={setControlValue}
viewport={viewport}
getLayer={getLayer}
getHighlightLayer={getHighlightLayer}
payload={payload}
getPoints={getPoints}
width={width}

View File

@@ -23,6 +23,7 @@ import { commonLayerProps } from '../common';
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { Point } from '../../types';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
export function getPoints(data: JsonObject[]) {
const points: Point[] = [];
@@ -73,7 +74,7 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
return new ArcLayer({
data,
getSourceColor: (d: any) => {
getSourceColor: (d: JsonObject) => {
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
return [sc.r, sc.g, sc.b, 255 * sc.a];
}
@@ -98,7 +99,50 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
filterState,
emitCrossFilters,
}),
opacity: filterState?.value ? 0.1 : 1,
});
};
export default createCategoricalDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<ArcLayer> = function ({
formData,
payload,
filterState,
}) {
const fd = formData;
const data = payload.data.features;
const getColor = (d: {
sourcePosition: [number, number];
targetPosition: [number, number];
}) => {
const sourcePosition = filterState?.value[0];
const targetPosition = filterState?.value[1];
if (
sourcePosition &&
targetPosition &&
d.sourcePosition[0] === sourcePosition[0] &&
d.sourcePosition[1] === sourcePosition[1] &&
d.targetPosition[0] === targetPosition[0] &&
d.targetPosition[1] === targetPosition[1]
) {
return HIGHLIGHT_COLOR_ARRAY;
}
return TRANSPARENT_COLOR_ARRAY;
};
return new ArcLayer({
data,
getSourceColor: getColor,
getTargetColor: getColor,
id: `path-hihglight-layer-${fd.slice_id}` as const,
getWidth: fd.stroke_width ? fd.stroke_width : 3,
});
};
export default createCategoricalDeckGLComponent(
getLayer,
getPoints,
getHighlightLayer,
);

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { ContourLayer } from '@deck.gl/aggregation-layers';
import { PolygonLayer } from '@deck.gl/layers';
import { Position } from '@deck.gl/core';
import { t } from '@superset-ui/core';
import { commonLayerProps } from '../common';
@@ -24,6 +25,7 @@ import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { ColorType } from '../../types';
import TooltipRow from '../../TooltipRow';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: any) {
return (
@@ -112,4 +114,56 @@ export function getPoints(data: any[]) {
return data.map(d => d.position);
}
export default createDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<PolygonLayer> = function ({
formData,
filterState,
setDataMask,
onContextMenu,
setTooltip,
emitCrossFilters,
}) {
const fd = formData;
const fromLonLat = filterState?.value[0];
const toLonLat = filterState?.value[1];
const minLon = fromLonLat[0];
const maxLon = toLonLat[0];
const minLat = fromLonLat[1];
const maxLat = toLonLat[1];
const boxPolygon = [
[minLon, minLat],
[maxLon, minLat],
[maxLon, maxLat],
[minLon, maxLat],
[minLon, minLat],
];
return new PolygonLayer({
id: `contour-highlight-layer-${fd.slice_id}`,
data: [{ polygon: boxPolygon }],
getPolygon: (d: any) => d.polygon,
getFillColor: [
HIGHLIGHT_COLOR_ARRAY[0],
HIGHLIGHT_COLOR_ARRAY[1],
HIGHLIGHT_COLOR_ARRAY[2],
100,
],
getLineColor: HIGHLIGHT_COLOR_ARRAY,
getLineWidth: 4,
filled: true,
stroked: true,
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
onContextMenu,
setDataMask,
filterState,
emitCrossFilters,
}),
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -43,6 +43,7 @@ import fitViewport, { Viewport } from '../../utils/fitViewport';
import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
import { GetLayerType } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -119,7 +120,21 @@ function setTooltipContent(o: JsonObject) {
);
}
const getFillColor = (feature: JsonObject) => feature?.properties?.fillColor;
const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
if (filterStateValue) {
if (
JSON.stringify(feature.geometry.coordinates) ===
JSON.stringify(filterStateValue?.[0])
) {
return HIGHLIGHT_COLOR_ARRAY;
}
const fillColor = feature?.properties?.fillColor;
fillColor[3] = 125;
return fillColor;
}
return feature?.properties?.fillColor;
};
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
@@ -160,7 +175,8 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
extruded: fd.extruded,
filled: fd.filled,
stroked: fd.stroked,
getFillColor,
getFillColor: (feature: JsonObject) =>
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
@@ -188,6 +204,7 @@ export type DeckGLGeoJsonProps = {
filterState: FilterState;
onContextMenu: HandlerFunction;
setDataMask: SetDataMaskHook;
emitCrossFilters?: boolean;
};
export function getPoints(data: Point[]) {
@@ -242,6 +259,7 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
onAddFilter,
payload,
formData,
emitCrossFilters: props.emitCrossFilters,
});
return (

View File

@@ -29,6 +29,7 @@ import sandboxedEval from '../../utils/sandbox';
import { createDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
return (
@@ -86,7 +87,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
: aggFunc;
return new GridLayer({
id: `grid-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
id: `grid-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}`,
data,
cellSize: fd.grid_size,
extruded: fd.extruded,
@@ -109,6 +110,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
onContextMenu,
emitCrossFilters,
}),
opacity: filterState?.value ? 0.1 : 1,
});
};
@@ -116,4 +118,43 @@ export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}
export default createDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<GridLayer> = function ({
formData,
payload,
filterState,
}) {
const fd = formData;
let data = payload.data.features;
if (fd.js_data_mutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
data = jsFnMutator(data);
}
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const selectedPointsSet = new Set(
filterState?.value?.map((sp: [number, number]) => `${sp[0]},${sp[1]}`),
);
const colorAggFunc = (p: JsonObject) =>
selectedPointsSet.has(`${p.position[0]},${p.position[1]}`) ? 1 : 0;
return new GridLayer({
id: `grid-highlight-layer-${fd.slice_id}-${JSON.stringify(filterState?.value)}`,
data,
cellSize: fd.grid_size,
extruded: fd.extruded,
colorDomain: [0, 1],
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
colorAggregation: 'MAX',
outline: false,
// @ts-ignore
getElevationValue: aggFunc,
getColorWeight: colorAggFunc,
opacity: 1,
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -19,10 +19,12 @@
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
import { Position } from '@deck.gl/core';
import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core';
import { isPointInBonds } from '../../utilities/utils';
import { commonLayerProps, getColorRange } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
return (
@@ -98,4 +100,49 @@ export function getPoints(data: any[]) {
return data.map(d => d.position);
}
export default createDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<HeatmapLayer> = ({
formData,
filterState,
payload,
}) => {
const fd = formData;
const {
intensity = 1,
radius_pixels: radiusPixels = 30,
aggregation = 'SUM',
js_data_mutator: jsFnMutator,
} = fd;
let data = payload.data.features;
if (jsFnMutator) {
// Applying user defined data mutator if defined
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
data = jsFnMutatorFunction(data);
}
const dataInside = data.filter((d: JsonObject) =>
isPointInBonds(d.position, filterState?.value),
);
return new HeatmapLayer({
id: `heatmap-layer-${fd.slice_id}` as const,
data: dataInside,
intensity,
radiusPixels,
colorRange: [
[
HIGHLIGHT_COLOR_ARRAY[0],
HIGHLIGHT_COLOR_ARRAY[1],
HIGHLIGHT_COLOR_ARRAY[2],
55,
],
HIGHLIGHT_COLOR_ARRAY,
],
aggregation: aggregation.toUpperCase(),
getPosition: (d: { position: Position; weight: number }) => d.position,
getWeight: (d: { position: number[]; weight: number }) =>
d.weight ? d.weight : 1,
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -29,6 +29,7 @@ import {
import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
return (
@@ -108,6 +109,7 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
onContextMenu,
emitCrossFilters,
}),
opacity: filterState?.value ? 0.3 : 1,
});
};
@@ -115,4 +117,43 @@ export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}
export default createDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<HexagonLayer> = function ({
formData,
payload,
filterState,
}) {
const fd = formData;
let data = payload.data.features;
if (fd.js_data_mutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
data = jsFnMutator(data);
}
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const selectedPointsSet = new Set(
filterState?.value?.map((sp: [number, number]) => `${sp[0]},${sp[1]}`),
);
const colorAggFunc = (p: JsonObject) =>
selectedPointsSet.has(`${p.position[0]},${p.position[1]}`) ? 1 : 0;
return new HexagonLayer({
id: `hex-highlight-layer-${fd.slice_id}-${JSON.stringify(filterState?.value)}`,
data,
radius: fd.grid_size,
extruded: fd.extruded,
colorDomain: [0, 1],
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
colorAggregation: 'MAX',
outline: false,
// @ts-ignore
getElevationValue: aggFunc,
getColorWeight: colorAggFunc,
opacity: 1,
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -24,6 +24,7 @@ import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { Point } from '../../types';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
return (
@@ -83,6 +84,7 @@ export const getLayer: GetLayerType<PathLayer> = function ({
onContextMenu,
emitCrossFilters,
}),
opacity: filterState?.value ? 0.3 : 1,
});
};
@@ -95,4 +97,40 @@ export function getPoints(data: JsonObject[]) {
return points;
}
export default createDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<PathLayer> = function ({
formData,
payload,
filterState,
}) {
const fd = formData;
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
let data = payload.data.features.map((feature: JsonObject) => ({
...feature,
path: feature.path,
width: fd.line_width,
color: fixedColor,
}));
if (fd.js_data_mutator) {
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
data = jsFnMutator(data);
}
const filteredData = data.filter(
(d: JsonObject) =>
JSON.stringify(d.path).replaceAll(' ', '') === filterState?.value[0],
);
return new PathLayer({
id: `path-highlight-layer-${fd.slice_id}` as const,
getColor: () => HIGHLIGHT_COLOR_ARRAY,
getPath: (d: any) => d.path,
getWidth: (d: any) => d.width,
data: filteredData,
rounded: true,
widthScale: 1,
widthUnits: fd.line_width_unit,
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -24,7 +24,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import {
ContextMenuFilters,
ensureIsArray,
FilterState,
HandlerFunction,
JsonObject,
@@ -43,6 +42,7 @@ import {
getBuckets,
getBreakPointColorScaler,
getColorBreakpointsBuckets,
TRANSPARENT_COLOR_ARRAY,
} from '../../utils';
import { commonLayerProps, getColorForBreakpoints } from '../common';
@@ -57,8 +57,7 @@ import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
const DOUBLE_CLICK_THRESHOLD = 250; // milliseconds
import { Point } from '../../types';
function getElevation(
d: JsonObject,
@@ -110,7 +109,6 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
setDataMask,
onContextMenu,
onSelect,
selected,
emitCrossFilters,
}) {
const fd = formData as PolygonFormData;
@@ -181,15 +179,20 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
}
// when polygons are selected, reduce the opacity of non-selected polygons
const colorScaler = (d: JsonObject): [number, number, number, number] => {
const baseColor = (baseColorScaler(d) as [
number,
number,
number,
number,
]) || [0, 0, 0, 0];
if (!ensureIsArray(selected).includes(d[fd.line_column])) {
baseColor[3] /= 2;
const colorScaler = (d: {
polygon: Point[];
}): [number, number, number, number] => {
const baseColor =
(baseColorScaler(d) as [number, number, number, number]) ||
TRANSPARENT_COLOR_ARRAY;
const polygonPoints = getPointsFromPolygon(d);
const isPolygonFilterSelected =
JSON.stringify(polygonPoints).replaceAll(' ', '') ===
filterState?.value?.[0];
if (filterState?.value && !isPolygonFilterSelected) {
baseColor[3] /= 3;
}
return baseColor;
@@ -216,6 +219,7 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
getElevation: (d: JsonObject) => getElevation(d, colorScaler),
elevationScale: fd.multiplier,
fp64: true,
opacity: fd.opacity ? fd.opacity / 100 : 1,
...commonLayerProps({
formData: fd,
setTooltip,
@@ -276,18 +280,14 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
return viewport;
}, [props]);
const [lastClick, setLastClick] = useState(0);
const [viewport, setViewport] = useState(getAdjustedViewport());
const [stateFormData, setStateFormData] = useState(props.payload.form_data);
const [selected, setSelected] = useState<JsonObject[]>([]);
useEffect(() => {
const { payload } = props;
if (payload.form_data !== stateFormData) {
setViewport(getAdjustedViewport());
setSelected([]);
setLastClick(0);
setStateFormData(payload.form_data);
}
}, [getAdjustedViewport, props, stateFormData, viewport]);
@@ -299,37 +299,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
}
}, []);
const onSelect = useCallback(
(polygon: JsonObject) => {
const { formData, onAddFilter } = props;
const now = new Date().getDate();
const doubleClick = now - lastClick <= DOUBLE_CLICK_THRESHOLD;
// toggle selected polygons
const selectedCopy = [...selected];
if (doubleClick) {
selectedCopy.splice(0, selectedCopy.length, polygon);
} else if (formData.toggle_polygons) {
const i = selectedCopy.indexOf(polygon);
if (i === -1) {
selectedCopy.push(polygon);
} else {
selectedCopy.splice(i, 1);
}
} else {
selectedCopy.splice(0, 1, polygon);
}
setSelected(selectedCopy);
setLastClick(now);
if (formData.table_filter) {
onAddFilter(formData.line_column, selected, false, true);
}
},
[lastClick, props, selected],
);
const getLayers = useCallback(() => {
const {
formData,
@@ -350,8 +319,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
payload,
onAddFilter,
setTooltip,
selected,
onSelect,
onContextMenu,
setDataMask,
filterState,
@@ -359,7 +326,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
});
return [layer];
}, [onSelect, selected, setTooltip, props]);
}, [setTooltip, props]);
const { payload, formData, setControlValue } = props;

View File

@@ -184,32 +184,6 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'table_filter',
config: {
type: 'CheckboxControl',
label: t('Emit Filter Events'),
renderTrigger: true,
default: false,
description: t('Whether to apply filter when items are clicked'),
},
},
],
[
{
name: 'toggle_polygons',
config: {
type: 'CheckboxControl',
label: t('Multiple filtering'),
renderTrigger: true,
default: true,
description: t(
'Allow sending multiple polygons as a filter event',
),
},
},
],
[legendPosition],
[legendFormat],
],

View File

@@ -23,10 +23,12 @@ import {
QueryFormData,
t,
} from '@superset-ui/core';
import { isPointInBonds } from '../../utilities/utils';
import { commonLayerProps } from '../common';
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { unitToRadius } from '../../utils/geo';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
@@ -105,7 +107,43 @@ export const getLayer: GetLayerType<ScatterplotLayer> = function ({
onContextMenu,
emitCrossFilters,
}),
opacity: filterState?.value ? 0.3 : 1,
});
};
export default createCategoricalDeckGLComponent(getLayer, getPoints);
export const getHighlightLayer: GetLayerType<ScatterplotLayer> = function ({
formData,
payload,
filterState,
}) {
const fd = formData;
const dataWithRadius = payload.data.features.map((d: JsonObject) => {
let radius = unitToRadius(fd.point_unit, d.radius) || 10;
if (fd.multiplier) {
radius *= fd.multiplier;
}
return { ...d, radius };
});
const dataInside = dataWithRadius.filter((d: JsonObject) =>
isPointInBonds(d.position, filterState?.value),
);
return new ScatterplotLayer({
id: `scatter-highlight-layer-${fd.slice_id}` as const,
data: dataInside,
fp64: true,
getFillColor: () => HIGHLIGHT_COLOR_ARRAY,
getRadius: (d: any) => d.radius,
radiusMinPixels: Number(fd.min_radius) || undefined,
radiusMaxPixels: Number(fd.max_radius) || undefined,
stroked: false,
});
};
export default createCategoricalDeckGLComponent(
getLayer,
getPoints,
getHighlightLayer,
);

View File

@@ -22,11 +22,16 @@
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core';
import { Color } from '@deck.gl/core';
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../../utilities/utils';
import {
COLOR_SCHEME_TYPES,
ColorSchemeType,
isPointInBonds,
} from '../../utilities/utils';
import sandboxedEval from '../../utils/sandbox';
import { commonLayerProps, getColorRange } from '../common';
import TooltipRow from '../../TooltipRow';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
@@ -113,7 +118,39 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
}),
getWeight: aggFunc,
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
opacity: filterState?.value ? 0.3 : 1,
});
};
export default createDeckGLComponent(getLayer, getPoints);
const getHighlightLayer: GetLayerType<ScreenGridLayer> = function ({
formData,
filterState,
payload,
}) {
const fd = formData;
let data = payload.data.features;
if (fd.js_data_mutator) {
// Applying user defined data mutator if defined
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
data = jsFnMutator(data);
}
const dataInside = data.filter((d: JsonObject) =>
isPointInBonds(d.position, filterState?.value),
);
const aggFunc = (d: JsonObject) => d.weight || 0;
return new ScreenGridLayer({
id: `screengrid-highlight-layer-${formData.slice_id}` as const,
data: dataInside,
cellSizePixels: formData.grid_size,
colorDomain: [0, 1],
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
outline: false,
getWeight: aggFunc,
colorScaleType: 'quantize',
});
};
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);

View File

@@ -116,6 +116,8 @@ export function commonLayerProps({
drillBy: {},
});
}
return true;
};
}

View File

@@ -38,3 +38,19 @@ export const isColorSchemeTypeVisible = (
controls: ControlStateMapping,
colorSchemeType: ColorSchemeType,
) => controls.color_scheme_type?.value === colorSchemeType;
export const isPointInBonds = (
position: [number, number],
area: [[number, number], [number, number]],
) => {
const [lon, lat] = position;
const fromLonLat = area[0];
const toLatLon = area[1];
return (
lon >= fromLonLat[0] &&
lon <= toLatLon[0] &&
lat >= fromLonLat[1] &&
lat <= toLatLon[1]
);
};

View File

@@ -31,6 +31,9 @@ import { BitmapLayer, PathLayer } from '@deck.gl/layers';
import { hexToRGB } from './utils/colors';
import { ColorBreakpointType } from './types';
export const TRANSPARENT_COLOR_ARRAY = [0, 0, 0, 0] as Color;
export const HIGHLIGHT_COLOR_ARRAY = [255, 0, 0, 255] as Color;
const DEFAULT_NUM_BUCKETS = 10;
export const MAPBOX_LAYER_PREFIX = 'mapbox://';
@@ -121,7 +124,7 @@ export function getBreakPointColorScaler(
: getSequentialSchemeRegistry().get(linearColorScheme);
if (!colorScheme) {
return () => [0, 0, 0, 0];
return () => TRANSPARENT_COLOR_ARRAY;
}
let scaler: ScaleLinear<string, string> | ScaleThreshold<number, string>;
let maskPoint: (v: number | undefined) => boolean;
@@ -160,7 +163,7 @@ export function getBreakPointColorScaler(
return (d: JsonObject): Color => {
const v = accessor(d);
if (!v) {
return [0, 0, 0, 0];
return TRANSPARENT_COLOR_ARRAY;
}
const c = hexToRGB(scaler(v));
if (maskPoint(v)) {

View File

@@ -101,18 +101,21 @@ describe('getCrossFilterDataMask', () => {
filters: [
{
col: 'LON',
op: '==',
val: -122.4205965,
op: 'IN',
val: [-122.4205965, -122.4215375],
},
{
col: 'LAT',
op: '==',
val: 37.8054735,
op: 'IN',
val: [37.8054735, 37.8058583],
},
],
},
filterState: {
value: [-122.4205965, 37.8054735],
value: [
[-122.4205965, 37.8054735],
[-122.4215375, 37.8058583],
],
customColumnLabel: 'LON, LAT',
},
},
@@ -157,7 +160,12 @@ describe('getCrossFilterDataMask', () => {
const dataMask = getCrossFilterDataMask({
formData: latlongFormData,
data: latlongPickingData,
filterState: { value: [-122.4205965, 37.8054735] },
filterState: {
value: [
[-122.4205965, 37.8054735],
[-122.4215375, 37.8058583],
],
},
});
const expected = {
@@ -213,13 +221,13 @@ describe('getCrossFilterDataMask', () => {
filters: [
{
col: 'LONLAT',
op: '==',
val: `-122.4205965,37.8054735`,
op: 'IN',
val: [`-122.4205965,37.8054735`, `-122.4215375,37.8058583`],
},
],
},
filterState: {
value: [`-122.4205965,37.8054735`],
value: [`-122.4205965,37.8054735`, `-122.4215375,37.8058583`],
},
},
isCurrentValueSelected: false,
@@ -267,13 +275,13 @@ describe('getCrossFilterDataMask', () => {
filters: [
{
col: 'LONLAT',
op: '==',
val: `37.8054735,-122.4205965`,
op: 'IN',
val: [`37.8054735,-122.4205965`, `37.8058583,-122.4215375`],
},
],
},
filterState: {
value: [`37.8054735,-122.4205965`],
value: [`37.8054735,-122.4205965`, `37.8058583,-122.4215375`],
},
},
isCurrentValueSelected: false,
@@ -316,8 +324,8 @@ describe('getCrossFilterDataMask', () => {
filters: [
{
col: 'geohash',
op: '==',
val: `9q8zn620c751`,
op: 'IN',
val: [`9q8zn620c751`],
},
],
},

View File

@@ -78,10 +78,12 @@ export interface ValidatedPickingData {
const getFiltersBySpatialType = ({
position,
positions,
positionBounds,
spatialData,
}: {
position: [number, number];
position?: [number, number];
positions?: [number, number][];
spatialData: SpatialData;
positionBounds?: PositionBounds;
}) => {
@@ -98,7 +100,7 @@ const getFiltersBySpatialType = ({
let filters: QueryObjectFilterClause[] = [];
let customColumnLabel;
if (!position && !positionBounds)
if (!position && !positions && !positionBounds)
throw new Error('Position of picked data is required');
switch (type) {
@@ -106,7 +108,23 @@ const getFiltersBySpatialType = ({
if (lonCol != null && latCol != null) {
const cols = [lonCol, latCol];
if (position) {
if (positions && positions.length > 0) {
values = positions;
customColumnLabel = cols.join(', ');
filters = [
{
col: lonCol,
op: 'IN',
val: positions.map(pos => pos[0]),
},
{
col: latCol,
op: 'IN',
val: positions.map(pos => pos[1]),
},
];
} else if (position) {
values = position;
customColumnLabel = cols.join(', ');
@@ -152,19 +170,35 @@ const getFiltersBySpatialType = ({
if (!col) throw new Error('Column is required');
const val = (reverseCheckbox ? position.reverse() : position).join(
delimiter,
);
if (positions && positions.length > 0) {
const vals = positions.map(pos =>
(reverseCheckbox ? [...pos].reverse() : pos).join(delimiter),
);
values = [val];
values = vals;
filters = [
{
col,
op: '==',
val,
},
];
filters = [
{
col,
op: 'IN',
val: vals,
},
];
} else if (position) {
const val = (reverseCheckbox ? position.reverse() : position).join(
delimiter,
);
values = [val];
filters = [
{
col,
op: '==',
val,
},
];
}
break;
}
@@ -173,18 +207,35 @@ const getFiltersBySpatialType = ({
if (!col) throw new Error('Column is required');
const [lon, lat] = position;
const val = ngeohash.encode(lat, lon, GEOHASH_PRECISION);
if (positions && positions.length > 0) {
const vals = positions.map(pos => {
const [lon, lat] = pos;
return ngeohash.encode(lat, lon, GEOHASH_PRECISION);
});
values = [val];
values = vals;
filters = [
{
col,
op: '==',
val,
},
];
filters = [
{
col,
op: 'IN',
val: vals,
},
];
} else if (position) {
const [lon, lat] = position;
const val = ngeohash.encode(lat, lon, GEOHASH_PRECISION);
values = [val];
filters = [
{
col,
op: '==',
val,
},
];
}
break;
}
@@ -280,31 +331,69 @@ const getStartEndSpatialFilters = ({
};
};
const isPointInBounds = (
point: [number, number],
bounds: PositionBounds,
): boolean =>
point[0] >= bounds.from[0] &&
point[0] <= bounds.to[0] &&
point[1] >= bounds.from[1] &&
point[1] <= bounds.to[1];
const getSpatialFilters = ({
formData,
data,
filterState,
}: {
formData: LayerFormData;
data: PickingInfo;
filterState?: FilterState;
}): FilterResult => {
const position = (data.object?.points?.[0]?.position ||
data.object?.position) as [number, number];
const positions = data.object?.points?.map(
(point: { position: [number, number]; weight: number }) => point.position,
) as [number, number][];
let positionBounds: PositionBounds | undefined;
if (!position && data.coordinate && data.viewport) {
if (!positions && data.coordinate && data.viewport) {
const pickedPositionBounds = calculatePickedPositionBounds({
pickedCoordinates: data.coordinate,
viewport: data.viewport,
});
positionBounds = pickedPositionBounds;
if (filterState?.value && data.coordinate) {
const currentFilterValues = filterState.value;
if (
Array.isArray(currentFilterValues) &&
currentFilterValues.length === 2
) {
const currentBounds: PositionBounds = {
from: currentFilterValues[0] as [number, number],
to: currentFilterValues[1] as [number, number],
};
const pickedPoint: [number, number] = [
data.coordinate[0],
data.coordinate[1],
];
if (isPointInBounds(pickedPoint, currentBounds)) {
return {
filters: [],
values: currentFilterValues,
customColumnLabel: filterState.customColumnLabel,
};
}
}
}
}
if (!formData.spatial) throw new Error('Spatial data is required');
return getFiltersBySpatialType({
position,
positions,
positionBounds,
spatialData: formData.spatial,
});
@@ -353,7 +442,7 @@ const getGeojsonFilters = ({
const val = `%${JSON.stringify(geometry)}%`;
return {
values: [val],
values: [geometry],
filters: [
{
col: {
@@ -385,7 +474,7 @@ export const getCrossFilterDataMask = ({
const result = getStartEndSpatialFilters({ formData, data });
({ values, filters, customColumnLabel } = result);
} else if (formData.spatial?.type) {
const result = getSpatialFilters({ formData, data });
const result = getSpatialFilters({ formData, data, filterState });
({ values, filters, customColumnLabel } = result);
} else if (formData.line_column) {
const result = getLineColumnFilters({ formData, data });

View File

@@ -36,18 +36,19 @@ import {
QueryModeLabel,
sections,
sharedControls,
shouldSkipMetricColumn,
isRegularMetric,
isPercentMetric,
} from '@superset-ui/chart-controls';
import {
ensureIsArray,
FeatureFlag,
GenericDataType,
getMetricLabel,
isAdhocColumn,
isFeatureEnabled,
isPhysicalColumn,
legacyValidateInteger,
QueryFormColumn,
QueryFormMetric,
QueryMode,
SMART_DATE_ID,
t,
@@ -533,14 +534,25 @@ const config: ControlPanelConfig = {
)
.forEach((colname, index) => {
if (
explore.form_data.metrics?.some(
metric => getMetricLabel(metric) === colname,
) ||
explore.form_data.percent_metrics?.some(
(metric: QueryFormMetric) =>
getMetricLabel(metric) === colname,
)
shouldSkipMetricColumn({
colname,
colnames,
formData: explore.form_data,
})
) {
return;
}
const isMetric = isRegularMetric(
colname,
explore.form_data,
);
const isPercentMetricValue = isPercentMetric(
colname,
explore.form_data,
);
if (isMetric || isPercentMetricValue) {
const comparisonColumns =
generateComparisonColumns(colname);
comparisonColumns.forEach((name, idx) => {

View File

@@ -22,6 +22,7 @@ import {
formatSelectOptionsForRange,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import { xAxisLabelRotation } from '../controls';
const sortAxisChoices = [
['alpha_asc', t('Axis ascending')],
@@ -153,7 +154,7 @@ const config: ControlPanelConfig = {
name: 'xscale_interval',
config: {
type: 'SelectControl',
label: t('XScale Interval'),
label: t('X-scale interval'),
renderTrigger: true,
choices: [[-1, t('Auto')]].concat(
formatSelectOptionsForRange(1, 50),
@@ -171,7 +172,7 @@ const config: ControlPanelConfig = {
name: 'yscale_interval',
config: {
type: 'SelectControl',
label: t('YScale Interval'),
label: t('Y-scale interval'),
choices: [[-1, t('Auto')]].concat(
formatSelectOptionsForRange(1, 50),
),
@@ -248,6 +249,7 @@ const config: ControlPanelConfig = {
],
['y_axis_format'],
['x_axis_time_format'],
[xAxisLabelRotation],
['currency_format'],
[
{

View File

@@ -99,6 +99,7 @@ export default function transformProps(
valueBounds,
yAxisFormat,
xAxisTimeFormat,
xAxisLabelRotation,
currencyFormat,
} = formData;
const metricLabel = getMetricLabel(metric);
@@ -251,6 +252,7 @@ export default function transformProps(
axisLabel: {
formatter: xAxisFormatter,
interval: xscaleInterval === -1 ? 'auto' : xscaleInterval - 1,
rotate: xAxisLabelRotation,
},
},
yAxis: {

View File

@@ -43,6 +43,7 @@ export interface HeatmapFormData extends QueryFormData {
sortYAxis?: string;
timeFormat?: string;
xAxis: QueryFormColumn;
xAxisLabelRotation: number;
xscaleInterval: number;
valueBounds: [number | undefined | null, number | undefined | null];
yAxisFormat?: string;

View File

@@ -184,7 +184,7 @@ export class EchartsChartPlugin<
super({
...restProps,
metadata: new ChartMetadata({
parseMethod: 'json-bigint',
parseMethod: 'json',
...metadata,
}),
});

View File

@@ -125,6 +125,6 @@ test('@superset-ui/plugin-chart-echarts-parsemethod-validation', () => {
];
plugins.forEach(plugin => {
expect(plugin.metadata.parseMethod).toEqual('json-bigint');
expect(plugin.metadata.parseMethod).toEqual('json');
});
});

View File

@@ -44,6 +44,6 @@
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.20",
"jest": "^30.0.4"
"jest": "^30.0.5"
}
}

View File

@@ -38,6 +38,6 @@
"devDependencies": {
"@babel/types": "^7.28.0",
"@types/jest": "^29.5.12",
"jest": "^30.0.4"
"jest": "^30.0.5"
}
}

View File

@@ -323,8 +323,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
if (typeof data?.[0]?.[key] === 'number') {
const nums = data.map(row => row[key]) as number[];
const nums = data
?.map(row => row?.[key])
.filter(value => typeof value === 'number') as number[];
if (data && nums.length === data.length) {
return (
alignPositiveNegative
? [0, d3Max(nums.map(Math.abs))]

View File

@@ -36,16 +36,17 @@ import {
QueryModeLabel,
sections,
sharedControls,
shouldSkipMetricColumn,
isRegularMetric,
isPercentMetric,
} from '@superset-ui/chart-controls';
import {
ensureIsArray,
GenericDataType,
getMetricLabel,
isAdhocColumn,
isPhysicalColumn,
legacyValidateInteger,
QueryFormColumn,
QueryFormMetric,
QueryMode,
SMART_DATE_ID,
t,
@@ -589,15 +590,29 @@ const config: ControlPanelConfig = {
last(colname.split('__')) !== timeComparisonValue,
)
.forEach((colname, index) => {
// Skip unprefixed percent metric columns if a prefixed version exists
// But don't skip if it's also a regular metric
if (
explore.form_data.metrics?.some(
metric => getMetricLabel(metric) === colname,
) ||
explore.form_data.percent_metrics?.some(
(metric: QueryFormMetric) =>
getMetricLabel(metric) === colname,
)
shouldSkipMetricColumn({
colname,
colnames,
formData: explore.form_data,
})
) {
return;
}
const isMetric = isRegularMetric(
colname,
explore.form_data,
);
const isPercentMetricValue = isPercentMetric(
colname,
explore.form_data,
);
// Generate comparison columns for metrics (time comparison feature)
if (isMetric || isPercentMetricValue) {
const comparisonColumns =
generateComparisonColumns(colname);
comparisonColumns.forEach((name, idx) => {

View File

@@ -324,6 +324,27 @@ describe('plugin-chart-table', () => {
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
it('render data with a bigint value in a raw record mode', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps(testData.bigint)}
sticky={false}
isRawRecords
/>
),
}),
);
const cells = document.querySelectorAll('td');
expect(document.querySelectorAll('th')[0]).toHaveTextContent('name');
expect(document.querySelectorAll('th')[1]).toHaveTextContent('id');
expect(cells[0]).toHaveTextContent('Michael');
expect(cells[1]).toHaveTextContent('4312');
expect(cells[2]).toHaveTextContent('John');
expect(cells[3]).toHaveTextContent('1234567890123456789');
});
it('render raw data', () => {
const props = transformProps({
...testData.raw,

View File

@@ -349,6 +349,27 @@ const empty = {
],
};
const bigint = {
...advanced,
queriesData: [
{
...basicQueryResult,
colnames: ['name', 'id'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
data: [
{
name: 'Michael',
id: 4312,
},
{
name: 'John',
id: 1234567890123456789n,
},
],
},
],
};
export default {
basic,
advanced,
@@ -357,4 +378,5 @@ export default {
comparisonWithConfig,
empty,
raw,
bigint,
};

View File

@@ -36,17 +36,17 @@ describe('ExploreResultsButton', () => {
const { queryByText } = setup(jest.fn(), {
database: { allows_subquery: true },
});
expect(queryByText('Create Chart')).toBeInTheDocument();
expect(queryByText('Create chart')).toBeInTheDocument();
// Updated line to match the actual button name that includes the icon
expect(screen.getByRole('button', { name: /Create Chart/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Create chart/i })).toBeEnabled();
});
it('renders disabled if subquery not allowed', async () => {
const { queryByText } = setup(jest.fn());
expect(queryByText('Create Chart')).toBeInTheDocument();
expect(queryByText('Create chart')).toBeInTheDocument();
// Updated line to match the actual button name that includes the icon
expect(
screen.getByRole('button', { name: /Create Chart/i }),
screen.getByRole('button', { name: /Create chart/i }),
).toBeDisabled();
});
});

View File

@@ -46,7 +46,7 @@ const ExploreResultsButton = ({
tooltip={t('Explore the result set in the data exploration view')}
data-test="explore-results-button"
>
{t('Create Chart')}
{t('Create chart')}
</Button>
);
};

View File

@@ -21,7 +21,7 @@ import { useMemo, FC, ReactElement } from 'react';
import { t, styled, useTheme, SupersetTheme } from '@superset-ui/core';
import { Button, DropdownButton } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { IconType, Icons } from '@superset-ui/core/components/Icons';
import { detectOS } from 'src/utils/common';
import { QueryButtonProps } from 'src/SqlLab/types';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
@@ -40,23 +40,24 @@ export interface RunQueryActionButtonProps {
overlayCreateAsMenu: ReactElement | null;
}
const buildText = (
const buildTextAndIcon = (
shouldShowStopButton: boolean,
selectedText: string | undefined,
theme: SupersetTheme,
): string | JSX.Element => {
if (shouldShowStopButton) {
return (
<>
<Icons.Square iconSize="xs" iconColor={theme.colorIcon} />
{t('Stop')}
</>
);
}
): { text: string; icon?: IconType } => {
let text = t('Run');
let icon: IconType | undefined;
if (selectedText) {
return t('Run selection');
text = t('Run selection');
}
return t('Run');
if (shouldShowStopButton) {
text = t('Stop');
icon = <Icons.Square iconSize="xs" iconColor={theme.colorIcon} />;
}
return {
text,
icon,
};
};
const onClick = (
@@ -130,6 +131,11 @@ const RunQueryActionButton = ({
[userOS],
);
const { text, icon } = useMemo(
() => buildTextAndIcon(shouldShowStopBtn, selectedText, theme),
[shouldShowStopBtn, selectedText, theme],
);
return (
<StyledButton>
<ButtonComponent
@@ -159,9 +165,10 @@ const RunQueryActionButton = ({
}
: {
buttonStyle: shouldShowStopBtn ? 'danger' : 'primary',
icon,
})}
>
{buildText(shouldShowStopBtn, selectedText, theme)}
{text}
</ButtonComponent>
</StyledButton>
);

View File

@@ -22,7 +22,9 @@ import {
ReactNode,
RefObject,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import ReactDOM from 'react-dom';
@@ -35,7 +37,9 @@ import {
ensureIsArray,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
isFeatureEnabled,
logging,
QueryFormData,
t,
useTheme,
@@ -48,6 +52,10 @@ import { updateDataMask } from 'src/dataMask/actions';
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { Dataset } from 'src/components/Chart/types';
import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
@@ -99,6 +107,9 @@ const ChartContextMenu = (
const crossFiltersEnabled = useSelector<RootState, boolean>(
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
);
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const [openKeys, setOpenKeys] = useState<Key[]>([]);
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
@@ -121,6 +132,7 @@ const ChartContextMenu = (
const [drillByColumn, setDrillByColumn] = useState<Column>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const [dataset, setDataset] = useState<Dataset>();
const [isLoadingDataset, setIsLoadingDataset] = useState(false);
const verboseMap = useVerboseMap(dataset);
const closeContextMenu = useCallback(() => {
@@ -129,12 +141,15 @@ const ChartContextMenu = (
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
const handleDrillBy = useCallback((column: Column) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
setShowDrillByModal(true);
}, []);
const loadDrillByOptionsExtension = getExtensionsRegistry().get(
'load.drillby.options',
);
const handleCloseDrillByModal = useCallback(() => {
setShowDrillByModal(false);
}, []);
@@ -151,6 +166,75 @@ const ChartContextMenu = (
canDrillBy &&
isDisplayed(ContextMenuItem.DrillBy);
useEffect(() => {
async function fetchDataset() {
if (!visible || dataset || (!showDrillBy && !showDrillToDetail)) return;
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingDataset(true);
let response;
if (loadDrillByOptionsExtension) {
response = await loadDrillByOptionsExtension(datasetId, formData);
} else {
const endpoint = `/api/v1/dataset/${datasetId}/drill_info/?q=(dashboard_id:${dashboardId})`;
response = await cachedSupersetGet({ endpoint });
}
const { json } = response;
const { result } = json;
setDataset(result);
} catch (error) {
logging.error('Failed to load dataset:', error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}/drill_info/`);
} finally {
setIsLoadingDataset(false);
}
}
fetchDataset();
}, [
visible,
showDrillBy,
showDrillToDetail,
formData.datasource,
loadDrillByOptionsExtension,
dashboardId,
]);
// Compute filteredDataset with all columns returned + a filtered list of valid drillable options
const filteredDataset = useMemo(() => {
if (!dataset || !showDrillBy) return dataset;
const filteredColumns = ensureIsArray(dataset.columns).filter(
column =>
// If using an extension, also filter by column.groupby since the extension might not do this
(!loadDrillByOptionsExtension || column.groupby) &&
!ensureIsArray(
formData[filters?.drillBy?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(additionalConfig?.drillBy?.excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
);
return {
...dataset,
drillable_columns: filteredColumns,
};
}, [
dataset,
showDrillBy,
filters?.drillBy?.groupbyFieldName,
formData.x_axis,
formData[filters?.drillBy?.groupbyFieldName ?? ''],
additionalConfig?.drillBy?.excludedColumns,
loadDrillByOptionsExtension,
]);
const showCrossFilters = isDisplayed(ContextMenuItem.CrossFilter);
const isCrossFilteringSupportedByChart = getChartMetadataRegistry()
@@ -254,6 +338,8 @@ const ChartContextMenu = (
onSelection={onSelection}
submenuIndex={showCrossFilters ? 2 : 1}
setShowModal={setDrillModalIsOpen}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillToDetail || {})}
/>,
);
@@ -277,6 +363,8 @@ const ChartContextMenu = (
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
onDrillBy={handleDrillBy}
dataset={filteredDataset}
isLoadingDataset={isLoadingDataset}
{...(additionalConfig?.drillBy || {})}
/>,
);
@@ -359,6 +447,7 @@ const ChartContextMenu = (
onHideModal={() => {
setDrillModalIsOpen(false);
}}
dataset={filteredDataset}
/>
)}
{showDrillByModal && drillByColumn && dataset && filters?.drillBy && (
@@ -367,7 +456,7 @@ const ChartContextMenu = (
drillByConfig={filters?.drillBy}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
dataset={{ ...filteredDataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}

View File

@@ -64,6 +64,7 @@ const setup = ({
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
['can_write', 'ExploreFormDataRestApi'],
['can_get_drill_info', 'Dataset'],
],
},
},
@@ -92,12 +93,13 @@ test('Context menu contains all displayed items only', () => {
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_explore` & `can_write` perms', () => {
test('Context menu shows "Drill by" with `can_drill`, `can_write` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -105,26 +107,14 @@ test('Context menu shows "Drill by" with `can_explore` & `can_write` perms', ()
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_drill` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.getByText('Drill by')).toBeInTheDocument();
});
test('Context menu shows "Drill by" with `can_drill` & `can_explore` + `can_write` perms', () => {
test('Context menu shows "Drill by" with `can_drill`, `can_get_drill_info` & `can_explore` + `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -152,12 +142,40 @@ test('Context menu does not show "Drill by" with just `can_dril` perm', () => {
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_samples` and `can_explore` perms', () => {
test('Context menu does not show "Drill by" with just `can_dril` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_drill', 'Dashboard'],
['can_write', 'ExploreFormDataRestApi'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill by" with just `can_drill`, `can_explore` & `can_write` perms', () => {
const result = setup({
roles: {
Admin: [
['can_write', 'ExploreFormDataRestApi'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_samples`, `can_explore` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -165,12 +183,13 @@ test('Context menu shows "Drill to detail" with `can_samples` and `can_explore`
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_drill` & `can_samples` perms', () => {
test('Context menu shows "Drill to detail" with `can_drill`, `can_samples` & `can_get_drill_info` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -178,13 +197,14 @@ test('Context menu shows "Drill to detail" with `can_drill` & `can_samples` perm
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Context menu shows "Drill to detail" with `can_drill` & `can_explore` + `can_write` perms', () => {
test('Context menu shows "Drill to detail" with `can_drill`, `can_get_drill_info` & `can_explore` + `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
},
});
@@ -202,7 +222,7 @@ test('Context menu does not show "Drill to detail" with neither of required perm
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with just `can_dril` perm', () => {
test('Context menu does not show "Drill to detail" with just `can_drill` perm', () => {
const result = setup({
roles: {
Admin: [['can_drill', 'Dashboard']],
@@ -211,3 +231,43 @@ test('Context menu does not show "Drill to detail" with just `can_dril` perm', (
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with just `can_drill` & `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_drill', 'Dashboard'],
['can_samples', 'Datasource'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with `can_samples` & `can_explore` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Context menu does not show "Drill to detail" with `can_drill`, `can_explore` + `can_samples` perms', () => {
const result = setup({
roles: {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
],
},
});
result.current.onContextMenu(0, 0, {});
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});

View File

@@ -36,9 +36,6 @@ import { DrillByMenuItems, DrillByMenuItemsProps } from './DrillByMenuItems';
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect*"] }] */
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/7*';
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
const { form_data: defaultFormData } = chartQueries[sliceId];
jest.mock('lodash/debounce', () => (fn: Function & { debounce: Function }) => {
@@ -61,6 +58,19 @@ const defaultColumns = [
{ column_name: 'col11', groupby: true },
];
const mockDataset = {
id: 7,
table_name: 'test_table',
columns: defaultColumns,
drillable_columns: defaultColumns,
changed_on_humanized: '1 day ago',
created_on_humanized: '2 days ago',
description: 'Test dataset',
owners: [],
changed_by: { first_name: 'Test', last_name: 'User' },
created_by: { first_name: 'Test', last_name: 'User' },
};
const defaultFilters = [
{
col: 'filter_col',
@@ -72,6 +82,7 @@ const defaultFilters = [
const renderMenu = ({
formData = defaultFormData,
drillByConfig = { filters: defaultFilters, groupbyFieldName: 'groupby' },
dataset = mockDataset,
...rest
}: Partial<DrillByMenuItemsProps>) =>
render(
@@ -79,6 +90,7 @@ const renderMenu = ({
<DrillByMenuItems
formData={formData ?? defaultFormData}
drillByConfig={drillByConfig}
dataset={dataset}
open
{...rest}
/>
@@ -151,20 +163,20 @@ test('render enabled menu item for supported chart, no filters', async () => {
});
test('render disabled menu item for supported chart, no columns', async () => {
fetchMock.get(DATASET_ENDPOINT, { result: { columns: [] } });
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
const emptyDataset = { ...mockDataset, columns: [], drillable_columns: [] };
renderMenu({ dataset: emptyDataset });
await expectDrillByEnabled();
screen.getByText('No columns found');
});
test('render menu item with submenu without searchbox', async () => {
const slicedColumns = defaultColumns.slice(0, 9);
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: slicedColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
const datasetWithSlicedColumns = {
...mockDataset,
columns: slicedColumns,
drillable_columns: slicedColumns,
};
renderMenu({ dataset: datasetWithSlicedColumns });
await expectDrillByEnabled();
// Check that each column appears in the drill-by submenu
@@ -180,11 +192,7 @@ test('render menu item with submenu without searchbox', async () => {
jest.setTimeout(20000);
test('render menu item with submenu and searchbox', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
});
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
renderMenu({ dataset: mockDataset });
await expectDrillByEnabled();
// Wait for all columns to be visible
@@ -236,18 +244,21 @@ test('render menu item with submenu and searchbox', async () => {
});
test('Do not display excluded column in the menu', async () => {
fetchMock.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
});
const excludedColNames = ['col3', 'col5'];
const filteredColumns = defaultColumns.filter(
col => !excludedColNames.includes(col.column_name),
);
const datasetWithFilteredColumns = {
...mockDataset,
drillable_columns: filteredColumns,
};
renderMenu({
dataset: datasetWithFilteredColumns,
excludedColumns: excludedColNames.map(colName => ({
column_name: colName,
})),
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for menu items to be loaded
@@ -274,19 +285,12 @@ test('Do not display excluded column in the menu', async () => {
});
test('When menu item is clicked, call onSelection with clicked column and drill by filters', async () => {
fetchMock
.get(DATASET_ENDPOINT, {
result: { columns: defaultColumns },
})
.post(FORM_DATA_KEY_ENDPOINT, {})
.post(CHART_DATA_ENDPOINT, {});
const onSelectionMock = jest.fn();
renderMenu({
dataset: mockDataset,
onSelection: onSelectionMock,
});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking

View File

@@ -32,25 +32,16 @@ import {
Behavior,
Column,
ContextMenuFilters,
JsonResponse,
css,
ensureIsArray,
getChartMetadataRegistry,
getExtensionsRegistry,
logging,
t,
useTheme,
} from '@superset-ui/core';
import { Constants, Input, Loading } from '@superset-ui/core/components';
import rison from 'rison';
import { debounce } from 'lodash';
import { FixedSizeList as List } from 'react-window';
import { Icons } from '@superset-ui/core/components/Icons';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
@@ -73,27 +64,10 @@ export interface DrillByMenuItemsProps {
excludedColumns?: Column[];
open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
dataset?: Dataset;
isLoadingDataset?: boolean;
}
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
const queryString = rison.encode({
columns: [
'table_name',
'owners.first_name',
'owners.last_name',
'created_by.first_name',
'created_by.last_name',
'created_on_humanized',
'changed_by.first_name',
'changed_by.last_name',
'changed_on_humanized',
'columns.column_name',
'columns.verbose_name',
'columns.groupby',
],
});
export const DrillByMenuItems = ({
drillByConfig,
formData,
@@ -106,18 +80,16 @@ export const DrillByMenuItems = ({
openNewModal = true,
open,
onDrillBy,
dataset,
isLoadingDataset = false,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
const { addDangerToast } = useToasts();
const [isLoadingColumns, setIsLoadingColumns] = useState(true);
const [searchInput, setSearchInput] = useState('');
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [dataset, setDataset] = useState<Dataset>();
const [columns, setColumns] = useState<Column[]>([]);
const ref = useRef<InputRef>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const columns = dataset ? ensureIsArray(dataset.drillable_columns) : [];
const showSearch = columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
@@ -151,56 +123,6 @@ export const DrillByMenuItems = ({
[formData.viz_type],
);
useEffect(() => {
async function loadOptions() {
const datasetId = Number(formData.datasource.split('__')[0]);
try {
setIsLoadingColumns(true);
let response: JsonResponse;
if (loadDrillByOptions) {
response = await loadDrillByOptions(datasetId, formData);
} else {
response = await cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}?q=${queryString}`,
});
}
const { json } = response;
const { result } = json;
setDataset(result);
setColumns(
ensureIsArray(result.columns)
.filter(column => column.groupby)
.filter(
column =>
!ensureIsArray(
formData[drillByConfig?.groupbyFieldName ?? ''],
).includes(column.column_name) &&
column.column_name !== formData.x_axis &&
ensureIsArray(excludedColumns)?.every(
excludedCol => excludedCol.column_name !== column.column_name,
),
),
);
} catch (error) {
logging.error(error);
supersetGetCache.delete(`/api/v1/dataset/${datasetId}`);
addDangerToast(t('Failed to load dimensions for drill by'));
} finally {
setIsLoadingColumns(false);
}
}
if (handlesDimensionContextMenu && hasDrillBy) {
loadOptions();
}
}, [
addDangerToast,
drillByConfig?.groupbyFieldName,
excludedColumns,
formData,
handlesDimensionContextMenu,
hasDrillBy,
]);
const debouncedSetSearchInput = useMemo(
() =>
debounce((value: string) => {
@@ -316,7 +238,7 @@ export const DrillByMenuItems = ({
value={searchInput}
/>
)}
{isLoadingColumns ? (
{isLoadingDataset ? (
<div
css={css`
padding: ${theme.sizeUnit * 3}px 0;

View File

@@ -32,6 +32,11 @@ import mockState from 'spec/fixtures/mockState';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import DrillByModal, { DrillByModalProps } from './DrillByModal';
// Mock the isEmbedded function
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn(() => false),
}));
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
@@ -67,9 +72,14 @@ const dataset = {
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{ column_name: 'name' },
],
verbose_map: {},
};
const renderModal = async (
@@ -159,7 +169,7 @@ test('should render alert banner when results fail to load', async () => {
test('should generate Explore url', async () => {
await renderModal({
column: { column_name: 'name' },
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
@@ -222,7 +232,7 @@ test('should render radio buttons', async () => {
test('render breadcrumbs', async () => {
await renderModal({
column: { column_name: 'name' },
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
@@ -270,3 +280,79 @@ test('should render "Edit chart" enabled with can_explore permission', async ()
);
expect(screen.getByRole('button', { name: 'Edit chart' })).toBeEnabled();
});
describe('Embedded mode behavior', () => {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
const { isEmbedded } = require('src/dashboard/util/isEmbedded');
beforeEach(() => {
(isEmbedded as jest.Mock).mockClear();
});
afterEach(() => {
(isEmbedded as jest.Mock).mockReturnValue(false);
});
test('should not render "Edit chart" button in embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(true);
await renderModal();
expect(
screen.queryByRole('button', { name: 'Edit chart' }),
).not.toBeInTheDocument();
const footerCloseButton = screen.getByTestId('close-drill-by-modal');
expect(footerCloseButton).toHaveTextContent('Close');
});
test('should not call postFormData API in embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(true);
await renderModal({
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
expect(fetchMock.called(FORM_DATA_KEY_ENDPOINT)).toBe(false);
});
test('should render "Edit chart" button in non-embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(false);
await renderModal();
expect(
screen.getByRole('button', { name: 'Edit chart' }),
).toBeInTheDocument();
});
test('should call postFormData API in non-embedded mode', async () => {
(isEmbedded as jest.Mock).mockReturnValue(false);
await renderModal({
column: { column_name: 'name', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
await waitFor(() => fetchMock.called(CHART_DATA_ENDPOINT));
await waitFor(() => {
expect(fetchMock.called(FORM_DATA_KEY_ENDPOINT)).toBe(true);
});
expect(
await screen.findByRole('link', { name: 'Edit chart' }),
).toHaveAttribute(
'href',
'/explore/?form_data_key=123&dashboard_page_id=1',
);
});
});

View File

@@ -56,6 +56,7 @@ import {
} from 'src/logger/LogUtils';
import { findPermission } from 'src/utils/findPermission';
import { getQuerySettings } from 'src/explore/exploreUtils';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Dataset, DrillByType } from '../types';
import DrillByChart from './DrillByChart';
import { ContextMenuItem } from '../ChartContextMenu/ChartContextMenu';
@@ -93,6 +94,8 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
const [datasource_id, datasource_type] = formData.datasource.split('__');
useEffect(() => {
// short circuit if the user is embedded as explore is not available
if (isEmbedded()) return;
postFormData(Number(datasource_id), datasource_type, formData, 0)
.then(key => {
setUrl(
@@ -113,28 +116,30 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
return (
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onEditChartClick}
disabled={isEditDisabled}
tooltip={
isEditDisabled
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
<Link
css={css`
&:hover {
text-decoration: none;
}
`}
to={url}
{!isEmbedded() && (
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onEditChartClick}
disabled={isEditDisabled}
tooltip={
isEditDisabled
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Link>
</Button>
<Link
css={css`
&:hover {
text-decoration: none;
}
`}
to={url}
>
{t('Edit chart')}
</Link>
</Button>
)}
<Button
buttonStyle="primary"

View File

@@ -42,6 +42,7 @@ import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
@@ -113,6 +114,8 @@ export type DrillDetailMenuItemsProps = {
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
dataset?: Dataset;
isLoadingDataset?: boolean;
};
const DrillDetailMenuItems = ({

View File

@@ -29,9 +29,11 @@ import {
import { Button, Modal } from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Slice } from 'src/types/Chart';
import { RootState } from 'src/dashboard/types';
import { findPermission } from 'src/utils/findPermission';
import { Dataset } from '../types';
import DrillDetailPane from './DrillDetailPane';
interface ModalFooterProps {
@@ -49,19 +51,21 @@ const ModalFooter = ({
return (
<>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Button>
{!isEmbedded() && (
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={exploreChart}
disabled={!canExplore}
tooltip={
!canExplore
? t('You do not have sufficient permissions to edit the chart')
: undefined
}
>
{t('Edit chart')}
</Button>
)}
<Button
buttonStyle="primary"
buttonSize="small"
@@ -83,6 +87,7 @@ interface DrillDetailModalProps {
initialFilters: BinaryQueryObjectFilterClause[];
showModal: boolean;
onHideModal: () => void;
dataset?: Dataset;
}
export default function DrillDetailModal({
@@ -91,6 +96,7 @@ export default function DrillDetailModal({
initialFilters,
showModal,
onHideModal,
dataset,
}: DrillDetailModalProps) {
const theme = useTheme();
const history = useHistory();
@@ -141,7 +147,11 @@ export default function DrillDetailModal({
destroyOnHidden
maskClosable={false}
>
<DrillDetailPane formData={formData} initialFilters={initialFilters} />
<DrillDetailPane
formData={formData}
initialFilters={initialFilters}
dataset={dataset}
/>
</Modal>
);
}

View File

@@ -164,7 +164,7 @@ test('should render the "No results" components', async () => {
test('should render the metadata bar', async () => {
fetchWithNoData();
setup();
setup({ dataset: MOCKED_DATASET });
expect(
await screen.findByText(MOCKED_DATASET.table_name),
).toBeInTheDocument();
@@ -181,15 +181,6 @@ test('should render the metadata bar', async () => {
).toBeInTheDocument();
});
test('should render an error message when fails to load the metadata', async () => {
fetchWithNoData();
fetchMock.get(DATASET_ENDPOINT, { status: 400 }, { overwriteRoutes: true });
setup();
expect(
await screen.findByText('There was an error loading the dataset metadata'),
).toBeInTheDocument();
});
test('should render the error', async () => {
jest
.spyOn(SupersetClient, 'post')

View File

@@ -46,9 +46,10 @@ import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { RootState } from 'src/dashboard/types';
import HeaderWithRadioGroup from '@superset-ui/core/components/Table/header-renderers/HeaderWithRadioGroup';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
import { Dataset } from '../types';
import TableControls from './DrillDetailTableControls';
import { getDrillPayload } from './utils';
import { ResultsPage } from './types';
@@ -79,9 +80,11 @@ enum TimeFormatting {
export default function DrillDetailPane({
formData,
initialFilters,
dataset,
}: {
formData: QueryFormData;
initialFilters: BinaryQueryObjectFilterClause[];
dataset?: Dataset;
}) {
const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0);
@@ -96,6 +99,10 @@ export default function DrillDetailPane({
Record<string, TimeFormatting>
>({});
const dashboardId = useSelector<RootState, number>(
({ dashboardInfo }) => dashboardInfo.id,
);
const SAMPLES_ROW_LIMIT = useSelector(
(state: { common: { conf: JsonObject } }) =>
state.common.conf.SAMPLES_ROW_LIMIT,
@@ -107,9 +114,10 @@ export default function DrillDetailPane({
[formData.datasource],
);
const { metadataBar, status: metadataBarStatus } = useDatasetMetadataBar({
datasetId: datasourceId,
const { metadataBar: metadataBarComponent } = useDatasetMetadataBar({
dataset,
});
// Get page of results
const resultsPage = useMemo(() => {
const nextResultsPage = resultsPages.get(pageIndex);
@@ -231,6 +239,7 @@ export default function DrillDetailPane({
jsonPayload,
PAGE_SIZE,
pageIndex + 1,
dashboardId,
)
.then(response => {
setResultsPages(
@@ -268,9 +277,7 @@ export default function DrillDetailPane({
resultsPages,
]);
const bootstrapping =
(!responseError && !resultsPages.size) ||
metadataBarStatus === ResourceStatus.Loading;
const bootstrapping = !responseError && !resultsPages.size;
const allowHTML = formData.allow_render_html ?? true;
@@ -318,7 +325,7 @@ export default function DrillDetailPane({
return (
<>
{!bootstrapping && metadataBar}
{!bootstrapping && metadataBarComponent}
{!bootstrapping && (
<TableControls
filters={filters}

View File

@@ -601,6 +601,7 @@ export const getDatasourceSamples = async (
jsonPayload,
perPage,
page,
dashboardId,
) => {
try {
const searchParams = {
@@ -609,6 +610,10 @@ export const getDatasourceSamples = async (
datasource_id: datasourceId,
};
if (isDefined(dashboardId)) {
searchParams.dashboard_id = dashboardId;
}
if (isDefined(perPage) && isDefined(page)) {
searchParams.per_page = perPage;
searchParams.page = page;

View File

@@ -41,6 +41,7 @@ export type Dataset = {
last_name: string;
}[];
columns?: Column[];
drillable_columns?: Column[];
metrics?: Metric[];
verbose_map?: Record<string, string>;
};

View File

@@ -26,12 +26,6 @@ import {
FormLabel,
} from '@superset-ui/core/components';
const formItemInlineCss = css`
.ant-form-item-control-input-content {
display: flex;
flex-direction: row;
}
`;
interface FieldProps<V> {
fieldKey: string;
value?: V;
@@ -64,10 +58,6 @@ export default function Field<V>({
[onChange, fieldKey],
);
const hookedControl = cloneElement(control, {
value,
onChange: onControlChange,
});
const theme = useTheme();
const extra = !compact && description ? description : undefined;
const infoTooltip =
@@ -83,13 +73,24 @@ export default function Field<V>({
<Icons.InfoCircleOutlined
iconSize="s"
css={css`
margin-left: ${theme.sizeUnit}px;
margin-left: ${theme.marginXXS}px;
`}
iconColor={theme.colorTextTertiary}
/>
</Tooltip>
) : undefined;
const hookedControl = cloneElement(control, {
value,
onChange: onControlChange,
label: (
<FormLabel>
{label || fieldKey}
{infoTooltip}
</FormLabel>
),
});
return (
<div
css={
@@ -101,14 +102,15 @@ export default function Field<V>({
>
{additionalControl}
<FormItem
label={
<FormLabel>
{label || fieldKey}
{infoTooltip}
</FormLabel>
}
css={inline && formItemInlineCss}
extra={extra}
css={
!inline &&
css`
.ControlHeader {
margin-bottom: ${theme.marginXXS}px;
}
`
}
>
{hookedControl}
</FormItem>
@@ -116,7 +118,7 @@ export default function Field<V>({
<div
css={(theme: SupersetTheme) => ({
color: theme.colorText,
[inline ? 'marginLeft' : 'marginTop']: theme.sizeUnit,
[inline ? 'marginLeft' : 'marginTop']: theme.marginXXS,
})}
>
{errorMessage}

View File

@@ -18,6 +18,7 @@
*/
import { ReactNode, useCallback } from 'react';
import { Divider, Form, Typography } from '@superset-ui/core/components';
import { css } from '@superset-ui/core';
import { recurseReactClone } from './utils';
import Field from './Field';
@@ -57,7 +58,12 @@ export default function Fieldset({
return (
<Form className="CRUD" layout="vertical">
{title && (
<Typography.Title level={5}>
<Typography.Title
level={5}
css={css`
margin-top: 0.5em;
`}
>
{title} <Divider />
</Typography.Title>
)}

View File

@@ -310,7 +310,7 @@ test('Drill to detail modal is under featureflag', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', () => {
test('Should show "Drill to detail" with `can_explore`, `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -320,13 +320,14 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', (
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', () => {
test('Should show "Drill to detail" with `can_drill` & `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -339,13 +340,14 @@ test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', ()
Admin: [
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
});
test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_samples` perms', () => {
test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_samples` & `can_get_drill_info` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -357,7 +359,9 @@ test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_
renderWrapper(props, {
Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
['can_drill', 'Dashboard'],
['can_get_drill_info', 'Dataset'],
],
});
openMenu();
@@ -380,7 +384,7 @@ test('Should not show "Drill to detail" with neither of required perms', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" only `can_dril` perm', () => {
test('Should not show "Drill to detail" only `can_drill` perm', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
@@ -396,6 +400,64 @@ test('Should not show "Drill to detail" only `can_dril` perm', () => {
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_drill` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_drill', 'Dashboard'],
['can_samples', 'Datasource'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_explore` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should not show "Drill to detail" with only `can_explore`, `can_drill` & `can_samples` perms', () => {
(global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true,
};
const props = {
...createProps(),
supersetCanExplore: false,
};
props.slice.slice_id = 18;
renderWrapper(props, {
Admin: [
['can_explore', 'Superset'],
['can_samples', 'Datasource'],
['can_drill', 'Dashboard'],
],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Should show "View query"', () => {
const props = {
...createProps(),

View File

@@ -81,14 +81,6 @@ const defaultProps = {
const DragDroppableStyles = styled.div`
${({ theme }) => css`
position: relative;
/*
Next line is a workaround for a bug in react-dnd where the drag
preview expands outside of the bounds of the drag source card, see:
https://github.com/react-dnd/react-dnd/issues/832#issuecomment-442071628
*/
&.dragdroppable--edit-mode {
transform: translate3d(0, 0, 0);
}
&.dragdroppable--dragging {
opacity: 0.2;

View File

@@ -45,7 +45,7 @@ export default function DragHandle({
}: DragHandleProps) {
return (
<DragHandleContainer ref={innerRef} position={position}>
<Icons.Drag />
<Icons.Drag iconSize="xl" />
</DragHandleContainer>
);
}

View File

@@ -207,6 +207,8 @@ const Chart = props => {
? descriptionRef.current?.offsetHeight
: 0;
setDescriptionHeight(descriptionHeight);
} else {
setDescriptionHeight(0);
}
}, [isExpanded]);

View File

@@ -676,7 +676,7 @@ const FiltersConfigForm = (
});
return excluded;
}, [
JSON.stringify(charts),
JSON.stringify(Object.values(charts).map(chart => chart.id)),
formFilter?.dataset?.value,
JSON.stringify(loadedDatasets),
]);

View File

@@ -43,7 +43,7 @@ const RemovedFilter: FC<RemovedFilterProps> = ({ onClick }) => (
buttonStyle="primary"
onClick={onClick}
>
{t('Restore Filter')}
{t('Restore filter')}
</Button>
</div>
</RemovedContent>

View File

@@ -76,6 +76,29 @@ const noTemporalColumnsState = () => {
};
};
const bigIntChartDataState = () => {
const state = defaultState();
return {
...state,
charts: {
...state.charts,
999: {
queriesResponse: [
{
status: 'success',
data: [
{ name: 'Abigail', count: 228 },
{ name: 'Aaron', count: 123012930123123n },
{ name: 'Adam', count: 454 },
],
applied_filters: [{ column: 'name' }],
},
],
},
},
};
};
const datasetResult = (id: number) => ({
description_columns: {},
id,
@@ -591,3 +614,24 @@ test('modifies the name of a filter', async () => {
),
);
});
test('renders a filter with a chart containing BigInt values', async () => {
const nativeFilterState = [
buildNativeFilter('NATIVE_FILTER-1', 'state', ['NATIVE_FILTER-2']),
buildNativeFilter('NATIVE_FILTER-2', 'country', []),
buildNativeFilter('NATIVE_FILTER-3', 'product', []),
];
const state = {
...bigIntChartDataState(),
dashboardInfo: {
metadata: { native_filter_configuration: nativeFilterState },
},
dashboardLayout,
};
defaultRender(state, {
...props,
createNewOnOpen: false,
});
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
});

View File

@@ -491,8 +491,8 @@ describe('getSlicePayload', () => {
dashboards: [],
};
test('should return the correct payload when no adhoc_filters are present in formDataWithNativeFilters', () => {
const result = getSlicePayload(
test('should return the correct payload when no adhoc_filters are present in formDataWithNativeFilters', async () => {
const result = await getSlicePayload(
sliceName,
formDataWithNativeFilters,
dashboards,
@@ -515,7 +515,7 @@ describe('getSlicePayload', () => {
);
});
test('should return the correct payload when adhoc_filters are present in formDataWithNativeFilters', () => {
test('should return the correct payload when adhoc_filters are present in formDataWithNativeFilters', async () => {
const formDataWithAdhocFilters: QueryFormData = {
...formDataWithNativeFilters,
adhoc_filters: [
@@ -528,7 +528,7 @@ describe('getSlicePayload', () => {
},
],
};
const result = getSlicePayload(
const result = await getSlicePayload(
sliceName,
formDataWithAdhocFilters,
dashboards,
@@ -551,7 +551,7 @@ describe('getSlicePayload', () => {
);
});
test('should return the correct payload when formDataWithNativeFilters has a filter with isExtra set to true', () => {
test('should return the correct payload when formDataWithNativeFilters has a filter with isExtra set to true', async () => {
const formDataWithAdhocFiltersWithExtra: QueryFormData = {
...formDataWithNativeFilters,
adhoc_filters: [
@@ -564,7 +564,7 @@ describe('getSlicePayload', () => {
},
],
};
const result = getSlicePayload(
const result = await getSlicePayload(
sliceName,
formDataWithAdhocFiltersWithExtra,
dashboards,
@@ -587,7 +587,7 @@ describe('getSlicePayload', () => {
);
});
test('should return the correct payload when formDataWithNativeFilters has a filter with isExtra set to true in mixed chart', () => {
test('should return the correct payload when formDataWithNativeFilters has a filter with isExtra set to true in mixed chart', async () => {
const formDataFromSliceWithAdhocFilterB: QueryFormData = {
...formDataFromSlice,
adhoc_filters_b: [
@@ -625,7 +625,7 @@ describe('getSlicePayload', () => {
},
],
};
const result = getSlicePayload(
const result = await getSlicePayload(
sliceName,
formDataWithAdhocFiltersWithExtra,
dashboards,
@@ -641,7 +641,7 @@ describe('getSlicePayload', () => {
);
});
test('should return the correct payload when formDataFromSliceWithAdhocFilter has no time range filters in mixed chart', () => {
test('should return the correct payload when formDataFromSliceWithAdhocFilter has no time range filters in mixed chart', async () => {
const formDataFromSliceWithAdhocFilterB: QueryFormData = {
...formDataFromSlice,
adhoc_filters: [],
@@ -672,7 +672,7 @@ describe('getSlicePayload', () => {
},
],
};
const result = getSlicePayload(
const result = await getSlicePayload(
sliceName,
formDataWithAdhocFiltersWithExtra,
dashboards,
@@ -690,7 +690,7 @@ describe('getSlicePayload', () => {
expect(hasTemporalRange).toBe(true);
});
test('should reset isExtra flag to false for temporal filter when saving as a new chart', () => {
test('should reset isExtra flag to false for temporal filter when saving as a new chart', async () => {
const formDataWithTemporalFilterWithExtra: QueryFormData = {
...formDataWithNativeFilters,
adhoc_filters: [
@@ -705,7 +705,7 @@ describe('getSlicePayload', () => {
],
};
const result = getSlicePayload(
const result = await getSlicePayload(
sliceName,
formDataWithTemporalFilterWithExtra,
dashboards,

View File

@@ -83,13 +83,13 @@ const hasTemporalRangeFilter = (formData: Partial<QueryFormData>): boolean =>
(filter: SimpleAdhocFilter) => filter.operator === Operators.TemporalRange,
);
export const getSlicePayload = (
export const getSlicePayload = async (
sliceName: string,
formDataWithNativeFilters: QueryFormData = {} as QueryFormData,
dashboards: number[],
owners: [],
formDataFromSlice: QueryFormData = {} as QueryFormData,
): Partial<PayloadSlice> => {
): Promise<Partial<PayloadSlice>> => {
const adhocFilters: Partial<QueryFormData> = extractAdhocFiltersFromFormData(
formDataWithNativeFilters,
);
@@ -168,7 +168,7 @@ export const getSlicePayload = (
dashboards,
owners,
query_context: JSON.stringify(
buildV1ChartDataPayload({
await buildV1ChartDataPayload({
formData,
force: false,
resultFormat: 'json',
@@ -242,7 +242,7 @@ export const updateSlice =
try {
const response = await SupersetClient.put({
endpoint: `/api/v1/chart/${sliceId}`,
jsonPayload: getSlicePayload(
jsonPayload: await getSlicePayload(
sliceName,
formData,
dashboards,
@@ -274,7 +274,7 @@ export const createSlice =
try {
const response = await SupersetClient.post({
endpoint: `/api/v1/chart/`,
jsonPayload: getSlicePayload(
jsonPayload: await getSlicePayload(
sliceName,
formData,
dashboards,

Some files were not shown because too many files have changed in this diff Show More