Compare commits

...

27 Commits

Author SHA1 Message Date
Elizabeth Thompson
98212189b8 fix(docker): bind webpack dev server to all interfaces
Change WEBPACK_DEVSERVER_HOST from 127.0.0.1 to 0.0.0.0 to make the
frontend accessible from outside the Docker container. This fixes an
issue where the frontend was only accessible from inside the container.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-23 17:03:49 -07:00
SBIN2010
1e4bc6ee78 fix: bug in tooltip timeseries chart in calculated total with annotation layer (#35179) 2025-09-19 10:30:42 -07:00
Pat Buxton
db178cf527 fix: Bump pandas to 2.1.4 for python 3.12 (#34999) 2025-09-19 09:18:00 -07:00
Alexandru Soare
5901320933 feat(database): Adding per-user caching option in Security tab (#34842) 2025-09-19 19:15:31 +03:00
SBIN2010
23bb4f88c0 fix(Funnel): onInit overridden row_limit to default value on save chart (#35076) 2025-09-19 09:13:45 -07:00
Levis Mbote
4130b92966 fix(gantt-chart): fix Y-axis label visibility in dark theme (#35189) 2025-09-19 12:33:53 +03:00
Mehmet Salih Yavuz
38297edc6b chore(matrixify): Remove leftover option (#35195) 2025-09-19 00:47:47 +03:00
sha174n
0c8f326258 docs: Add security warning for ENABLE_TEMPLATE_PROCESSING (#35192) 2025-09-18 17:36:41 -04:00
Joe Li
127f6b3d66 fix(tests): migrate Cypress control tests to React Testing Library (#35181)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 14:23:33 -07:00
Maxime Beauchemin
ea519a77b5 fix: only block showtime for unauthorized users on push (#35184)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-18 12:28:28 -07:00
Michael S. Molina
6cb3ef9f5d chore: TypeScript Configuration Modernization and Cleanup (#35159) 2025-09-18 16:27:57 -03:00
Kamil Gabryjelski
a889ae75fc chore: Bump ag grid to 34.2.0 (#35193) 2025-09-18 19:09:22 +02:00
Mehmet Salih Yavuz
b60be9655f feat(TimeTable): add other sparkline type options (#35180) 2025-09-18 16:41:05 +03:00
lc-4918
fd6da21ce0 chore(i18n): update French translations (#35070)
Co-authored-by: l.clement <l.clement@altereo.fr>
2025-09-17 21:05:15 -07:00
marun
1bf112a57a fix(CrudThemeProvider): Optimized theme loading logic (#35155) 2025-09-17 20:49:26 -07:00
marun
1f530d45cb fix(embedded): resolve theme context error in Loading component (#35168)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 20:44:04 -07:00
Joe Li
1187902e68 feat(playwright): Add Playwright CI Integration for Cypress Migration (SIP-178) (#35110)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 17:13:47 -07:00
Maxime Beauchemin
ad3eff9e90 feat(matrixify): replace single toggle with separate horizontal/vertical layout controls (#35067)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-09-17 22:57:06 +03:00
SBIN2010
3e554674ff feat(waterfall): add changes label series and grouping customize settings (#34847) 2025-09-17 08:24:45 -03:00
Amin Ghadersohi
dced2f8564 feat: Add BaseDAO improvements and test reorganization (#35018)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 18:15:16 -07:00
Maxime Beauchemin
05c6a1bf20 fix(viz): resolve dark mode compatibility issues in BigNumber and Heatmap (#35151)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 10:21:47 -07:00
SBIN2010
c193d6d6a1 fix: import bug template params (#35144) 2025-09-16 10:21:29 -07:00
Joe Li
fb840b8e71 fix(deck.gl): restore legend display for Polygon charts with linear palette and fixed color schemes (#35142)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 20:20:42 +03:00
Maxime Beauchemin
d0cc6f115b feat: add optional garbage collection after requests (#35061)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 09:23:39 -07:00
Hugh A. Miles II
966e231f94 feat: Add Dashboard Filter Support for Alert Reports (#32196)
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Hugh A Miles II <hugh@Mac.home>
2025-09-16 10:52:28 -04:00
Richard Fogaca Nienkotter
a66737cb05 feat(custom-tooltip): custom tooltip on deck.gl charts (#34276) 2025-09-16 17:11:19 +03:00
Michael S. Molina
bc6859a99d refactor: Organizes the src/core folder (#35119) 2025-09-16 08:21:16 -03:00
227 changed files with 14287 additions and 2560 deletions

View File

@@ -182,6 +182,76 @@ cypress-run-all() {
kill $flaskProcessId
}
playwright-install() {
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Install Playwright browsers"
npx playwright install --with-deps chromium
# Create output directories for test results and debugging
mkdir -p playwright-results
mkdir -p test-results
say "::endgroup::"
}
playwright-run() {
local APP_ROOT=$1
# Start Flask from the project root (same as Cypress)
cd "$GITHUB_WORKSPACE"
local flasklog="${HOME}/flask-playwright.log"
local port=8081
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
export SUPERSET_APP_ROOT=$APP_ROOT
PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/
fi
export PLAYWRIGHT_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# Ensure cleanup on exit
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
# Wait for server to be ready with health check
local timeout=60
say "Waiting for Flask server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
say "Flask server is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::Flask server failed to start within 60 seconds"
echo "::group::Flask startup log"
cat "$flasklog"
echo "::endgroup::"
return 1
fi
# Change to frontend directory for Playwright execution
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Run Playwright tests"
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
npx playwright test auth/login --reporter=github --output=playwright-results
local status=$?
say "::endgroup::"
# After job is done, print out Flask log for debugging
echo "::group::Flask log for Playwright run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
return $status
}
eyes-storybook-dependencies() {
say "::group::install eyes-storyook dependencies"
sudo apt-get update -y && sudo apt-get -y install gconf-service ca-certificates libxshmfence-dev fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libglib2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils libappindicator1

View File

@@ -61,17 +61,8 @@ jobs:
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
const authorized = ['write', 'admin'].includes(permission.permission);
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
// If this is a synchronize event, check if Showtime is active and set blocked label
if (context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
// If this is a synchronize event from unauthorized user, check if Showtime is active and set blocked label
if (!authorized && context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
// Check if PR has any circus tent labels (Showtime is in use)
@@ -99,6 +90,15 @@ jobs:
}
}
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |

View File

@@ -143,7 +143,7 @@ jobs:
- name: tsc
run: |
docker run --rm $TAG bash -c \
"npm run type"
"npm run plugins:build && npm run type"
validate-frontend:
needs: frontend-build

View File

@@ -0,0 +1,141 @@
name: Playwright E2E Tests
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
ref:
description: 'The branch or tag to checkout'
required: false
default: ''
pr_id:
description: 'The pull request ID to checkout'
required: false
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
playwright-tests:
runs-on: ubuntu-22.04
# Allow workflow to succeed even if tests fail during shadow mode
continue-on-error: true
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@v5
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run ${{ matrix.app_root }}
- name: Set safe app root
if: failure()
id: set-safe-app-root
run: |
APP_ROOT="${{ matrix.app_root }}"
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}

17
LLMS.md
View File

@@ -15,8 +15,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Testing Strategy Migration
- **Prefer unit tests** over integration tests
- **Prefer integration tests** over Cypress end-to-end tests
- **Cypress is last resort** - Actively moving away from Cypress
- **Prefer integration tests** over end-to-end tests
- **Use Playwright for E2E tests** - Migrating from Cypress
- **Cypress is deprecated** - Will be removed once migration is completed
- **Use Jest + React Testing Library** for component testing
- **Use `test()` instead of `describe()`** - Follow [avoid nesting when testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) principles
@@ -107,6 +108,18 @@ superset/
npm run test # All tests
npm run test -- filename.test.tsx # Single file
# E2E Tests (Playwright - NEW)
npm run playwright:test # All Playwright tests
npm run playwright:ui # Interactive UI mode
npm run playwright:headed # See browser during tests
npx playwright test tests/auth/login.spec.ts # Single file
npm run playwright:debug tests/auth/login.spec.ts # Debug specific file
# E2E Tests (Cypress - DEPRECATED)
cd superset-frontend/cypress-base
npm run cypress-run-chrome # All Cypress tests (headless)
npm run cypress-debug # Interactive Cypress UI
# Backend
pytest # All tests
pytest tests/unit_tests/specific_test.py # Single file

View File

@@ -163,7 +163,7 @@ services:
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces

View File

@@ -10,8 +10,15 @@ version: 1
## Jinja Templates
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in
`superset_config.py`. When templating is enabled, python code can be embedded in virtual datasets and
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
> #### ⚠️ Security Warning
>
> While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
>
> If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
When templating is enabled, python code can be embedded in virtual datasets and
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
made available in the Jinja context:

View File

@@ -631,7 +631,7 @@ can find all of the workflows and other assets under the `.github/` folder. This
- running the backend unit test suites (`tests/`)
- running the frontend test suites (`superset-frontend/src/**.*.test.*`)
- running our Cypress end-to-end tests (`superset-frontend/cypress-base/`)
- running our Playwright end-to-end tests (`superset-frontend/playwright/`) and legacy Cypress tests (`superset-frontend/cypress-base/`)
- linting the codebase, including all Python, Typescript and Javascript, yaml and beyond
- checking for all sorts of other rules conventions

View File

@@ -225,21 +225,57 @@ npm run test -- path/to/file.js
### E2E Integration Testing
For E2E testing, we recommend that you use a `docker compose` backend
**Note: We are migrating from Cypress to Playwright. Use Playwright for new tests.**
#### Playwright (Recommended - NEW)
For E2E testing with Playwright, use the same `docker compose` backend:
```bash
CYPRESS_CONFIG=true docker compose up --build
```
`docker compose` will get to work and expose a Cypress-ready Superset app.
This app uses a different database schema (`superset_cypress`) to keep it isolated from
your other dev environment(s), a specific set of examples, and a set of configurations that
aligns with the expectations within the end-to-end tests. Also note that it's served on a
different port than the default port for the backend (`8088`).
Now in another terminal, let's get ready to execute some Cypress commands. First, tell cypress
to connect to the Cypress-ready Superset backend.
The backend setup is identical - this exposes a test-ready Superset app on port 8081 with isolated database schema (`superset_cypress`), test data, and configurations.
Now in another terminal, run Playwright tests:
```bash
# Navigate to frontend directory (Playwright config is here)
cd superset-frontend
# Run all Playwright tests
npm run playwright:test
# or: npx playwright test
# Run with interactive UI for debugging
npm run playwright:ui
# or: npx playwright test --ui
# Run in headed mode (see browser)
npm run playwright:headed
# or: npx playwright test --headed
# Run specific test file
npx playwright test tests/auth/login.spec.ts
# Run with debug mode (step through tests)
npm run playwright:debug tests/auth/login.spec.ts
# or: npx playwright test --debug tests/auth/login.spec.ts
# Generate test report
npx playwright show-report
```
Configuration is in `superset-frontend/playwright.config.ts`. Base URL is automatically set to `http://localhost:8088` but will use `PLAYWRIGHT_BASE_URL` if provided.
#### Cypress (DEPRECATED - will be removed in Phase 5)
:::warning
Cypress is being phased out in favor of Playwright. Use Playwright for all new tests.
:::
```bash
# Set base URL for Cypress
CYPRESS_BASE_URL=http://localhost:8081
```

View File

@@ -76,7 +76,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.0.3, <2.1",
"pandas[excel]>=2.0.3, <2.2",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -84,6 +84,7 @@ dependencies = [
"pgsanity",
"Pillow>=11.0.0, <12",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.0.6, <4",
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies

View File

@@ -6,6 +6,8 @@ alembic==1.15.2
# via flask-migrate
amqp==5.3.1
# via kombu
annotated-types==0.7.0
# via pydantic
apispec==6.6.1
# via
# -r requirements/base.in
@@ -115,7 +117,9 @@ flask==2.3.3
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.0
# via apache-superset (pyproject.toml)
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.3.1
@@ -156,6 +160,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -262,7 +267,7 @@ packaging==25.0
# limits
# marshmallow
# shillelagh
pandas==2.0.3
pandas==2.1.4
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -294,6 +299,10 @@ pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
pydantic==2.11.7
# via apache-superset (pyproject.toml)
pydantic-core==2.33.2
# via pydantic
pygments==2.19.1
# via rich
pyjwt==2.10.1
@@ -404,10 +413,15 @@ typing-extensions==4.14.0
# alembic
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via pydantic
tzdata==2025.2
# via
# kombu

View File

@@ -18,6 +18,10 @@ amqp==5.3.1
# via
# -c requirements/base-constraint.txt
# kombu
annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
apispec==6.6.1
# via
# -c requirements/base-constraint.txt
@@ -212,6 +216,7 @@ flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
@@ -326,6 +331,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -531,7 +537,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.0.3
pandas==2.1.4
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -631,6 +637,14 @@ pycparser==2.22
# via
# -c requirements/base-constraint.txt
# cffi
pydantic==2.11.7
# via
# -c requirements/base-constraint.txt
# apache-superset
pydantic-core==2.33.2
# via
# -c requirements/base-constraint.txt
# pydantic
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -874,10 +888,17 @@ typing-extensions==4.14.0
# apache-superset
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via
# -c requirements/base-constraint.txt
# pydantic
tzdata==2025.2
# via
# -c requirements/base-constraint.txt

View File

@@ -323,6 +323,7 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'playwright/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
@@ -397,6 +398,13 @@ module.exports = {
'react/no-void-elements': 0,
},
},
{
files: ['playwright/**/*'],
rules: {
'import/no-unresolved': 0, // Playwright is not installed in main build
'import/no-extraneous-dependencies': 0, // Playwright is not installed in main build
},
},
],
// eslint-disable-next-line no-dupe-keys
rules: {

View File

@@ -175,7 +175,7 @@ describe('Charts list', () => {
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediatly without this
// Modal closes immediately without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');

View File

@@ -1,186 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// ***********************************************
// Tests for setting controls in the UI
// ***********************************************
import { interceptChart, setSelectSearchInput } from 'cypress/utils';
describe('Datasource control', () => {
const newMetricName = `abc${Date.now()}`;
it('should allow edit dataset', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('[data-test="edit-dataset-tabs"]').within(() => {
cy.contains('Metrics').click();
});
// create new metric
cy.get('[data-test="crud-add-table-item"]', { timeout: 10000 }).click();
cy.wait(1000);
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.click();
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.focus();
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
// select new metric
cy.get('[data-test=metrics]')
.contains('Drop columns/metrics here or click')
.click();
cy.get('input[aria-label="Select saved metrics"]')
.should('exist')
.then($input => {
setSelectSearchInput($input, newMetricName);
});
// delete metric
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('.ant-modal-content').within(() => {
cy.get('[data-test="collection-tab-Metrics"]')
.contains('Metrics')
.click();
});
cy.get(`[data-test="textarea-editable-title-input"]`)
.contains(newMetricName)
.closest('tr')
.find('[data-test="crud-delete-icon"]')
.click();
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
cy.get('[data-test="metrics"]').contains(newMetricName).should('not.exist');
});
});
describe('Color scheme control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('should show color options with and without tooltips', () => {
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.ant-select-selection-item .color-scheme-label').contains(
'Superset Colors',
);
cy.get('.ant-select-selection-item .color-scheme-label').trigger(
'mouseover',
);
cy.get('.color-scheme-tooltip').should('be.visible');
cy.get('.color-scheme-tooltip').contains('Superset Colors');
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseover');
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseout');
cy.focused().type('lyftColors');
cy.getBySel('lyftColors').should('exist');
cy.getBySel('lyftColors').trigger('mouseover', { force: true });
cy.get('.color-scheme-tooltip').should('not.be.visible');
});
});
describe('VizType control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('bigNumberChartData');
});
it('Can change vizType', () => {
cy.visitChartByName('Daily Totals');
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.contains('View all charts').click();
cy.get('.ant-modal-content').within(() => {
cy.get('button').contains('KPI').click(); // change categories
cy.get('[role="button"]').contains('Big Number').click();
cy.get('button').contains('Select').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@bigNumberChartData',
});
});
});
describe('Test datatable', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('lineChartData');
cy.visitChartByName('Daily Totals');
});
it('Data Pane opens and loads results', () => {
cy.contains('Results').click();
cy.get('[data-test="row-count-label"]').contains('26 rows');
cy.get('.ant-empty-description').should('not.exist');
});
it('Datapane loads view samples', () => {
cy.intercept(
'**/datasource/samples?force=false&datasource_type=table&datasource_id=*',
).as('Samples');
cy.contains('Samples').click();
cy.wait('@Samples');
cy.get('.ant-tabs-tab-active').contains('Samples');
cy.get('[data-test="row-count-label"]').contains('1k rows');
cy.get('.ant-empty-description').should('not.exist');
});
});
describe('Groupby control', () => {
it('Set groupby', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=groupby]')
.contains('Drop columns here or click')
.click();
cy.get('[id="adhoc-metric-edit-tabs-tab-simple"]').click();
cy.get('input[aria-label="Column"]').click();
cy.get('input[aria-label="Column"]').type('state{enter}');
cy.get('[data-test="ColumnEdit#save"]').contains('Save').click();
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
});

View File

@@ -33,6 +33,7 @@ module.exports = {
'^@superset-ui/([^/]+)$': '<rootDir>/node_modules/@superset-ui/$1/src',
// mapping @apache-superset/core to local package
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
},
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],

View File

@@ -54,6 +54,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -159,6 +161,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -10109,6 +10112,22 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -18696,27 +18715,27 @@
}
},
"node_modules/ag-charts-types": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz",
"integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz",
"integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "12.0.2"
"ag-charts-types": "12.2.0"
}
},
"node_modules/ag-grid-react": {
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz",
"integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "34.0.2",
"ag-grid-community": "34.2.0",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -45517,6 +45536,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-async": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
@@ -60624,7 +60690,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc3",
"version": "0.0.1-rc4",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
@@ -63321,10 +63387,10 @@
"version": "0.20.3",
"license": "Apache-2.0",
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
@@ -63349,14 +63415,15 @@
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"core-js": "^3.38.1",
@@ -65227,6 +65294,8 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
@@ -65393,6 +65462,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -65444,6 +65514,7 @@
"lodash": "^4.17.21"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"echarts": "*",
@@ -66621,6 +66692,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
@@ -67752,6 +67824,7 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -63,6 +63,11 @@
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
"plugins:release-from-tag": "npm run prune && npm run plugins:build && lerna publish from-package --yes",
"plugins:storybook": "cd packages/superset-ui-demo && npm run storybook",
"playwright:test": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"playwright:report": "playwright show-report",
"prettier": "npm run _prettier -- --write",
"prettier-check": "npm run _prettier -- --check",
"prod": "npm run build",
@@ -122,6 +127,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -227,6 +234,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",

View File

@@ -22,19 +22,6 @@ To add the package to Superset, go to the `superset-frontend` subdirectory in yo
npm i -S ../../<%= packageName %>
```
If your Superset plugin exists in the `superset-frontend` directory and you wish to resolve TypeScript errors about `@superset-ui/core` not being resolved correctly, add the following to your `tsconfig.json` file:
```
"references": [
{
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
}
]
```
You may also wish to add the following to the `include` array in `tsconfig.json` to make Superset types available to your plugin:
```

View File

@@ -1,44 +1,19 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": false,
"jsx": "react",
"lib": [
"dom",
"esnext"
],
"module": "esnext",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"pretty": true,
"removeComments": false,
"strict": true,
"target": "es2015",
"useDefineForClassFields": false,
"composite": true,
"declarationMap": true,
"rootDir": "src",
"skipLibCheck": true,
"emitDeclarationOnly": true,
"resolveJsonModule": true,
"types": ["jest"],
"typeRoots": [
"./node_modules/@types"
]
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"lib",
"test"
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"include": [
"src/**/*",
"types/**/*"
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@apache-superset/core",
"version": "0.0.1-rc3",
"version": "0.0.1-rc4",
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
"sideEffects": false,
"main": "lib/index.js",

View File

@@ -1,19 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"outDir": "lib",
"strict": true,
"rootDir": "src",
"jsx": "preserve",
"baseUrl": ".",
"module": "esnext",
"moduleResolution": "node",
"skipLibCheck": true,
"target": "es2020",
"esModuleInterop": true
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*.ts*"],
"exclude": ["lib"]
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
}

View File

@@ -24,10 +24,10 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -20,20 +20,32 @@ import { t } from '@superset-ui/core';
import { ControlPanelSectionConfig } from '../types';
export const matrixifyEnableSection: ControlPanelSectionConfig = {
label: t('Enable matrixify'),
label: t('Matrixify'),
expanded: true,
controlSetRows: [
[
{
name: 'matrixify_enabled',
name: 'matrixify_enable_horizontal_layout',
config: {
type: 'CheckboxControl',
label: t('Enable matrixify'),
label: t('Enable horizontal layout (columns)'),
description: t(
'Create matrix columns by placing charts side-by-side',
),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'matrixify_enable_vertical_layout',
config: {
type: 'CheckboxControl',
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
default: false,
renderTrigger: true,
description: t(
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
},
},
],
@@ -42,9 +54,11 @@ export const matrixifyEnableSection: ControlPanelSectionConfig = {
};
export const matrixifySection: ControlPanelSectionConfig = {
label: t('Matrixify'),
label: t('Cell layout & styling'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
controlSetRows: [
[
{
@@ -105,9 +119,10 @@ export const matrixifySection: ControlPanelSectionConfig = {
};
export const matrixifyRowSection: ControlPanelSectionConfig = {
label: t('Vertical layout'),
label: t('Vertical layout (rows)'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true,
controlSetRows: [
['matrixify_show_row_labels'],
['matrixify_mode_rows'],
@@ -118,13 +133,14 @@ export const matrixifyRowSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_rows'],
['matrixify_topn_order_rows'],
],
tabOverride: 'data',
tabOverride: 'matrixify',
};
export const matrixifyColumnSection: ControlPanelSectionConfig = {
label: t('Horizontal layout'),
label: t('Horizontal layout (columns)'),
expanded: false,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
visibility: ({ controls }) =>
controls?.matrixify_enable_horizontal_layout?.value === true,
controlSetRows: [
['matrixify_show_column_headers'],
['matrixify_mode_columns'],
@@ -135,5 +151,5 @@ export const matrixifyColumnSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_columns'],
['matrixify_topn_order_columns'],
],
tabOverride: 'data',
tabOverride: 'matrixify',
};

View File

@@ -18,21 +18,49 @@
* under the License.
*/
import { t } from '@superset-ui/core';
import { t, validateNonEmpty } from '@superset-ui/core';
import { SharedControlConfig } from '../types';
import { dndAdhocMetricControl } from './dndControls';
import { defineSavedMetrics } from '../utils';
/**
* Matrixify control definitions
* Controls for transforming charts into matrix/grid layouts
*/
// Utility function to check if matrixify controls should be visible
const isMatrixifyVisible = (
controls: any,
axis: 'rows' | 'columns',
mode?: 'metrics' | 'dimensions',
selectionMode?: 'members' | 'topn',
) => {
const layoutControl = `matrixify_enable_${axis === 'rows' ? 'vertical' : 'horizontal'}_layout`;
const modeControl = `matrixify_mode_${axis}`;
const selectionModeControl = `matrixify_dimension_selection_mode_${axis}`;
const isLayoutEnabled = controls?.[layoutControl]?.value === true;
if (!isLayoutEnabled) return false;
if (mode) {
const isModeMatch = controls?.[modeControl]?.value === mode;
if (!isModeMatch) return false;
if (selectionMode && mode === 'dimensions') {
return controls?.[selectionModeControl]?.value === selectionMode;
}
}
return true;
};
// Initialize the controls object that will be populated dynamically
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
// Dynamically add axis-specific controls (rows and columns)
['columns', 'rows'].forEach(axisParam => {
const axis = axisParam; // Capture the value in a local variable
(['columns', 'rows'] as const).forEach(axisParam => {
const axis: 'rows' | 'columns' = axisParam;
matrixifyControls[`matrixify_mode_${axis}`] = {
type: 'RadioButtonControl',
@@ -43,17 +71,18 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['dimensions', t('Dimension members')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
};
matrixifyControls[`matrixify_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metrics`),
multi: true,
validators: [], // Not required
// description: t(`Select metrics for ${axis}`),
validators: [], // No validation - rely on visibility
renderTrigger: true,
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'metrics',
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'metrics'),
};
// Combined dimension and values control
@@ -62,8 +91,9 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
label: t(`Dimension selection`),
description: t(`Select dimension and values`),
default: { dimension: '', values: [] },
validators: [], // Not required
validators: [], // No validation - rely on visibility
renderTrigger: true,
tabOverride: 'matrixify',
shouldMapStateToProps: (prevState, state) => {
// Recalculate when any relevant form_data field changes
const fieldsToCheck = [
@@ -82,24 +112,40 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
const getValue = (key: string, defaultValue?: any) =>
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
return {
datasource,
selectionMode: getValue(
const selectionMode = getValue(
`matrixify_dimension_selection_mode_${axis}`,
'members',
),
);
const isVisible = isMatrixifyVisible(controls, axis, 'dimensions');
// Validate dimension is selected when visible
const dimensionValidator = (value: any) => {
if (!value?.dimension) {
return t('Dimension is required');
}
return false;
};
// Additional validation for topN mode
const validators = isVisible
? [dimensionValidator, validateNonEmpty]
: [];
return {
datasource,
selectionMode,
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
topNValue: getValue(`matrixify_topn_value_${axis}`),
topNOrder: getValue(`matrixify_topn_order_${axis}`),
formData: form_data,
validators,
};
},
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
isMatrixifyVisible(controls, axis, 'dimensions'),
};
// Dimension picker for TopN mode (just dimension, no values)
// NOTE: This is now handled by matrixify_dimension control, so hiding it
matrixifyControls[`matrixify_topn_dimension_${axis}`] = {
type: 'SelectControl',
label: t('Dimension'),
@@ -127,33 +173,67 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['topn', t('Top n')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
isMatrixifyVisible(controls, axis, 'dimensions'),
};
// TopN controls
matrixifyControls[`matrixify_topn_value_${axis}`] = {
type: 'TextControl',
type: 'NumberControl',
label: t(`Number of top values`),
description: t(`How many top values to select`),
default: 10,
isInt: true,
validators: [],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: ({ controls }) => {
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
'topn',
);
return {
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metric for ordering`),
multi: false,
validators: [], // Not required
validators: [],
description: t(`Metric to use for ordering Top N values`),
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: (state, controlState) => {
const { controls, datasource } = state;
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
'topn',
);
const originalProps =
dndAdhocMetricControl.mapStateToProps?.(state, controlState) || {};
return {
...originalProps,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_order_${axis}`] = {
@@ -164,10 +244,10 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['asc', t('Ascending')],
['desc', t('Descending')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
};
});
@@ -213,15 +293,22 @@ matrixifyControls.matrixify_charts_per_row = {
!controls?.matrixify_fit_columns_dynamically?.value,
};
// Main enable control
matrixifyControls.matrixify_enabled = {
matrixifyControls.matrixify_enable_vertical_layout = {
type: 'CheckboxControl',
label: t('Enable matrixify'),
description: t(
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
matrixifyControls.matrixify_enable_horizontal_layout = {
type: 'CheckboxControl',
label: t('Enable horizontal layout (columns)'),
description: t('Create matrix columns by placing charts side-by-side'),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
// Cell title control for Matrixify
@@ -234,8 +321,8 @@ matrixifyControls.matrixify_cell_title_template = {
default: '',
renderTrigger: true,
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
};
// Matrix display controls
@@ -245,9 +332,9 @@ matrixifyControls.matrixify_show_row_labels = {
description: t('Display labels for each row on the left side of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_vertical_layout?.value === true,
};
matrixifyControls.matrixify_show_column_headers = {
@@ -256,9 +343,9 @@ matrixifyControls.matrixify_show_column_headers = {
description: t('Display headers for each column at the top of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
controls?.matrixify_enable_horizontal_layout?.value === true,
};
export { matrixifyControls };

View File

@@ -17,11 +17,7 @@
* under the License.
*/
import { ensureIsArray, GenericDataType, ValueOf } from '@superset-ui/core';
import {
ControlPanelState,
isDataset,
isQueryResponse,
} from '@superset-ui/chart-controls';
import { ControlPanelState, isDataset, isQueryResponse } from '../types';
export function checkColumnType(
columnName: string,

View File

@@ -2,18 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,22 +1,13 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": [
"lib",
"test"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"references": [
{
"path": "../superset-ui-core"
}
{ "path": "../superset-core" },
{ "path": "../superset-ui-core" }
]
}

View File

@@ -24,14 +24,15 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"csstype": "^3.1.3",

View File

@@ -276,10 +276,10 @@ export function generateMatrixifyGrid(
const cellFormData = generateCellFormData(
formData,
rowCount > 1 ? config.rows : null,
colCount > 1 ? config.columns : null,
rowCount > 1 ? row : null,
colCount > 1 ? col : null,
rowCount > 0 ? config.rows : null,
colCount > 0 ? config.columns : null,
rowCount > 0 ? row : null,
colCount > 0 ? col : null,
);
// Generate title using template if provided

View File

@@ -74,7 +74,8 @@ test('should create single group when fitting columns dynamically', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: true,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -123,7 +124,8 @@ test('should create multiple groups when not fitting columns dynamically', () =>
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -158,7 +160,8 @@ test('should handle exact division of columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -186,7 +189,8 @@ test('should handle case where charts_per_row exceeds total columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 5,
matrixify_show_row_labels: true,
@@ -216,7 +220,8 @@ test('should show headers for each group when wrapping occurs', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -250,7 +255,8 @@ test('should show headers only on first row when not wrapping', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: true, // No wrapping
matrixify_show_row_labels: true,
matrixify_show_column_headers: true,
@@ -279,7 +285,8 @@ test('should hide headers when disabled', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_show_row_labels: false,
matrixify_show_column_headers: false,
};
@@ -306,7 +313,8 @@ test('should place cells correctly in wrapped layout', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -336,7 +344,8 @@ test('should handle null grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
};
const { container } = renderWithTheme(
@@ -357,7 +366,8 @@ test('should handle empty grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
};
const { container } = renderWithTheme(
@@ -381,7 +391,8 @@ test('should use default values for missing configuration', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
// Missing optional configurations
};

View File

@@ -128,9 +128,13 @@ function MatrixifyGridRenderer({
[formData],
);
// Determine layout parameters
const showRowLabels = formData.matrixify_show_row_labels ?? true;
const showColumnHeaders = formData.matrixify_show_column_headers ?? true;
// Determine layout parameters - only show headers/labels if layout is enabled
const showRowLabels =
formData.matrixify_enable_vertical_layout === true &&
(formData.matrixify_show_row_labels ?? true);
const showColumnHeaders =
formData.matrixify_enable_horizontal_layout === true &&
(formData.matrixify_show_column_headers ?? true);
const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT;
const fitColumnsDynamically =
formData.matrixify_fit_columns_dynamically ?? true;

View File

@@ -37,10 +37,11 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi
expect(isMatrixifyEnabled(formData)).toBe(false);
});
test('isMatrixifyEnabled should return false when matrixify_enabled is false', () => {
test('isMatrixifyEnabled should return false when layout controls are false', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: false,
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
} as MatrixifyFormData;
@@ -51,7 +52,7 @@ test('isMatrixifyEnabled should return false when matrixify_enabled is false', (
test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -64,7 +65,7 @@ test('isMatrixifyEnabled should return true for valid metrics mode configuration
test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -77,7 +78,7 @@ test('isMatrixifyEnabled should return true for valid dimensions mode configurat
test('isMatrixifyEnabled should return true for mixed mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'dimensions',
matrixify_rows: [createMetric('Revenue')],
@@ -90,7 +91,7 @@ test('isMatrixifyEnabled should return true for mixed mode configuration', () =>
test('isMatrixifyEnabled should return true for topn dimension selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -109,7 +110,7 @@ test('isMatrixifyEnabled should return true for topn dimension selection mode',
test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [],
@@ -122,7 +123,7 @@ test('isMatrixifyEnabled should return false when both axes have empty metrics a
test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: [] },
@@ -140,7 +141,7 @@ test('getMatrixifyConfig should return null when no matrixify configuration exis
test('getMatrixifyConfig should return valid config for metrics mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -158,7 +159,7 @@ test('getMatrixifyConfig should return valid config for metrics mode', () => {
test('getMatrixifyConfig should return valid config for dimensions mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -182,7 +183,7 @@ test('getMatrixifyConfig should return valid config for dimensions mode', () =>
test('getMatrixifyConfig should handle topn selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -203,7 +204,8 @@ test('getMatrixifyConfig should handle topn selection mode', () => {
test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: false,
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
} as MatrixifyFormData;
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
@@ -212,7 +214,7 @@ test('getMatrixifyValidationErrors should return empty array when matrixify is n
test('getMatrixifyValidationErrors should return empty array when properly configured', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -225,7 +227,7 @@ test('getMatrixifyValidationErrors should return empty array when properly confi
test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
} as MatrixifyFormData;
const errors = getMatrixifyValidationErrors(formData);
@@ -235,7 +237,7 @@ test('getMatrixifyValidationErrors should return error when enabled but no confi
test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [],
matrixify_columns: [],
@@ -261,7 +263,7 @@ test('should handle empty form data object', () => {
test('should handle partial configuration with one axis only', () => {
const formData = {
viz_type: 'table',
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
// No columns configuration

View File

@@ -96,8 +96,9 @@ export interface MatrixifyAxisConfig {
* Complete Matrixify configuration in form data
*/
export interface MatrixifyFormData {
// Enable/disable matrixify functionality
matrixify_enabled?: boolean;
// Layout enable controls
matrixify_enable_vertical_layout?: boolean;
matrixify_enable_horizontal_layout?: boolean;
// Row axis configuration
matrixify_mode_rows?: MatrixifyMode;
@@ -177,8 +178,12 @@ export function getMatrixifyConfig(
* Check if Matrixify is enabled and properly configured
*/
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
// First check if matrixify is explicitly enabled via checkbox
if (!formData.matrixify_enabled) {
// Check if either vertical or horizontal layout is enabled
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
return false;
}
@@ -216,7 +221,11 @@ export function getMatrixifyValidationErrors(
const errors: string[] = [];
// Only validate if matrixify is enabled
if (!formData.matrixify_enabled) {
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
return errors;
}

View File

@@ -123,21 +123,33 @@ export function AsyncAceEditor(
const cssWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-css'
);
const javascriptWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-javascript'
);
const htmlWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-html'
);
const acequirePromise = import('ace-builds/src-min-noconflict/ace');
const [
{ default: ReactAceEditor },
{ config },
{ default: cssWorkerUrl },
{ default: javascriptWorkerUrl },
{ default: htmlWorkerUrl },
{ require: acequire },
] = await Promise.all([
reactAcePromise,
aceBuildsConfigPromise,
cssWorkerUrlPromise,
javascriptWorkerUrlPromise,
htmlWorkerUrlPromise,
acequirePromise,
]);
config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));

View File

@@ -0,0 +1,106 @@
/* eslint-disable import/first */
/**
* 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 { FC } from 'react';
import AceEditor, { IAceEditorProps } from 'react-ace';
import ace from 'ace-builds/src-noconflict/ace';
// Disable workers to avoid localhost loading issues
ace.config.set('useWorker', false);
// Import required modes and themes after ace is loaded
import 'ace-builds/src-min-noconflict/mode-handlebars';
import 'ace-builds/src-min-noconflict/mode-css';
import 'ace-builds/src-min-noconflict/mode-json';
import 'ace-builds/src-min-noconflict/mode-sql';
import 'ace-builds/src-min-noconflict/mode-markdown';
import 'ace-builds/src-min-noconflict/mode-javascript';
import 'ace-builds/src-min-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode =
| 'handlebars'
| 'css'
| 'json'
| 'sql'
| 'markdown'
| 'javascript'
| 'html';
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;
}
export const CodeEditor: FC<CodeEditorProps> = ({
mode = 'handlebars',
theme = 'dark',
name,
width = '100%',
height = '300px',
value,
fontSize = 14,
showPrintMargin = true,
focus = true,
wrapEnabled = true,
highlightActiveLine = true,
editorProps = { $blockScrolling: true },
setOptions,
...rest
}: CodeEditorProps) => {
const editorName = name || Math.random().toString(36).substring(7);
const aceTheme = theme === 'light' ? 'github' : 'monokai';
return (
<AceEditor
mode={mode}
theme={aceTheme}
name={editorName}
height={height}
width={width}
value={value}
fontSize={fontSize}
showPrintMargin={showPrintMargin}
focus={focus}
editorProps={editorProps}
wrapEnabled={wrapEnabled}
highlightActiveLine={highlightActiveLine}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
tabSize: 2,
showGutter: true,
fontFamily:
'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
...setOptions,
}}
{...rest}
/>
);
};
export default CodeEditor;

View File

@@ -19,8 +19,10 @@
import { useEffect, useState, FunctionComponent } from 'react';
import { t, styled, css, useTheme } from '@superset-ui/core';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import { extendedDayjs } from '../../utils/dates';
import 'dayjs/plugin/updateLocale';
import 'dayjs/plugin/calendar';
import { Icons } from '../Icons';
import type { LastUpdatedProps } from './types';
@@ -46,9 +48,7 @@ export const LastUpdated: FunctionComponent<LastUpdatedProps> = ({
update,
}) => {
const theme = useTheme();
const [timeSince, setTimeSince] = useState<dayjs.Dayjs>(
extendedDayjs(updatedAt),
);
const [timeSince, setTimeSince] = useState<Dayjs>(extendedDayjs(updatedAt));
useEffect(() => {
setTimeSince(() => extendedDayjs(updatedAt));

View File

@@ -181,3 +181,9 @@ export {
type ThemedAgGridReactProps,
setupAGGridModules,
} from './ThemedAgGridReact';
export {
CodeEditor,
type CodeEditorProps,
type CodeEditorMode,
type CodeEditorTheme,
} from './CodeEditor';

View File

@@ -43,6 +43,7 @@ dayjs.updateLocale('en', {
});
export const extendedDayjs = dayjs;
export type { Dayjs };
export const fDuration = function (
t1: number,

View File

@@ -26,6 +26,7 @@ export enum FeatureFlag {
AlertReports = 'ALERT_REPORTS',
AlertReportTabs = 'ALERT_REPORT_TABS',
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',

View File

@@ -2,14 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,24 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"],
"@superset-ui/core": ["src"],
"@superset-ui/core/*": ["src/*"]
}
"baseUrl": "../..",
"outDir": "lib"
},
"exclude": [
"lib",
"test"
],
"include": [
"src/**/*",
"spec/**/*",
"types/**/*"
],
"references": []
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"references": [{ "path": "../superset-core" }]
}

View File

@@ -17,4 +17,6 @@
* under the License.
*/
declare module 'ace-builds/src-min-noconflict/worker-css';
declare module 'ace-builds/src-min-noconflict/worker-javascript';
declare module 'ace-builds/src-min-noconflict/worker-html';
declare module 'ace-builds/src-min-noconflict/ace';

View File

@@ -19,3 +19,5 @@
declare module '*.gif';
declare module '*.svg';
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';

View File

@@ -1,18 +1,9 @@
{
"compilerOptions": {
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"exclude": [
"lib",
"src/**/*.test.ts"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"references": []
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
}

View File

@@ -0,0 +1,90 @@
/**
* 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.
*/
/// <reference types="node" />
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
// Parallel execution
fullyParallel: true,
workers: process.env.CI ? 2 : 1,
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
['github'], // GitHub Actions annotations
['list'], // Detailed output with summary table
['html', { outputFolder: 'playwright-report', open: 'never' }], // Interactive report
['json', { outputFile: 'test-results/results.json' }], // Machine-readable
]
: [
['list'], // Shows summary table locally
['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Auto-open on failure
],
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088 for local
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
// Browser settings
headless: !!process.env.CI,
viewport: { width: 1280, height: 1024 },
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Trace collection for debugging
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
},
},
],
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
});

View File

@@ -0,0 +1,218 @@
<!--
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.
-->
# Playwright E2E Tests for Superset
This directory contains Playwright end-to-end tests for Apache Superset, designed as a replacement for the existing Cypress tests during the migration to Playwright.
## Architecture
```
playwright/
├── components/core/ # Reusable UI components
├── pages/ # Page Object Models
├── tests/ # Test files organized by feature
├── utils/ # Shared constants and utilities
└── README.md # This file
```
## Design Principles
We follow **YAGNI** (You Aren't Gonna Need It), **DRY** (Don't Repeat Yourself), and **KISS** (Keep It Simple, Stupid) principles:
- Build only what's needed now
- Reuse existing patterns and components
- Keep solutions simple and maintainable
## Component Architecture
### Core Components (`components/core/`)
Reusable UI interaction classes for common elements:
- **Form**: Container with properly scoped child element access
- **Input**: Supports `fill()`, `type()`, and `pressSequentially()` methods
- **Button**: Standard click, hover, focus interactions
**Usage Example:**
```typescript
import { Form } from '../components/core';
const loginForm = new Form(page, '[data-test="login-form"]');
const usernameInput = loginForm.getInput('[data-test="username-input"]');
await usernameInput.fill('admin');
```
### Page Objects (`pages/`)
Each page object encapsulates:
- **Actions**: What you can do on the page
- **Queries**: Information you can get from the page
- **Selectors**: Centralized in private static SELECTORS constant
- **NO Assertions**: Keep assertions in test files
**Page Object Pattern:**
```typescript
export class AuthPage {
// Selectors centralized in the page object
private static readonly SELECTORS = {
LOGIN_FORM: '[data-test="login-form"]',
USERNAME_INPUT: '[data-test="username-input"]',
} as const;
// Actions - what you can do
async loginWithCredentials(username: string, password: string) { }
// Queries - information you can get
async getCurrentUrl(): Promise<string> { }
// NO assertions - those belong in tests
}
```
### Tests (`tests/`)
Organized by feature/area (auth, dashboard, charts, etc.):
- Use page objects for actions
- Keep assertions in test files
- Import shared constants from `utils/`
**Test Pattern:**
```typescript
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { LOGIN } from '../../utils/urls';
test('should login with correct credentials', async ({ page }) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.loginWithCredentials('admin', 'general');
// Assertions belong in tests, not page objects
expect(await authPage.getCurrentUrl()).not.toContain(LOGIN);
});
```
### Utilities (`utils/`)
Shared constants and utilities:
- **urls.ts**: URL paths and request patterns
- Keep flat exports (no premature namespacing)
## Contributing Guidelines
### Adding New Tests
1. **Check existing components** before creating new ones
2. **Use page objects** for page interactions
3. **Keep assertions in tests**, not page objects
4. **Follow naming conventions**: `feature.spec.ts`
### Adding New Components
1. **Follow YAGNI**: Only build what's immediately needed
2. **Use Locator-based scoping** for proper element isolation
3. **Support both string selectors and Locator objects** via constructor overloads
4. **Add to `components/core/index.ts`** for easy importing
### Adding New Page Objects
1. **Centralize selectors** in private static SELECTORS constant
2. **Import shared constants** from `utils/urls.ts`
3. **Actions and queries only** - no assertions
4. **Use existing components** for DOM interactions
## Running Tests
```bash
# Run all tests
npm run playwright:test
# or: npx playwright test
# Run specific test file
npx playwright test tests/auth/login.spec.ts
# Run with UI mode for debugging
npm run playwright:ui
# or: npx playwright test --ui
# Run in headed mode (see browser)
npm run playwright:headed
# or: npx playwright test --headed
# Debug specific test file
npm run playwright:debug tests/auth/login.spec.ts
# or: npx playwright test --debug tests/auth/login.spec.ts
```
## Test Reports
Playwright generates multiple reports for better visibility:
```bash
# View interactive HTML report (opens automatically on failure)
npm run playwright:report
# or: npx playwright show-report
# View test trace for debugging failures
npx playwright show-trace test-results/[test-name]/trace.zip
```
### Report Types
- **List Reporter**: Shows progress and summary table in terminal
- **HTML Report**: Interactive web interface with screenshots, videos, and traces
- **JSON Report**: Machine-readable format in `test-results/results.json`
- **GitHub Actions**: Annotations in CI for failed tests
### Debugging Failed Tests
When tests fail, Playwright automatically captures:
- **Screenshots** at the point of failure
- **Videos** of the entire test run
- **Traces** with timeline and network activity
- **Error context** with detailed debugging information
All debugging artifacts are available in the HTML report for easy analysis.
## Configuration
- **Config**: `playwright.config.ts` - matches Cypress settings
- **Base URL**: `http://localhost:8088` (assumes Superset running)
- **Browsers**: Chrome only for Phase 1 (YAGNI)
- **Retries**: 2 in CI, 0 locally (matches Cypress)
## Migration from Cypress
When porting Cypress tests:
1. **Port the logic**, not the implementation
2. **Use page objects** instead of inline selectors
3. **Replace `cy.intercept/cy.wait`** with `page.waitForRequest()`
4. **Use shared constants** from `utils/urls.ts`
5. **Follow the established patterns** shown in `tests/auth/login.spec.ts`
## Best Practices
- **Centralize selectors** in page objects
- **Centralize URLs** in `utils/urls.ts`
- **Use meaningful test descriptions**
- **Keep page objects action-focused**
- **Put assertions in tests, not page objects**
- **Follow the existing patterns** for consistency

View File

@@ -0,0 +1,119 @@
/**
* 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 { Locator, Page } from '@playwright/test';
export class Button {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the button element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Clicks the button
* @param options - Optional click options
*/
async click(options?: {
timeout?: number;
force?: boolean;
delay?: number;
button?: 'left' | 'right' | 'middle';
}): Promise<void> {
await this.element.click(options);
}
/**
* Gets the button text content
*/
async getText(): Promise<string> {
return (await this.element.textContent()) ?? '';
}
/**
* Gets a specific attribute value from the button
* @param attribute - The attribute name to retrieve
*/
async getAttribute(attribute: string): Promise<string | null> {
return this.element.getAttribute(attribute);
}
/**
* Checks if the button is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the button is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Checks if the button is disabled
*/
async isDisabled(): Promise<boolean> {
return this.element.isDisabled();
}
/**
* Hovers over the button
* @param options - Optional hover options
*/
async hover(options?: { timeout?: number; force?: boolean }): Promise<void> {
await this.element.hover(options);
}
/**
* Focuses on the button
*/
async focus(): Promise<void> {
await this.element.focus();
}
/**
* Double clicks the button
* @param options - Optional click options
*/
async doubleClick(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.element.dblclick(options);
}
}

View File

@@ -0,0 +1,110 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { Input } from './Input';
import { Button } from './Button';
export class Form {
private readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the form element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets an input field within the form (properly scoped)
* @param inputSelector - Selector for the input field
*/
getInput(inputSelector: string): Input {
const scopedLocator = this.locator.locator(inputSelector);
return new Input(this.page, scopedLocator);
}
/**
* Gets a button within the form (properly scoped)
* @param buttonSelector - Selector for the button
*/
getButton(buttonSelector: string): Button {
const scopedLocator = this.locator.locator(buttonSelector);
return new Button(this.page, scopedLocator);
}
/**
* Checks if the form is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Submits the form (triggers submit event)
*/
async submit(): Promise<void> {
await this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
form.submit();
}
});
}
/**
* Waits for the form to be visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.locator.waitFor({ state: 'visible', ...options });
}
/**
* Gets all form data as key-value pairs
* Useful for validation and debugging
*/
async getFormData(): Promise<Record<string, string>> {
return this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
const formData = new FormData(form);
const result: Record<string, string> = {};
formData.forEach((value, key) => {
result[key] = value.toString();
});
return result;
}
return {};
});
}
}

View File

@@ -0,0 +1,111 @@
/**
* 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 { Locator, Page } from '@playwright/test';
export class Input {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the input element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fast fill - clears the input and sets the value directly
* @param value - The value to fill
* @param options - Optional fill options
*/
async fill(
value: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.element.fill(value, options);
}
/**
* Types text character by character (simulates real typing)
* @param text - The text to type
* @param options - Optional typing options
*/
async type(text: string, options?: { delay?: number }): Promise<void> {
await this.element.type(text, options);
}
/**
* Types text sequentially with more control over timing
* @param text - The text to type
* @param options - Optional sequential typing options
*/
async pressSequentially(
text: string,
options?: { delay?: number },
): Promise<void> {
await this.element.pressSequentially(text, options);
}
/**
* Gets the current value of the input
*/
async getValue(): Promise<string> {
return this.element.inputValue();
}
/**
* Clears the input field
*/
async clear(): Promise<void> {
await this.element.clear();
}
/**
* Checks if the input is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the input is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Focuses on the input field
*/
async focus(): Promise<void> {
await this.element.focus();
}
}

View File

@@ -0,0 +1,23 @@
/**
* 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.
*/
// Core Playwright Components for Superset
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';

View File

@@ -0,0 +1,122 @@
/**
* 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 { Page, Response } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
export class AuthPage {
private readonly page: Page;
private readonly loginForm: Form;
// Selectors specific to the auth/login page
private static readonly SELECTORS = {
LOGIN_FORM: '[data-test="login-form"]',
USERNAME_INPUT: '[data-test="username-input"]',
PASSWORD_INPUT: '[data-test="password-input"]',
LOGIN_BUTTON: '[data-test="login-button"]',
ERROR_SELECTORS: [
'[role="alert"]',
'.ant-form-item-explain-error',
'.ant-form-item-explain.ant-form-item-explain-error',
'.alert-danger',
],
} as const;
constructor(page: Page) {
this.page = page;
this.loginForm = new Form(page, AuthPage.SELECTORS.LOGIN_FORM);
}
/**
* Navigate to the login page
*/
async goto(): Promise<void> {
await this.page.goto(URL.LOGIN);
}
/**
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
}
/**
* Login with provided credentials
* @param username - Username to enter
* @param password - Password to enter
*/
async loginWithCredentials(
username: string,
password: string,
): Promise<void> {
const usernameInput = this.loginForm.getInput(
AuthPage.SELECTORS.USERNAME_INPUT,
);
const passwordInput = this.loginForm.getInput(
AuthPage.SELECTORS.PASSWORD_INPUT,
);
const loginButton = this.loginForm.getButton(
AuthPage.SELECTORS.LOGIN_BUTTON,
);
await usernameInput.fill(username);
await passwordInput.fill(password);
await loginButton.click();
}
/**
* Get current page URL
*/
async getCurrentUrl(): Promise<string> {
return this.page.url();
}
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
}
/**
* Check if login form has validation errors
*/
async hasLoginError(): Promise<boolean> {
const visibilityPromises = AuthPage.SELECTORS.ERROR_SELECTORS.map(
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
}
/**
* Wait for a login request to be made and return the response
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* 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 { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
test.describe('Login view', () => {
let authPage: AuthPage;
test.beforeEach(async ({ page }: any) => {
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Attempt login with incorrect credentials
await authPage.loginWithCredentials('admin', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
timeout: 10000,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials('admin', 'general');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(
(url: any) => url.pathname.endsWith('superset/welcome/'),
{
timeout: 10000,
},
);
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
/**
* 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.
*/
export const URL = {
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -2,18 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,19 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": ".",
"paths": {
"d3v3": ["./types/d3v3"]
}
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -2,18 +2,8 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": ".."
}
]
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,17 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@superset-ui/core/components": ["../../packages/superset-ui-core/src/components"]
}
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -1,14 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

View File

@@ -43,6 +43,8 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",

View File

@@ -29,6 +29,7 @@ import {
useEffect,
useImperativeHandle,
useState,
isValidElement,
useRef,
} from 'react';
import { isEqual } from 'lodash';
@@ -110,7 +111,9 @@ export const DeckGLContainer = memo(
const layers = useCallback(() => {
if (
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
OSM_LAYER_KEYWORDS.some(tilek => props.mapStyle?.includes(tilek))) &&
OSM_LAYER_KEYWORDS.some((tilek: string) =>
props.mapStyle?.includes(tilek),
)) &&
props.layers.some(
l => typeof l !== 'function' && l?.id === 'tile-layer',
) === false
@@ -132,6 +135,20 @@ export const DeckGLContainer = memo(
return props.layers as Layer[];
}, [props.layers, props.mapStyle]);
const isCustomTooltip = (content: ReactNode): boolean =>
isValidElement(content) &&
content.props?.['data-tooltip-type'] === 'custom';
const renderTooltip = (tooltipState: TooltipProps['tooltip']) => {
if (!tooltipState) return null;
if (isCustomTooltip(tooltipState.content)) {
return <Tooltip tooltip={tooltipState} variant="custom" />;
}
return <Tooltip tooltip={tooltipState} />;
};
const { children = null, height, width } = props;
return (
@@ -150,7 +167,7 @@ export const DeckGLContainer = memo(
layers={layers()}
viewState={viewState}
onViewStateChange={onViewStateChange}
onAfterRender={context => {
onAfterRender={(context: any) => {
glContextRef.current = context.gl;
}}
>
@@ -164,7 +181,7 @@ export const DeckGLContainer = memo(
</DeckGL>
{children}
</div>
<Tooltip tooltip={tooltip} />
{renderTooltip(tooltip)}
</>
);
}),

View File

@@ -34,6 +34,8 @@ const StyledLegend = styled.div`
outline: none;
overflow-y: scroll;
max-height: 200px;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
& ul {
list-style: none;

View File

@@ -29,26 +29,42 @@ export type TooltipProps = {
}
| null
| undefined;
variant?: 'default' | 'custom';
};
const StyledDiv = styled.div<{ top: number; left: number }>`
${({ theme, top, left }) => `
const StyledDiv = styled.div<{
top: number;
left: number;
variant: 'default' | 'custom';
}>`
${({ theme, top, left, variant }) => `
position: absolute;
top: ${top}px;
left: ${left}px;
zIndex: 9;
pointerEvents: none;
${
variant === 'default'
? `
padding: ${theme.sizeUnit * 2}px;
margin: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
color: ${theme.colorText};
maxWidth: 300px;
fontSize: ${theme.fontSizeSM}px;
zIndex: 9;
pointerEvents: none;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
box-shadow: ${theme.boxShadowSecondary};
`
: `
margin: ${theme.sizeUnit * 3}px;
`
}
`}
`;
export default function Tooltip(props: TooltipProps) {
const { tooltip } = props;
const { tooltip, variant = 'default' } = props;
if (typeof tooltip === 'undefined' || tooltip === null) {
return null;
}
@@ -58,7 +74,7 @@ export default function Tooltip(props: TooltipProps) {
typeof content === 'string' ? safeHtmlSpan(content) : content;
return (
<StyledDiv top={y} left={x}>
<StyledDiv top={y} left={x} variant={variant}>
{safeContent}
</StyledDiv>
);

View File

@@ -17,14 +17,26 @@
* under the License.
*/
import { ArcLayer } from '@deck.gl/layers';
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { commonLayerProps } from '../common';
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { Point } from '../../types';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
interface ArcDataItem {
sourceColor?: number[];
targetColor?: number[];
color?: number[];
sourcePosition: number[];
targetPosition: number[];
[key: string]: unknown;
}
export function getPoints(data: JsonObject[]) {
const points: Point[] = [];
data.forEach(d => {
@@ -36,24 +48,14 @@ export function getPoints(data: JsonObject[]) {
}
function setTooltipContent(formData: QueryFormData) {
return (o: JsonObject) => (
const defaultTooltipGenerator = (o: JsonObject) => (
<div className="deckgl-tooltip">
<TooltipRow
label={t('Start (Longitude, Latitude): ')}
value={`${o.object?.sourcePosition?.[0]}, ${o.object?.sourcePosition?.[1]}`}
/>
<TooltipRow
label={t('End (Longitude, Latitude): ')}
value={`${o.object?.targetPosition?.[0]}, ${o.object?.targetPosition?.[1]}`}
/>
{formData.dimension && (
<TooltipRow
label={`${formData?.dimension}: `}
value={`${o.object?.cat_color}`}
/>
)}
{CommonTooltipRows.arcPositions(o)}
{CommonTooltipRows.category(o)}
</div>
);
return createTooltipContent(formData, defaultTooltipGenerator);
}
export const getLayer: GetLayerType<ArcLayer> = function ({
@@ -74,19 +76,27 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
return new ArcLayer({
data,
getSourceColor: (d: JsonObject) => {
getSourceColor: (d: ArcDataItem): [number, number, number, number] => {
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
return [sc.r, sc.g, sc.b, 255 * sc.a];
}
return d.targetColor || d.color;
return (d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a]) as [
number,
number,
number,
number,
];
},
getTargetColor: (d: any) => {
getTargetColor: (d: ArcDataItem): [number, number, number, number] => {
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
return [tc.r, tc.g, tc.b, 255 * tc.a];
}
return d.targetColor || d.color;
return (d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a]) as [
number,
number,
number,
number,
];
},
id: `path-layer-${fd.slice_id}` as const,
getWidth: fd.stroke_width ? fd.stroke_width : 3,

View File

@@ -38,6 +38,8 @@ import {
legendPosition,
viewport,
mapboxStyle,
tooltipContents,
tooltipTemplate,
deckGLCategoricalColor,
deckGLCategoricalColorSchemeSelect,
deckGLCategoricalColorSchemeTypeSelect,
@@ -77,6 +79,8 @@ const config: ControlPanelConfig = {
],
['row_limit', filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -25,8 +25,24 @@ import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { ColorType } from '../../types';
import TooltipRow from '../../TooltipRow';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function defaultTooltipGenerator(o: any) {
return (
<div className="deckgl-tooltip">
{CommonTooltipRows.centroid(o)}
<TooltipRow
label={t('Threshold: ')}
value={`${o?.object?.contour?.threshold}`}
/>
</div>
);
}
function setTooltipContent(o: any) {
return (
<div className="deckgl-tooltip">
@@ -41,6 +57,7 @@ function setTooltipContent(o: any) {
</div>
);
}
export const getLayer: GetLayerType<ContourLayer> = function ({
formData,
payload,
@@ -59,6 +76,18 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
} = fd;
let data = payload.data.features;
// Store original data for tooltip access
const originalDataMap = new Map();
data.forEach((d: any) => {
if (d.position) {
const key = `${Math.floor(d.position[0] * 1000)},${Math.floor(d.position[1] * 1000)}`;
if (!originalDataMap.has(key)) {
originalDataMap.set(key, []);
}
originalDataMap.get(key)?.push(d.originalData || d);
}
});
const contours = rawContours?.map(
(contour: {
color: ColorType;
@@ -89,6 +118,47 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
data = jsFnMutatorFunction(data);
}
// Create wrapper for tooltip content that adds nearby points
const tooltipContentGenerator = (o: any) => {
// Find nearby points based on hover coordinate
const nearbyPoints: any[] = [];
if (o.coordinate) {
const searchKey = `${Math.floor(o.coordinate[0] * 1000)},${Math.floor(o.coordinate[1] * 1000)}`;
const points = originalDataMap.get(searchKey) || [];
nearbyPoints.push(...points);
// Also check neighboring cells for better coverage
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
if (dx !== 0 || dy !== 0) {
const neighborKey = `${Math.floor(o.coordinate[0] * 1000) + dx},${Math.floor(o.coordinate[1] * 1000) + dy}`;
const neighborPoints = originalDataMap.get(neighborKey) || [];
nearbyPoints.push(...neighborPoints);
}
}
}
// Enhance the object with nearby points data
if (nearbyPoints.length > 0) {
const enhancedObject = {
...o.object,
nearbyPoints: nearbyPoints.slice(0, 5), // Limit to first 5 points
totalPoints: nearbyPoints.length,
// Add first point's data at top level for easy access
...nearbyPoints[0],
};
Object.assign(o, { object: enhancedObject });
}
}
// Use createTooltipContent with the enhanced object
const baseTooltipContent = createTooltipContent(
fd,
defaultTooltipGenerator,
);
return baseTooltipContent(o);
};
return new ContourLayer({
id: `contourLayer-${fd.slice_id}`,
data,
@@ -101,7 +171,7 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setTooltipContent: tooltipContentGenerator,
onContextMenu,
setDataMask,
filterState,

View File

@@ -31,6 +31,8 @@ import {
mapboxStyle,
spatial,
viewport,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
const config: ControlPanelConfig = {
@@ -44,6 +46,8 @@ const config: ControlPanelConfig = {
['size'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -34,6 +34,8 @@ import {
mapboxStyle,
autozoom,
lineWidth,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { dndGeojsonColumn } from '../../utilities/sharedDndControls';
@@ -47,6 +49,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { GridLayer } from '@deck.gl/aggregation-layers';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import {
CategoricalColorNamespace,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import {
commonLayerProps,
@@ -29,20 +33,21 @@ import sandboxedEval from '../../utils/sandbox';
import { createDeckGLComponent, GetLayerType } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) {
const metricLabel = formData.size?.label || formData.size?.value || 'Height';
return (
<div className="deckgl-tooltip">
{CommonTooltipRows.centroid(o)}
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Longitude and Latitude') + ': '}
value={`${o.coordinate[0]}, ${o.coordinate[1]}`}
/>
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Height') + ': '}
value={`${o.object.elevationValue}`}
label={`${metricLabel}: `}
value={`${o.object?.elevationValue || o.object?.value || 'N/A'}`}
/>
</div>
);
@@ -63,7 +68,6 @@ export const getLayer: GetLayerType<GridLayer> = function ({
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);
}
@@ -81,6 +85,10 @@ export const getLayer: GetLayerType<GridLayer> = function ({
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
const tooltipContent = createTooltipContent(fd, (o: JsonObject) =>
defaultTooltipGenerator(o, fd),
);
const colorAggFunc =
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
@@ -105,7 +113,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
formData: fd,
setDataMask,
setTooltip,
setTooltipContent,
setTooltipContent: tooltipContent,
filterState,
onContextMenu,
emitCrossFilters,

View File

@@ -33,6 +33,8 @@ import {
viewport,
spatial,
mapboxStyle,
tooltipContents,
tooltipTemplate,
legendPosition,
generateDeckGLColorSchemeControls,
} from '../../utilities/Shared_DeckGL';
@@ -49,6 +51,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -18,31 +18,91 @@
*/
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
import { Position } from '@deck.gl/core';
import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core';
import {
t,
getSequentialSchemeRegistry,
JsonObject,
QueryFormData,
} 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 { createTooltipContent } from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
function setTooltipContent(formData: QueryFormData) {
const defaultTooltipGenerator = (o: JsonObject) => {
const metricLabel =
formData.size?.label || formData.size?.value || 'Weight';
const lon = o.coordinate?.[0];
const lat = o.coordinate?.[1];
const hasCustomTooltip =
formData.tooltip_template ||
(formData.tooltip_contents && formData.tooltip_contents.length > 0);
const hasObjectData = o.object && Object.keys(o.object).length > 0;
return (
<div className="deckgl-tooltip">
<TooltipRow
label={t('Centroid (Longitude and Latitude): ')}
value={`(${o?.coordinate[0]}, ${o?.coordinate[1]})`}
label={`${t('Longitude and Latitude')}: `}
value={`${lon?.toFixed(6)}, ${lat?.toFixed(6)}`}
/>
<TooltipRow label="LON: " value={lon?.toFixed(6)} />
<TooltipRow label="LAT: " value={lat?.toFixed(6)} />
<TooltipRow
label={`${metricLabel}: `}
value={`${o.object?.weight || o.object?.value || 'Aggregated Cell'}`}
/>
{hasCustomTooltip && !hasObjectData && (
<TooltipRow
label={`${t('Note')}: `}
value={t('Custom fields not available in aggregated heatmap cells')}
/>
)}
</div>
);
};
return (o: JsonObject) => {
// Try to find the closest data point to the hovered coordinate
let closestPoint = null;
if (o.coordinate && o.layer?.props?.data) {
const [hoveredLon, hoveredLat] = o.coordinate;
let minDistance = Infinity;
for (const point of o.layer.props.data) {
if (point.position) {
const [pointLon, pointLat] = point.position;
const distance = Math.sqrt(
Math.pow(hoveredLon - pointLon, 2) +
Math.pow(hoveredLat - pointLat, 2),
);
if (distance < minDistance) {
minDistance = distance;
closestPoint = point;
}
}
}
}
const modifiedO = {
...o,
object: closestPoint || o.object,
};
return createTooltipContent(formData, defaultTooltipGenerator)(modifiedO);
};
}
export const getLayer: GetLayerType<HeatmapLayer> = ({
formData,
payload,
setTooltip,
setDataMask,
onContextMenu,
filterState,
setDataMask,
setTooltip,
payload,
emitCrossFilters,
}) => {
const fd = formData;
@@ -56,7 +116,6 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
let data = payload.data.features;
if (jsFnMutator) {
// Applying user defined data mutator if defined
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
data = jsFnMutatorFunction(data);
}
@@ -74,6 +133,8 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
colorScale,
})?.reverse();
const tooltipContent = setTooltipContent(fd);
return new HeatmapLayer({
id: `heatmap-layer-${fd.slice_id}` as const,
data,
@@ -84,10 +145,12 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
getPosition: (d: { position: Position; weight: number }) => d.position,
getWeight: (d: { position: number[]; weight: number }) =>
d.weight ? d.weight : 1,
opacity: 0.8,
threshold: 0.03,
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setTooltipContent: tooltipContent,
setDataMask,
filterState,
onContextMenu,

View File

@@ -39,6 +39,8 @@ import {
mapboxStyle,
spatial,
viewport,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
@@ -62,6 +64,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
[
{
name: 'intensity',

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { HexagonLayer } from '@deck.gl/aggregation-layers';
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
import {
CategoricalColorNamespace,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import {
@@ -28,20 +32,22 @@ import {
} from '../common';
import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import TooltipRow from '../../TooltipRow';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) {
const metricLabel = formData.size?.label || formData.size?.value || 'Height';
return (
<div className="deckgl-tooltip">
{CommonTooltipRows.centroid(o)}
<TooltipRow
label={t('Centroid (Longitude and Latitude): ')}
value={`(${o.coordinate[0]}, ${o.coordinate[1]})`}
/>
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Height') + ': '}
value={`${o.object.elevationValue}`}
label={`${metricLabel}: `}
value={`${o.object?.elevationValue}`}
/>
</div>
);
@@ -85,6 +91,10 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
: aggFunc;
const tooltipContent = createTooltipContent(fd, (o: JsonObject) =>
defaultTooltipGenerator(o, fd),
);
return new HexagonLayer({
id: `hex-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
data,
@@ -103,7 +113,7 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setTooltipContent: tooltipContent,
setDataMask,
filterState,
onContextMenu,

View File

@@ -34,6 +34,8 @@ import {
mapboxStyle,
spatial,
viewport,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
@@ -48,6 +50,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -18,28 +18,26 @@
* under the License.
*/
import { PathLayer } from '@deck.gl/layers';
import { JsonObject } from '@superset-ui/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import { commonLayerProps } from '../common';
import sandboxedEval from '../../utils/sandbox';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import TooltipRow from '../../TooltipRow';
import { Point } from '../../types';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
function setTooltipContent(o: JsonObject) {
return (
o.object?.extraProps && (
function setTooltipContent(formData: QueryFormData) {
const defaultTooltipGenerator = (o: JsonObject) => (
<div className="deckgl-tooltip">
{Object.keys(o.object.extraProps).map((prop, index) => (
<TooltipRow
key={`prop-${index}`}
label={`${prop}: `}
value={`${o.object.extraProps[prop]}`}
/>
))}
{CommonTooltipRows.position(o)}
{CommonTooltipRows.category(o)}
</div>
)
);
return createTooltipContent(formData, defaultTooltipGenerator);
}
export const getLayer: GetLayerType<PathLayer> = function ({
@@ -78,7 +76,7 @@ export const getLayer: GetLayerType<PathLayer> = function ({
...commonLayerProps({
formData: fd,
setTooltip,
setTooltipContent,
setTooltipContent: setTooltipContent(fd),
setDataMask,
filterState,
onContextMenu,

View File

@@ -30,6 +30,8 @@ import {
lineType,
reverseLongLat,
mapboxStyle,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { dndLineColumn } from '../../utilities/sharedDndControls';
@@ -55,6 +57,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -0,0 +1,355 @@
/**
* 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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import DeckGLPolygon, { getPoints } from './Polygon';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import * as utils from '../../utils';
// Mock the utils functions
const mockGetBuckets = jest.spyOn(utils, 'getBuckets');
const mockGetColorBreakpointsBuckets = jest.spyOn(
utils,
'getColorBreakpointsBuckets',
);
// Mock DeckGL container and Legend
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ children }: any) => (
<div data-testid="deckgl-container">{children}</div>
),
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
<div
data-testid="legend"
data-categories={JSON.stringify(categories)}
data-position={position}
>
Legend Mock
</div>
));
const mockProps = {
formData: {
// Required QueryFormData properties
datasource: 'test_datasource',
viz_type: 'deck_polygon',
// Polygon-specific properties
metric: { label: 'population' },
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
legend_position: 'tr',
legend_format: '.2f',
autozoom: false,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
opacity: 80,
filled: true,
stroked: true,
extruded: false,
line_width: 1,
line_width_unit: 'pixels',
multiplier: 1,
break_points: [],
num_buckets: '5',
linear_color_scheme: 'blue_white_yellow',
},
payload: {
data: {
features: [
{
population: 100000,
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
population: 200000,
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
],
mapboxApiKey: 'test-key',
},
form_data: {},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
width: 800,
height: 600,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
filterState: undefined,
emitCrossFilters: false,
};
describe('DeckGLPolygon bucket generation logic', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
const propsWithLinearPalette = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithLinearPalette} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for fixed_color color scheme', () => {
const propsWithFixedColor = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithFixedColor} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
const propsWithBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: [
{
minValue: 0,
maxValue: 100000,
color: { r: 255, g: 0, b: 0, a: 100 },
},
{
minValue: 100001,
maxValue: 200000,
color: { r: 0, g: 255, b: 0, a: 100 },
},
],
},
};
mockGetColorBreakpointsBuckets.mockReturnValue({
'0 - 100000': { color: [255, 0, 0], enabled: true },
'100001 - 200000': { color: [0, 255, 0], enabled: true },
});
renderWithTheme(<DeckGLPolygon {...propsWithBreakpoints} />);
// Should call getColorBreakpointsBuckets, not getBuckets
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalled();
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
const propsWithUndefinedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUndefinedScheme} />);
// Should call getBuckets for backward compatibility
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
const propsWithUnsupportedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUnsupportedScheme} />);
// Should fall back to getBuckets for unsupported color schemes
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
});
describe('DeckGLPolygon Error Handling and Edge Cases', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
const propsWithEmptyData = {
...mockProps,
payload: {
...mockProps.payload,
data: {
...mockProps.payload.data,
features: [],
},
},
};
renderWithTheme(<DeckGLPolygon {...propsWithEmptyData} />);
// Should still call getBuckets with empty data
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('handles missing color_breakpoints for color_breakpoints scheme', () => {
const propsWithMissingBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithMissingBreakpoints} />);
// Should call getColorBreakpointsBuckets even with undefined breakpoints
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalledWith(undefined);
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('handles null legend_position correctly', () => {
const propsWithNullLegendPosition = {
...mockProps,
formData: {
...mockProps.formData,
legend_position: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithNullLegendPosition} />);
// Legend should not be rendered when position is null
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('DeckGLPolygon Legend Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
// Verify the component renders and calls the correct bucket function
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
// Verify the legend mock was rendered with non-empty categories
const legendElement = container.querySelector('[data-testid="legend"]');
expect(legendElement).toBeTruthy();
const categoriesAttr = legendElement?.getAttribute('data-categories');
const categoriesData = JSON.parse(categoriesAttr || '{}');
expect(Object.keys(categoriesData)).toHaveLength(2);
});
test('does not render legend when metric is null', () => {
const propsWithoutMetric = {
...mockProps,
formData: {
...mockProps.formData,
metric: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithoutMetric} />);
// Legend should not be rendered when no metric is defined
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('getPoints utility', () => {
test('extracts points from polygon data', () => {
const data = [
{
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
];
const points = getPoints(data);
expect(points).toHaveLength(8); // 4 points per polygon * 2 polygons
expect(points[0]).toEqual([0, 0]);
expect(points[4]).toEqual([2, 2]);
});
});

View File

@@ -57,6 +57,10 @@ import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
import { Point } from '../../types';
function getElevation(
@@ -71,26 +75,25 @@ function getElevation(
return colorScaler(d)[3] === 0 ? 0 : d.elevation;
}
function setTooltipContent(formData: PolygonFormData) {
return (o: JsonObject) => {
const metricLabel = formData?.metric?.label || formData?.metric;
function defaultTooltipGenerator(
o: JsonObject,
fd: PolygonFormData,
metricLabel: string,
) {
return (
<div className="deckgl-tooltip">
{o.object?.name && (
<TooltipRow label={`${t('name')}: `} value={`${o.object.name}`} />
)}
{o.object?.[fd?.line_column] && (
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('name') + ': '}
value={`${o.object.name}`}
label={`${fd.line_column}: `}
value={`${o.object[fd.line_column]}`}
/>
)}
{o.object?.[formData?.line_column] && (
<TooltipRow
label={`${formData.line_column}: `}
value={`${o.object[formData.line_column]}`}
/>
)}
{formData?.metric && (
{CommonTooltipRows.centroid(o)}
{CommonTooltipRows.category(o)}
{fd?.metric && (
<TooltipRow
label={`${metricLabel}: `}
value={`${o.object?.[metricLabel]}`}
@@ -98,7 +101,6 @@ function setTooltipContent(formData: PolygonFormData) {
)}
</div>
);
};
}
export const getLayer: GetLayerType<PolygonLayer> = function ({
@@ -198,12 +200,9 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
return baseColor;
};
const tooltipContentGenerator =
fd.line_column &&
fd.metric &&
['json', 'geohash', 'zipcode'].includes(fd.line_type)
? setTooltipContent(fd)
: () => null;
const tooltipContentGenerator = createTooltipContent(fd, (o: JsonObject) =>
defaultTooltipGenerator(o, fd, metricLabel),
);
return new PolygonLayer({
id: `path-layer-${fd.slice_id}` as const,
@@ -336,7 +335,8 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
const accessor = (d: JsonObject) => d[metricLabel];
const colorSchemeType = formData.color_scheme_type;
const buckets = colorSchemeType
const buckets =
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);

View File

@@ -48,6 +48,8 @@ import {
deckGLLinearColorSchemeSelect,
deckGLColorBreakpointsSelect,
breakpointsDefaultColor,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { dndLineColumn } from '../../utilities/sharedDndControls';
@@ -89,6 +91,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[reverseLongLat],
[filterNulls],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -17,42 +17,45 @@
* under the License.
*/
import { ScatterplotLayer } from '@deck.gl/layers';
import {
getMetricLabel,
JsonObject,
QueryFormData,
t,
} from '@superset-ui/core';
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
import { isPointInBonds } from '../../utilities/utils';
import { commonLayerProps } from '../common';
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
import { createTooltipContent } from '../../utilities/tooltipUtils';
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);
function getMetricLabel(metric: any) {
if (typeof metric === 'string') {
return metric;
}
if (metric?.label) {
return metric.label;
}
if (metric?.verbose_name) {
return metric.verbose_name;
}
return metric?.value || 'Metric';
}
function setTooltipContent(
formData: QueryFormData,
verboseMap?: Record<string, string>,
) {
return (o: JsonObject) => {
const defaultTooltipGenerator = (o: JsonObject) => {
const label =
verboseMap?.[formData.point_radius_fixed.value] ||
getMetricLabel(formData.point_radius_fixed?.value);
return (
<div className="deckgl-tooltip">
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Longitude and Latitude') + ': '}
label={`${t('Longitude and Latitude')}: `}
value={`${o.object?.position?.[0]}, ${o.object?.position?.[1]}`}
/>
{o.object?.cat_color && (
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Category') + ': '}
label={`${t('Category')}: `}
value={`${o.object?.cat_color}`}
/>
)}
@@ -62,6 +65,19 @@ function setTooltipContent(
</div>
);
};
return createTooltipContent(formData, defaultTooltipGenerator);
}
interface ScatterDataItem {
color: number[];
radius: number;
position: number[];
[key: string]: unknown;
}
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}
export const getLayer: GetLayerType<ScatterplotLayer> = function ({
@@ -93,8 +109,9 @@ export const getLayer: GetLayerType<ScatterplotLayer> = function ({
id: `scatter-layer-${fd.slice_id}` as const,
data: dataWithRadius,
fp64: true,
getFillColor: (d: any) => d.color,
getRadius: (d: any) => d.radius,
getFillColor: (d: ScatterDataItem): [number, number, number, number] =>
d.color as [number, number, number, number],
getRadius: (d: ScatterDataItem): number => d.radius,
radiusMinPixels: Number(fd.min_radius) || undefined,
radiusMaxPixels: Number(fd.max_radius) || undefined,
stroked: false,

View File

@@ -34,6 +34,8 @@ import {
multiplier,
mapboxStyle,
generateDeckGLColorSchemeControls,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
@@ -57,6 +59,8 @@ const config: ControlPanelConfig = {
[spatial, null],
['row_limit', filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -1,5 +1,3 @@
/* eslint-disable react/sort-prop-types */
/* eslint-disable react/jsx-handler-names */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -20,8 +18,14 @@
*/
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core';
import { Color } from '@deck.gl/core';
import {
JsonObject,
QueryFormData,
styled,
CategoricalColorNamespace,
t,
} from '@superset-ui/core';
import {
COLOR_SCHEME_TYPES,
ColorSchemeType,
@@ -32,14 +36,29 @@ import { commonLayerProps, getColorRange } from '../common';
import TooltipRow from '../../TooltipRow';
import { GetLayerType, createDeckGLComponent } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
import {
createTooltipContent,
CommonTooltipRows,
} from '../../utilities/tooltipUtils';
const MoreRecordsIndicator = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit}px;
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorTextSecondary};
`;
export function getPoints(data: JsonObject[]) {
return data.map(d => d.position);
}
function setTooltipContent(o: JsonObject) {
function defaultTooltipGenerator(o: JsonObject, formData: QueryFormData) {
const metricLabel = formData.size?.label || formData.size?.value || 'Weight';
const points = o.points || [];
const pointCount = points.length || 0;
return (
<div className="deckgl-tooltip">
{CommonTooltipRows.centroid(o)}
<TooltipRow
// eslint-disable-next-line prefer-template
label={t('Longitude and Latitude') + ': '}
@@ -50,6 +69,35 @@ function setTooltipContent(o: JsonObject) {
label={t('Weight') + ': '}
value={`${o.object?.value}`}
/>
<TooltipRow
label={`${metricLabel}: `}
value={`${o.object?.cellWeight}`}
/>
<TooltipRow label="Points: " value={`${pointCount} records`} />
{points.length > 0 && points.length <= 3 && (
<div style={{ marginTop: 8, fontSize: '12px' }}>
<strong>Records:</strong>
{points.slice(0, 3).map((point: JsonObject, index: number) => (
<div key={index} style={{ marginTop: 4, paddingLeft: '8px' }}>
{Object.entries(point).map(([key, value]) =>
key !== 'position' &&
key !== 'weight' &&
key !== '__timestamp' &&
key !== 'points' ? (
<span key={key} style={{ marginRight: '8px' }}>
<strong>{key}:</strong> {String(value)}
</span>
) : null,
)}
</div>
))}
</div>
)}
{points.length > 3 && (
<MoreRecordsIndicator>
... and {points.length - 3} more records
</MoreRecordsIndicator>
)}
</div>
);
}
@@ -69,7 +117,6 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
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);
}
@@ -94,8 +141,54 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
[189, 0, 38],
] as Color[];
// Passing a layer creator function instead of a layer since the
// layer needs to be regenerated at each render
const cellSize = fd.grid_size || 50;
const cellToPointsMap = new Map();
data.forEach((point: JsonObject) => {
const { position } = point;
if (position) {
const cellX = Math.floor(position[0] / (cellSize * 0.01));
const cellY = Math.floor(position[1] / (cellSize * 0.01));
const cellKey = `${cellX},${cellY}`;
if (!cellToPointsMap.has(cellKey)) {
cellToPointsMap.set(cellKey, []);
}
cellToPointsMap.get(cellKey).push(point);
}
});
const tooltipContent = createTooltipContent(fd, (o: JsonObject) =>
defaultTooltipGenerator(o, fd),
);
const customOnHover = (info: JsonObject) => {
if (info.picked) {
const cellCenter = info.coordinate;
const cellX = Math.floor(cellCenter[0] / (cellSize * 0.01));
const cellY = Math.floor(cellCenter[1] / (cellSize * 0.01));
const cellKey = `${cellX},${cellY}`;
const pointsInCell = cellToPointsMap.get(cellKey) || [];
const enhancedInfo = {
...info,
object: {
...info.object,
points: pointsInCell,
},
};
setTooltip({
content: tooltipContent(enhancedInfo),
x: info.x,
y: info.y,
});
} else {
setTooltip(null);
}
return true;
};
return new ScreenGridLayer({
id: `screengrid-layer-${fd.slice_id}` as const,
data,
@@ -111,13 +204,15 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
formData: fd,
setDataMask,
setTooltip,
setTooltipContent,
setTooltipContent: tooltipContent,
filterState,
onContextMenu,
emitCrossFilters,
}),
getWeight: aggFunc,
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
onHover: customOnHover,
pickable: true,
opacity: filterState?.value ? 0.3 : 1,
});
};

View File

@@ -36,6 +36,8 @@ import {
deckGLFixedColor,
deckGLCategoricalColorSchemeSelect,
deckGLCategoricalColorSchemeTypeSelect,
tooltipContents,
tooltipTemplate,
} from '../../utilities/Shared_DeckGL';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
@@ -50,6 +52,8 @@ const config: ControlPanelConfig = {
['row_limit'],
[filterNulls],
['adhoc_filters'],
[tooltipContents],
[tooltipTemplate],
],
},
{

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { ReactNode, isValidElement } from 'react';
import {
ascending as d3ascending,
quantile as d3quantile,
@@ -73,15 +73,29 @@ export function commonLayerProps({
tooltipContentGenerator = sandboxedEval(fd.js_tooltip);
}
if (tooltipContentGenerator) {
let currentTooltipContent: ReactNode = null;
const isCustomTooltip = (content: ReactNode): boolean =>
isValidElement(content) &&
content.props?.['data-tooltip-type'] === 'custom';
onHover = (o: JsonObject) => {
if (o.picked) {
currentTooltipContent = tooltipContentGenerator(o);
}
if (
currentTooltipContent &&
(o.picked || isCustomTooltip(currentTooltipContent))
) {
setTooltip({
content: tooltipContentGenerator(o),
content: currentTooltipContent,
x: o.x,
y: o.y,
});
} else {
setTooltip(null);
currentTooltipContent = null;
}
return true;
};

View File

@@ -0,0 +1,231 @@
/**
* 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 { useEffect, useState, memo } from 'react';
import { styled, t } from '@superset-ui/core';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { SafeMarkdown } from '@superset-ui/core/components';
import Handlebars from 'handlebars';
import { isPlainObject } from 'lodash';
export interface HandlebarsRendererProps {
templateSource: string;
data: any;
}
const ErrorContainer = styled.pre`
white-space: pre-wrap;
color: ${({ theme }) => theme.colorError};
background-color: ${({ theme }) => theme.colorErrorBg};
padding: ${({ theme }) => theme.sizeUnit * 2}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
`;
export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
({ templateSource, data }) => {
const [renderedTemplate, setRenderedTemplate] = useState('');
const [error, setError] = useState('');
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
const htmlSchemaOverrides =
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
useEffect(() => {
try {
const template = Handlebars.compile(templateSource);
const result = template(data);
setRenderedTemplate(result);
setError('');
} catch (error) {
setRenderedTemplate('');
setError(error.message || 'Unknown template error');
}
}, [templateSource, data]);
if (error) {
return <ErrorContainer>{error}</ErrorContainer>;
}
if (renderedTemplate || renderedTemplate === '') {
return (
<div
style={{
maxWidth: '300px',
wordWrap: 'break-word',
fontSize: '12px',
lineHeight: '1.4',
}}
>
<SafeMarkdown
source={renderedTemplate || ''}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
</div>
);
}
return <p>{t('Loading...')}</p>;
},
);
Handlebars.registerHelper('dateFormat', function (context, options) {
const format = options.hash.format || 'YYYY-MM-DD HH:mm:ss';
if (!context) return '';
try {
if (typeof context === 'number') {
const timestamp = context > 1000000000000 ? context : context * 1000;
return dayjs(timestamp).format(format);
}
return dayjs(context).format(format);
} catch (e) {
return String(context);
}
});
Handlebars.registerHelper('formatNumber', function (number, options) {
if (typeof number !== 'number') {
return number;
}
const locale = options.hash.locale || 'en-US';
const { minimumFractionDigits } = options.hash;
const { maximumFractionDigits } = options.hash;
const formatOptions: Intl.NumberFormatOptions = {};
if (minimumFractionDigits !== undefined) {
formatOptions.minimumFractionDigits = minimumFractionDigits;
}
if (maximumFractionDigits !== undefined) {
formatOptions.maximumFractionDigits = maximumFractionDigits;
}
return number.toLocaleString(locale, formatOptions);
});
Handlebars.registerHelper('stringify', function (obj) {
if (obj === undefined || obj === null) {
return '';
}
if (isPlainObject(obj)) {
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
return String(obj);
}
}
return String(obj);
});
Handlebars.registerHelper(
'ifExists',
function (this: any, value: any, options: any) {
if (value !== null && value !== undefined && value !== '') {
return options.fn(this);
}
return options.inverse(this);
},
);
Handlebars.registerHelper('default', function (value, fallback) {
return value !== null && value !== undefined && value !== ''
? value
: fallback;
});
Handlebars.registerHelper('truncate', function (text, length) {
if (typeof text !== 'string') {
return text;
}
if (text.length <= length) {
return text;
}
return `${text.substring(0, length)}...`;
});
Handlebars.registerHelper('formatCoordinate', function (longitude, latitude) {
if (
longitude === null ||
longitude === undefined ||
latitude === null ||
latitude === undefined
) {
return '';
}
const lng = typeof longitude === 'number' ? longitude.toFixed(6) : longitude;
const lat = typeof latitude === 'number' ? latitude.toFixed(6) : latitude;
return `${lng}, ${lat}`;
});
Handlebars.registerHelper('first', function (array) {
if (Array.isArray(array) && array.length > 0) {
return array[0];
}
return null;
});
Handlebars.registerHelper('getField', function (array, fieldName) {
if (!Array.isArray(array) || array.length === 0) {
return '';
}
const values = array
.map(item => item[fieldName])
.filter(
(value, index, self) =>
value !== undefined && value !== null && self.indexOf(value) === index,
);
if (values.length === 0) return '';
if (values.length === 1) return values[0];
return values.slice(0, 3).join(', ') + (values.length > 3 ? '...' : '');
});
Handlebars.registerHelper('limit', function (value, limit) {
if (!value) return '';
// Handle arrays
if (Array.isArray(value)) {
const limitedArray = value.slice(0, limit);
return limitedArray.join(', ') + (value.length > limit ? '...' : '');
}
// Handle strings (comma-separated values)
if (typeof value === 'string') {
const items = value.split(',').map(item => item.trim());
if (items.length <= limit) return value;
const limitedItems = items.slice(0, limit);
return `${limitedItems.join(', ')}...`;
}
// For other types, return as-is
return value;
});
export default HandlebarsRenderer;

View File

@@ -17,8 +17,6 @@
* under the License.
*/
// These are control configurations that are shared ONLY within the DeckGL viz plugin repo.
import {
FeatureFlag,
isFeatureEnabled,
@@ -42,6 +40,7 @@ import {
ColorSchemeType,
isColorSchemeTypeVisible,
} from './utils';
import { TooltipTemplateControl } from './TooltipTemplateControl';
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
@@ -344,9 +343,7 @@ export const viewport = {
label: t('Viewport'),
renderTrigger: false,
description: t('Parameters related to the view and perspective on the map'),
// default is whole world mostly centered
default: DEFAULT_VIEWPORT,
// Viewport changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
};
@@ -446,6 +443,78 @@ export const geojsonColumn = {
},
};
const extractMetricsFromFormData = (formData: any) => {
const metrics = new Set<string>();
if (formData.metrics) {
(Array.isArray(formData.metrics)
? formData.metrics
: [formData.metrics]
).forEach((metric: any) => metrics.add(metric));
}
if (formData.point_radius_fixed?.value) {
metrics.add(formData.point_radius_fixed.value);
}
Object.entries(formData).forEach(([, value]) => {
if (!value || typeof value !== 'object') return;
if ((value as any).type === 'metric' && (value as any).value) {
metrics.add((value as any).value);
}
});
return Array.from(metrics).filter(metric => metric != null);
};
export const tooltipContents = {
name: 'tooltip_contents',
config: {
type: 'DndColumnMetricSelect',
label: t('Tooltip contents'),
multi: true,
freeForm: true,
clearable: true,
default: [],
description: t(
'Drag columns and metrics here to customize tooltip content. Order matters - items will appear in the same order in tooltips. Click the button to manually select columns and metrics.',
),
ghostButtonText: t('Drop columns/metrics here or click'),
disabledTabs: new Set(['saved', 'sqlExpression']),
mapStateToProps: (state: any) => {
const { datasource, form_data: formData } = state;
const selectedMetrics = formData
? extractMetricsFromFormData(formData)
: [];
return {
columns: datasource?.columns || [],
savedMetrics: datasource?.metrics || [],
datasource,
selectedMetrics,
disabledTabs: new Set(['saved', 'sqlExpression']),
formData,
};
},
},
};
export const tooltipTemplate = {
name: 'tooltip_template',
config: {
type: TooltipTemplateControl,
label: t('Customize tooltips template'),
debounceDelay: 30,
default: '',
description: '',
placeholder: '',
mapStateToProps: (state: any, control: any) => ({
value: control.value,
}),
},
};
export const deckGLCategoricalColorSchemeTypeSelect: CustomControlItem = {
name: 'color_scheme_type',
config: {

View File

@@ -0,0 +1,82 @@
/**
* 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 { useCallback } from 'react';
import { debounce } from 'lodash';
import { t, useTheme } from '@superset-ui/core';
import { InfoTooltip, Constants } from '@superset-ui/core/components';
import { ControlHeader } from '@superset-ui/chart-controls';
import { TooltipTemplateEditor } from './TooltipTemplateEditor';
interface TooltipTemplateControlProps {
value: string;
onChange: (value: string) => void;
label?: string;
name: string;
height?: number;
}
const debounceFunc = debounce(
(func: (val: string) => void, source: string) => func(source),
Constants.SLOW_DEBOUNCE,
);
export function TooltipTemplateControl({
value,
onChange,
label,
name,
}: TooltipTemplateControlProps) {
const theme = useTheme();
const handleTemplateChange = useCallback(
(newValue: string) => {
debounceFunc(onChange, newValue || '');
},
[onChange],
);
const tooltipContent = t(
'Use Handlebars syntax to create custom tooltips. Available variables are based on your tooltip contents selection above.',
);
return (
<div>
<ControlHeader
name={name}
label={
<>
{label || t('Customize tooltips template')}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={tooltipContent}
/>
</>
}
/>
<TooltipTemplateEditor
value={value}
onChange={handleTemplateChange}
name={name}
/>
</div>
);
}
export default TooltipTemplateControl;

View File

@@ -0,0 +1,76 @@
/**
* 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 { useCallback, useEffect, useState } from 'react';
import { styled, css, useThemeMode } from '@superset-ui/core';
import { CodeEditor } from '@superset-ui/core/components';
const EditorContainer = styled.div`
${({ theme }) => css`
min-height: ${theme.sizeUnit * 50}px;
width: 100%;
.ace_editor {
font-family: ${theme.fontFamilyCode};
}
`}
`;
interface TooltipTemplateEditorProps {
value: string;
onChange: (value: string) => void;
name: string;
}
export function TooltipTemplateEditor({
value,
onChange,
name,
}: TooltipTemplateEditorProps) {
const [localValue, setLocalValue] = useState(value);
const isDarkMode = useThemeMode();
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = useCallback(
(newValue: string) => {
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
return (
<div>
<EditorContainer>
<CodeEditor
mode="handlebars"
theme={isDarkMode ? 'dark' : 'light'}
name={name}
height="200px"
width="100%"
value={localValue}
onChange={handleChange}
/>
</EditorContainer>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* 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 { ControlType } from '@superset-ui/chart-controls';
import { TooltipTemplateControl } from './TooltipTemplateControl';
/**
* Registry for custom control components used in DeckGL charts
*/
export const deckGLControlRegistry = {
TooltipTemplateControl,
};
/**
* Expand control type to include local DeckGL controls
*/
export function expandDeckGLControlType(controlType: ControlType) {
if (typeof controlType === 'string' && controlType in deckGLControlRegistry) {
return deckGLControlRegistry[
controlType as keyof typeof deckGLControlRegistry
];
}
return controlType;
}
/**
* HOC to wrap control components with DeckGL-specific logic
*/
export function withDeckGLControls(Component: React.ComponentType<any>) {
return function DeckGLControlWrapper(props: any) {
const { type, ...otherProps } = props;
const ExpandedComponent = expandDeckGLControlType(type) || Component;
if (typeof ExpandedComponent === 'string') {
// If it's a string, it's a built-in control type, use the original Component
return <Component {...otherProps} />;
}
return <ExpandedComponent {...otherProps} />;
};
}
export default deckGLControlRegistry;

View File

@@ -0,0 +1,142 @@
/**
* 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 { QueryFormData } from '@superset-ui/core';
interface TooltipItem {
item_type?: string;
column_name?: string;
metric_name?: string;
label?: string;
verbose_name?: string;
}
export const AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_screengrid',
'deck_heatmap',
'deck_contour',
'deck_hex',
'deck_grid',
];
export const NON_AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_scatter',
'deck_arc',
'deck_path',
'deck_polygon',
'deck_geojson',
];
export function isAggregatedDeckGLChart(vizType: string): boolean {
return AGGREGATED_DECK_GL_CHART_TYPES.includes(vizType);
}
export function fieldHasMultipleValues(
item: TooltipItem | string,
formData: QueryFormData,
): boolean {
if (!isAggregatedDeckGLChart(formData.viz_type)) {
return false;
}
if (typeof item === 'object' && item?.item_type === 'metric') {
return false;
}
// TODO: Currently only screengrid supports multi-value fields. Support for other aggregated charts will be added in future releases
const supportsMultiValue = ['deck_screengrid'].includes(formData.viz_type);
if (!supportsMultiValue) {
return false;
}
if (typeof item === 'object' && item?.item_type === 'column') {
return true;
}
if (typeof item === 'string') {
return true;
}
return false;
}
const getFieldName = (item: TooltipItem | string): string | null => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') return item.column_name ?? null;
if (item?.item_type === 'metric')
return item.metric_name ?? item.label ?? null;
return null;
};
const getFieldLabel = (item: TooltipItem | string): string => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') {
return item.verbose_name || item.column_name || 'Column';
}
if (item?.item_type === 'metric') {
return item.verbose_name || item.metric_name || item.label || 'Metric';
}
return 'Field';
};
const createMultiValueTemplate = (
fieldName: string,
fieldLabel: string,
): string => {
const pluralFieldName = `${fieldName}s`;
return `<div><strong>${fieldLabel}:</strong> {{#if ${pluralFieldName}}}{{limit ${pluralFieldName} 10}}{{#if ${fieldName}_count}} ({{${fieldName}_count}} total){{/if}}{{else}}N/A{{/if}}</div>`;
};
const createSingleValueTemplate = (
fieldName: string,
fieldLabel: string,
): string =>
`<div><strong>${fieldLabel}:</strong> {{#if ${fieldName}}}{{${fieldName}}}{{else}}N/A{{/if}}</div>`;
export function createDefaultTemplateWithLimits(
tooltipContents: (TooltipItem | string)[],
formData: QueryFormData,
): string {
if (!tooltipContents?.length) {
return '';
}
const templateLines: string[] = [];
tooltipContents.forEach(item => {
const fieldName = getFieldName(item);
const fieldLabel = getFieldLabel(item);
if (!fieldName) return;
const hasMultipleValues = fieldHasMultipleValues(item, formData);
if (hasMultipleValues) {
templateLines.push(createMultiValueTemplate(fieldName, fieldLabel));
} else {
templateLines.push(createSingleValueTemplate(fieldName, fieldLabel));
}
});
return templateLines.join('\n');
}
export const MULTI_VALUE_WARNING_MESSAGE =
'This metric or column contains many values, they may not be able to be all displayed in the tooltip';

View File

@@ -0,0 +1,384 @@
/**
* 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 { t, JsonObject, QueryFormData } from '@superset-ui/core';
import { useMemo, memo } from 'react';
import { HandlebarsRenderer } from './HandlebarsRenderer';
import TooltipRow from '../TooltipRow';
import { createDefaultTemplateWithLimits } from './multiValueUtils';
const MemoizedHandlebarsRenderer = memo(HandlebarsRenderer);
export const CommonTooltipRows = {
position: (o: JsonObject, position?: [number, number]) => (
<TooltipRow
label={`${t('Longitude and Latitude')}: `}
value={`${position?.[0] || o.object?.position?.[0]}, ${position?.[1] || o.object?.position?.[1]}`}
/>
),
arcPositions: (o: JsonObject) => (
<>
<TooltipRow
label={t('Start (Longitude, Latitude): ')}
value={`${o.object?.sourcePosition?.[0]}, ${o.object?.sourcePosition?.[1]}`}
/>
<TooltipRow
label={t('End (Longitude, Latitude): ')}
value={`${o.object?.targetPosition?.[0]}, ${o.object?.targetPosition?.[1]}`}
/>
</>
),
centroid: (o: JsonObject) => (
<TooltipRow
label={t('Centroid (Longitude and Latitude): ')}
value={`(${o.coordinate?.[0]}, ${o.coordinate?.[1]})`}
/>
),
category: (o: JsonObject) =>
o.object?.cat_color ? (
<TooltipRow
label={`${t('Category')}: `}
value={`${o.object.cat_color}`}
/>
) : null,
metric: (
o: JsonObject,
formData: QueryFormData,
verboseMap?: Record<string, string>,
) => {
const metricConfig =
formData.point_radius_fixed || formData.size || formData.metric;
if (!metricConfig) return null;
const label =
verboseMap?.[metricConfig.value] ||
metricConfig?.value ||
metricConfig?.label ||
'Metric';
return o.object?.metric ? (
<TooltipRow label={`${label}: `} value={`${o.object.metric}`} />
) : null;
},
};
function extractValue(
o: JsonObject,
fieldName: string,
checkPoints = true,
): any {
let value =
o.object?.[fieldName] ||
o.object?.properties?.[fieldName] ||
o.object?.data?.[fieldName] ||
'';
if (!value && checkPoints && Array.isArray(o.object?.points)) {
const allVals = o.object.points
.map((pt: any) => pt[fieldName])
.filter((v: any) => v !== undefined && v !== null);
if (allVals.length > 0) {
value = allVals[0];
return { value, allValues: allVals };
}
}
return { value, allValues: [] };
}
function formatValue(value: any): string {
if (value === '') return '';
if (
typeof value === 'string' &&
value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
) {
return new Date(value).toLocaleString();
}
return `${value}`;
}
function buildFieldBasedTooltipItems(
o: JsonObject,
formData: QueryFormData,
): JSX.Element[] {
const tooltipItems: JSX.Element[] = [];
formData.tooltip_contents.forEach((item: any, index: number) => {
let label = '';
let fieldName = '';
if (typeof item === 'string') {
label = item;
fieldName = item;
} else if (item.item_type === 'column') {
label = item.verbose_name || item.column_name || item.label;
fieldName = item.column_name;
} else if (item.item_type === 'metric') {
label = item.verbose_name || item.metric_name || item.label;
fieldName = item.metric_name || item.label;
}
if (!label || !fieldName) return;
let { value } = extractValue(o, fieldName);
if (!value && item.item_type === 'metric') {
value = o.object?.metric || '';
}
if (
formData.viz_type === 'deck_screengrid' &&
!value &&
Array.isArray(o.object?.points)
) {
const { allValues } = extractValue(o, fieldName);
if (allValues.length > 0) {
value = allValues.join(', ');
}
}
if (value !== '') {
const formattedValue = formatValue(value);
tooltipItems.push(
<TooltipRow
key={`tooltip-${index}`}
label={`${label}: `}
value={formattedValue}
/>,
);
}
});
return tooltipItems;
}
function createScreenGridData(
o: JsonObject,
fieldName: string,
extractResult: { value: any; allValues: any[] },
): Record<string, any> {
const result: Record<string, any> = {};
if (extractResult.allValues.length > 0) {
result[fieldName] = extractResult.allValues;
result[`${fieldName}s`] = extractResult.allValues.join(', ');
result[`${fieldName}_count`] = extractResult.allValues.length;
} else {
const count = o.object?.count || 0;
const value = o.object?.value || 0;
const aggregatedValue = `Aggregated: ${count} points, total value: ${value}`;
result[fieldName] = aggregatedValue;
result[`${fieldName}_aggregated`] = aggregatedValue;
}
return result;
}
function processTooltipContentItem(
item: any,
o: JsonObject,
formData: QueryFormData,
): Record<string, any> {
let fieldName = '';
if (typeof item === 'string') {
fieldName = item;
} else if (item?.item_type === 'column') {
fieldName = item.column_name;
} else if (item?.item_type === 'metric') {
fieldName = item.metric_name || item.label;
}
if (!fieldName) return {};
const extractResult = extractValue(o, fieldName);
let { value } = extractResult;
if (item?.item_type === 'metric' && !value) {
value = o.object?.metric || '';
}
if (formData.viz_type === 'deck_screengrid' && !value) {
return createScreenGridData(o, fieldName, extractResult);
}
if (extractResult.allValues.length > 0) {
return {
[fieldName]: extractResult.allValues,
[`${fieldName}s`]: extractResult.allValues.join(', '),
[`${fieldName}_count`]: extractResult.allValues.length,
};
}
if (value !== '') {
return { [fieldName]: value };
}
return {};
}
export function createHandlebarsTooltipData(
o: JsonObject,
formData: QueryFormData,
): Record<string, any> {
const initialData: Record<string, any> = {
...(o.object || {}),
coordinate: o.coordinate,
index: o.index,
picked: o.picked,
title: formData.viz_type || 'Chart',
coordinateString: o.coordinate
? `${o.coordinate[0]}, ${o.coordinate[1]}`
: '',
positionString: o.object?.position
? `${o.object.position[0]}, ${o.object.position[1]}`
: '',
threshold: o.object?.contour?.threshold,
contourThreshold: o.object?.contour?.threshold,
nearbyPoints: o.object?.nearbyPoints,
totalPoints: o.object?.totalPoints,
};
let data = { ...initialData };
if (
formData.viz_type === 'deck_heatmap' ||
formData.viz_type === 'deck_contour'
) {
if (o.object?.position) {
data = {
...data,
LON: o.object.position[0],
LAT: o.object.position[1],
};
}
if (o.coordinate) {
data = {
...data,
LON: o.coordinate[0],
LAT: o.coordinate[1],
};
}
if (!o.object && formData.viz_type === 'deck_heatmap') {
data = {
...data,
aggregated: true,
note: 'Aggregated cell - individual point data not available',
};
}
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipData = formData.tooltip_contents.reduce(
(acc: any, item: any) => {
const itemData = processTooltipContentItem(item, o, formData);
return { ...acc, ...itemData };
},
{},
);
data = { ...data, ...tooltipData };
}
return data;
}
export function generateEnhancedDefaultTemplate(
tooltipContents: any[],
formData: QueryFormData,
): string {
return createDefaultTemplateWithLimits(tooltipContents, formData);
}
export function useTooltipContent(
formData: QueryFormData,
defaultTooltipGenerator: (o: JsonObject) => JSX.Element,
) {
const tooltipContentGenerator = useMemo(
() => (o: JsonObject) => {
if (
formData.tooltip_template?.trim() &&
!formData.tooltip_template.includes(
'Drop columns/metrics in "Tooltip contents" above',
)
) {
const tooltipData = createHandlebarsTooltipData(o, formData);
return (
<div className="deckgl-tooltip" data-tooltip-type="custom">
<MemoizedHandlebarsRenderer
templateSource={formData.tooltip_template}
data={tooltipData}
/>
</div>
);
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipItems = buildFieldBasedTooltipItems(o, formData);
return <div className="deckgl-tooltip">{tooltipItems}</div>;
}
return defaultTooltipGenerator(o);
},
[
formData.tooltip_template,
formData.tooltip_contents,
formData.viz_type,
defaultTooltipGenerator,
],
);
return tooltipContentGenerator;
}
export function createTooltipContent(
formData: QueryFormData,
defaultTooltipGenerator: (o: JsonObject) => JSX.Element,
) {
return (o: JsonObject) => {
if (
formData.tooltip_template?.trim() &&
!formData.tooltip_template.includes(
'Drop columns/metrics in "Tooltip contents" above',
)
) {
const tooltipData = createHandlebarsTooltipData(o, formData);
return (
<div className="deckgl-tooltip" data-tooltip-type="custom">
<MemoizedHandlebarsRenderer
templateSource={formData.tooltip_template}
data={tooltipData}
/>
</div>
);
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipItems = buildFieldBasedTooltipItems(o, formData);
return <div className="deckgl-tooltip">{tooltipItems}</div>;
}
return defaultTooltipGenerator(o);
};
}

View File

@@ -28,8 +28,6 @@ export const COLOR_SCHEME_TYPES = {
export type ColorSchemeType =
(typeof COLOR_SCHEME_TYPES)[keyof typeof COLOR_SCHEME_TYPES];
/* eslint camelcase: 0 */
export function formatSelectOptions(options: (string | number)[]) {
return options.map(opt => [opt, opt.toString()]);
}

View File

@@ -1,14 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": "../..",
"outDir": "lib"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]

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