mirror of
https://github.com/apache/superset.git
synced 2026-07-09 00:05:36 +00:00
Compare commits
51 Commits
refactor_q
...
msyavuz/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e6d85104 | ||
|
|
ad592c717e | ||
|
|
38e15196f2 | ||
|
|
8131c24acd | ||
|
|
952b620465 | ||
|
|
f3e3bd0348 | ||
|
|
1e1310dbd8 | ||
|
|
adaae8ba15 | ||
|
|
a66b7e98e0 | ||
|
|
3e12d97e8e | ||
|
|
00304f77e1 | ||
|
|
e88db9f403 | ||
|
|
53e9cf6d17 | ||
|
|
5a004590e0 | ||
|
|
53503e32ae | ||
|
|
246181a546 | ||
|
|
6f5d9c989a | ||
|
|
8515792b04 | ||
|
|
923b2b1d77 | ||
|
|
486b0122d0 | ||
|
|
ae090fa74c | ||
|
|
35ec6d308a | ||
|
|
c62a6f5cee | ||
|
|
cdd140b3cc | ||
|
|
09cf49c2ba | ||
|
|
ac4b4c7646 | ||
|
|
d0a6c78966 | ||
|
|
65f2071aa4 | ||
|
|
e8f37a3f89 | ||
|
|
19d229ea12 | ||
|
|
622a62d7a1 | ||
|
|
4a556f4ac4 | ||
|
|
7a1839ba1b | ||
|
|
8f2afb8f4d | ||
|
|
02586981da | ||
|
|
8700a0b939 | ||
|
|
d843fef2ce | ||
|
|
f45654c03c | ||
|
|
761daec53d | ||
|
|
407fb67f1e | ||
|
|
49689eec6c | ||
|
|
791ea9860d | ||
|
|
2f8939d229 | ||
|
|
ccf6290120 | ||
|
|
96a1aa60e8 | ||
|
|
2ea0368c2d | ||
|
|
9e407e4e80 | ||
|
|
360e58c181 | ||
|
|
22d5eb7835 | ||
|
|
7c4a77a909 | ||
|
|
4e209e51d0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,3 +131,4 @@ superset/static/stats/statistics.html
|
||||
# LLM-related
|
||||
CLAUDE.local.md
|
||||
.aider*
|
||||
.claude_rc*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
152
docker/docker-pytest-entrypoint.sh
Executable file
152
docker/docker-pytest-entrypoint.sh
Executable 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
|
||||
55
docker/pythonpath_dev/superset_test_config_light.py
Normal file
55
docker/pythonpath_dev/superset_test_config_light.py
Normal 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
190
docker/tag_latest_release.sh
Executable 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
|
||||
@@ -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": {
|
||||
|
||||
200
docs/yarn.lock
200
docs/yarn.lock
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -195,6 +195,7 @@ development = [
|
||||
"grpcio>=1.55.3",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
"pre-commit",
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 can’t 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,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
1490
superset-frontend/package-lock.json
generated
1490
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from './getStandardizedControls';
|
||||
export * from './getTemporalColumns';
|
||||
export * from './displayTimeRelatedControls';
|
||||
export * from './colorControls';
|
||||
export * from './metricColumnFilter';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};` : ''
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -116,6 +116,8 @@ export function commonLayerProps({
|
||||
drillBy: {},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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`],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -184,7 +184,7 @@ export class EchartsChartPlugin<
|
||||
super({
|
||||
...restProps,
|
||||
metadata: new ChartMetadata({
|
||||
parseMethod: 'json-bigint',
|
||||
parseMethod: 'json',
|
||||
...metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,6 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"jest": "^30.0.4"
|
||||
"jest": "^30.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,6 @@
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^30.0.4"
|
||||
"jest": "^30.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,6 +41,7 @@ export type Dataset = {
|
||||
last_name: string;
|
||||
}[];
|
||||
columns?: Column[];
|
||||
drillable_columns?: Column[];
|
||||
metrics?: Metric[];
|
||||
verbose_map?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function DragHandle({
|
||||
}: DragHandleProps) {
|
||||
return (
|
||||
<DragHandleContainer ref={innerRef} position={position}>
|
||||
<Icons.Drag />
|
||||
<Icons.Drag iconSize="xl" />
|
||||
</DragHandleContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,6 +207,8 @@ const Chart = props => {
|
||||
? descriptionRef.current?.offsetHeight
|
||||
: 0;
|
||||
setDescriptionHeight(descriptionHeight);
|
||||
} else {
|
||||
setDescriptionHeight(0);
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -43,7 +43,7 @@ const RemovedFilter: FC<RemovedFilterProps> = ({ onClick }) => (
|
||||
buttonStyle="primary"
|
||||
onClick={onClick}
|
||||
>
|
||||
{t('Restore Filter')}
|
||||
{t('Restore filter')}
|
||||
</Button>
|
||||
</div>
|
||||
</RemovedContent>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user