mirror of
https://github.com/apache/superset.git
synced 2026-05-05 16:04:19 +00:00
Compare commits
26 Commits
fix-sql-la
...
fix-duckdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44d3f769cb | ||
|
|
1cf14f1081 | ||
|
|
482b7108ae | ||
|
|
b2129b4277 | ||
|
|
55e3da478a | ||
|
|
38297edc6b | ||
|
|
0c8f326258 | ||
|
|
127f6b3d66 | ||
|
|
ea519a77b5 | ||
|
|
6cb3ef9f5d | ||
|
|
a889ae75fc | ||
|
|
b60be9655f | ||
|
|
fd6da21ce0 | ||
|
|
1bf112a57a | ||
|
|
1f530d45cb | ||
|
|
1187902e68 | ||
|
|
ad3eff9e90 | ||
|
|
3e554674ff | ||
|
|
dced2f8564 | ||
|
|
05c6a1bf20 | ||
|
|
c193d6d6a1 | ||
|
|
fb840b8e71 | ||
|
|
d0cc6f115b | ||
|
|
966e231f94 | ||
|
|
a66737cb05 | ||
|
|
bc6859a99d |
70
.github/workflows/bashlib.sh
vendored
70
.github/workflows/bashlib.sh
vendored
@@ -182,6 +182,76 @@ cypress-run-all() {
|
|||||||
kill $flaskProcessId
|
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() {
|
eyes-storybook-dependencies() {
|
||||||
say "::group::install eyes-storyook 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
|
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
|
||||||
|
|||||||
22
.github/workflows/showtime-trigger.yml
vendored
22
.github/workflows/showtime-trigger.yml
vendored
@@ -61,17 +61,8 @@ jobs:
|
|||||||
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
|
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
|
||||||
const authorized = ['write', 'admin'].includes(permission.permission);
|
const authorized = ['write', 'admin'].includes(permission.permission);
|
||||||
|
|
||||||
if (!authorized) {
|
// If this is a synchronize event from unauthorized user, check if Showtime is active and set blocked label
|
||||||
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
|
if (!authorized && context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
|
||||||
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') {
|
|
||||||
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
|
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
|
||||||
|
|
||||||
// Check if PR has any circus tent labels (Showtime is in use)
|
// 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
|
- name: Install Superset Showtime
|
||||||
if: steps.auth.outputs.authorized == 'true'
|
if: steps.auth.outputs.authorized == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
|||||||
- name: tsc
|
- name: tsc
|
||||||
run: |
|
run: |
|
||||||
docker run --rm $TAG bash -c \
|
docker run --rm $TAG bash -c \
|
||||||
"npm run type"
|
"npm run plugins:build && npm run type"
|
||||||
|
|
||||||
validate-frontend:
|
validate-frontend:
|
||||||
needs: frontend-build
|
needs: frontend-build
|
||||||
|
|||||||
141
.github/workflows/superset-playwright.yml
vendored
Normal file
141
.github/workflows/superset-playwright.yml
vendored
Normal 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 }}
|
||||||
@@ -26,6 +26,9 @@ ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
|
|||||||
# Include translations in the final build
|
# Include translations in the final build
|
||||||
ARG BUILD_TRANSLATIONS="false"
|
ARG BUILD_TRANSLATIONS="false"
|
||||||
|
|
||||||
|
# Build arg to pre-populate examples DuckDB file
|
||||||
|
ARG LOAD_EXAMPLES_DUCKDB="false"
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
# superset-node-ci used as a base for building frontend assets and CI
|
# superset-node-ci used as a base for building frontend assets and CI
|
||||||
######################################################################
|
######################################################################
|
||||||
@@ -143,8 +146,8 @@ RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
|
|||||||
######################################################################
|
######################################################################
|
||||||
FROM python-base AS python-common
|
FROM python-base AS python-common
|
||||||
|
|
||||||
# Build arg to pre-populate examples DuckDB file
|
# Re-declare build arg to receive it in this stage
|
||||||
ARG LOAD_EXAMPLES_DUCKDB="false"
|
ARG LOAD_EXAMPLES_DUCKDB
|
||||||
|
|
||||||
ENV SUPERSET_HOME="/app/superset_home" \
|
ENV SUPERSET_HOME="/app/superset_home" \
|
||||||
HOME="/app/superset_home" \
|
HOME="/app/superset_home" \
|
||||||
|
|||||||
17
LLMS.md
17
LLMS.md
@@ -15,8 +15,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
|
|||||||
|
|
||||||
### Testing Strategy Migration
|
### Testing Strategy Migration
|
||||||
- **Prefer unit tests** over integration tests
|
- **Prefer unit tests** over integration tests
|
||||||
- **Prefer integration tests** over Cypress end-to-end tests
|
- **Prefer integration tests** over end-to-end tests
|
||||||
- **Cypress is last resort** - Actively moving away from Cypress
|
- **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 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
|
- **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 # All tests
|
||||||
npm run test -- filename.test.tsx # Single file
|
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
|
# Backend
|
||||||
pytest # All tests
|
pytest # All tests
|
||||||
pytest tests/unit_tests/specific_test.py # Single file
|
pytest tests/unit_tests/specific_test.py # Single file
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ version: 1
|
|||||||
## Jinja Templates
|
## Jinja Templates
|
||||||
|
|
||||||
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
|
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
|
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
|
||||||
`superset_config.py`. When templating is enabled, python code can be embedded in virtual datasets and
|
|
||||||
|
> #### ⚠️ 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
|
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
|
||||||
made available in the Jinja context:
|
made available in the Jinja context:
|
||||||
|
|
||||||
|
|||||||
@@ -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 backend unit test suites (`tests/`)
|
||||||
- running the frontend test suites (`superset-frontend/src/**.*.test.*`)
|
- 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
|
- linting the codebase, including all Python, Typescript and Javascript, yaml and beyond
|
||||||
- checking for all sorts of other rules conventions
|
- checking for all sorts of other rules conventions
|
||||||
|
|
||||||
|
|||||||
@@ -225,21 +225,57 @@ npm run test -- path/to/file.js
|
|||||||
|
|
||||||
### E2E Integration Testing
|
### 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
|
```bash
|
||||||
CYPRESS_CONFIG=true docker compose up --build
|
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
|
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.
|
||||||
to connect to the Cypress-ready Superset backend.
|
|
||||||
|
|
||||||
|
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
|
CYPRESS_BASE_URL=http://localhost:8081
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ dependencies = [
|
|||||||
"pgsanity",
|
"pgsanity",
|
||||||
"Pillow>=11.0.0, <12",
|
"Pillow>=11.0.0, <12",
|
||||||
"polyline>=2.0.0, <3.0",
|
"polyline>=2.0.0, <3.0",
|
||||||
|
"pydantic>=2.8.0",
|
||||||
"pyparsing>=3.0.6, <4",
|
"pyparsing>=3.0.6, <4",
|
||||||
"python-dateutil",
|
"python-dateutil",
|
||||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||||
@@ -132,7 +133,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
|
|||||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||||
druid = ["pydruid>=0.6.5,<0.7"]
|
druid = ["pydruid>=0.6.5,<0.7"]
|
||||||
duckdb = ["duckdb-engine>=0.17.0"]
|
duckdb = ["duckdb==0.10.2", "duckdb-engine==0.12.1"]
|
||||||
dynamodb = ["pydynamodb>=0.4.2"]
|
dynamodb = ["pydynamodb>=0.4.2"]
|
||||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||||
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
|
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ alembic==1.15.2
|
|||||||
# via flask-migrate
|
# via flask-migrate
|
||||||
amqp==5.3.1
|
amqp==5.3.1
|
||||||
# via kombu
|
# via kombu
|
||||||
|
annotated-types==0.7.0
|
||||||
|
# via pydantic
|
||||||
apispec==6.6.1
|
apispec==6.6.1
|
||||||
# via
|
# via
|
||||||
# -r requirements/base.in
|
# -r requirements/base.in
|
||||||
@@ -115,7 +117,9 @@ flask==2.3.3
|
|||||||
# flask-sqlalchemy
|
# flask-sqlalchemy
|
||||||
# flask-wtf
|
# flask-wtf
|
||||||
flask-appbuilder==5.0.0
|
flask-appbuilder==5.0.0
|
||||||
# via apache-superset (pyproject.toml)
|
# via
|
||||||
|
# apache-superset (pyproject.toml)
|
||||||
|
# apache-superset-core
|
||||||
flask-babel==3.1.0
|
flask-babel==3.1.0
|
||||||
# via flask-appbuilder
|
# via flask-appbuilder
|
||||||
flask-caching==2.3.1
|
flask-caching==2.3.1
|
||||||
@@ -294,6 +298,10 @@ pyasn1-modules==0.4.2
|
|||||||
# via google-auth
|
# via google-auth
|
||||||
pycparser==2.22
|
pycparser==2.22
|
||||||
# via cffi
|
# via cffi
|
||||||
|
pydantic==2.11.7
|
||||||
|
# via apache-superset (pyproject.toml)
|
||||||
|
pydantic-core==2.33.2
|
||||||
|
# via pydantic
|
||||||
pygments==2.19.1
|
pygments==2.19.1
|
||||||
# via rich
|
# via rich
|
||||||
pyjwt==2.10.1
|
pyjwt==2.10.1
|
||||||
@@ -404,10 +412,15 @@ typing-extensions==4.14.0
|
|||||||
# alembic
|
# alembic
|
||||||
# cattrs
|
# cattrs
|
||||||
# limits
|
# limits
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
# referencing
|
# referencing
|
||||||
# selenium
|
# selenium
|
||||||
# shillelagh
|
# shillelagh
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
# via pydantic
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
# via
|
# via
|
||||||
# kombu
|
# kombu
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ amqp==5.3.1
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# kombu
|
# kombu
|
||||||
|
annotated-types==0.7.0
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# pydantic
|
||||||
apispec==6.6.1
|
apispec==6.6.1
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
@@ -212,6 +216,7 @@ flask-appbuilder==5.0.0
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# apache-superset
|
# apache-superset
|
||||||
|
# apache-superset-core
|
||||||
flask-babel==3.1.0
|
flask-babel==3.1.0
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
@@ -631,6 +636,14 @@ pycparser==2.22
|
|||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# cffi
|
# 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
|
pydata-google-auth==1.9.0
|
||||||
# via pandas-gbq
|
# via pandas-gbq
|
||||||
pydruid==0.6.9
|
pydruid==0.6.9
|
||||||
@@ -874,10 +887,17 @@ typing-extensions==4.14.0
|
|||||||
# apache-superset
|
# apache-superset
|
||||||
# cattrs
|
# cattrs
|
||||||
# limits
|
# limits
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
# referencing
|
# referencing
|
||||||
# selenium
|
# selenium
|
||||||
# shillelagh
|
# shillelagh
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.1
|
||||||
|
# via
|
||||||
|
# -c requirements/base-constraint.txt
|
||||||
|
# pydantic
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ module.exports = {
|
|||||||
'*.stories.tsx',
|
'*.stories.tsx',
|
||||||
'*.stories.jsx',
|
'*.stories.jsx',
|
||||||
'fixtures.*',
|
'fixtures.*',
|
||||||
|
'playwright/**/*',
|
||||||
],
|
],
|
||||||
excludedFiles: 'cypress-base/cypress/**/*',
|
excludedFiles: 'cypress-base/cypress/**/*',
|
||||||
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
|
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
|
||||||
@@ -397,6 +398,13 @@ module.exports = {
|
|||||||
'react/no-void-elements': 0,
|
'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
|
// eslint-disable-next-line no-dupe-keys
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('Charts list', () => {
|
|||||||
interceptDelete();
|
interceptDelete();
|
||||||
cy.getBySel('sort-header').contains('Name').click();
|
cy.getBySel('sort-header').contains('Name').click();
|
||||||
|
|
||||||
// Modal closes immediatly without this
|
// Modal closes immediately without this
|
||||||
cy.wait(2000);
|
cy.wait(2000);
|
||||||
|
|
||||||
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
|
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');
|
||||||
|
|||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -33,6 +33,7 @@ module.exports = {
|
|||||||
'^@superset-ui/([^/]+)$': '<rootDir>/node_modules/@superset-ui/$1/src',
|
'^@superset-ui/([^/]+)$': '<rootDir>/node_modules/@superset-ui/$1/src',
|
||||||
// mapping @apache-superset/core to local package
|
// mapping @apache-superset/core to local package
|
||||||
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
|
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
|
||||||
|
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
|
||||||
},
|
},
|
||||||
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
|
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
|
||||||
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],
|
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],
|
||||||
|
|||||||
105
superset-frontend/package-lock.json
generated
105
superset-frontend/package-lock.json
generated
@@ -54,6 +54,8 @@
|
|||||||
"@visx/scale": "^3.5.0",
|
"@visx/scale": "^3.5.0",
|
||||||
"@visx/tooltip": "^3.0.0",
|
"@visx/tooltip": "^3.0.0",
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
|
"ag-grid-community": "34.2.0",
|
||||||
|
"ag-grid-react": "34.2.0",
|
||||||
"antd": "^5.24.6",
|
"antd": "^5.24.6",
|
||||||
"chrono-node": "^2.7.8",
|
"chrono-node": "^2.7.8",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
"@hot-loader/react-dom": "^17.0.2",
|
"@hot-loader/react-dom": "^17.0.2",
|
||||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
"@storybook/addon-actions": "8.1.11",
|
"@storybook/addon-actions": "8.1.11",
|
||||||
"@storybook/addon-controls": "8.1.11",
|
"@storybook/addon-controls": "8.1.11",
|
||||||
"@storybook/addon-essentials": "8.1.11",
|
"@storybook/addon-essentials": "8.1.11",
|
||||||
@@ -10109,6 +10112,22 @@
|
|||||||
"url": "https://opencollective.com/unts"
|
"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": {
|
"node_modules/@pnpm/config.env-replace": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
|
"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": {
|
"node_modules/ag-charts-types": {
|
||||||
"version": "12.0.2",
|
"version": "12.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz",
|
||||||
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
|
"integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ag-grid-community": {
|
"node_modules/ag-grid-community": {
|
||||||
"version": "34.0.2",
|
"version": "34.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz",
|
||||||
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
|
"integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-charts-types": "12.0.2"
|
"ag-charts-types": "12.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ag-grid-react": {
|
"node_modules/ag-grid-react": {
|
||||||
"version": "34.0.2",
|
"version": "34.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz",
|
||||||
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
|
"integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ag-grid-community": "34.0.2",
|
"ag-grid-community": "34.2.0",
|
||||||
"prop-types": "^15.8.1"
|
"prop-types": "^15.8.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -45517,6 +45536,53 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/png-async": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
|
||||||
@@ -60624,7 +60690,7 @@
|
|||||||
},
|
},
|
||||||
"packages/superset-core": {
|
"packages/superset-core": {
|
||||||
"name": "@apache-superset/core",
|
"name": "@apache-superset/core",
|
||||||
"version": "0.0.1-rc3",
|
"version": "0.0.1-rc4",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.26.4",
|
"@babel/cli": "^7.26.4",
|
||||||
@@ -63321,10 +63387,10 @@
|
|||||||
"version": "0.20.3",
|
"version": "0.20.3",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21"
|
||||||
"prop-types": "^15.8.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
@@ -63349,14 +63415,15 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@babel/runtime": "^7.28.2",
|
"@babel/runtime": "^7.28.2",
|
||||||
"@fontsource/fira-code": "^5.2.6",
|
"@fontsource/fira-code": "^5.2.6",
|
||||||
"@fontsource/inter": "^5.2.6",
|
"@fontsource/inter": "^5.2.6",
|
||||||
"@types/json-bigint": "^1.0.4",
|
"@types/json-bigint": "^1.0.4",
|
||||||
"@visx/responsive": "^3.12.0",
|
"@visx/responsive": "^3.12.0",
|
||||||
"ace-builds": "^1.43.1",
|
"ace-builds": "^1.43.1",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "34.2.0",
|
||||||
"ag-grid-react": "34.0.2",
|
"ag-grid-react": "34.2.0",
|
||||||
"brace": "^0.11.1",
|
"brace": "^0.11.1",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
@@ -65227,6 +65294,8 @@
|
|||||||
"d3-array": "^1.2.4",
|
"d3-array": "^1.2.4",
|
||||||
"d3-color": "^1.4.1",
|
"d3-color": "^1.4.1",
|
||||||
"d3-scale": "^3.0.0",
|
"d3-scale": "^3.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
@@ -65393,6 +65462,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@superset-ui/chart-controls": "*",
|
"@superset-ui/chart-controls": "*",
|
||||||
"@superset-ui/core": "*",
|
"@superset-ui/core": "*",
|
||||||
"@testing-library/dom": "^8.20.1",
|
"@testing-library/dom": "^8.20.1",
|
||||||
@@ -65444,6 +65514,7 @@
|
|||||||
"lodash": "^4.17.21"
|
"lodash": "^4.17.21"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@superset-ui/chart-controls": "*",
|
"@superset-ui/chart-controls": "*",
|
||||||
"@superset-ui/core": "*",
|
"@superset-ui/core": "*",
|
||||||
"echarts": "*",
|
"echarts": "*",
|
||||||
@@ -66621,6 +66692,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@superset-ui/chart-controls": "*",
|
"@superset-ui/chart-controls": "*",
|
||||||
"@superset-ui/core": "*",
|
"@superset-ui/core": "*",
|
||||||
"lodash": "^4.17.11",
|
"lodash": "^4.17.11",
|
||||||
@@ -67752,6 +67824,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@superset-ui/chart-controls": "*",
|
"@superset-ui/chart-controls": "*",
|
||||||
"@superset-ui/core": "*",
|
"@superset-ui/core": "*",
|
||||||
"@testing-library/dom": "^8.20.1",
|
"@testing-library/dom": "^8.20.1",
|
||||||
|
|||||||
@@ -63,6 +63,11 @@
|
|||||||
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
|
"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: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",
|
"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": "npm run _prettier -- --write",
|
||||||
"prettier-check": "npm run _prettier -- --check",
|
"prettier-check": "npm run _prettier -- --check",
|
||||||
"prod": "npm run build",
|
"prod": "npm run build",
|
||||||
@@ -122,6 +127,8 @@
|
|||||||
"@visx/scale": "^3.5.0",
|
"@visx/scale": "^3.5.0",
|
||||||
"@visx/tooltip": "^3.0.0",
|
"@visx/tooltip": "^3.0.0",
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
|
"ag-grid-community": "34.2.0",
|
||||||
|
"ag-grid-react": "34.2.0",
|
||||||
"antd": "^5.24.6",
|
"antd": "^5.24.6",
|
||||||
"chrono-node": "^2.7.8",
|
"chrono-node": "^2.7.8",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
@@ -227,6 +234,7 @@
|
|||||||
"@hot-loader/react-dom": "^17.0.2",
|
"@hot-loader/react-dom": "^17.0.2",
|
||||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
"@storybook/addon-actions": "8.1.11",
|
"@storybook/addon-actions": "8.1.11",
|
||||||
"@storybook/addon-controls": "8.1.11",
|
"@storybook/addon-controls": "8.1.11",
|
||||||
"@storybook/addon-essentials": "8.1.11",
|
"@storybook/addon-essentials": "8.1.11",
|
||||||
|
|||||||
@@ -22,19 +22,6 @@ To add the package to Superset, go to the `superset-frontend` subdirectory in yo
|
|||||||
npm i -S ../../<%= packageName %>
|
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:
|
You may also wish to add the following to the `include` array in `tsconfig.json` to make Superset types available to your plugin:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,44 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowSyntheticDefaultImports": true,
|
"baseUrl": "../..",
|
||||||
"declaration": true,
|
"outDir": "lib"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"lib",
|
"src/**/*.js",
|
||||||
"test"
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
],
|
],
|
||||||
"include": [
|
"references": [
|
||||||
"src/**/*",
|
{ "path": "../../packages/superset-core" },
|
||||||
"types/**/*"
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@apache-superset/core",
|
"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.",
|
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|||||||
@@ -1,19 +1,9 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"allowSyntheticDefaultImports": true,
|
"baseUrl": "../..",
|
||||||
"declaration": true,
|
"outDir": "lib"
|
||||||
"declarationDir": "lib",
|
|
||||||
"outDir": "lib",
|
|
||||||
"strict": true,
|
|
||||||
"rootDir": "src",
|
|
||||||
"jsx": "preserve",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"target": "es2020",
|
|
||||||
"esModuleInterop": true
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts*"],
|
"include": ["src/**/*", "types/**/*"],
|
||||||
"exclude": ["lib"]
|
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
"lib"
|
"lib"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21"
|
||||||
"prop-types": "^15.8.1"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
|||||||
@@ -20,20 +20,32 @@ import { t } from '@superset-ui/core';
|
|||||||
import { ControlPanelSectionConfig } from '../types';
|
import { ControlPanelSectionConfig } from '../types';
|
||||||
|
|
||||||
export const matrixifyEnableSection: ControlPanelSectionConfig = {
|
export const matrixifyEnableSection: ControlPanelSectionConfig = {
|
||||||
label: t('Enable matrixify'),
|
label: t('Matrixify'),
|
||||||
expanded: true,
|
expanded: true,
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'matrixify_enabled',
|
name: 'matrixify_enable_horizontal_layout',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
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,
|
default: false,
|
||||||
renderTrigger: true,
|
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 = {
|
export const matrixifySection: ControlPanelSectionConfig = {
|
||||||
label: t('Matrixify'),
|
label: t('Cell layout & styling'),
|
||||||
expanded: false,
|
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: [
|
controlSetRows: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -105,9 +119,10 @@ export const matrixifySection: ControlPanelSectionConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const matrixifyRowSection: ControlPanelSectionConfig = {
|
export const matrixifyRowSection: ControlPanelSectionConfig = {
|
||||||
label: t('Vertical layout'),
|
label: t('Vertical layout (rows)'),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
|
visibility: ({ controls }) =>
|
||||||
|
controls?.matrixify_enable_vertical_layout?.value === true,
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
['matrixify_show_row_labels'],
|
['matrixify_show_row_labels'],
|
||||||
['matrixify_mode_rows'],
|
['matrixify_mode_rows'],
|
||||||
@@ -118,13 +133,14 @@ export const matrixifyRowSection: ControlPanelSectionConfig = {
|
|||||||
['matrixify_topn_metric_rows'],
|
['matrixify_topn_metric_rows'],
|
||||||
['matrixify_topn_order_rows'],
|
['matrixify_topn_order_rows'],
|
||||||
],
|
],
|
||||||
tabOverride: 'data',
|
tabOverride: 'matrixify',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matrixifyColumnSection: ControlPanelSectionConfig = {
|
export const matrixifyColumnSection: ControlPanelSectionConfig = {
|
||||||
label: t('Horizontal layout'),
|
label: t('Horizontal layout (columns)'),
|
||||||
expanded: false,
|
expanded: false,
|
||||||
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
|
visibility: ({ controls }) =>
|
||||||
|
controls?.matrixify_enable_horizontal_layout?.value === true,
|
||||||
controlSetRows: [
|
controlSetRows: [
|
||||||
['matrixify_show_column_headers'],
|
['matrixify_show_column_headers'],
|
||||||
['matrixify_mode_columns'],
|
['matrixify_mode_columns'],
|
||||||
@@ -135,5 +151,5 @@ export const matrixifyColumnSection: ControlPanelSectionConfig = {
|
|||||||
['matrixify_topn_metric_columns'],
|
['matrixify_topn_metric_columns'],
|
||||||
['matrixify_topn_order_columns'],
|
['matrixify_topn_order_columns'],
|
||||||
],
|
],
|
||||||
tabOverride: 'data',
|
tabOverride: 'matrixify',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,21 +18,49 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t } from '@superset-ui/core';
|
import { t, validateNonEmpty } from '@superset-ui/core';
|
||||||
import { SharedControlConfig } from '../types';
|
import { SharedControlConfig } from '../types';
|
||||||
import { dndAdhocMetricControl } from './dndControls';
|
import { dndAdhocMetricControl } from './dndControls';
|
||||||
|
import { defineSavedMetrics } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Matrixify control definitions
|
* Matrixify control definitions
|
||||||
* Controls for transforming charts into matrix/grid layouts
|
* 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
|
// Initialize the controls object that will be populated dynamically
|
||||||
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
||||||
|
|
||||||
// Dynamically add axis-specific controls (rows and columns)
|
// Dynamically add axis-specific controls (rows and columns)
|
||||||
['columns', 'rows'].forEach(axisParam => {
|
(['columns', 'rows'] as const).forEach(axisParam => {
|
||||||
const axis = axisParam; // Capture the value in a local variable
|
const axis: 'rows' | 'columns' = axisParam;
|
||||||
|
|
||||||
matrixifyControls[`matrixify_mode_${axis}`] = {
|
matrixifyControls[`matrixify_mode_${axis}`] = {
|
||||||
type: 'RadioButtonControl',
|
type: 'RadioButtonControl',
|
||||||
@@ -43,17 +71,18 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
|||||||
['dimensions', t('Dimension members')],
|
['dimensions', t('Dimension members')],
|
||||||
],
|
],
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
|
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
|
||||||
};
|
};
|
||||||
|
|
||||||
matrixifyControls[`matrixify_${axis}`] = {
|
matrixifyControls[`matrixify_${axis}`] = {
|
||||||
...dndAdhocMetricControl,
|
...dndAdhocMetricControl,
|
||||||
label: t(`Metrics`),
|
label: t(`Metrics`),
|
||||||
multi: true,
|
multi: true,
|
||||||
validators: [], // Not required
|
validators: [], // No validation - rely on visibility
|
||||||
// description: t(`Select metrics for ${axis}`),
|
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
visibility: ({ controls }) =>
|
tabOverride: 'matrixify',
|
||||||
controls?.[`matrixify_mode_${axis}`]?.value === 'metrics',
|
visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'metrics'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combined dimension and values control
|
// Combined dimension and values control
|
||||||
@@ -62,8 +91,9 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
|||||||
label: t(`Dimension selection`),
|
label: t(`Dimension selection`),
|
||||||
description: t(`Select dimension and values`),
|
description: t(`Select dimension and values`),
|
||||||
default: { dimension: '', values: [] },
|
default: { dimension: '', values: [] },
|
||||||
validators: [], // Not required
|
validators: [], // No validation - rely on visibility
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
shouldMapStateToProps: (prevState, state) => {
|
shouldMapStateToProps: (prevState, state) => {
|
||||||
// Recalculate when any relevant form_data field changes
|
// Recalculate when any relevant form_data field changes
|
||||||
const fieldsToCheck = [
|
const fieldsToCheck = [
|
||||||
@@ -82,24 +112,40 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
|||||||
const getValue = (key: string, defaultValue?: any) =>
|
const getValue = (key: string, defaultValue?: any) =>
|
||||||
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
|
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
datasource,
|
datasource,
|
||||||
selectionMode: getValue(
|
selectionMode,
|
||||||
`matrixify_dimension_selection_mode_${axis}`,
|
|
||||||
'members',
|
|
||||||
),
|
|
||||||
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
|
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
|
||||||
topNValue: getValue(`matrixify_topn_value_${axis}`),
|
topNValue: getValue(`matrixify_topn_value_${axis}`),
|
||||||
topNOrder: getValue(`matrixify_topn_order_${axis}`),
|
topNOrder: getValue(`matrixify_topn_order_${axis}`),
|
||||||
formData: form_data,
|
formData: form_data,
|
||||||
|
validators,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
visibility: ({ controls }) =>
|
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}`] = {
|
matrixifyControls[`matrixify_topn_dimension_${axis}`] = {
|
||||||
type: 'SelectControl',
|
type: 'SelectControl',
|
||||||
label: t('Dimension'),
|
label: t('Dimension'),
|
||||||
@@ -127,33 +173,67 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
|||||||
['topn', t('Top n')],
|
['topn', t('Top n')],
|
||||||
],
|
],
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
|
isMatrixifyVisible(controls, axis, 'dimensions'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// TopN controls
|
// TopN controls
|
||||||
matrixifyControls[`matrixify_topn_value_${axis}`] = {
|
matrixifyControls[`matrixify_topn_value_${axis}`] = {
|
||||||
type: 'TextControl',
|
type: 'NumberControl',
|
||||||
label: t(`Number of top values`),
|
label: t(`Number of top values`),
|
||||||
description: t(`How many top values to select`),
|
description: t(`How many top values to select`),
|
||||||
default: 10,
|
default: 10,
|
||||||
isInt: true,
|
isInt: true,
|
||||||
|
validators: [],
|
||||||
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
|
||||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
mapStateToProps: ({ controls }) => {
|
||||||
|
const isVisible = isMatrixifyVisible(
|
||||||
|
controls,
|
||||||
|
axis,
|
||||||
|
'dimensions',
|
||||||
'topn',
|
'topn',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
validators: isVisible ? [validateNonEmpty] : [],
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
|
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
|
||||||
...dndAdhocMetricControl,
|
...dndAdhocMetricControl,
|
||||||
label: t(`Metric for ordering`),
|
label: t(`Metric for ordering`),
|
||||||
multi: false,
|
multi: false,
|
||||||
validators: [], // Not required
|
validators: [],
|
||||||
description: t(`Metric to use for ordering Top N values`),
|
description: t(`Metric to use for ordering Top N values`),
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
|
||||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
mapStateToProps: (state, controlState) => {
|
||||||
|
const { controls, datasource } = state;
|
||||||
|
const isVisible = isMatrixifyVisible(
|
||||||
|
controls,
|
||||||
|
axis,
|
||||||
|
'dimensions',
|
||||||
'topn',
|
'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}`] = {
|
matrixifyControls[`matrixify_topn_order_${axis}`] = {
|
||||||
@@ -164,10 +244,10 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
|||||||
['asc', t('Ascending')],
|
['asc', t('Ascending')],
|
||||||
['desc', t('Descending')],
|
['desc', t('Descending')],
|
||||||
],
|
],
|
||||||
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
|
||||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
|
||||||
'topn',
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,15 +293,22 @@ matrixifyControls.matrixify_charts_per_row = {
|
|||||||
!controls?.matrixify_fit_columns_dynamically?.value,
|
!controls?.matrixify_fit_columns_dynamically?.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main enable control
|
matrixifyControls.matrixify_enable_vertical_layout = {
|
||||||
matrixifyControls.matrixify_enabled = {
|
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('Enable matrixify'),
|
label: t('Enable vertical layout (rows)'),
|
||||||
description: t(
|
description: t('Create matrix rows by stacking charts vertically'),
|
||||||
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
|
|
||||||
),
|
|
||||||
default: false,
|
default: false,
|
||||||
renderTrigger: true,
|
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
|
// Cell title control for Matrixify
|
||||||
@@ -234,8 +321,8 @@ matrixifyControls.matrixify_cell_title_template = {
|
|||||||
default: '',
|
default: '',
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
(controls?.matrixify_mode_rows?.value ||
|
controls?.matrixify_enable_vertical_layout?.value === true ||
|
||||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
controls?.matrixify_enable_horizontal_layout?.value === true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Matrix display controls
|
// 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'),
|
description: t('Display labels for each row on the left side of the matrix'),
|
||||||
default: true,
|
default: true,
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
(controls?.matrixify_mode_rows?.value ||
|
controls?.matrixify_enable_vertical_layout?.value === true,
|
||||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
matrixifyControls.matrixify_show_column_headers = {
|
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'),
|
description: t('Display headers for each column at the top of the matrix'),
|
||||||
default: true,
|
default: true,
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
|
tabOverride: 'matrixify',
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
(controls?.matrixify_mode_rows?.value ||
|
controls?.matrixify_enable_horizontal_layout?.value === true,
|
||||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { matrixifyControls };
|
export { matrixifyControls };
|
||||||
|
|||||||
@@ -17,11 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ensureIsArray, GenericDataType, ValueOf } from '@superset-ui/core';
|
import { ensureIsArray, GenericDataType, ValueOf } from '@superset-ui/core';
|
||||||
import {
|
import { ControlPanelState, isDataset, isQueryResponse } from '../types';
|
||||||
ControlPanelState,
|
|
||||||
isDataset,
|
|
||||||
isQueryResponse,
|
|
||||||
} from '@superset-ui/chart-controls';
|
|
||||||
|
|
||||||
export function checkColumnType(
|
export function checkColumnType(
|
||||||
columnName: string,
|
columnName: string,
|
||||||
|
|||||||
@@ -2,18 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
"noEmit": true,
|
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"include": [
|
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||||
"**/*",
|
|
||||||
"../types/**/*",
|
|
||||||
"../../../types/**/*"
|
|
||||||
],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
|
||||||
"declarationDir": "lib",
|
|
||||||
"outDir": "lib",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"lib",
|
|
||||||
"test"
|
|
||||||
],
|
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"include": [
|
"compilerOptions": {
|
||||||
"src/**/*",
|
"baseUrl": "../..",
|
||||||
"types/**/*",
|
"outDir": "lib"
|
||||||
"../../types/**/*"
|
},
|
||||||
],
|
"include": ["src/**/*", "types/**/*"],
|
||||||
|
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{ "path": "../superset-core" },
|
||||||
"path": "../superset-ui-core"
|
{ "path": "../superset-ui-core" }
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,15 @@
|
|||||||
"lib"
|
"lib"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apache-superset/core": "*",
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"@babel/runtime": "^7.28.2",
|
"@babel/runtime": "^7.28.2",
|
||||||
"@fontsource/fira-code": "^5.2.6",
|
"@fontsource/fira-code": "^5.2.6",
|
||||||
"@fontsource/inter": "^5.2.6",
|
"@fontsource/inter": "^5.2.6",
|
||||||
"@types/json-bigint": "^1.0.4",
|
"@types/json-bigint": "^1.0.4",
|
||||||
"ace-builds": "^1.43.1",
|
"ace-builds": "^1.43.1",
|
||||||
"ag-grid-community": "^34.0.2",
|
"ag-grid-community": "34.2.0",
|
||||||
"ag-grid-react": "34.0.2",
|
"ag-grid-react": "34.2.0",
|
||||||
"brace": "^0.11.1",
|
"brace": "^0.11.1",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
"csstype": "^3.1.3",
|
"csstype": "^3.1.3",
|
||||||
|
|||||||
@@ -276,10 +276,10 @@ export function generateMatrixifyGrid(
|
|||||||
|
|
||||||
const cellFormData = generateCellFormData(
|
const cellFormData = generateCellFormData(
|
||||||
formData,
|
formData,
|
||||||
rowCount > 1 ? config.rows : null,
|
rowCount > 0 ? config.rows : null,
|
||||||
colCount > 1 ? config.columns : null,
|
colCount > 0 ? config.columns : null,
|
||||||
rowCount > 1 ? row : null,
|
rowCount > 0 ? row : null,
|
||||||
colCount > 1 ? col : null,
|
colCount > 0 ? col : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate title using template if provided
|
// Generate title using template if provided
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ test('should create single group when fitting columns dynamically', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: true,
|
matrixify_fit_columns_dynamically: true,
|
||||||
matrixify_charts_per_row: 3,
|
matrixify_charts_per_row: 3,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -123,7 +124,8 @@ test('should create multiple groups when not fitting columns dynamically', () =>
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: false,
|
matrixify_fit_columns_dynamically: false,
|
||||||
matrixify_charts_per_row: 3,
|
matrixify_charts_per_row: 3,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -158,7 +160,8 @@ test('should handle exact division of columns', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: false,
|
matrixify_fit_columns_dynamically: false,
|
||||||
matrixify_charts_per_row: 2,
|
matrixify_charts_per_row: 2,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -186,7 +189,8 @@ test('should handle case where charts_per_row exceeds total columns', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: false,
|
matrixify_fit_columns_dynamically: false,
|
||||||
matrixify_charts_per_row: 5,
|
matrixify_charts_per_row: 5,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -216,7 +220,8 @@ test('should show headers for each group when wrapping occurs', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: false,
|
matrixify_fit_columns_dynamically: false,
|
||||||
matrixify_charts_per_row: 2,
|
matrixify_charts_per_row: 2,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -250,7 +255,8 @@ test('should show headers only on first row when not wrapping', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
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_fit_columns_dynamically: true, // No wrapping
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
matrixify_show_column_headers: true,
|
matrixify_show_column_headers: true,
|
||||||
@@ -279,7 +285,8 @@ test('should hide headers when disabled', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_show_row_labels: false,
|
matrixify_show_row_labels: false,
|
||||||
matrixify_show_column_headers: false,
|
matrixify_show_column_headers: false,
|
||||||
};
|
};
|
||||||
@@ -306,7 +313,8 @@ test('should place cells correctly in wrapped layout', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
matrixify_fit_columns_dynamically: false,
|
matrixify_fit_columns_dynamically: false,
|
||||||
matrixify_charts_per_row: 2,
|
matrixify_charts_per_row: 2,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
@@ -336,7 +344,8 @@ test('should handle null grid gracefully', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container } = renderWithTheme(
|
const { container } = renderWithTheme(
|
||||||
@@ -357,7 +366,8 @@ test('should handle empty grid gracefully', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { container } = renderWithTheme(
|
const { container } = renderWithTheme(
|
||||||
@@ -381,7 +391,8 @@ test('should use default values for missing configuration', () => {
|
|||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'test_chart',
|
viz_type: 'test_chart',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
|
matrixify_enable_horizontal_layout: true,
|
||||||
// Missing optional configurations
|
// Missing optional configurations
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -128,9 +128,13 @@ function MatrixifyGridRenderer({
|
|||||||
[formData],
|
[formData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine layout parameters
|
// Determine layout parameters - only show headers/labels if layout is enabled
|
||||||
const showRowLabels = formData.matrixify_show_row_labels ?? true;
|
const showRowLabels =
|
||||||
const showColumnHeaders = formData.matrixify_show_column_headers ?? true;
|
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 rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT;
|
||||||
const fitColumnsDynamically =
|
const fitColumnsDynamically =
|
||||||
formData.matrixify_fit_columns_dynamically ?? true;
|
formData.matrixify_fit_columns_dynamically ?? true;
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi
|
|||||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
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 = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: false,
|
matrixify_enable_vertical_layout: false,
|
||||||
|
matrixify_enable_horizontal_layout: false,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
matrixify_rows: [createMetric('Revenue')],
|
||||||
} as MatrixifyFormData;
|
} 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', () => {
|
test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_mode_columns: 'metrics',
|
matrixify_mode_columns: 'metrics',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
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', () => {
|
test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'dimensions',
|
matrixify_mode_rows: 'dimensions',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
|
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', () => {
|
test('isMatrixifyEnabled should return true for mixed mode configuration', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
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', () => {
|
test('isMatrixifyEnabled should return true for topn dimension selection mode', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'dimensions',
|
matrixify_mode_rows: 'dimensions',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_dimension_rows: {
|
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', () => {
|
test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_mode_columns: 'metrics',
|
matrixify_mode_columns: 'metrics',
|
||||||
matrixify_rows: [],
|
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', () => {
|
test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'dimensions',
|
matrixify_mode_rows: 'dimensions',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_dimension_rows: { dimension: 'country', values: [] },
|
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', () => {
|
test('getMatrixifyConfig should return valid config for metrics mode', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_mode_columns: 'metrics',
|
matrixify_mode_columns: 'metrics',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
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', () => {
|
test('getMatrixifyConfig should return valid config for dimensions mode', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'dimensions',
|
matrixify_mode_rows: 'dimensions',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
|
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', () => {
|
test('getMatrixifyConfig should handle topn selection mode', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'dimensions',
|
matrixify_mode_rows: 'dimensions',
|
||||||
matrixify_mode_columns: 'dimensions',
|
matrixify_mode_columns: 'dimensions',
|
||||||
matrixify_dimension_rows: {
|
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', () => {
|
test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: false,
|
matrixify_enable_vertical_layout: false,
|
||||||
|
matrixify_enable_horizontal_layout: false,
|
||||||
} as MatrixifyFormData;
|
} as MatrixifyFormData;
|
||||||
|
|
||||||
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
|
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', () => {
|
test('getMatrixifyValidationErrors should return empty array when properly configured', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_mode_columns: 'metrics',
|
matrixify_mode_columns: 'metrics',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
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', () => {
|
test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
} as MatrixifyFormData;
|
} as MatrixifyFormData;
|
||||||
|
|
||||||
const errors = getMatrixifyValidationErrors(formData);
|
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', () => {
|
test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_rows: [],
|
matrixify_rows: [],
|
||||||
matrixify_columns: [],
|
matrixify_columns: [],
|
||||||
@@ -261,7 +263,7 @@ test('should handle empty form data object', () => {
|
|||||||
test('should handle partial configuration with one axis only', () => {
|
test('should handle partial configuration with one axis only', () => {
|
||||||
const formData = {
|
const formData = {
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
matrixify_enabled: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_mode_rows: 'metrics',
|
matrixify_mode_rows: 'metrics',
|
||||||
matrixify_rows: [createMetric('Revenue')],
|
matrixify_rows: [createMetric('Revenue')],
|
||||||
// No columns configuration
|
// No columns configuration
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ export interface MatrixifyAxisConfig {
|
|||||||
* Complete Matrixify configuration in form data
|
* Complete Matrixify configuration in form data
|
||||||
*/
|
*/
|
||||||
export interface MatrixifyFormData {
|
export interface MatrixifyFormData {
|
||||||
// Enable/disable matrixify functionality
|
// Layout enable controls
|
||||||
matrixify_enabled?: boolean;
|
matrixify_enable_vertical_layout?: boolean;
|
||||||
|
matrixify_enable_horizontal_layout?: boolean;
|
||||||
|
|
||||||
// Row axis configuration
|
// Row axis configuration
|
||||||
matrixify_mode_rows?: MatrixifyMode;
|
matrixify_mode_rows?: MatrixifyMode;
|
||||||
@@ -177,8 +178,12 @@ export function getMatrixifyConfig(
|
|||||||
* Check if Matrixify is enabled and properly configured
|
* Check if Matrixify is enabled and properly configured
|
||||||
*/
|
*/
|
||||||
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
|
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
|
||||||
// First check if matrixify is explicitly enabled via checkbox
|
// Check if either vertical or horizontal layout 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 false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +221,11 @@ export function getMatrixifyValidationErrors(
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Only validate if matrixify is enabled
|
// 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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,21 +123,33 @@ export function AsyncAceEditor(
|
|||||||
const cssWorkerUrlPromise = import(
|
const cssWorkerUrlPromise = import(
|
||||||
'ace-builds/src-min-noconflict/worker-css'
|
'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 acequirePromise = import('ace-builds/src-min-noconflict/ace');
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ default: ReactAceEditor },
|
{ default: ReactAceEditor },
|
||||||
{ config },
|
{ config },
|
||||||
{ default: cssWorkerUrl },
|
{ default: cssWorkerUrl },
|
||||||
|
{ default: javascriptWorkerUrl },
|
||||||
|
{ default: htmlWorkerUrl },
|
||||||
{ require: acequire },
|
{ require: acequire },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
reactAcePromise,
|
reactAcePromise,
|
||||||
aceBuildsConfigPromise,
|
aceBuildsConfigPromise,
|
||||||
cssWorkerUrlPromise,
|
cssWorkerUrlPromise,
|
||||||
|
javascriptWorkerUrlPromise,
|
||||||
|
htmlWorkerUrlPromise,
|
||||||
acequirePromise,
|
acequirePromise,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
|
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]()));
|
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,8 +19,10 @@
|
|||||||
import { useEffect, useState, FunctionComponent } from 'react';
|
import { useEffect, useState, FunctionComponent } from 'react';
|
||||||
|
|
||||||
import { t, styled, css, useTheme } from '@superset-ui/core';
|
import { t, styled, css, useTheme } from '@superset-ui/core';
|
||||||
import dayjs from 'dayjs';
|
import { Dayjs } from 'dayjs';
|
||||||
import { extendedDayjs } from '../../utils/dates';
|
import { extendedDayjs } from '../../utils/dates';
|
||||||
|
import 'dayjs/plugin/updateLocale';
|
||||||
|
import 'dayjs/plugin/calendar';
|
||||||
import { Icons } from '../Icons';
|
import { Icons } from '../Icons';
|
||||||
import type { LastUpdatedProps } from './types';
|
import type { LastUpdatedProps } from './types';
|
||||||
|
|
||||||
@@ -46,9 +48,7 @@ export const LastUpdated: FunctionComponent<LastUpdatedProps> = ({
|
|||||||
update,
|
update,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [timeSince, setTimeSince] = useState<dayjs.Dayjs>(
|
const [timeSince, setTimeSince] = useState<Dayjs>(extendedDayjs(updatedAt));
|
||||||
extendedDayjs(updatedAt),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeSince(() => extendedDayjs(updatedAt));
|
setTimeSince(() => extendedDayjs(updatedAt));
|
||||||
|
|||||||
@@ -181,3 +181,9 @@ export {
|
|||||||
type ThemedAgGridReactProps,
|
type ThemedAgGridReactProps,
|
||||||
setupAGGridModules,
|
setupAGGridModules,
|
||||||
} from './ThemedAgGridReact';
|
} from './ThemedAgGridReact';
|
||||||
|
export {
|
||||||
|
CodeEditor,
|
||||||
|
type CodeEditorProps,
|
||||||
|
type CodeEditorMode,
|
||||||
|
type CodeEditorTheme,
|
||||||
|
} from './CodeEditor';
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ dayjs.updateLocale('en', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const extendedDayjs = dayjs;
|
export const extendedDayjs = dayjs;
|
||||||
|
export type { Dayjs };
|
||||||
|
|
||||||
export const fDuration = function (
|
export const fDuration = function (
|
||||||
t1: number,
|
t1: number,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export enum FeatureFlag {
|
|||||||
AlertReports = 'ALERT_REPORTS',
|
AlertReports = 'ALERT_REPORTS',
|
||||||
AlertReportTabs = 'ALERT_REPORT_TABS',
|
AlertReportTabs = 'ALERT_REPORT_TABS',
|
||||||
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
|
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
|
||||||
|
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
|
||||||
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
|
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
|
||||||
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
|
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
|
||||||
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
|
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
|
||||||
|
|||||||
@@ -2,14 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
"noEmit": true,
|
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"include": ["**/*", "../types/**/*", "../../../types/**/*"],
|
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declarationDir": "lib",
|
"baseUrl": "../..",
|
||||||
"outDir": "lib",
|
"outDir": "lib"
|
||||||
"rootDir": "src",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"src/*": ["./src/*"],
|
|
||||||
"@superset-ui/core": ["src"],
|
|
||||||
"@superset-ui/core/*": ["src/*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"include": ["src/**/*", "types/**/*"],
|
||||||
"lib",
|
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
|
||||||
"test"
|
"references": [{ "path": "../superset-core" }]
|
||||||
],
|
|
||||||
"include": [
|
|
||||||
"src/**/*",
|
|
||||||
"spec/**/*",
|
|
||||||
"types/**/*"
|
|
||||||
],
|
|
||||||
"references": []
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,6 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
declare module 'ace-builds/src-min-noconflict/worker-css';
|
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';
|
declare module 'ace-builds/src-min-noconflict/ace';
|
||||||
|
|||||||
@@ -19,3 +19,5 @@
|
|||||||
declare module '*.gif';
|
declare module '*.gif';
|
||||||
declare module '*.svg';
|
declare module '*.svg';
|
||||||
declare module '*.png';
|
declare module '*.png';
|
||||||
|
declare module '*.jpg';
|
||||||
|
declare module '*.jpeg';
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
|
||||||
"declarationDir": "lib",
|
|
||||||
"outDir": "lib",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"lib",
|
|
||||||
"src/**/*.test.ts"
|
|
||||||
],
|
|
||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"include": [
|
"compilerOptions": {
|
||||||
"src/**/*",
|
"baseUrl": "../..",
|
||||||
"types/**/*",
|
"outDir": "lib"
|
||||||
"../../types/**/*"
|
},
|
||||||
],
|
"include": ["src/**/*", "types/**/*"],
|
||||||
"references": []
|
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
|
||||||
}
|
}
|
||||||
|
|||||||
90
superset-frontend/playwright.config.ts
Normal file
90
superset-frontend/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
218
superset-frontend/playwright/README.md
Normal file
218
superset-frontend/playwright/README.md
Normal 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
|
||||||
119
superset-frontend/playwright/components/core/Button.ts
Normal file
119
superset-frontend/playwright/components/core/Button.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
110
superset-frontend/playwright/components/core/Form.ts
Normal file
110
superset-frontend/playwright/components/core/Form.ts
Normal 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 {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
111
superset-frontend/playwright/components/core/Input.ts
Normal file
111
superset-frontend/playwright/components/core/Input.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
superset-frontend/playwright/components/core/index.ts
Normal file
23
superset-frontend/playwright/components/core/index.ts
Normal 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';
|
||||||
122
superset-frontend/playwright/pages/AuthPage.ts
Normal file
122
superset-frontend/playwright/pages/AuthPage.ts
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
superset-frontend/playwright/tests/auth/login.spec.ts
Normal file
88
superset-frontend/playwright/tests/auth/login.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
superset-frontend/playwright/utils/urls.ts
Normal file
23
superset-frontend/playwright/utils/urls.ts
Normal 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;
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,18 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
"noEmit": true,
|
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"include": [
|
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||||
"**/*",
|
|
||||||
"../types/**/*",
|
|
||||||
"../../../types/**/*"
|
|
||||||
],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"d3v3": ["./types/d3v3"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,18 +2,8 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"emitDeclarationOnly": false,
|
"emitDeclarationOnly": false,
|
||||||
"noEmit": true,
|
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"extends": "../../../tsconfig.json",
|
"extends": "../../../tsconfig.json",
|
||||||
"include": [
|
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
|
||||||
"**/*",
|
|
||||||
"../types/**/*",
|
|
||||||
"../../../types/**/*"
|
|
||||||
],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": ".."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@superset-ui/core/components": ["../../packages/superset-ui-core/src/components"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": [
|
||||||
|
"src/**/*.js",
|
||||||
|
"src/**/*.jsx",
|
||||||
|
"src/**/*.test.*",
|
||||||
|
"src/**/*.stories.*"
|
||||||
|
],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
"d3-array": "^1.2.4",
|
"d3-array": "^1.2.4",
|
||||||
"d3-color": "^1.4.1",
|
"d3-color": "^1.4.1",
|
||||||
"d3-scale": "^3.0.0",
|
"d3-scale": "^3.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useState,
|
useState,
|
||||||
|
isValidElement,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
@@ -110,7 +111,9 @@ export const DeckGLContainer = memo(
|
|||||||
const layers = useCallback(() => {
|
const layers = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
|
(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(
|
props.layers.some(
|
||||||
l => typeof l !== 'function' && l?.id === 'tile-layer',
|
l => typeof l !== 'function' && l?.id === 'tile-layer',
|
||||||
) === false
|
) === false
|
||||||
@@ -132,6 +135,20 @@ export const DeckGLContainer = memo(
|
|||||||
return props.layers as Layer[];
|
return props.layers as Layer[];
|
||||||
}, [props.layers, props.mapStyle]);
|
}, [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;
|
const { children = null, height, width } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,7 +167,7 @@ export const DeckGLContainer = memo(
|
|||||||
layers={layers()}
|
layers={layers()}
|
||||||
viewState={viewState}
|
viewState={viewState}
|
||||||
onViewStateChange={onViewStateChange}
|
onViewStateChange={onViewStateChange}
|
||||||
onAfterRender={context => {
|
onAfterRender={(context: any) => {
|
||||||
glContextRef.current = context.gl;
|
glContextRef.current = context.gl;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -164,7 +181,7 @@ export const DeckGLContainer = memo(
|
|||||||
</DeckGL>
|
</DeckGL>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip tooltip={tooltip} />
|
{renderTooltip(tooltip)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const StyledLegend = styled.div`
|
|||||||
outline: none;
|
outline: none;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
|
border: 1px solid ${theme.colorBorder};
|
||||||
|
border-radius: ${theme.borderRadius}px;
|
||||||
|
|
||||||
& ul {
|
& ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -29,26 +29,42 @@ export type TooltipProps = {
|
|||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
|
variant?: 'default' | 'custom';
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledDiv = styled.div<{ top: number; left: number }>`
|
const StyledDiv = styled.div<{
|
||||||
${({ theme, top, left }) => `
|
top: number;
|
||||||
|
left: number;
|
||||||
|
variant: 'default' | 'custom';
|
||||||
|
}>`
|
||||||
|
${({ theme, top, left, variant }) => `
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: ${top}px;
|
top: ${top}px;
|
||||||
left: ${left}px;
|
left: ${left}px;
|
||||||
padding: ${theme.sizeUnit * 2}px;
|
|
||||||
margin: ${theme.sizeUnit * 2}px;
|
|
||||||
background: ${theme.colorBgElevated};
|
|
||||||
color: ${theme.colorText};
|
|
||||||
maxWidth: 300px;
|
|
||||||
fontSize: ${theme.fontSizeSM}px;
|
|
||||||
zIndex: 9;
|
zIndex: 9;
|
||||||
pointerEvents: none;
|
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;
|
||||||
|
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) {
|
export default function Tooltip(props: TooltipProps) {
|
||||||
const { tooltip } = props;
|
const { tooltip, variant = 'default' } = props;
|
||||||
if (typeof tooltip === 'undefined' || tooltip === null) {
|
if (typeof tooltip === 'undefined' || tooltip === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,7 +74,7 @@ export default function Tooltip(props: TooltipProps) {
|
|||||||
typeof content === 'string' ? safeHtmlSpan(content) : content;
|
typeof content === 'string' ? safeHtmlSpan(content) : content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDiv top={y} left={x}>
|
<StyledDiv top={y} left={x} variant={variant}>
|
||||||
{safeContent}
|
{safeContent}
|
||||||
</StyledDiv>
|
</StyledDiv>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,14 +17,26 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ArcLayer } from '@deck.gl/layers';
|
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 { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
import { commonLayerProps } from '../common';
|
import { commonLayerProps } from '../common';
|
||||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||||
import TooltipRow from '../../TooltipRow';
|
|
||||||
import { Point } from '../../types';
|
import { Point } from '../../types';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
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[]) {
|
export function getPoints(data: JsonObject[]) {
|
||||||
const points: Point[] = [];
|
const points: Point[] = [];
|
||||||
data.forEach(d => {
|
data.forEach(d => {
|
||||||
@@ -36,24 +48,14 @@ export function getPoints(data: JsonObject[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTooltipContent(formData: QueryFormData) {
|
function setTooltipContent(formData: QueryFormData) {
|
||||||
return (o: JsonObject) => (
|
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
<TooltipRow
|
{CommonTooltipRows.arcPositions(o)}
|
||||||
label={t('Start (Longitude, Latitude): ')}
|
{CommonTooltipRows.category(o)}
|
||||||
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}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createTooltipContent(formData, defaultTooltipGenerator);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLayer: GetLayerType<ArcLayer> = function ({
|
export const getLayer: GetLayerType<ArcLayer> = function ({
|
||||||
@@ -74,19 +76,27 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
|
|||||||
|
|
||||||
return new ArcLayer({
|
return new ArcLayer({
|
||||||
data,
|
data,
|
||||||
getSourceColor: (d: JsonObject) => {
|
getSourceColor: (d: ArcDataItem): [number, number, number, number] => {
|
||||||
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
||||||
return [sc.r, sc.g, sc.b, 255 * sc.a];
|
return [sc.r, sc.g, sc.b, 255 * sc.a];
|
||||||
}
|
}
|
||||||
|
return (d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a]) as [
|
||||||
return d.targetColor || d.color;
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
getTargetColor: (d: any) => {
|
getTargetColor: (d: ArcDataItem): [number, number, number, number] => {
|
||||||
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
||||||
return [tc.r, tc.g, tc.b, 255 * tc.a];
|
return [tc.r, tc.g, tc.b, 255 * tc.a];
|
||||||
}
|
}
|
||||||
|
return (d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a]) as [
|
||||||
return d.targetColor || d.color;
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
},
|
},
|
||||||
id: `path-layer-${fd.slice_id}` as const,
|
id: `path-layer-${fd.slice_id}` as const,
|
||||||
getWidth: fd.stroke_width ? fd.stroke_width : 3,
|
getWidth: fd.stroke_width ? fd.stroke_width : 3,
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
legendPosition,
|
legendPosition,
|
||||||
viewport,
|
viewport,
|
||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
deckGLCategoricalColor,
|
deckGLCategoricalColor,
|
||||||
deckGLCategoricalColorSchemeSelect,
|
deckGLCategoricalColorSchemeSelect,
|
||||||
deckGLCategoricalColorSchemeTypeSelect,
|
deckGLCategoricalColorSchemeTypeSelect,
|
||||||
@@ -77,6 +79,8 @@ const config: ControlPanelConfig = {
|
|||||||
],
|
],
|
||||||
['row_limit', filterNulls],
|
['row_limit', filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,8 +25,24 @@ import sandboxedEval from '../../utils/sandbox';
|
|||||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||||
import { ColorType } from '../../types';
|
import { ColorType } from '../../types';
|
||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
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) {
|
function setTooltipContent(o: any) {
|
||||||
return (
|
return (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
@@ -41,6 +57,7 @@ function setTooltipContent(o: any) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLayer: GetLayerType<ContourLayer> = function ({
|
export const getLayer: GetLayerType<ContourLayer> = function ({
|
||||||
formData,
|
formData,
|
||||||
payload,
|
payload,
|
||||||
@@ -59,6 +76,18 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
|||||||
} = fd;
|
} = fd;
|
||||||
let data = payload.data.features;
|
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(
|
const contours = rawContours?.map(
|
||||||
(contour: {
|
(contour: {
|
||||||
color: ColorType;
|
color: ColorType;
|
||||||
@@ -89,6 +118,47 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
|||||||
data = jsFnMutatorFunction(data);
|
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({
|
return new ContourLayer({
|
||||||
id: `contourLayer-${fd.slice_id}`,
|
id: `contourLayer-${fd.slice_id}`,
|
||||||
data,
|
data,
|
||||||
@@ -101,7 +171,7 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
|||||||
...commonLayerProps({
|
...commonLayerProps({
|
||||||
formData: fd,
|
formData: fd,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: tooltipContentGenerator,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
filterState,
|
filterState,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
spatial,
|
spatial,
|
||||||
viewport,
|
viewport,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
|
|
||||||
const config: ControlPanelConfig = {
|
const config: ControlPanelConfig = {
|
||||||
@@ -44,6 +46,8 @@ const config: ControlPanelConfig = {
|
|||||||
['size'],
|
['size'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
autozoom,
|
autozoom,
|
||||||
lineWidth,
|
lineWidth,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { dndGeojsonColumn } from '../../utilities/sharedDndControls';
|
import { dndGeojsonColumn } from '../../utilities/sharedDndControls';
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { GridLayer } from '@deck.gl/aggregation-layers';
|
import { GridLayer } from '@deck.gl/aggregation-layers';
|
||||||
import { t, CategoricalColorNamespace, JsonObject } from '@superset-ui/core';
|
import {
|
||||||
|
CategoricalColorNamespace,
|
||||||
|
JsonObject,
|
||||||
|
QueryFormData,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
commonLayerProps,
|
commonLayerProps,
|
||||||
@@ -29,20 +33,21 @@ import sandboxedEval from '../../utils/sandbox';
|
|||||||
import { createDeckGLComponent, GetLayerType } from '../../factory';
|
import { createDeckGLComponent, GetLayerType } from '../../factory';
|
||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
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 (
|
return (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
|
{CommonTooltipRows.centroid(o)}
|
||||||
<TooltipRow
|
<TooltipRow
|
||||||
// eslint-disable-next-line prefer-template
|
label={`${metricLabel}: `}
|
||||||
label={t('Longitude and Latitude') + ': '}
|
value={`${o.object?.elevationValue || o.object?.value || 'N/A'}`}
|
||||||
value={`${o.coordinate[0]}, ${o.coordinate[1]}`}
|
|
||||||
/>
|
|
||||||
<TooltipRow
|
|
||||||
// eslint-disable-next-line prefer-template
|
|
||||||
label={t('Height') + ': '}
|
|
||||||
value={`${o.object.elevationValue}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -63,7 +68,6 @@ export const getLayer: GetLayerType<GridLayer> = function ({
|
|||||||
let data = payload.data.features;
|
let data = payload.data.features;
|
||||||
|
|
||||||
if (fd.js_data_mutator) {
|
if (fd.js_data_mutator) {
|
||||||
// Applying user defined data mutator if defined
|
|
||||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||||
data = jsFnMutator(data);
|
data = jsFnMutator(data);
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,10 @@ export const getLayer: GetLayerType<GridLayer> = function ({
|
|||||||
|
|
||||||
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
|
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
|
||||||
|
|
||||||
|
const tooltipContent = createTooltipContent(fd, (o: JsonObject) =>
|
||||||
|
defaultTooltipGenerator(o, fd),
|
||||||
|
);
|
||||||
|
|
||||||
const colorAggFunc =
|
const colorAggFunc =
|
||||||
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
|
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
|
||||||
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
|
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
|
||||||
@@ -105,7 +113,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
|
|||||||
formData: fd,
|
formData: fd,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: tooltipContent,
|
||||||
filterState,
|
filterState,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
emitCrossFilters,
|
emitCrossFilters,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
viewport,
|
viewport,
|
||||||
spatial,
|
spatial,
|
||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
legendPosition,
|
legendPosition,
|
||||||
generateDeckGLColorSchemeControls,
|
generateDeckGLColorSchemeControls,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
@@ -49,6 +51,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,31 +18,91 @@
|
|||||||
*/
|
*/
|
||||||
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
|
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
|
||||||
import { Position } from '@deck.gl/core';
|
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 { isPointInBonds } from '../../utilities/utils';
|
||||||
import { commonLayerProps, getColorRange } from '../common';
|
import { commonLayerProps, getColorRange } from '../common';
|
||||||
import sandboxedEval from '../../utils/sandbox';
|
import sandboxedEval from '../../utils/sandbox';
|
||||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
|
import { createTooltipContent } from '../../utilities/tooltipUtils';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||||
|
|
||||||
function setTooltipContent(o: JsonObject) {
|
function setTooltipContent(formData: QueryFormData) {
|
||||||
return (
|
const defaultTooltipGenerator = (o: JsonObject) => {
|
||||||
<div className="deckgl-tooltip">
|
const metricLabel =
|
||||||
<TooltipRow
|
formData.size?.label || formData.size?.value || 'Weight';
|
||||||
label={t('Centroid (Longitude and Latitude): ')}
|
const lon = o.coordinate?.[0];
|
||||||
value={`(${o?.coordinate[0]}, ${o?.coordinate[1]})`}
|
const lat = o.coordinate?.[1];
|
||||||
/>
|
|
||||||
</div>
|
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('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> = ({
|
export const getLayer: GetLayerType<HeatmapLayer> = ({
|
||||||
formData,
|
formData,
|
||||||
|
payload,
|
||||||
|
setTooltip,
|
||||||
|
setDataMask,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
filterState,
|
filterState,
|
||||||
setDataMask,
|
|
||||||
setTooltip,
|
|
||||||
payload,
|
|
||||||
emitCrossFilters,
|
emitCrossFilters,
|
||||||
}) => {
|
}) => {
|
||||||
const fd = formData;
|
const fd = formData;
|
||||||
@@ -56,7 +116,6 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
|
|||||||
let data = payload.data.features;
|
let data = payload.data.features;
|
||||||
|
|
||||||
if (jsFnMutator) {
|
if (jsFnMutator) {
|
||||||
// Applying user defined data mutator if defined
|
|
||||||
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
|
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
|
||||||
data = jsFnMutatorFunction(data);
|
data = jsFnMutatorFunction(data);
|
||||||
}
|
}
|
||||||
@@ -74,6 +133,8 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
|
|||||||
colorScale,
|
colorScale,
|
||||||
})?.reverse();
|
})?.reverse();
|
||||||
|
|
||||||
|
const tooltipContent = setTooltipContent(fd);
|
||||||
|
|
||||||
return new HeatmapLayer({
|
return new HeatmapLayer({
|
||||||
id: `heatmap-layer-${fd.slice_id}` as const,
|
id: `heatmap-layer-${fd.slice_id}` as const,
|
||||||
data,
|
data,
|
||||||
@@ -84,10 +145,12 @@ export const getLayer: GetLayerType<HeatmapLayer> = ({
|
|||||||
getPosition: (d: { position: Position; weight: number }) => d.position,
|
getPosition: (d: { position: Position; weight: number }) => d.position,
|
||||||
getWeight: (d: { position: number[]; weight: number }) =>
|
getWeight: (d: { position: number[]; weight: number }) =>
|
||||||
d.weight ? d.weight : 1,
|
d.weight ? d.weight : 1,
|
||||||
|
opacity: 0.8,
|
||||||
|
threshold: 0.03,
|
||||||
...commonLayerProps({
|
...commonLayerProps({
|
||||||
formData: fd,
|
formData: fd,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: tooltipContent,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
filterState,
|
filterState,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
spatial,
|
spatial,
|
||||||
viewport,
|
viewport,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
|
|
||||||
@@ -62,6 +64,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'intensity',
|
name: 'intensity',
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { HexagonLayer } from '@deck.gl/aggregation-layers';
|
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 { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
import {
|
import {
|
||||||
@@ -28,20 +32,22 @@ import {
|
|||||||
} from '../common';
|
} from '../common';
|
||||||
import sandboxedEval from '../../utils/sandbox';
|
import sandboxedEval from '../../utils/sandbox';
|
||||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
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 (
|
return (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
|
{CommonTooltipRows.centroid(o)}
|
||||||
<TooltipRow
|
<TooltipRow
|
||||||
label={t('Centroid (Longitude and Latitude): ')}
|
label={`${metricLabel}: `}
|
||||||
value={`(${o.coordinate[0]}, ${o.coordinate[1]})`}
|
value={`${o.object?.elevationValue}`}
|
||||||
/>
|
|
||||||
<TooltipRow
|
|
||||||
// eslint-disable-next-line prefer-template
|
|
||||||
label={t('Height') + ': '}
|
|
||||||
value={`${o.object.elevationValue}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -85,6 +91,10 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
|
|||||||
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
|
? (p: number[]) => getColorForBreakpoints(aggFunc, p, colorBreakpoints)
|
||||||
: aggFunc;
|
: aggFunc;
|
||||||
|
|
||||||
|
const tooltipContent = createTooltipContent(fd, (o: JsonObject) =>
|
||||||
|
defaultTooltipGenerator(o, fd),
|
||||||
|
);
|
||||||
|
|
||||||
return new HexagonLayer({
|
return new HexagonLayer({
|
||||||
id: `hex-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
|
id: `hex-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
|
||||||
data,
|
data,
|
||||||
@@ -103,7 +113,7 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
|
|||||||
...commonLayerProps({
|
...commonLayerProps({
|
||||||
formData: fd,
|
formData: fd,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: tooltipContent,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
filterState,
|
filterState,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
spatial,
|
spatial,
|
||||||
viewport,
|
viewport,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
|
|
||||||
@@ -48,6 +50,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,28 +18,26 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PathLayer } from '@deck.gl/layers';
|
import { PathLayer } from '@deck.gl/layers';
|
||||||
import { JsonObject } from '@superset-ui/core';
|
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||||
import { commonLayerProps } from '../common';
|
import { commonLayerProps } from '../common';
|
||||||
import sandboxedEval from '../../utils/sandbox';
|
import sandboxedEval from '../../utils/sandbox';
|
||||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||||
import TooltipRow from '../../TooltipRow';
|
|
||||||
import { Point } from '../../types';
|
import { Point } from '../../types';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||||
|
|
||||||
function setTooltipContent(o: JsonObject) {
|
function setTooltipContent(formData: QueryFormData) {
|
||||||
return (
|
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||||
o.object?.extraProps && (
|
<div className="deckgl-tooltip">
|
||||||
<div className="deckgl-tooltip">
|
{CommonTooltipRows.position(o)}
|
||||||
{Object.keys(o.object.extraProps).map((prop, index) => (
|
{CommonTooltipRows.category(o)}
|
||||||
<TooltipRow
|
</div>
|
||||||
key={`prop-${index}`}
|
|
||||||
label={`${prop}: `}
|
|
||||||
value={`${o.object.extraProps[prop]}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createTooltipContent(formData, defaultTooltipGenerator);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLayer: GetLayerType<PathLayer> = function ({
|
export const getLayer: GetLayerType<PathLayer> = function ({
|
||||||
@@ -78,7 +76,7 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
|||||||
...commonLayerProps({
|
...commonLayerProps({
|
||||||
formData: fd,
|
formData: fd,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: setTooltipContent(fd),
|
||||||
setDataMask,
|
setDataMask,
|
||||||
filterState,
|
filterState,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import {
|
|||||||
lineType,
|
lineType,
|
||||||
reverseLongLat,
|
reverseLongLat,
|
||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||||
|
|
||||||
@@ -55,6 +57,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,10 @@ import { TooltipProps } from '../../components/Tooltip';
|
|||||||
import { GetLayerType } from '../../factory';
|
import { GetLayerType } from '../../factory';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
|
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
|
||||||
|
import {
|
||||||
|
createTooltipContent,
|
||||||
|
CommonTooltipRows,
|
||||||
|
} from '../../utilities/tooltipUtils';
|
||||||
import { Point } from '../../types';
|
import { Point } from '../../types';
|
||||||
|
|
||||||
function getElevation(
|
function getElevation(
|
||||||
@@ -71,34 +75,32 @@ function getElevation(
|
|||||||
return colorScaler(d)[3] === 0 ? 0 : d.elevation;
|
return colorScaler(d)[3] === 0 ? 0 : d.elevation;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTooltipContent(formData: PolygonFormData) {
|
function defaultTooltipGenerator(
|
||||||
return (o: JsonObject) => {
|
o: JsonObject,
|
||||||
const metricLabel = formData?.metric?.label || formData?.metric;
|
fd: PolygonFormData,
|
||||||
|
metricLabel: string,
|
||||||
return (
|
) {
|
||||||
<div className="deckgl-tooltip">
|
return (
|
||||||
{o.object?.name && (
|
<div className="deckgl-tooltip">
|
||||||
<TooltipRow
|
{o.object?.name && (
|
||||||
// eslint-disable-next-line prefer-template
|
<TooltipRow label={`${t('name')}: `} value={`${o.object.name}`} />
|
||||||
label={t('name') + ': '}
|
)}
|
||||||
value={`${o.object.name}`}
|
{o.object?.[fd?.line_column] && (
|
||||||
/>
|
<TooltipRow
|
||||||
)}
|
label={`${fd.line_column}: `}
|
||||||
{o.object?.[formData?.line_column] && (
|
value={`${o.object[fd.line_column]}`}
|
||||||
<TooltipRow
|
/>
|
||||||
label={`${formData.line_column}: `}
|
)}
|
||||||
value={`${o.object[formData.line_column]}`}
|
{CommonTooltipRows.centroid(o)}
|
||||||
/>
|
{CommonTooltipRows.category(o)}
|
||||||
)}
|
{fd?.metric && (
|
||||||
{formData?.metric && (
|
<TooltipRow
|
||||||
<TooltipRow
|
label={`${metricLabel}: `}
|
||||||
label={`${metricLabel}: `}
|
value={`${o.object?.[metricLabel]}`}
|
||||||
value={`${o.object?.[metricLabel]}`}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLayer: GetLayerType<PolygonLayer> = function ({
|
export const getLayer: GetLayerType<PolygonLayer> = function ({
|
||||||
@@ -198,12 +200,9 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
|
|||||||
return baseColor;
|
return baseColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tooltipContentGenerator =
|
const tooltipContentGenerator = createTooltipContent(fd, (o: JsonObject) =>
|
||||||
fd.line_column &&
|
defaultTooltipGenerator(o, fd, metricLabel),
|
||||||
fd.metric &&
|
);
|
||||||
['json', 'geohash', 'zipcode'].includes(fd.line_type)
|
|
||||||
? setTooltipContent(fd)
|
|
||||||
: () => null;
|
|
||||||
|
|
||||||
return new PolygonLayer({
|
return new PolygonLayer({
|
||||||
id: `path-layer-${fd.slice_id}` as const,
|
id: `path-layer-${fd.slice_id}` as const,
|
||||||
@@ -336,9 +335,10 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
|||||||
const accessor = (d: JsonObject) => d[metricLabel];
|
const accessor = (d: JsonObject) => d[metricLabel];
|
||||||
|
|
||||||
const colorSchemeType = formData.color_scheme_type;
|
const colorSchemeType = formData.color_scheme_type;
|
||||||
const buckets = colorSchemeType
|
const buckets =
|
||||||
? getColorBreakpointsBuckets(formData.color_breakpoints)
|
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
|
||||||
: getBuckets(formData, payload.data.features, accessor);
|
? getColorBreakpointsBuckets(formData.color_breakpoints)
|
||||||
|
: getBuckets(formData, payload.data.features, accessor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import {
|
|||||||
deckGLLinearColorSchemeSelect,
|
deckGLLinearColorSchemeSelect,
|
||||||
deckGLColorBreakpointsSelect,
|
deckGLColorBreakpointsSelect,
|
||||||
breakpointsDefaultColor,
|
breakpointsDefaultColor,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
import { dndLineColumn } from '../../utilities/sharedDndControls';
|
||||||
|
|
||||||
@@ -89,6 +91,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[reverseLongLat],
|
[reverseLongLat],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,42 +17,45 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||||
import {
|
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
|
||||||
getMetricLabel,
|
|
||||||
JsonObject,
|
|
||||||
QueryFormData,
|
|
||||||
t,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import { isPointInBonds } from '../../utilities/utils';
|
import { isPointInBonds } from '../../utilities/utils';
|
||||||
import { commonLayerProps } from '../common';
|
import { commonLayerProps } from '../common';
|
||||||
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
|
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
|
||||||
|
import { createTooltipContent } from '../../utilities/tooltipUtils';
|
||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
import { unitToRadius } from '../../utils/geo';
|
import { unitToRadius } from '../../utils/geo';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||||
|
|
||||||
export function getPoints(data: JsonObject[]) {
|
function getMetricLabel(metric: any) {
|
||||||
return data.map(d => d.position);
|
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(
|
function setTooltipContent(
|
||||||
formData: QueryFormData,
|
formData: QueryFormData,
|
||||||
verboseMap?: Record<string, string>,
|
verboseMap?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
return (o: JsonObject) => {
|
const defaultTooltipGenerator = (o: JsonObject) => {
|
||||||
const label =
|
const label =
|
||||||
verboseMap?.[formData.point_radius_fixed.value] ||
|
verboseMap?.[formData.point_radius_fixed.value] ||
|
||||||
getMetricLabel(formData.point_radius_fixed?.value);
|
getMetricLabel(formData.point_radius_fixed?.value);
|
||||||
return (
|
return (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
<TooltipRow
|
<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]}`}
|
value={`${o.object?.position?.[0]}, ${o.object?.position?.[1]}`}
|
||||||
/>
|
/>
|
||||||
{o.object?.cat_color && (
|
{o.object?.cat_color && (
|
||||||
<TooltipRow
|
<TooltipRow
|
||||||
// eslint-disable-next-line prefer-template
|
label={`${t('Category')}: `}
|
||||||
label={t('Category') + ': '}
|
|
||||||
value={`${o.object?.cat_color}`}
|
value={`${o.object?.cat_color}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -62,6 +65,19 @@ function setTooltipContent(
|
|||||||
</div>
|
</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 ({
|
export const getLayer: GetLayerType<ScatterplotLayer> = function ({
|
||||||
@@ -93,8 +109,9 @@ export const getLayer: GetLayerType<ScatterplotLayer> = function ({
|
|||||||
id: `scatter-layer-${fd.slice_id}` as const,
|
id: `scatter-layer-${fd.slice_id}` as const,
|
||||||
data: dataWithRadius,
|
data: dataWithRadius,
|
||||||
fp64: true,
|
fp64: true,
|
||||||
getFillColor: (d: any) => d.color,
|
getFillColor: (d: ScatterDataItem): [number, number, number, number] =>
|
||||||
getRadius: (d: any) => d.radius,
|
d.color as [number, number, number, number],
|
||||||
|
getRadius: (d: ScatterDataItem): number => d.radius,
|
||||||
radiusMinPixels: Number(fd.min_radius) || undefined,
|
radiusMinPixels: Number(fd.min_radius) || undefined,
|
||||||
radiusMaxPixels: Number(fd.max_radius) || undefined,
|
radiusMaxPixels: Number(fd.max_radius) || undefined,
|
||||||
stroked: false,
|
stroked: false,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
multiplier,
|
multiplier,
|
||||||
mapboxStyle,
|
mapboxStyle,
|
||||||
generateDeckGLColorSchemeControls,
|
generateDeckGLColorSchemeControls,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ const config: ControlPanelConfig = {
|
|||||||
[spatial, null],
|
[spatial, null],
|
||||||
['row_limit', filterNulls],
|
['row_limit', filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
* or more contributor license agreements. See the NOTICE file
|
* or more contributor license agreements. See the NOTICE file
|
||||||
@@ -20,8 +18,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
|
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
|
||||||
import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core';
|
|
||||||
import { Color } from '@deck.gl/core';
|
import { Color } from '@deck.gl/core';
|
||||||
|
import {
|
||||||
|
JsonObject,
|
||||||
|
QueryFormData,
|
||||||
|
styled,
|
||||||
|
CategoricalColorNamespace,
|
||||||
|
t,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
COLOR_SCHEME_TYPES,
|
COLOR_SCHEME_TYPES,
|
||||||
ColorSchemeType,
|
ColorSchemeType,
|
||||||
@@ -32,14 +36,29 @@ import { commonLayerProps, getColorRange } from '../common';
|
|||||||
import TooltipRow from '../../TooltipRow';
|
import TooltipRow from '../../TooltipRow';
|
||||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
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[]) {
|
export function getPoints(data: JsonObject[]) {
|
||||||
return data.map(d => d.position);
|
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 (
|
return (
|
||||||
<div className="deckgl-tooltip">
|
<div className="deckgl-tooltip">
|
||||||
|
{CommonTooltipRows.centroid(o)}
|
||||||
<TooltipRow
|
<TooltipRow
|
||||||
// eslint-disable-next-line prefer-template
|
// eslint-disable-next-line prefer-template
|
||||||
label={t('Longitude and Latitude') + ': '}
|
label={t('Longitude and Latitude') + ': '}
|
||||||
@@ -50,6 +69,35 @@ function setTooltipContent(o: JsonObject) {
|
|||||||
label={t('Weight') + ': '}
|
label={t('Weight') + ': '}
|
||||||
value={`${o.object?.value}`}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +117,6 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
|
|||||||
let data = payload.data.features;
|
let data = payload.data.features;
|
||||||
|
|
||||||
if (fd.js_data_mutator) {
|
if (fd.js_data_mutator) {
|
||||||
// Applying user defined data mutator if defined
|
|
||||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||||
data = jsFnMutator(data);
|
data = jsFnMutator(data);
|
||||||
}
|
}
|
||||||
@@ -94,8 +141,54 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
|
|||||||
[189, 0, 38],
|
[189, 0, 38],
|
||||||
] as Color[];
|
] as Color[];
|
||||||
|
|
||||||
// Passing a layer creator function instead of a layer since the
|
const cellSize = fd.grid_size || 50;
|
||||||
// layer needs to be regenerated at each render
|
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({
|
return new ScreenGridLayer({
|
||||||
id: `screengrid-layer-${fd.slice_id}` as const,
|
id: `screengrid-layer-${fd.slice_id}` as const,
|
||||||
data,
|
data,
|
||||||
@@ -111,13 +204,15 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
|
|||||||
formData: fd,
|
formData: fd,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
setTooltip,
|
setTooltip,
|
||||||
setTooltipContent,
|
setTooltipContent: tooltipContent,
|
||||||
filterState,
|
filterState,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
emitCrossFilters,
|
emitCrossFilters,
|
||||||
}),
|
}),
|
||||||
getWeight: aggFunc,
|
getWeight: aggFunc,
|
||||||
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
|
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
|
||||||
|
onHover: customOnHover,
|
||||||
|
pickable: true,
|
||||||
opacity: filterState?.value ? 0.3 : 1,
|
opacity: filterState?.value ? 0.3 : 1,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
deckGLFixedColor,
|
deckGLFixedColor,
|
||||||
deckGLCategoricalColorSchemeSelect,
|
deckGLCategoricalColorSchemeSelect,
|
||||||
deckGLCategoricalColorSchemeTypeSelect,
|
deckGLCategoricalColorSchemeTypeSelect,
|
||||||
|
tooltipContents,
|
||||||
|
tooltipTemplate,
|
||||||
} from '../../utilities/Shared_DeckGL';
|
} from '../../utilities/Shared_DeckGL';
|
||||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ const config: ControlPanelConfig = {
|
|||||||
['row_limit'],
|
['row_limit'],
|
||||||
[filterNulls],
|
[filterNulls],
|
||||||
['adhoc_filters'],
|
['adhoc_filters'],
|
||||||
|
[tooltipContents],
|
||||||
|
[tooltipTemplate],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode, isValidElement } from 'react';
|
||||||
import {
|
import {
|
||||||
ascending as d3ascending,
|
ascending as d3ascending,
|
||||||
quantile as d3quantile,
|
quantile as d3quantile,
|
||||||
@@ -73,15 +73,29 @@ export function commonLayerProps({
|
|||||||
tooltipContentGenerator = sandboxedEval(fd.js_tooltip);
|
tooltipContentGenerator = sandboxedEval(fd.js_tooltip);
|
||||||
}
|
}
|
||||||
if (tooltipContentGenerator) {
|
if (tooltipContentGenerator) {
|
||||||
|
let currentTooltipContent: ReactNode = null;
|
||||||
|
|
||||||
|
const isCustomTooltip = (content: ReactNode): boolean =>
|
||||||
|
isValidElement(content) &&
|
||||||
|
content.props?.['data-tooltip-type'] === 'custom';
|
||||||
|
|
||||||
onHover = (o: JsonObject) => {
|
onHover = (o: JsonObject) => {
|
||||||
if (o.picked) {
|
if (o.picked) {
|
||||||
|
currentTooltipContent = tooltipContentGenerator(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentTooltipContent &&
|
||||||
|
(o.picked || isCustomTooltip(currentTooltipContent))
|
||||||
|
) {
|
||||||
setTooltip({
|
setTooltip({
|
||||||
content: tooltipContentGenerator(o),
|
content: currentTooltipContent,
|
||||||
x: o.x,
|
x: o.x,
|
||||||
y: o.y,
|
y: o.y,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setTooltip(null);
|
setTooltip(null);
|
||||||
|
currentTooltipContent = null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -17,8 +17,6 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// These are control configurations that are shared ONLY within the DeckGL viz plugin repo.
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
isFeatureEnabled,
|
isFeatureEnabled,
|
||||||
@@ -42,6 +40,7 @@ import {
|
|||||||
ColorSchemeType,
|
ColorSchemeType,
|
||||||
isColorSchemeTypeVisible,
|
isColorSchemeTypeVisible,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { TooltipTemplateControl } from './TooltipTemplateControl';
|
||||||
|
|
||||||
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
|
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
|
||||||
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
|
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
|
||||||
@@ -344,9 +343,7 @@ export const viewport = {
|
|||||||
label: t('Viewport'),
|
label: t('Viewport'),
|
||||||
renderTrigger: false,
|
renderTrigger: false,
|
||||||
description: t('Parameters related to the view and perspective on the map'),
|
description: t('Parameters related to the view and perspective on the map'),
|
||||||
// default is whole world mostly centered
|
|
||||||
default: DEFAULT_VIEWPORT,
|
default: DEFAULT_VIEWPORT,
|
||||||
// Viewport changes shouldn't prompt user to re-run query
|
|
||||||
dontRefreshOnChange: true,
|
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 = {
|
export const deckGLCategoricalColorSchemeTypeSelect: CustomControlItem = {
|
||||||
name: 'color_scheme_type',
|
name: 'color_scheme_type',
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,8 +28,6 @@ export const COLOR_SCHEME_TYPES = {
|
|||||||
export type ColorSchemeType =
|
export type ColorSchemeType =
|
||||||
(typeof COLOR_SCHEME_TYPES)[keyof typeof COLOR_SCHEME_TYPES];
|
(typeof COLOR_SCHEME_TYPES)[keyof typeof COLOR_SCHEME_TYPES];
|
||||||
|
|
||||||
/* eslint camelcase: 0 */
|
|
||||||
|
|
||||||
export function formatSelectOptions(options: (string | number)[]) {
|
export function formatSelectOptions(options: (string | number)[]) {
|
||||||
return options.map(opt => [opt, opt.toString()]);
|
return options.map(opt => [opt, opt.toString()]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"baseUrl": "../..",
|
||||||
"rootDir": "src",
|
"outDir": "lib"
|
||||||
"outDir": "lib",
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "types/**/*"],
|
"include": ["src/**/*", "types/**/*"],
|
||||||
"exclude": ["lib", "test"],
|
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
|
||||||
"references": [
|
"references": [
|
||||||
|
{ "path": "../../packages/superset-core" },
|
||||||
{ "path": "../../packages/superset-ui-core" },
|
{ "path": "../../packages/superset-ui-core" },
|
||||||
{ "path": "../../packages/superset-ui-chart-controls" }
|
{ "path": "../../packages/superset-ui-chart-controls" }
|
||||||
]
|
]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user