Compare commits

...

12 Commits

Author SHA1 Message Date
Joe Li
50ca0067ea fix(docs): escape comparison operators in MDX files to resolve build errors
Wrap version comparison operators (<=, >=) in backticks to prevent MDX
from interpreting them as JSX syntax, which was causing CI build failures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 22:35:38 -07:00
Joe Li
1187902e68 feat(playwright): Add Playwright CI Integration for Cypress Migration (SIP-178) (#35110)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-17 17:13:47 -07:00
Maxime Beauchemin
ad3eff9e90 feat(matrixify): replace single toggle with separate horizontal/vertical layout controls (#35067)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-09-17 22:57:06 +03:00
SBIN2010
3e554674ff feat(waterfall): add changes label series and grouping customize settings (#34847) 2025-09-17 08:24:45 -03:00
Amin Ghadersohi
dced2f8564 feat: Add BaseDAO improvements and test reorganization (#35018)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 18:15:16 -07:00
Maxime Beauchemin
05c6a1bf20 fix(viz): resolve dark mode compatibility issues in BigNumber and Heatmap (#35151)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 10:21:47 -07:00
SBIN2010
c193d6d6a1 fix: import bug template params (#35144) 2025-09-16 10:21:29 -07:00
Joe Li
fb840b8e71 fix(deck.gl): restore legend display for Polygon charts with linear palette and fixed color schemes (#35142)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 20:20:42 +03:00
Maxime Beauchemin
d0cc6f115b feat: add optional garbage collection after requests (#35061)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-16 09:23:39 -07:00
Hugh A. Miles II
966e231f94 feat: Add Dashboard Filter Support for Alert Reports (#32196)
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Hugh A Miles II <hugh@Mac.home>
2025-09-16 10:52:28 -04:00
Richard Fogaca Nienkotter
a66737cb05 feat(custom-tooltip): custom tooltip on deck.gl charts (#34276) 2025-09-16 17:11:19 +03:00
Michael S. Molina
bc6859a99d refactor: Organizes the src/core folder (#35119) 2025-09-16 08:21:16 -03:00
142 changed files with 11158 additions and 1387 deletions

View File

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

View File

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

17
LLMS.md
View File

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

View File

@@ -36,11 +36,11 @@ Screenshots will be taken but no messages actually sent as long as `ALERT_REPORT
#### In your `Dockerfile`
You'll need to extend the Superset image to include a headless browser. Your options include:
- Use Playwright with Chrome: this is the recommended approach as of version >=4.1.x. A working example of a Dockerfile that installs these tools is provided under Building your own production Docker image on the [Docker Builds](/docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
- Use Playwright with Chrome: this is the recommended approach as of version `>=4.1.x`. A working example of a Dockerfile that installs these tools is provided under "Building your own production Docker image" on the [Docker Builds](/docs/installation/docker-builds#building-your-own-production-docker-image) page. Read the code comments there as you'll also need to change a feature flag in your config.
- Use Firefox: you'll need to install geckodriver and Firefox.
- Use Chrome without Playwright: you'll need to install Chrome and set the value of `WEBDRIVER_TYPE` to `"chrome"` in your `superset_config.py`.
In Superset versions <=4.0x, users installed Firefox or Chrome and that was documented here.
In Superset versions `<=4.0.x`, users installed Firefox or Chrome and that was documented here.
Only the worker container needs the browser.

View File

@@ -7,7 +7,7 @@ version: 1
# Theming Superset
:::note
apache-superset>=6.0
`apache-superset>=6.0`
:::
Superset now rides on **Ant Design v5's token-based theming**.

View File

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

View File

@@ -130,7 +130,7 @@ Committers may also update title to reflect the issue/PR content if the author-p
If the PR passes CI tests and does not have any `need:` labels, it is ready for review, add label `review` and/or `design-review`.
If an issue/PR has been inactive for >=30 days, it will be closed. If it does not have any status label, add `inactive`.
If an issue/PR has been inactive for `>=30` days, it will be closed. If it does not have any status label, add `inactive`.
When creating a PR, if you're aiming to have it included in a specific release, please tag it with the version label. For example, to have a PR considered for inclusion in Superset 1.1 use the label `v1.1`.

View File

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

View File

@@ -84,6 +84,7 @@ dependencies = [
"pgsanity",
"Pillow>=11.0.0, <12",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.0.6, <4",
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies

View File

@@ -6,6 +6,8 @@ alembic==1.15.2
# via flask-migrate
amqp==5.3.1
# via kombu
annotated-types==0.7.0
# via pydantic
apispec==6.6.1
# via
# -r requirements/base.in
@@ -115,7 +117,9 @@ flask==2.3.3
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.0
# via apache-superset (pyproject.toml)
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.3.1
@@ -294,6 +298,10 @@ pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
pydantic==2.11.7
# via apache-superset (pyproject.toml)
pydantic-core==2.33.2
# via pydantic
pygments==2.19.1
# via rich
pyjwt==2.10.1
@@ -404,10 +412,15 @@ typing-extensions==4.14.0
# alembic
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via pydantic
tzdata==2025.2
# via
# kombu

View File

@@ -18,6 +18,10 @@ amqp==5.3.1
# via
# -c requirements/base-constraint.txt
# kombu
annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
apispec==6.6.1
# via
# -c requirements/base-constraint.txt
@@ -212,6 +216,7 @@ flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
@@ -631,6 +636,14 @@ pycparser==2.22
# via
# -c requirements/base-constraint.txt
# cffi
pydantic==2.11.7
# via
# -c requirements/base-constraint.txt
# apache-superset
pydantic-core==2.33.2
# via
# -c requirements/base-constraint.txt
# pydantic
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -874,10 +887,17 @@ typing-extensions==4.14.0
# apache-superset
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via
# -c requirements/base-constraint.txt
# pydantic
tzdata==2025.2
# via
# -c requirements/base-constraint.txt

View File

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

View File

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

View File

@@ -124,10 +124,13 @@ describe('VizType control', () => {
});
it('Can change vizType', () => {
cy.visitChartByName('Daily Totals');
cy.visitChartByName('Daily Totals').then(() => {
cy.get('.slice_container').should('be.visible');
});
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.contains('View all charts').click();
cy.contains('View all charts').should('be.visible').click();
cy.get('.ant-modal-content').within(() => {
cy.get('button').contains('KPI').click(); // change categories
@@ -176,8 +179,12 @@ describe('Groupby control', () => {
.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('input[aria-label="Columns and metrics"]', { timeout: 10000 })
.should('be.visible')
.click();
cy.get('input[aria-label="Columns and metrics"]').type('state{enter}');
cy.get('[data-test="ColumnEdit#save"]').contains('Save').click();
cy.get('button[data-test="run-query-button"]').click();

View File

@@ -159,6 +159,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -10109,6 +10110,22 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -45517,6 +45534,53 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-async": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
@@ -63323,8 +63387,7 @@
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
@@ -65227,6 +65290,8 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",

View File

@@ -63,6 +63,11 @@
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
"plugins:release-from-tag": "npm run prune && npm run plugins:build && lerna publish from-package --yes",
"plugins:storybook": "cd packages/superset-ui-demo && npm run storybook",
"playwright:test": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"playwright:report": "playwright show-report",
"prettier": "npm run _prettier -- --write",
"prettier-check": "npm run _prettier -- --check",
"prod": "npm run build",
@@ -227,6 +232,7 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",

View File

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

View File

@@ -26,8 +26,7 @@
"dependencies": {
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
"lodash": "^4.17.21"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
/* eslint-disable import/first */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import AceEditor, { IAceEditorProps } from 'react-ace';
import ace from 'ace-builds/src-noconflict/ace';
// Disable workers to avoid localhost loading issues
ace.config.set('useWorker', false);
// Import required modes and themes after ace is loaded
import 'ace-builds/src-min-noconflict/mode-handlebars';
import 'ace-builds/src-min-noconflict/mode-css';
import 'ace-builds/src-min-noconflict/mode-json';
import 'ace-builds/src-min-noconflict/mode-sql';
import 'ace-builds/src-min-noconflict/mode-markdown';
import 'ace-builds/src-min-noconflict/mode-javascript';
import 'ace-builds/src-min-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode =
| 'handlebars'
| 'css'
| 'json'
| 'sql'
| 'markdown'
| 'javascript'
| 'html';
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;
}
export const CodeEditor: FC<CodeEditorProps> = ({
mode = 'handlebars',
theme = 'dark',
name,
width = '100%',
height = '300px',
value,
fontSize = 14,
showPrintMargin = true,
focus = true,
wrapEnabled = true,
highlightActiveLine = true,
editorProps = { $blockScrolling: true },
setOptions,
...rest
}: CodeEditorProps) => {
const editorName = name || Math.random().toString(36).substring(7);
const aceTheme = theme === 'light' ? 'github' : 'monokai';
return (
<AceEditor
mode={mode}
theme={aceTheme}
name={editorName}
height={height}
width={width}
value={value}
fontSize={fontSize}
showPrintMargin={showPrintMargin}
focus={focus}
editorProps={editorProps}
wrapEnabled={wrapEnabled}
highlightActiveLine={highlightActiveLine}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
tabSize: 2,
showGutter: true,
fontFamily:
'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
...setOptions,
}}
{...rest}
/>
);
};
export default CodeEditor;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/// <reference types="node" />
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './playwright/tests',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
// Parallel execution
fullyParallel: true,
workers: process.env.CI ? 2 : 1,
// Retry logic - 2 retries in CI, 0 locally
retries: process.env.CI ? 2 : 0,
// Reporter configuration - multiple reporters for better visibility
reporter: process.env.CI
? [
['github'], // GitHub Actions annotations
['list'], // Detailed output with summary table
['html', { outputFolder: 'playwright-report', open: 'never' }], // Interactive report
['json', { outputFile: 'test-results/results.json' }], // Machine-readable
]
: [
['list'], // Shows summary table locally
['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Auto-open on failure
],
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088 for local
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
// Browser settings
headless: !!process.env.CI,
viewport: { width: 1280, height: 1024 },
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Trace collection for debugging
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
},
},
],
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
});

View File

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

View File

@@ -0,0 +1,119 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
export class Button {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the button element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Clicks the button
* @param options - Optional click options
*/
async click(options?: {
timeout?: number;
force?: boolean;
delay?: number;
button?: 'left' | 'right' | 'middle';
}): Promise<void> {
await this.element.click(options);
}
/**
* Gets the button text content
*/
async getText(): Promise<string> {
return (await this.element.textContent()) ?? '';
}
/**
* Gets a specific attribute value from the button
* @param attribute - The attribute name to retrieve
*/
async getAttribute(attribute: string): Promise<string | null> {
return this.element.getAttribute(attribute);
}
/**
* Checks if the button is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the button is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Checks if the button is disabled
*/
async isDisabled(): Promise<boolean> {
return this.element.isDisabled();
}
/**
* Hovers over the button
* @param options - Optional hover options
*/
async hover(options?: { timeout?: number; force?: boolean }): Promise<void> {
await this.element.hover(options);
}
/**
* Focuses on the button
*/
async focus(): Promise<void> {
await this.element.focus();
}
/**
* Double clicks the button
* @param options - Optional click options
*/
async doubleClick(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.element.dblclick(options);
}
}

View File

@@ -0,0 +1,110 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Input } from './Input';
import { Button } from './Button';
export class Form {
private readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the form element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets an input field within the form (properly scoped)
* @param inputSelector - Selector for the input field
*/
getInput(inputSelector: string): Input {
const scopedLocator = this.locator.locator(inputSelector);
return new Input(this.page, scopedLocator);
}
/**
* Gets a button within the form (properly scoped)
* @param buttonSelector - Selector for the button
*/
getButton(buttonSelector: string): Button {
const scopedLocator = this.locator.locator(buttonSelector);
return new Button(this.page, scopedLocator);
}
/**
* Checks if the form is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Submits the form (triggers submit event)
*/
async submit(): Promise<void> {
await this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
form.submit();
}
});
}
/**
* Waits for the form to be visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.locator.waitFor({ state: 'visible', ...options });
}
/**
* Gets all form data as key-value pairs
* Useful for validation and debugging
*/
async getFormData(): Promise<Record<string, string>> {
return this.locator.evaluate((form: HTMLElement) => {
if (form instanceof HTMLFormElement) {
const formData = new FormData(form);
const result: Record<string, string> = {};
formData.forEach((value, key) => {
result[key] = value.toString();
});
return result;
}
return {};
});
}
}

View File

@@ -0,0 +1,111 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
export class Input {
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the input element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fast fill - clears the input and sets the value directly
* @param value - The value to fill
* @param options - Optional fill options
*/
async fill(
value: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.element.fill(value, options);
}
/**
* Types text character by character (simulates real typing)
* @param text - The text to type
* @param options - Optional typing options
*/
async type(text: string, options?: { delay?: number }): Promise<void> {
await this.element.type(text, options);
}
/**
* Types text sequentially with more control over timing
* @param text - The text to type
* @param options - Optional sequential typing options
*/
async pressSequentially(
text: string,
options?: { delay?: number },
): Promise<void> {
await this.element.pressSequentially(text, options);
}
/**
* Gets the current value of the input
*/
async getValue(): Promise<string> {
return this.element.inputValue();
}
/**
* Clears the input field
*/
async clear(): Promise<void> {
await this.element.clear();
}
/**
* Checks if the input is visible
*/
async isVisible(): Promise<boolean> {
return this.element.isVisible();
}
/**
* Checks if the input is enabled
*/
async isEnabled(): Promise<boolean> {
return this.element.isEnabled();
}
/**
* Focuses on the input field
*/
async focus(): Promise<void> {
await this.element.focus();
}
}

View File

@@ -0,0 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Core Playwright Components for Superset
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';

View File

@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page, Response } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
export class AuthPage {
private readonly page: Page;
private readonly loginForm: Form;
// Selectors specific to the auth/login page
private static readonly SELECTORS = {
LOGIN_FORM: '[data-test="login-form"]',
USERNAME_INPUT: '[data-test="username-input"]',
PASSWORD_INPUT: '[data-test="password-input"]',
LOGIN_BUTTON: '[data-test="login-button"]',
ERROR_SELECTORS: [
'[role="alert"]',
'.ant-form-item-explain-error',
'.ant-form-item-explain.ant-form-item-explain-error',
'.alert-danger',
],
} as const;
constructor(page: Page) {
this.page = page;
this.loginForm = new Form(page, AuthPage.SELECTORS.LOGIN_FORM);
}
/**
* Navigate to the login page
*/
async goto(): Promise<void> {
await this.page.goto(URL.LOGIN);
}
/**
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
}
/**
* Login with provided credentials
* @param username - Username to enter
* @param password - Password to enter
*/
async loginWithCredentials(
username: string,
password: string,
): Promise<void> {
const usernameInput = this.loginForm.getInput(
AuthPage.SELECTORS.USERNAME_INPUT,
);
const passwordInput = this.loginForm.getInput(
AuthPage.SELECTORS.PASSWORD_INPUT,
);
const loginButton = this.loginForm.getButton(
AuthPage.SELECTORS.LOGIN_BUTTON,
);
await usernameInput.fill(username);
await passwordInput.fill(password);
await loginButton.click();
}
/**
* Get current page URL
*/
async getCurrentUrl(): Promise<string> {
return this.page.url();
}
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
}
/**
* Check if login form has validation errors
*/
async hasLoginError(): Promise<boolean> {
const visibilityPromises = AuthPage.SELECTORS.ERROR_SELECTORS.map(
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
}
/**
* Wait for a login request to be made and return the response
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
test.describe('Login view', () => {
let authPage: AuthPage;
test.beforeEach(async ({ page }: any) => {
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Attempt login with incorrect credentials
await authPage.loginWithCredentials('admin', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
timeout: 10000,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials('admin', 'general');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(
(url: any) => url.pathname.endsWith('superset/welcome/'),
{
timeout: 10000,
},
);
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const URL = {
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,355 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import DeckGLPolygon, { getPoints } from './Polygon';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import * as utils from '../../utils';
// Mock the utils functions
const mockGetBuckets = jest.spyOn(utils, 'getBuckets');
const mockGetColorBreakpointsBuckets = jest.spyOn(
utils,
'getColorBreakpointsBuckets',
);
// Mock DeckGL container and Legend
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ children }: any) => (
<div data-testid="deckgl-container">{children}</div>
),
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
<div
data-testid="legend"
data-categories={JSON.stringify(categories)}
data-position={position}
>
Legend Mock
</div>
));
const mockProps = {
formData: {
// Required QueryFormData properties
datasource: 'test_datasource',
viz_type: 'deck_polygon',
// Polygon-specific properties
metric: { label: 'population' },
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
legend_position: 'tr',
legend_format: '.2f',
autozoom: false,
mapbox_style: 'mapbox://styles/mapbox/light-v9',
opacity: 80,
filled: true,
stroked: true,
extruded: false,
line_width: 1,
line_width_unit: 'pixels',
multiplier: 1,
break_points: [],
num_buckets: '5',
linear_color_scheme: 'blue_white_yellow',
},
payload: {
data: {
features: [
{
population: 100000,
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
population: 200000,
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
],
mapboxApiKey: 'test-key',
},
form_data: {},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
width: 800,
height: 600,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
filterState: undefined,
emitCrossFilters: false,
};
describe('DeckGLPolygon bucket generation logic', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
const propsWithLinearPalette = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.linear_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithLinearPalette} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for fixed_color color scheme', () => {
const propsWithFixedColor = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.fixed_color,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithFixedColor} />);
// Should call getBuckets, not getColorBreakpointsBuckets
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
const propsWithBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: [
{
minValue: 0,
maxValue: 100000,
color: { r: 255, g: 0, b: 0, a: 100 },
},
{
minValue: 100001,
maxValue: 200000,
color: { r: 0, g: 255, b: 0, a: 100 },
},
],
},
};
mockGetColorBreakpointsBuckets.mockReturnValue({
'0 - 100000': { color: [255, 0, 0], enabled: true },
'100001 - 200000': { color: [0, 255, 0], enabled: true },
});
renderWithTheme(<DeckGLPolygon {...propsWithBreakpoints} />);
// Should call getColorBreakpointsBuckets, not getBuckets
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalled();
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
const propsWithUndefinedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUndefinedScheme} />);
// Should call getBuckets for backward compatibility
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
const propsWithUnsupportedScheme = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.categorical_palette,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithUnsupportedScheme} />);
// Should fall back to getBuckets for unsupported color schemes
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
});
describe('DeckGLPolygon Error Handling and Edge Cases', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
const propsWithEmptyData = {
...mockProps,
payload: {
...mockProps.payload,
data: {
...mockProps.payload.data,
features: [],
},
},
};
renderWithTheme(<DeckGLPolygon {...propsWithEmptyData} />);
// Should still call getBuckets with empty data
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('handles missing color_breakpoints for color_breakpoints scheme', () => {
const propsWithMissingBreakpoints = {
...mockProps,
formData: {
...mockProps.formData,
color_scheme_type: COLOR_SCHEME_TYPES.color_breakpoints,
color_breakpoints: undefined,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithMissingBreakpoints} />);
// Should call getColorBreakpointsBuckets even with undefined breakpoints
expect(mockGetColorBreakpointsBuckets).toHaveBeenCalledWith(undefined);
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('handles null legend_position correctly', () => {
const propsWithNullLegendPosition = {
...mockProps,
formData: {
...mockProps.formData,
legend_position: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithNullLegendPosition} />);
// Legend should not be rendered when position is null
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('DeckGLPolygon Legend Integration', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({
'100000 - 150000': { color: [0, 100, 200], enabled: true },
'150000 - 200000': { color: [50, 150, 250], enabled: true },
});
});
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
// Verify the component renders and calls the correct bucket function
expect(mockGetBuckets).toHaveBeenCalled();
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
// Verify the legend mock was rendered with non-empty categories
const legendElement = container.querySelector('[data-testid="legend"]');
expect(legendElement).toBeTruthy();
const categoriesAttr = legendElement?.getAttribute('data-categories');
const categoriesData = JSON.parse(categoriesAttr || '{}');
expect(Object.keys(categoriesData)).toHaveLength(2);
});
test('does not render legend when metric is null', () => {
const propsWithoutMetric = {
...mockProps,
formData: {
...mockProps.formData,
metric: null,
},
};
renderWithTheme(<DeckGLPolygon {...propsWithoutMetric} />);
// Legend should not be rendered when no metric is defined
expect(screen.queryByTestId('legend')).not.toBeInTheDocument();
});
});
describe('getPoints utility', () => {
test('extracts points from polygon data', () => {
const data = [
{
polygon: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
],
},
{
polygon: [
[2, 2],
[3, 2],
[3, 3],
[2, 3],
],
},
];
const points = getPoints(data);
expect(points).toHaveLength(8); // 4 points per polygon * 2 polygons
expect(points[0]).toEqual([0, 0]);
expect(points[4]).toEqual([2, 2]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,231 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, memo } from 'react';
import { styled, t } from '@superset-ui/core';
import { SafeMarkdown } from '@superset-ui/core/components';
import Handlebars from 'handlebars';
import dayjs from 'dayjs';
import { isPlainObject } from 'lodash';
export interface HandlebarsRendererProps {
templateSource: string;
data: any;
}
const ErrorContainer = styled.pre`
white-space: pre-wrap;
color: ${({ theme }) => theme.colorError};
background-color: ${({ theme }) => theme.colorErrorBg};
padding: ${({ theme }) => theme.sizeUnit * 2}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
`;
export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
({ templateSource, data }) => {
const [renderedTemplate, setRenderedTemplate] = useState('');
const [error, setError] = useState('');
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
const htmlSchemaOverrides =
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
useEffect(() => {
try {
const template = Handlebars.compile(templateSource);
const result = template(data);
setRenderedTemplate(result);
setError('');
} catch (error) {
setRenderedTemplate('');
setError(error.message || 'Unknown template error');
}
}, [templateSource, data]);
if (error) {
return <ErrorContainer>{error}</ErrorContainer>;
}
if (renderedTemplate || renderedTemplate === '') {
return (
<div
style={{
maxWidth: '300px',
wordWrap: 'break-word',
fontSize: '12px',
lineHeight: '1.4',
}}
>
<SafeMarkdown
source={renderedTemplate || ''}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
</div>
);
}
return <p>{t('Loading...')}</p>;
},
);
Handlebars.registerHelper('dateFormat', function (context, options) {
const format = options.hash.format || 'YYYY-MM-DD HH:mm:ss';
if (!context) return '';
try {
if (typeof context === 'number') {
const timestamp = context > 1000000000000 ? context : context * 1000;
return dayjs(timestamp).format(format);
}
return dayjs(context).format(format);
} catch (e) {
return String(context);
}
});
Handlebars.registerHelper('formatNumber', function (number, options) {
if (typeof number !== 'number') {
return number;
}
const locale = options.hash.locale || 'en-US';
const { minimumFractionDigits } = options.hash;
const { maximumFractionDigits } = options.hash;
const formatOptions: Intl.NumberFormatOptions = {};
if (minimumFractionDigits !== undefined) {
formatOptions.minimumFractionDigits = minimumFractionDigits;
}
if (maximumFractionDigits !== undefined) {
formatOptions.maximumFractionDigits = maximumFractionDigits;
}
return number.toLocaleString(locale, formatOptions);
});
Handlebars.registerHelper('stringify', function (obj) {
if (obj === undefined || obj === null) {
return '';
}
if (isPlainObject(obj)) {
try {
return JSON.stringify(obj, null, 2);
} catch (e) {
return String(obj);
}
}
return String(obj);
});
Handlebars.registerHelper(
'ifExists',
function (this: any, value: any, options: any) {
if (value !== null && value !== undefined && value !== '') {
return options.fn(this);
}
return options.inverse(this);
},
);
Handlebars.registerHelper('default', function (value, fallback) {
return value !== null && value !== undefined && value !== ''
? value
: fallback;
});
Handlebars.registerHelper('truncate', function (text, length) {
if (typeof text !== 'string') {
return text;
}
if (text.length <= length) {
return text;
}
return `${text.substring(0, length)}...`;
});
Handlebars.registerHelper('formatCoordinate', function (longitude, latitude) {
if (
longitude === null ||
longitude === undefined ||
latitude === null ||
latitude === undefined
) {
return '';
}
const lng = typeof longitude === 'number' ? longitude.toFixed(6) : longitude;
const lat = typeof latitude === 'number' ? latitude.toFixed(6) : latitude;
return `${lng}, ${lat}`;
});
Handlebars.registerHelper('first', function (array) {
if (Array.isArray(array) && array.length > 0) {
return array[0];
}
return null;
});
Handlebars.registerHelper('getField', function (array, fieldName) {
if (!Array.isArray(array) || array.length === 0) {
return '';
}
const values = array
.map(item => item[fieldName])
.filter(
(value, index, self) =>
value !== undefined && value !== null && self.indexOf(value) === index,
);
if (values.length === 0) return '';
if (values.length === 1) return values[0];
return values.slice(0, 3).join(', ') + (values.length > 3 ? '...' : '');
});
Handlebars.registerHelper('limit', function (value, limit) {
if (!value) return '';
// Handle arrays
if (Array.isArray(value)) {
const limitedArray = value.slice(0, limit);
return limitedArray.join(', ') + (value.length > limit ? '...' : '');
}
// Handle strings (comma-separated values)
if (typeof value === 'string') {
const items = value.split(',').map(item => item.trim());
if (items.length <= limit) return value;
const limitedItems = items.slice(0, limit);
return `${limitedItems.join(', ')}...`;
}
// For other types, return as-is
return value;
});
export default HandlebarsRenderer;

View File

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

View File

@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { debounce } from 'lodash';
import { t, useTheme } from '@superset-ui/core';
import { InfoTooltip, Constants } from '@superset-ui/core/components';
import { ControlHeader } from '@superset-ui/chart-controls';
import { TooltipTemplateEditor } from './TooltipTemplateEditor';
interface TooltipTemplateControlProps {
value: string;
onChange: (value: string) => void;
label?: string;
name: string;
height?: number;
}
const debounceFunc = debounce(
(func: (val: string) => void, source: string) => func(source),
Constants.SLOW_DEBOUNCE,
);
export function TooltipTemplateControl({
value,
onChange,
label,
name,
}: TooltipTemplateControlProps) {
const theme = useTheme();
const handleTemplateChange = useCallback(
(newValue: string) => {
debounceFunc(onChange, newValue || '');
},
[onChange],
);
const tooltipContent = t(
'Use Handlebars syntax to create custom tooltips. Available variables are based on your tooltip contents selection above.',
);
return (
<div>
<ControlHeader
name={name}
label={
<>
{label || t('Customize tooltips template')}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={tooltipContent}
/>
</>
}
/>
<TooltipTemplateEditor
value={value}
onChange={handleTemplateChange}
name={name}
/>
</div>
);
}
export default TooltipTemplateControl;

View File

@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import { styled, css, useThemeMode } from '@superset-ui/core';
import { CodeEditor } from '@superset-ui/core/components';
const EditorContainer = styled.div`
${({ theme }) => css`
min-height: ${theme.sizeUnit * 50}px;
width: 100%;
.ace_editor {
font-family: ${theme.fontFamilyCode};
}
`}
`;
interface TooltipTemplateEditorProps {
value: string;
onChange: (value: string) => void;
name: string;
}
export function TooltipTemplateEditor({
value,
onChange,
name,
}: TooltipTemplateEditorProps) {
const [localValue, setLocalValue] = useState(value);
const isDarkMode = useThemeMode();
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = useCallback(
(newValue: string) => {
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
return (
<div>
<EditorContainer>
<CodeEditor
mode="handlebars"
theme={isDarkMode ? 'dark' : 'light'}
name={name}
height="200px"
width="100%"
value={localValue}
onChange={handleChange}
/>
</EditorContainer>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ControlType } from '@superset-ui/chart-controls';
import { TooltipTemplateControl } from './TooltipTemplateControl';
/**
* Registry for custom control components used in DeckGL charts
*/
export const deckGLControlRegistry = {
TooltipTemplateControl,
};
/**
* Expand control type to include local DeckGL controls
*/
export function expandDeckGLControlType(controlType: ControlType) {
if (typeof controlType === 'string' && controlType in deckGLControlRegistry) {
return deckGLControlRegistry[
controlType as keyof typeof deckGLControlRegistry
];
}
return controlType;
}
/**
* HOC to wrap control components with DeckGL-specific logic
*/
export function withDeckGLControls(Component: React.ComponentType<any>) {
return function DeckGLControlWrapper(props: any) {
const { type, ...otherProps } = props;
const ExpandedComponent = expandDeckGLControlType(type) || Component;
if (typeof ExpandedComponent === 'string') {
// If it's a string, it's a built-in control type, use the original Component
return <Component {...otherProps} />;
}
return <ExpandedComponent {...otherProps} />;
};
}
export default deckGLControlRegistry;

View File

@@ -0,0 +1,142 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
interface TooltipItem {
item_type?: string;
column_name?: string;
metric_name?: string;
label?: string;
verbose_name?: string;
}
export const AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_screengrid',
'deck_heatmap',
'deck_contour',
'deck_hex',
'deck_grid',
];
export const NON_AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_scatter',
'deck_arc',
'deck_path',
'deck_polygon',
'deck_geojson',
];
export function isAggregatedDeckGLChart(vizType: string): boolean {
return AGGREGATED_DECK_GL_CHART_TYPES.includes(vizType);
}
export function fieldHasMultipleValues(
item: TooltipItem | string,
formData: QueryFormData,
): boolean {
if (!isAggregatedDeckGLChart(formData.viz_type)) {
return false;
}
if (typeof item === 'object' && item?.item_type === 'metric') {
return false;
}
// TODO: Currently only screengrid supports multi-value fields. Support for other aggregated charts will be added in future releases
const supportsMultiValue = ['deck_screengrid'].includes(formData.viz_type);
if (!supportsMultiValue) {
return false;
}
if (typeof item === 'object' && item?.item_type === 'column') {
return true;
}
if (typeof item === 'string') {
return true;
}
return false;
}
const getFieldName = (item: TooltipItem | string): string | null => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') return item.column_name ?? null;
if (item?.item_type === 'metric')
return item.metric_name ?? item.label ?? null;
return null;
};
const getFieldLabel = (item: TooltipItem | string): string => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') {
return item.verbose_name || item.column_name || 'Column';
}
if (item?.item_type === 'metric') {
return item.verbose_name || item.metric_name || item.label || 'Metric';
}
return 'Field';
};
const createMultiValueTemplate = (
fieldName: string,
fieldLabel: string,
): string => {
const pluralFieldName = `${fieldName}s`;
return `<div><strong>${fieldLabel}:</strong> {{#if ${pluralFieldName}}}{{limit ${pluralFieldName} 10}}{{#if ${fieldName}_count}} ({{${fieldName}_count}} total){{/if}}{{else}}N/A{{/if}}</div>`;
};
const createSingleValueTemplate = (
fieldName: string,
fieldLabel: string,
): string =>
`<div><strong>${fieldLabel}:</strong> {{#if ${fieldName}}}{{${fieldName}}}{{else}}N/A{{/if}}</div>`;
export function createDefaultTemplateWithLimits(
tooltipContents: (TooltipItem | string)[],
formData: QueryFormData,
): string {
if (!tooltipContents?.length) {
return '';
}
const templateLines: string[] = [];
tooltipContents.forEach(item => {
const fieldName = getFieldName(item);
const fieldLabel = getFieldLabel(item);
if (!fieldName) return;
const hasMultipleValues = fieldHasMultipleValues(item, formData);
if (hasMultipleValues) {
templateLines.push(createMultiValueTemplate(fieldName, fieldLabel));
} else {
templateLines.push(createSingleValueTemplate(fieldName, fieldLabel));
}
});
return templateLines.join('\n');
}
export const MULTI_VALUE_WARNING_MESSAGE =
'This metric or column contains many values, they may not be able to be all displayed in the tooltip';

View File

@@ -0,0 +1,384 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t, JsonObject, QueryFormData } from '@superset-ui/core';
import { useMemo, memo } from 'react';
import { HandlebarsRenderer } from './HandlebarsRenderer';
import TooltipRow from '../TooltipRow';
import { createDefaultTemplateWithLimits } from './multiValueUtils';
const MemoizedHandlebarsRenderer = memo(HandlebarsRenderer);
export const CommonTooltipRows = {
position: (o: JsonObject, position?: [number, number]) => (
<TooltipRow
label={`${t('Longitude and Latitude')}: `}
value={`${position?.[0] || o.object?.position?.[0]}, ${position?.[1] || o.object?.position?.[1]}`}
/>
),
arcPositions: (o: JsonObject) => (
<>
<TooltipRow
label={t('Start (Longitude, Latitude): ')}
value={`${o.object?.sourcePosition?.[0]}, ${o.object?.sourcePosition?.[1]}`}
/>
<TooltipRow
label={t('End (Longitude, Latitude): ')}
value={`${o.object?.targetPosition?.[0]}, ${o.object?.targetPosition?.[1]}`}
/>
</>
),
centroid: (o: JsonObject) => (
<TooltipRow
label={t('Centroid (Longitude and Latitude): ')}
value={`(${o.coordinate?.[0]}, ${o.coordinate?.[1]})`}
/>
),
category: (o: JsonObject) =>
o.object?.cat_color ? (
<TooltipRow
label={`${t('Category')}: `}
value={`${o.object.cat_color}`}
/>
) : null,
metric: (
o: JsonObject,
formData: QueryFormData,
verboseMap?: Record<string, string>,
) => {
const metricConfig =
formData.point_radius_fixed || formData.size || formData.metric;
if (!metricConfig) return null;
const label =
verboseMap?.[metricConfig.value] ||
metricConfig?.value ||
metricConfig?.label ||
'Metric';
return o.object?.metric ? (
<TooltipRow label={`${label}: `} value={`${o.object.metric}`} />
) : null;
},
};
function extractValue(
o: JsonObject,
fieldName: string,
checkPoints = true,
): any {
let value =
o.object?.[fieldName] ||
o.object?.properties?.[fieldName] ||
o.object?.data?.[fieldName] ||
'';
if (!value && checkPoints && Array.isArray(o.object?.points)) {
const allVals = o.object.points
.map((pt: any) => pt[fieldName])
.filter((v: any) => v !== undefined && v !== null);
if (allVals.length > 0) {
value = allVals[0];
return { value, allValues: allVals };
}
}
return { value, allValues: [] };
}
function formatValue(value: any): string {
if (value === '') return '';
if (
typeof value === 'string' &&
value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
) {
return new Date(value).toLocaleString();
}
return `${value}`;
}
function buildFieldBasedTooltipItems(
o: JsonObject,
formData: QueryFormData,
): JSX.Element[] {
const tooltipItems: JSX.Element[] = [];
formData.tooltip_contents.forEach((item: any, index: number) => {
let label = '';
let fieldName = '';
if (typeof item === 'string') {
label = item;
fieldName = item;
} else if (item.item_type === 'column') {
label = item.verbose_name || item.column_name || item.label;
fieldName = item.column_name;
} else if (item.item_type === 'metric') {
label = item.verbose_name || item.metric_name || item.label;
fieldName = item.metric_name || item.label;
}
if (!label || !fieldName) return;
let { value } = extractValue(o, fieldName);
if (!value && item.item_type === 'metric') {
value = o.object?.metric || '';
}
if (
formData.viz_type === 'deck_screengrid' &&
!value &&
Array.isArray(o.object?.points)
) {
const { allValues } = extractValue(o, fieldName);
if (allValues.length > 0) {
value = allValues.join(', ');
}
}
if (value !== '') {
const formattedValue = formatValue(value);
tooltipItems.push(
<TooltipRow
key={`tooltip-${index}`}
label={`${label}: `}
value={formattedValue}
/>,
);
}
});
return tooltipItems;
}
function createScreenGridData(
o: JsonObject,
fieldName: string,
extractResult: { value: any; allValues: any[] },
): Record<string, any> {
const result: Record<string, any> = {};
if (extractResult.allValues.length > 0) {
result[fieldName] = extractResult.allValues;
result[`${fieldName}s`] = extractResult.allValues.join(', ');
result[`${fieldName}_count`] = extractResult.allValues.length;
} else {
const count = o.object?.count || 0;
const value = o.object?.value || 0;
const aggregatedValue = `Aggregated: ${count} points, total value: ${value}`;
result[fieldName] = aggregatedValue;
result[`${fieldName}_aggregated`] = aggregatedValue;
}
return result;
}
function processTooltipContentItem(
item: any,
o: JsonObject,
formData: QueryFormData,
): Record<string, any> {
let fieldName = '';
if (typeof item === 'string') {
fieldName = item;
} else if (item?.item_type === 'column') {
fieldName = item.column_name;
} else if (item?.item_type === 'metric') {
fieldName = item.metric_name || item.label;
}
if (!fieldName) return {};
const extractResult = extractValue(o, fieldName);
let { value } = extractResult;
if (item?.item_type === 'metric' && !value) {
value = o.object?.metric || '';
}
if (formData.viz_type === 'deck_screengrid' && !value) {
return createScreenGridData(o, fieldName, extractResult);
}
if (extractResult.allValues.length > 0) {
return {
[fieldName]: extractResult.allValues,
[`${fieldName}s`]: extractResult.allValues.join(', '),
[`${fieldName}_count`]: extractResult.allValues.length,
};
}
if (value !== '') {
return { [fieldName]: value };
}
return {};
}
export function createHandlebarsTooltipData(
o: JsonObject,
formData: QueryFormData,
): Record<string, any> {
const initialData: Record<string, any> = {
...(o.object || {}),
coordinate: o.coordinate,
index: o.index,
picked: o.picked,
title: formData.viz_type || 'Chart',
coordinateString: o.coordinate
? `${o.coordinate[0]}, ${o.coordinate[1]}`
: '',
positionString: o.object?.position
? `${o.object.position[0]}, ${o.object.position[1]}`
: '',
threshold: o.object?.contour?.threshold,
contourThreshold: o.object?.contour?.threshold,
nearbyPoints: o.object?.nearbyPoints,
totalPoints: o.object?.totalPoints,
};
let data = { ...initialData };
if (
formData.viz_type === 'deck_heatmap' ||
formData.viz_type === 'deck_contour'
) {
if (o.object?.position) {
data = {
...data,
LON: o.object.position[0],
LAT: o.object.position[1],
};
}
if (o.coordinate) {
data = {
...data,
LON: o.coordinate[0],
LAT: o.coordinate[1],
};
}
if (!o.object && formData.viz_type === 'deck_heatmap') {
data = {
...data,
aggregated: true,
note: 'Aggregated cell - individual point data not available',
};
}
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipData = formData.tooltip_contents.reduce(
(acc: any, item: any) => {
const itemData = processTooltipContentItem(item, o, formData);
return { ...acc, ...itemData };
},
{},
);
data = { ...data, ...tooltipData };
}
return data;
}
export function generateEnhancedDefaultTemplate(
tooltipContents: any[],
formData: QueryFormData,
): string {
return createDefaultTemplateWithLimits(tooltipContents, formData);
}
export function useTooltipContent(
formData: QueryFormData,
defaultTooltipGenerator: (o: JsonObject) => JSX.Element,
) {
const tooltipContentGenerator = useMemo(
() => (o: JsonObject) => {
if (
formData.tooltip_template?.trim() &&
!formData.tooltip_template.includes(
'Drop columns/metrics in "Tooltip contents" above',
)
) {
const tooltipData = createHandlebarsTooltipData(o, formData);
return (
<div className="deckgl-tooltip" data-tooltip-type="custom">
<MemoizedHandlebarsRenderer
templateSource={formData.tooltip_template}
data={tooltipData}
/>
</div>
);
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipItems = buildFieldBasedTooltipItems(o, formData);
return <div className="deckgl-tooltip">{tooltipItems}</div>;
}
return defaultTooltipGenerator(o);
},
[
formData.tooltip_template,
formData.tooltip_contents,
formData.viz_type,
defaultTooltipGenerator,
],
);
return tooltipContentGenerator;
}
export function createTooltipContent(
formData: QueryFormData,
defaultTooltipGenerator: (o: JsonObject) => JSX.Element,
) {
return (o: JsonObject) => {
if (
formData.tooltip_template?.trim() &&
!formData.tooltip_template.includes(
'Drop columns/metrics in "Tooltip contents" above',
)
) {
const tooltipData = createHandlebarsTooltipData(o, formData);
return (
<div className="deckgl-tooltip" data-tooltip-type="custom">
<MemoizedHandlebarsRenderer
templateSource={formData.tooltip_template}
data={tooltipData}
/>
</div>
);
}
if (formData.tooltip_contents && formData.tooltip_contents.length > 0) {
const tooltipItems = buildFieldBasedTooltipItems(o, formData);
return <div className="deckgl-tooltip">{tooltipItems}</div>;
}
return defaultTooltipGenerator(o);
};
}

View File

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

View File

@@ -4,7 +4,10 @@
"composite": true,
"rootDir": "src",
"outDir": "lib",
"baseUrl": "."
"baseUrl": ".",
"paths": {
"@superset-ui/core/components": ["../../packages/superset-ui-core/src/components"]
}
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["lib", "test"],

View File

@@ -25,7 +25,6 @@ import {
getXAxisLabel,
Metric,
getValueFormatter,
supersetTheme,
t,
tooltipHtml,
} from '@superset-ui/core';
@@ -281,7 +280,7 @@ export default function transformProps(
},
{
offset: 1,
color: supersetTheme.colorBgContainer,
color: 'transparent',
},
]),
},

View File

@@ -27,7 +27,6 @@ import {
getValueFormatter,
rgbToHex,
addAlpha,
supersetTheme,
tooltipHtml,
} from '@superset-ui/core';
import memoizeOne from 'memoize-one';
@@ -78,7 +77,8 @@ export default function transformProps(
chartProps: HeatmapChartProps,
): HeatmapTransformedProps {
const refs: Refs = {};
const { width, height, formData, queriesData, datasource } = chartProps;
const { width, height, formData, queriesData, datasource, theme } =
chartProps;
const {
bottomMargin,
xAxis,
@@ -176,9 +176,9 @@ export default function transformProps(
},
emphasis: {
itemStyle: {
borderColor: supersetTheme.colorBgContainer,
borderColor: 'transparent',
shadowBlur: 10,
shadowColor: supersetTheme.colorTextBase,
shadowColor: addAlpha(theme.colorText, 0.3),
},
},
},

View File

@@ -58,41 +58,109 @@ const config: ControlPanelConfig = {
},
},
],
],
},
{
label: t('Series settings'),
expanded: true,
controlSetRows: [
[
<ControlSubSectionHeader>
{t('Series colors')}
{t('Series increase setting')}
</ControlSubSectionHeader>,
],
[
{
name: 'increase_color',
config: {
label: t('Increase'),
label: t('Increase color'),
type: 'ColorPickerControl',
default: { r: 90, g: 193, b: 137, a: 1 },
renderTrigger: true,
description: t(
'Select the color used for values that indicate an increase in the chart',
),
},
},
{
name: 'decrease_color',
name: 'increase_label',
config: {
label: t('Decrease'),
type: 'ColorPickerControl',
default: { r: 224, g: 67, b: 85, a: 1 },
renderTrigger: true,
},
},
{
name: 'total_color',
config: {
label: t('Total'),
type: 'ColorPickerControl',
default: { r: 102, g: 102, b: 102, a: 1 },
label: t('Increase label'),
type: 'TextControl',
renderTrigger: true,
description: t(
'Customize the label displayed for increasing values in the chart tooltips and legend.',
),
},
},
],
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
<ControlSubSectionHeader>
{t('Series decrease setting')}
</ControlSubSectionHeader>,
],
[
{
name: 'decrease_color',
config: {
label: t('Decrease color'),
type: 'ColorPickerControl',
default: { r: 224, g: 67, b: 85, a: 1 },
renderTrigger: true,
description: t(
'Select the color used for values that indicate a decrease in the chart.',
),
},
},
{
name: 'decrease_label',
config: {
label: t('Decrease label'),
type: 'TextControl',
renderTrigger: true,
description: t(
'Customize the label displayed for decreasing values in the chart tooltips and legend.',
),
},
},
],
[
<ControlSubSectionHeader>
{t('Series total setting')}
</ControlSubSectionHeader>,
],
[
{
name: 'total_color',
config: {
label: t('Total color'),
type: 'ColorPickerControl',
default: { r: 102, g: 102, b: 102, a: 1 },
renderTrigger: true,
description: t(
'Select the color used for values that represent total bars in the chart',
),
},
},
{
name: 'total_label',
config: {
label: t('Total label'),
type: 'TextControl',
renderTrigger: true,
description: t(
'Customize the label displayed for total values in the chart tooltips, legend, and chart axis.',
),
},
},
],
],
},
{
label: t('X Axis'),
expanded: true,
controlSetRows: [
[
{
name: 'x_axis_label',
@@ -134,7 +202,12 @@ const config: ControlPanelConfig = {
},
},
],
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
],
},
{
label: t('Y Axis'),
expanded: true,
controlSetRows: [
[
{
name: 'y_axis_label',

View File

@@ -51,11 +51,13 @@ function formatTooltip({
breakdownName,
defaultFormatter,
xAxisFormatter,
totalMark,
}: {
params: ICallbackDataParams[];
breakdownName?: string;
defaultFormatter: NumberFormatter | CurrencyFormatter;
xAxisFormatter: (value: number | string, index: number) => string;
totalMark: string;
}) {
const series = params.find(
param => param.seriesName !== ASSIST_MARK && param.data.value !== TOKEN,
@@ -66,7 +68,7 @@ function formatTooltip({
return '';
}
const isTotal = series?.seriesName === LEGEND.TOTAL;
const isTotal = series?.seriesName === totalMark;
if (!series) {
return NULL_STRING;
}
@@ -82,7 +84,7 @@ function formatTooltip({
defaultFormatter(series.data.originalValue),
]);
}
rows.push([TOTAL_MARK, defaultFormatter(series.data.totalSum)]);
rows.push([totalMark, defaultFormatter(series.data.totalSum)]);
return tooltipHtml(rows, title);
}
@@ -91,11 +93,13 @@ function transformer({
xAxis,
metric,
breakdown,
totalMark,
}: {
data: DataRecord[];
xAxis: string;
metric: string;
breakdown?: string;
totalMark: string;
}) {
// Group by series (temporary map)
const groupedData = data.reduce((acc, cur) => {
@@ -119,7 +123,7 @@ function transformer({
// Push total per period to the end of period values array
tempValue.push({
[xAxis]: key,
[breakdown]: TOTAL_MARK,
[breakdown]: totalMark,
[metric]: sum,
});
transformedData.push(...tempValue);
@@ -138,7 +142,7 @@ function transformer({
total += sum;
});
transformedData.push({
[xAxis]: TOTAL_MARK,
[xAxis]: totalMark,
[metric]: total,
});
}
@@ -179,11 +183,21 @@ export default function transformProps(
xAxisLabel,
yAxisFormat,
showValue,
totalLabel,
increaseLabel,
decreaseLabel,
} = formData;
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: yAxisFormat, currency: currencyFormat })
: getNumberFormatter(yAxisFormat);
const totalMark = totalLabel || TOTAL_MARK;
const legendNames = {
INCREASE: increaseLabel || LEGEND.INCREASE,
DECREASE: decreaseLabel || LEGEND.DECREASE,
TOTAL: totalLabel || LEGEND.TOTAL,
};
const seriesformatter = (params: ICallbackDataParams) => {
const { data } = params;
const { originalValue } = data;
@@ -205,6 +219,7 @@ export default function transformProps(
breakdown: breakdownName,
xAxis: xAxisName,
metric: metricLabel,
totalMark,
});
const assistData: ISeriesData[] = [];
@@ -217,18 +232,18 @@ export default function transformProps(
transformedData.forEach((datum, index, self) => {
const totalSum = self.slice(0, index + 1).reduce((prev, cur, i) => {
if (breakdownName) {
if (cur[breakdownName] !== TOTAL_MARK || i === 0) {
if (cur[breakdownName] !== totalMark || i === 0) {
return prev + ((cur[metricLabel] as number) ?? 0);
}
} else if (cur[xAxisName] !== TOTAL_MARK) {
} else if (cur[xAxisName] !== totalMark) {
return prev + ((cur[metricLabel] as number) ?? 0);
}
return prev;
}, 0);
const isTotal =
(breakdownName && datum[breakdownName] === TOTAL_MARK) ||
datum[xAxisName] === TOTAL_MARK;
(breakdownName && datum[breakdownName] === totalMark) ||
datum[xAxisName] === totalMark;
const originalValue = datum[metricLabel] as number;
let value = originalValue;
@@ -270,9 +285,9 @@ export default function transformProps(
: 'transparent';
let opacity = 1;
if (legendState?.[LEGEND.INCREASE] === false && value > 0) {
if (legendState?.[legendNames.INCREASE] === false && value > 0) {
opacity = 0;
} else if (legendState?.[LEGEND.DECREASE] === false && value < 0) {
} else if (legendState?.[legendNames.DECREASE] === false && value < 0) {
opacity = 0;
}
@@ -301,7 +316,7 @@ export default function transformProps(
const xAxisData = transformedData.map(row => {
let column = xAxisName;
let value = row[xAxisName];
if (breakdownName && row[breakdownName] !== TOTAL_MARK) {
if (breakdownName && row[breakdownName] !== totalMark) {
column = breakdownName;
value = row[breakdownName];
}
@@ -316,8 +331,8 @@ export default function transformProps(
});
const xAxisFormatter = (value: number | string, index: number) => {
if (value === TOTAL_MARK) {
return TOTAL_MARK;
if (value === totalMark) {
return totalMark;
}
if (coltypeMapping[xAxisColumns[index]] === GenericDataType.Temporal) {
if (typeof value === 'string') {
@@ -370,7 +385,7 @@ export default function transformProps(
},
{
...seriesProps,
name: LEGEND.INCREASE,
name: legendNames.INCREASE,
label: {
...labelProps,
position: 'top',
@@ -382,7 +397,7 @@ export default function transformProps(
},
{
...seriesProps,
name: LEGEND.DECREASE,
name: legendNames.DECREASE,
label: {
...labelProps,
position: 'bottom',
@@ -394,7 +409,7 @@ export default function transformProps(
},
{
...seriesProps,
name: LEGEND.TOTAL,
name: legendNames.TOTAL,
label: {
...labelProps,
position: 'top',
@@ -417,7 +432,7 @@ export default function transformProps(
legend: {
show: showLegend,
selected: legendState,
data: [LEGEND.INCREASE, LEGEND.DECREASE, LEGEND.TOTAL],
data: [legendNames.INCREASE, legendNames.DECREASE, legendNames.TOTAL],
},
xAxis: {
data: xAxisData,
@@ -450,6 +465,7 @@ export default function transformProps(
breakdownName,
defaultFormatter,
xAxisFormatter,
totalMark,
}),
},
series: barSeries,

View File

@@ -57,6 +57,9 @@ export type EchartsWaterfallFormData = QueryFormData &
xTicksLayout?: WaterfallFormXTicksLayout;
yAxisLabel: string;
yAxisFormat: string;
increaseLabel?: string;
decreaseLabel?: string;
totalLabel?: string;
};
export const DEFAULT_FORM_DATA: Partial<EchartsWaterfallFormData> = {

View File

@@ -128,7 +128,7 @@ export const showValueControl: ControlSetItem = {
name: 'show_value',
config: {
type: 'CheckboxControl',
label: t('Show Value'),
label: t('Show value'),
default: false,
renderTrigger: true,
description: t('Show series values on the chart'),

View File

@@ -31,6 +31,14 @@ const extractSeries = (props: WaterfallChartTransformedProps) => {
return series.map(item => item.data).map(item => item.map(i => i.value));
};
const extractSeriesName = (props: WaterfallChartTransformedProps) => {
const { echartOptions } = props;
const { series } = echartOptions as unknown as {
series: [{ name: string }];
};
return series.map(item => item.name);
};
describe('Waterfall tranformProps', () => {
const data = [
{ year: '2019', name: 'Sylvester', sum: 10 },
@@ -94,4 +102,44 @@ describe('Waterfall tranformProps', () => {
['-', '-', 13, '-', '-', 8],
]);
});
it('renaming series names, checking legend and X axis labels', () => {
const chartProps = new ChartProps({
formData: {
...formData,
increaseLabel: 'sale increase',
decreaseLabel: 'sale decrease',
totalLabel: 'sale total',
},
width: 800,
height: 600,
queriesData: [
{
data,
},
],
theme: supersetTheme,
});
const transformedProps = transformProps(
chartProps as unknown as EchartsWaterfallChartProps,
);
expect((transformedProps.echartOptions.legend as any).data).toEqual([
'sale increase',
'sale decrease',
'sale total',
]);
expect((transformedProps.echartOptions.xAxis as any).data).toEqual([
'2019',
'2020',
'sale total',
]);
expect(extractSeriesName(transformedProps)).toEqual([
'Assist',
'sale increase',
'sale decrease',
'sale total',
]);
});
});

View File

@@ -17,64 +17,9 @@
* under the License.
*/
import { FC } from 'react';
import AceEditor, { IAceEditorProps } from 'react-ace';
// must go after AceEditor import
import 'ace-builds/src-min-noconflict/mode-handlebars';
import 'ace-builds/src-min-noconflict/mode-css';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode = 'handlebars' | 'css';
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps extends IAceEditorProps {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;
}
export const CodeEditor: FC<CodeEditorProps> = ({
mode,
theme,
name,
width,
height,
value,
...rest
}: CodeEditorProps) => {
const m_name = name || Math.random().toString(36).substring(7);
const m_theme = theme === 'light' ? 'github' : 'monokai';
const m_mode = mode || 'handlebars';
const m_height = height || '300px';
const m_width = width || '100%';
return (
<div className="code-editor" style={{ minHeight: height, width: m_width }}>
<AceEditor
mode={m_mode}
theme={m_theme}
name={m_name}
height={m_height}
width={m_width}
fontSize={14}
showPrintMargin
focus
editorProps={{ $blockScrolling: true }}
wrapEnabled
highlightActiveLine
value={value}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
tabSize: 2,
showGutter: true,
}}
{...rest}
/>
</div>
);
};
export {
CodeEditor,
type CodeEditorProps,
type CodeEditorMode,
type CodeEditorTheme,
} from '@superset-ui/core/components';

View File

@@ -76,7 +76,7 @@ export const HandlebarsViewer = ({
return <p>{t('Loading...')}</p>;
};
// usage: {{dateFormat my_date format="MMMM YYYY"}}
// usage: {{ dateFormat my_date format="MMMM YYYY" }}
Handlebars.registerHelper('dateFormat', function (context, block) {
const f = block.hash.format || 'YYYY-MM-DD';
return dayjs(context).format(f);

View File

@@ -32,3 +32,9 @@ expect.extend(matchers);
// Allow JSX tests to have React import readily available
global.React = React;
// Mock ace-builds globally for tests
jest.mock('ace-builds/src-min-noconflict/mode-handlebars', () => ({}));
jest.mock('ace-builds/src-min-noconflict/mode-css', () => ({}));
jest.mock('ace-builds/src-noconflict/theme-github', () => ({}));
jest.mock('ace-builds/src-noconflict/theme-monokai', () => ({}));

View File

@@ -188,7 +188,10 @@ const ChartContextMenu = (
isFeatureEnabled(FeatureFlag.DrillBy) &&
canDrillBy &&
isDisplayed(ContextMenuItem.DrillBy) &&
!formData.matrixify_enabled; // Disable drill by when matrixify is enabled
!(
formData.matrixify_enable_vertical_layout === true ||
formData.matrixify_enable_horizontal_layout === true
); // Disable drill by when matrixify is enabled
const datasetResource = useDatasetDrillInfo(
formData.datasource,

View File

@@ -153,7 +153,10 @@ class ChartRenderer extends Component {
// Check if any matrixify-related properties have changed
const hasMatrixifyChanges = () => {
if (!nextProps.formData.matrixify_enabled) return false;
const isMatrixifyEnabled =
nextProps.formData.matrixify_enable_vertical_layout === true ||
nextProps.formData.matrixify_enable_horizontal_layout === true;
if (!isMatrixifyEnabled) return false;
// Check all matrixify-related properties
const matrixifyKeys = Object.keys(nextProps.formData).filter(key =>

View File

@@ -99,7 +99,7 @@ test('should detect changes in matrixify properties', () => {
...requiredProps,
formData: {
...requiredProps.formData,
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
matrixify_dimension_y: { dimension: 'category', values: ['Tech'] },
matrixify_charts_per_row: 3,
@@ -114,7 +114,7 @@ test('should detect changes in matrixify properties', () => {
// Since we can't directly test shouldComponentUpdate, we verify the component
// correctly identifies matrixify-related properties by checking the implementation
expect(initialProps.formData.matrixify_enabled).toBe(true);
expect(initialProps.formData.matrixify_enable_vertical_layout).toBe(true);
expect(initialProps.formData.matrixify_dimension_x).toEqual({
dimension: 'country',
values: ['USA'],
@@ -143,7 +143,7 @@ test('should identify matrixify property changes correctly', () => {
const initialProps = {
...requiredProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
matrixify_charts_per_row: 3,
},
@@ -161,7 +161,7 @@ test('should identify matrixify property changes correctly', () => {
const updatedProps = {
...initialProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: {
dimension: 'country',
values: ['USA', 'Canada'], // Changed
@@ -199,7 +199,7 @@ test('should handle matrixify-related form data changes', () => {
const updatedProps = {
...initialProps,
formData: {
matrixify_enabled: true, // This is a significant change
matrixify_enable_vertical_layout: true, // This is a significant change
regular_control: 'value1',
},
};
@@ -216,7 +216,7 @@ test('should detect matrixify property addition', () => {
const initialProps = {
...requiredProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
// No matrixify_dimension_x initially
},
queriesResponse: [{ data: 'current' }],
@@ -233,7 +233,7 @@ test('should detect matrixify property addition', () => {
const updatedProps = {
...initialProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, // Added
},
};
@@ -250,7 +250,7 @@ test('should detect nested matrixify property changes', () => {
const initialProps = {
...requiredProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: {
dimension: 'country',
values: ['USA'],
@@ -271,7 +271,7 @@ test('should detect nested matrixify property changes', () => {
const updatedProps = {
...initialProps,
formData: {
matrixify_enabled: true,
matrixify_enable_vertical_layout: true,
matrixify_dimension_x: {
dimension: 'country',
values: ['USA'],

View File

@@ -201,7 +201,10 @@ export const DrillByMenuItems = ({
};
// Don't render drill by menu items when matrixify is enabled
if (formData.matrixify_enabled) {
if (
formData.matrixify_enable_vertical_layout === true ||
formData.matrixify_enable_horizontal_layout === true
) {
return null;
}

View File

@@ -18,7 +18,7 @@
*/
import { logging } from '@superset-ui/core';
import type { commands as commandsType } from '@apache-superset/core';
import { Disposable } from './core';
import { Disposable } from '../models';
const commandRegistry: Map<string, (...args: any[]) => any> = new Map();

View File

@@ -16,9 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
import { core as coreType } from '@apache-superset/core';
import { getExtensionsContextValue } from '../extensions/ExtensionsContextUtils';
import { Disposable } from './models';
export const registerViewProvider: typeof coreType.registerViewProvider = (
id,
viewProvider,
) => {
const { registerViewProvider: register, unregisterViewProvider: unregister } =
getExtensionsContextValue();
register(id, viewProvider);
return new Disposable(() => unregister(id));
};
const { GenericDataType } = coreType;
export const core: typeof coreType = {
GenericDataType,
registerViewProvider,
Disposable,
};
export * from './authentication';
export * from './core';
export * from './commands';
export * from './extensions';
export * from './environment';
export * from './models';
export * from './sqlLab';
export * from './utils';

View File

@@ -16,10 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { core as coreType, sqlLab as sqlLabType } from '@apache-superset/core';
import { getExtensionsContextValue } from '../extensions/ExtensionsContextUtils';
const { GenericDataType } = coreType;
import { core as coreType } from '@apache-superset/core';
export class Table implements coreType.Table {
name: string;
@@ -114,71 +111,3 @@ export class Disposable implements coreType.Disposable {
export class ExtensionContext implements coreType.ExtensionContext {
disposables: coreType.Disposable[] = [];
}
export class Panel implements sqlLabType.Panel {
id: string;
constructor(id: string) {
this.id = id;
}
}
export class Editor implements sqlLabType.Editor {
content: string;
databaseId: number;
schema: string;
// TODO: Check later if we'll use objects instead of strings.
catalog: string | null;
table: string | null;
constructor(
content: string,
databaseId: number,
catalog: string | null = null,
schema = '',
table: string | null = null,
) {
this.content = content;
this.databaseId = databaseId;
this.catalog = catalog;
this.schema = schema;
this.table = table;
}
}
export class Tab implements sqlLabType.Tab {
id: string;
title: string;
editor: Editor;
panels: Panel[];
constructor(id: string, title: string, editor: Editor, panels: Panel[] = []) {
this.id = id;
this.title = title;
this.editor = editor;
this.panels = panels;
}
}
const registerViewProvider: typeof coreType.registerViewProvider = (
id,
viewProvider,
) => {
const { registerViewProvider: register, unregisterViewProvider: unregister } =
getExtensionsContextValue();
register(id, viewProvider);
return new Disposable(() => unregister(id));
};
export const core: typeof coreType = {
GenericDataType,
registerViewProvider,
Disposable,
};

View File

@@ -1,537 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { sqlLab as sqlLabType, core as coreType } from '@apache-superset/core';
import {
QUERY_FAILED,
QUERY_SUCCESS,
QUERY_EDITOR_SETDB,
querySuccess,
startQuery,
START_QUERY,
stopQuery,
STOP_QUERY,
createQueryFailedAction,
} from 'src/SqlLab/actions/sqlLab';
import { RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { Disposable, Editor, Panel, Tab } from './core';
import { createActionListener } from './utils';
const { CTASMethod } = sqlLabType;
export class CTAS implements sqlLabType.CTAS {
method: sqlLabType.CTASMethod;
tempTable: string;
constructor(asView: boolean, tempTable: string) {
this.method = asView ? CTASMethod.View : CTASMethod.Table;
this.tempTable = tempTable;
}
}
export class QueryContext implements sqlLabType.QueryContext {
clientId: string;
ctas: sqlLabType.CTAS | null;
editor: Editor;
requestedLimit: number | null;
runAsync: boolean;
startDttm: number;
tab: Tab;
private templateParams: string;
private parsedParams: Record<string, any>;
constructor(
clientId: string,
tab: Tab,
runAsync: boolean,
startDttm: number,
options: {
templateParams?: string;
ctasMethod?: string;
tempTable?: string;
requestedLimit?: number;
} = {},
) {
this.clientId = clientId;
this.tab = tab;
this.runAsync = runAsync;
this.startDttm = startDttm;
this.requestedLimit = options.requestedLimit ?? null;
this.ctas = options.tempTable
? new CTAS(options.ctasMethod === CTASMethod.View, options.tempTable)
: null;
this.templateParams = options.templateParams ?? '';
}
/**
* A custom accessor is used to process JSON parsing only
* when necessary for better performance.
*/
get templateParameters() {
if (this.parsedParams) {
return this.parsedParams;
}
let parsed = {};
try {
parsed = JSON.parse(this.templateParams);
} catch (e) {
// ignore invalid format string.
}
this.parsedParams = parsed;
return parsed;
}
}
export class QueryResultContext
extends QueryContext
implements sqlLabType.QueryResultContext
{
appliedLimit: number;
appliedLimitingFactor: string;
endDttm: number;
executedSql: string;
remoteId: number;
result: sqlLabType.QueryResult;
constructor(
clientId: string,
remoteId: number,
executedSql: string,
columns: sqlLabType.QueryResult['columns'],
data: sqlLabType.QueryResult['data'],
tab: Tab,
runAsync: boolean,
startDttm: number,
endDttm: number,
options: {
appliedLimit?: number;
appliedLimitingFactor?: string;
templateParams?: string;
ctasMethod?: string;
tempTable?: string;
requestedLimit?: number;
} = {},
) {
const { appliedLimit, appliedLimitingFactor, ...opt } = options;
super(clientId, tab, runAsync, startDttm, opt);
this.remoteId = remoteId;
this.executedSql = executedSql;
this.endDttm = endDttm;
this.result = {
columns,
data,
};
this.appliedLimit = appliedLimit ?? data.length;
this.appliedLimitingFactor = options.appliedLimitingFactor ?? '';
}
}
export class QueryErrorResultContext
extends QueryContext
implements sqlLabType.QueryErrorResultContext
{
endDttm: number;
errorMessage: string;
errors: coreType.SupersetError[] | null;
executedSql: string | null;
constructor(
clientId: string,
errorMessage: string,
errors: coreType.SupersetError[],
tab: Tab,
runAsync: boolean,
startDttm: number,
options: {
ctasMethod?: string;
executedSql?: string;
endDttm?: number;
templateParams?: string;
tempTable?: string;
requestedLimist?: number;
queryId?: number;
} = {},
) {
const { queryId, executedSql, endDttm, ...opt } = options;
super(clientId, tab, runAsync, startDttm, opt);
this.executedSql = executedSql ?? null;
this.errorMessage = errorMessage;
this.errors = errors;
this.endDttm = endDttm ?? Date.now();
}
}
const getActiveEditorImmutableId = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
const { queryEditors, tabHistory } = sqlLab;
const activeEditorId = tabHistory[tabHistory.length - 1];
const activeEditor = queryEditors.find(
editor => editor.id === activeEditorId,
);
return activeEditor?.immutableId;
};
const activeEditorId = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
const { tabHistory } = sqlLab;
return tabHistory[tabHistory.length - 1];
};
const getCurrentTab: typeof sqlLabType.getCurrentTab = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
const { queryEditors } = sqlLab;
const queryEditor = queryEditors.find(
editor => editor.id === activeEditorId(),
);
if (queryEditor) {
const { id, name } = queryEditor;
const editor = new Editor(
queryEditor.sql,
queryEditor.dbId!,
queryEditor.catalog,
queryEditor.schema,
null, // TODO: Populate table if needed
);
const panels: Panel[] = []; // TODO: Populate panels
return new Tab(id, name, editor, panels);
}
return undefined;
};
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
// Capture the immutable ID of the active editor at the time the listener is created
// This ID never changes for a tab, ensuring stable event routing
const registrationImmutableId = getActiveEditorImmutableId();
return action => {
if (action.type !== actionType) return false;
// If we don't have a registration ID, don't filter events
if (!registrationImmutableId) return true;
// For query events, use the immutableId directly from the action payload
if (action.query?.immutableId) {
return action.query.immutableId === registrationImmutableId;
}
// For tab events, we need to find the immutable ID of the affected tab
if (action.queryEditor?.id) {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } =
store.getState();
const { queryEditors } = sqlLab;
const queryEditor = queryEditors.find(
editor => editor.id === action.queryEditor.id,
);
return queryEditor?.immutableId === registrationImmutableId;
}
// Fallback: do not allow the event if we can't determine the source
return false;
};
};
export const onDidQueryRun: typeof sqlLabType.onDidQueryRun = (
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(START_QUERY),
listener,
(action: ReturnType<typeof startQuery>) => {
const { query } = action;
const {
id,
dbId,
catalog,
schema,
sql,
startDttm,
ctas_method: ctasMethod,
runAsync,
tempTable,
templateParams,
queryLimit,
} = query;
const editor = new Editor(sql, dbId, catalog, schema);
const panels: Panel[] = []; // TODO: Populate panels
const tab = new Tab(query.sqlEditorId, query.tab, editor, panels);
return new QueryContext(id, tab, runAsync, startDttm, {
ctasMethod,
tempTable,
templateParams,
requestedLimit: queryLimit,
});
},
thisArgs,
);
export const onDidQuerySuccess: typeof sqlLabType.onDidQuerySuccess = (
listener: (queryResultContext: sqlLabType.QueryResultContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_SUCCESS),
listener,
(action: ReturnType<typeof querySuccess>) => {
const { query, results } = action;
const {
id,
dbId,
catalog,
schema,
sql,
startDttm,
ctas_method: ctasMethod,
runAsync,
templateParams,
} = query;
const {
query_id: queryId,
columns,
data,
query: { endDttm, executedSql, tempTable, limit, limitingFactor },
} = results;
const editor = new Editor(sql, dbId, catalog, schema);
const panels: Panel[] = []; // TODO: Populate panels
const tab = new Tab(query.sqlEditorId, query.tab, editor, panels);
return new QueryResultContext(
id,
queryId,
executedSql ?? sql,
columns,
data,
tab,
runAsync,
startDttm,
endDttm,
{
ctasMethod,
tempTable,
templateParams,
appliedLimit: limit,
appliedLimitingFactor: limitingFactor,
},
);
},
thisArgs,
);
export const onDidQueryStop: typeof sqlLabType.onDidQueryStop = (
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(STOP_QUERY),
listener,
(action: ReturnType<typeof stopQuery>) => {
const { query } = action;
const {
id,
dbId,
catalog,
schema,
sql,
startDttm,
ctas_method: ctasMethod,
runAsync,
tempTable,
templateParams,
} = query;
const editor = new Editor(sql, dbId, catalog, schema);
const panels: Panel[] = []; // TODO: Populate panels
const tab = new Tab(query.sqlEditorId, query.tab, editor, panels);
return new QueryContext(id, tab, runAsync, startDttm, {
ctasMethod,
tempTable,
templateParams,
});
},
thisArgs,
);
export const onDidQueryFail: typeof sqlLabType.onDidQueryFail = (
listener: (
queryErrorResultContext: sqlLabType.QueryErrorResultContext,
) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_FAILED),
listener,
(action: ReturnType<typeof createQueryFailedAction>) => {
const { query, msg: errorMessage, errors } = action;
const {
id,
dbId,
catalog,
endDttm,
executedSql,
schema,
sql,
startDttm,
ctas_method: ctasMethod,
runAsync,
templateParams,
query_id: queryId,
tempTable,
} = query;
const editor = new Editor(sql, dbId, catalog, schema);
const panels: Panel[] = []; // TODO: Populate panels
const tab = new Tab(query.sqlEditorId, query.tab, editor, panels);
return new QueryErrorResultContext(
id,
errorMessage,
errors,
tab,
runAsync,
startDttm,
{
queryId,
executedSql,
endDttm,
ctasMethod,
tempTable,
templateParams,
},
);
},
thisArgs,
);
export const onDidChangeEditorDatabase: typeof sqlLabType.onDidChangeEditorDatabase =
(listener: (e: number) => void, thisArgs?: any): Disposable =>
createActionListener(
predicate(QUERY_EDITOR_SETDB),
listener,
(action: {
type: string;
dbId?: number;
queryEditor: { dbId: number };
}) => action.dbId || action.queryEditor.dbId,
thisArgs,
);
const onDidChangeEditorContent: typeof sqlLabType.onDidChangeEditorContent =
() => {
throw new Error('Not implemented yet');
};
const onDidChangeEditorCatalog: typeof sqlLabType.onDidChangeEditorCatalog =
() => {
throw new Error('Not implemented yet');
};
const onDidChangeEditorSchema: typeof sqlLabType.onDidChangeEditorSchema =
() => {
throw new Error('Not implemented yet');
};
const onDidChangeEditorTable: typeof sqlLabType.onDidChangeEditorTable = () => {
throw new Error('Not implemented yet');
};
const onDidClosePanel: typeof sqlLabType.onDidClosePanel = () => {
throw new Error('Not implemented yet');
};
const onDidChangeActivePanel: typeof sqlLabType.onDidChangeActivePanel = () => {
throw new Error('Not implemented yet');
};
const onDidChangeTabTitle: typeof sqlLabType.onDidChangeTabTitle = () => {
throw new Error('Not implemented yet');
};
const getDatabases: typeof sqlLabType.getDatabases = () => {
throw new Error('Not implemented yet');
};
const getTabs: typeof sqlLabType.getTabs = () => {
throw new Error('Not implemented yet');
};
const onDidCloseTab: typeof sqlLabType.onDidCloseTab = () => {
throw new Error('Not implemented yet');
};
const onDidChangeActiveTab: typeof sqlLabType.onDidChangeActiveTab = () => {
throw new Error('Not implemented yet');
};
const onDidRefreshDatabases: typeof sqlLabType.onDidRefreshDatabases = () => {
throw new Error('Not implemented yet');
};
const onDidRefreshCatalogs: typeof sqlLabType.onDidRefreshCatalogs = () => {
throw new Error('Not implemented yet');
};
const onDidRefreshSchemas: typeof sqlLabType.onDidRefreshSchemas = () => {
throw new Error('Not implemented yet');
};
const onDidRefreshTables: typeof sqlLabType.onDidRefreshTables = () => {
throw new Error('Not implemented yet');
};
export const sqlLab: typeof sqlLabType = {
CTASMethod,
getCurrentTab,
onDidChangeEditorContent,
onDidChangeEditorDatabase,
onDidChangeEditorCatalog,
onDidChangeEditorSchema,
onDidChangeEditorTable,
onDidClosePanel,
onDidChangeActivePanel,
onDidChangeTabTitle,
onDidQueryRun,
onDidQueryStop,
onDidQueryFail,
onDidQuerySuccess,
getDatabases,
getTabs,
onDidCloseTab,
onDidChangeActiveTab,
onDidRefreshDatabases,
onDidRefreshCatalogs,
onDidRefreshSchemas,
onDidRefreshTables,
};

View File

@@ -0,0 +1,343 @@
/**
* 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 { sqlLab as sqlLabType } from '@apache-superset/core';
import {
QUERY_FAILED,
QUERY_SUCCESS,
QUERY_EDITOR_SETDB,
querySuccess,
startQuery,
START_QUERY,
stopQuery,
STOP_QUERY,
createQueryFailedAction,
} from 'src/SqlLab/actions/sqlLab';
import { RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import memoizeOne from 'memoize-one';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { Disposable } from '../models';
import { createActionListener } from '../utils';
import {
Panel,
Editor,
Tab,
QueryContext,
QueryResultContext,
QueryErrorResultContext,
} from './models';
const { CTASMethod } = sqlLabType;
const getSqlLabState = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
return sqlLab;
};
const activeEditorId = () => {
const { tabHistory } = getSqlLabState();
return tabHistory[tabHistory.length - 1];
};
const findQueryEditor = (editorId: string) => {
const { queryEditors } = getSqlLabState();
return queryEditors.find(editor => editor.id === editorId);
};
const createTab = (
id: string,
name: string,
sql: string,
dbId: number,
catalog?: string,
schema?: string,
table?: any,
) => {
const editor = new Editor(sql, dbId, catalog, schema, table);
const panels: Panel[] = []; // TODO: Populate panels
return new Tab(id, name, editor, panels);
};
const notImplemented = (): never => {
throw new Error('Not implemented yet');
};
function extractBaseData(action: any): {
baseParams: [string, Tab, boolean, number];
options: {
ctasMethod?: string;
tempTable?: string;
templateParams?: string;
requestedLimit?: number;
};
} {
const { query } = action;
const {
id,
sql,
startDttm,
runAsync,
dbId,
catalog,
schema,
sqlEditorId,
tab: tabName,
ctas_method: ctasMethod,
tempTable,
templateParams,
queryLimit,
} = query;
const tab = createTab(sqlEditorId, tabName, sql, dbId, catalog, schema);
return {
baseParams: [id, tab, runAsync ?? false, startDttm ?? 0],
options: {
ctasMethod,
tempTable,
templateParams,
requestedLimit: queryLimit,
},
};
}
function createQueryContext(
action: ReturnType<typeof startQuery> | ReturnType<typeof stopQuery>,
): QueryContext {
const { baseParams, options } = extractBaseData(action);
return new QueryContext(...baseParams, options);
}
function createQueryResultContext(
action: ReturnType<typeof querySuccess>,
): QueryResultContext {
const { baseParams, options } = extractBaseData(action);
const [_, tab] = baseParams;
const { results } = action;
const {
query_id: queryId,
columns,
data,
query: {
endDttm,
executedSql,
tempTable: resultTempTable,
limit,
limitingFactor,
},
} = results;
return new QueryResultContext(
...baseParams,
queryId,
executedSql ?? tab.editor.content,
columns,
data,
endDttm,
{
...options,
tempTable: resultTempTable || options.tempTable,
appliedLimit: limit,
appliedLimitingFactor: limitingFactor,
},
);
}
function createQueryErrorContext(
action: ReturnType<typeof createQueryFailedAction>,
): QueryErrorResultContext {
const { baseParams, options } = extractBaseData(action);
const { msg: errorMessage, errors, query } = action;
const { endDttm, executedSql, query_id: queryId } = query;
return new QueryErrorResultContext(...baseParams, errorMessage, errors, {
...options,
queryId,
executedSql: executedSql ?? null,
endDttm: endDttm ?? Date.now(),
});
}
const getCurrentTab: typeof sqlLabType.getCurrentTab = () => {
const queryEditor = findQueryEditor(activeEditorId());
if (queryEditor) {
const { id, name, sql, dbId, catalog, schema } = queryEditor;
return createTab(
id,
name,
sql,
dbId!,
catalog ?? undefined,
schema ?? undefined,
undefined,
);
}
return undefined;
};
const getActiveEditorImmutableId = () => {
const { tabHistory } = getSqlLabState();
const activeEditorId = tabHistory[tabHistory.length - 1];
const activeEditor = findQueryEditor(activeEditorId);
return activeEditor?.immutableId;
};
// Memoized version to avoid repeated store lookups when active editor hasn't changed
const getActiveEditorId = memoizeOne(getActiveEditorImmutableId);
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
// Capture the immutable ID of the active editor at the time the listener is created
// This ID never changes for a tab, ensuring stable event routing
const registrationImmutableId = getActiveEditorId();
return action => {
if (action.type !== actionType) return false;
// If we don't have a registration ID, don't filter events
if (!registrationImmutableId) return true;
// For query events, use the immutableId directly from the action payload
if (action.query?.immutableId) {
return action.query.immutableId === registrationImmutableId;
}
// For tab events, we need to find the immutable ID of the affected tab
if (action.queryEditor?.id) {
const queryEditor = findQueryEditor(action.queryEditor.id);
return queryEditor?.immutableId === registrationImmutableId;
}
// Fallback: do not allow the event if we can't determine the source
return false;
};
};
const onDidQueryRun: typeof sqlLabType.onDidQueryRun = (
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(START_QUERY),
listener,
(action: ReturnType<typeof startQuery>) => createQueryContext(action),
thisArgs,
);
const onDidQuerySuccess: typeof sqlLabType.onDidQuerySuccess = (
listener: (queryResultContext: sqlLabType.QueryResultContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_SUCCESS),
listener,
(action: ReturnType<typeof querySuccess>) =>
createQueryResultContext(action),
thisArgs,
);
const onDidQueryStop: typeof sqlLabType.onDidQueryStop = (
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(STOP_QUERY),
listener,
(action: ReturnType<typeof stopQuery>) => createQueryContext(action),
thisArgs,
);
const onDidQueryFail: typeof sqlLabType.onDidQueryFail = (
listener: (
queryErrorResultContext: sqlLabType.QueryErrorResultContext,
) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_FAILED),
listener,
(action: ReturnType<typeof createQueryFailedAction>) =>
createQueryErrorContext(action),
thisArgs,
);
const onDidChangeEditorDatabase: typeof sqlLabType.onDidChangeEditorDatabase = (
listener: (e: number) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_EDITOR_SETDB),
listener,
(action: { type: string; dbId?: number; queryEditor: { dbId: number } }) =>
action.dbId || action.queryEditor.dbId,
thisArgs,
);
const onDidChangeEditorContent: typeof sqlLabType.onDidChangeEditorContent =
notImplemented;
const onDidChangeEditorCatalog: typeof sqlLabType.onDidChangeEditorCatalog =
notImplemented;
const onDidChangeEditorSchema: typeof sqlLabType.onDidChangeEditorSchema =
notImplemented;
const onDidChangeEditorTable: typeof sqlLabType.onDidChangeEditorTable =
notImplemented;
const onDidClosePanel: typeof sqlLabType.onDidClosePanel = notImplemented;
const onDidChangeActivePanel: typeof sqlLabType.onDidChangeActivePanel =
notImplemented;
const onDidChangeTabTitle: typeof sqlLabType.onDidChangeTabTitle =
notImplemented;
const getDatabases: typeof sqlLabType.getDatabases = notImplemented;
const getTabs: typeof sqlLabType.getTabs = notImplemented;
const onDidCloseTab: typeof sqlLabType.onDidCloseTab = notImplemented;
const onDidChangeActiveTab: typeof sqlLabType.onDidChangeActiveTab =
notImplemented;
const onDidRefreshDatabases: typeof sqlLabType.onDidRefreshDatabases =
notImplemented;
const onDidRefreshCatalogs: typeof sqlLabType.onDidRefreshCatalogs =
notImplemented;
const onDidRefreshSchemas: typeof sqlLabType.onDidRefreshSchemas =
notImplemented;
const onDidRefreshTables: typeof sqlLabType.onDidRefreshTables = notImplemented;
export const sqlLab: typeof sqlLabType = {
CTASMethod,
getCurrentTab,
onDidChangeEditorContent,
onDidChangeEditorDatabase,
onDidChangeEditorCatalog,
onDidChangeEditorSchema,
onDidChangeEditorTable,
onDidClosePanel,
onDidChangeActivePanel,
onDidChangeTabTitle,
onDidQueryRun,
onDidQueryStop,
onDidQueryFail,
onDidQuerySuccess,
getDatabases,
getTabs,
onDidCloseTab,
onDidChangeActiveTab,
onDidRefreshDatabases,
onDidRefreshCatalogs,
onDidRefreshSchemas,
onDidRefreshTables,
};
// Export all models
export * from './models';

View File

@@ -0,0 +1,235 @@
/**
* 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 { sqlLab as sqlLabType, core as coreType } from '@apache-superset/core';
const { CTASMethod } = sqlLabType;
export class Panel implements sqlLabType.Panel {
id: string;
constructor(id: string) {
this.id = id;
}
}
export class Editor implements sqlLabType.Editor {
content: string;
databaseId: number;
schema: string;
// TODO: Check later if we'll use objects instead of strings.
catalog: string | null;
table: string | null;
constructor(
content: string,
databaseId: number,
catalog: string | null = null,
schema = '',
table: string | null = null,
) {
this.content = content;
this.databaseId = databaseId;
this.catalog = catalog;
this.schema = schema;
this.table = table;
}
}
export class Tab implements sqlLabType.Tab {
id: string;
title: string;
editor: Editor;
panels: Panel[];
constructor(id: string, title: string, editor: Editor, panels: Panel[] = []) {
this.id = id;
this.title = title;
this.editor = editor;
this.panels = panels;
}
}
export class CTAS implements sqlLabType.CTAS {
method: sqlLabType.CTASMethod;
tempTable: string;
constructor(asView: boolean, tempTable: string) {
this.method = asView ? CTASMethod.View : CTASMethod.Table;
this.tempTable = tempTable;
}
}
export class QueryContext implements sqlLabType.QueryContext {
clientId: string;
ctas: sqlLabType.CTAS | null;
editor: Editor;
requestedLimit: number | null;
runAsync: boolean;
startDttm: number;
tab: Tab;
private templateParams: string;
private parsedParams: Record<string, any>;
constructor(
clientId: string,
tab: Tab,
runAsync: boolean,
startDttm: number,
options: {
templateParams?: string;
ctasMethod?: string;
tempTable?: string;
requestedLimit?: number;
} = {},
) {
this.clientId = clientId;
this.tab = tab;
this.editor = tab.editor;
this.runAsync = runAsync;
this.startDttm = startDttm;
this.requestedLimit = options.requestedLimit ?? null;
this.ctas = options.tempTable
? new CTAS(options.ctasMethod === CTASMethod.View, options.tempTable)
: null;
this.templateParams = options.templateParams ?? '';
}
/**
* A custom accessor is used to process JSON parsing only
* when necessary for better performance.
*/
get templateParameters() {
if (this.parsedParams) {
return this.parsedParams;
}
let parsed = {};
try {
parsed = JSON.parse(this.templateParams);
} catch (e) {
// ignore invalid format string.
}
this.parsedParams = parsed;
return parsed;
}
}
export class QueryResultContext
extends QueryContext
implements sqlLabType.QueryResultContext
{
appliedLimit: number;
appliedLimitingFactor: string;
endDttm: number;
executedSql: string;
remoteId: number;
result: sqlLabType.QueryResult;
constructor(
clientId: string,
tab: Tab,
runAsync: boolean,
startDttm: number,
remoteId: number,
executedSql: string,
columns: sqlLabType.QueryResult['columns'],
data: sqlLabType.QueryResult['data'],
endDttm: number,
options: {
appliedLimit?: number;
appliedLimitingFactor?: string;
templateParams?: string;
ctasMethod?: string;
tempTable?: string;
requestedLimit?: number;
} = {},
) {
const { appliedLimit, appliedLimitingFactor, ...opt } = options;
super(clientId, tab, runAsync, startDttm, opt);
this.remoteId = remoteId;
this.executedSql = executedSql;
this.endDttm = endDttm;
this.result = {
columns,
data,
};
this.appliedLimit = appliedLimit ?? data.length;
this.appliedLimitingFactor = options.appliedLimitingFactor ?? '';
}
}
export class QueryErrorResultContext
extends QueryContext
implements sqlLabType.QueryErrorResultContext
{
endDttm: number;
errorMessage: string;
errors: coreType.SupersetError[] | null;
executedSql: string | null;
constructor(
clientId: string,
tab: Tab,
runAsync: boolean,
startDttm: number,
errorMessage: string,
errors: coreType.SupersetError[],
options: {
ctasMethod?: string;
executedSql?: string;
endDttm?: number;
templateParams?: string;
tempTable?: string;
requestedLimit?: number;
queryId?: number;
} = {},
) {
const { queryId, executedSql, endDttm, ...opt } = options;
super(clientId, tab, runAsync, startDttm, opt);
this.executedSql = executedSql ?? null;
this.errorMessage = errorMessage;
this.errors = errors;
this.endDttm = endDttm ?? Date.now();
}
}

View File

@@ -96,6 +96,7 @@ export type ControlPanelsContainerProps = {
form_data: QueryFormData;
isDatasourceMetaLoading: boolean;
errorMessage: ReactNode;
buttonErrorMessage?: ReactNode; // Error message for RunQueryButton (includes all errors)
onQuery: () => void;
onStop: () => void;
canStopQuery: boolean;
@@ -149,9 +150,35 @@ const Styles = styled.div`
}
.tab-content {
overflow: auto;
overflow: visible;
flex: 1 1 100%;
}
// Ensure Ant Design tabs allow content to expand
.ant-tabs-content {
overflow: visible;
height: auto;
}
.ant-tabs-content-holder {
overflow: visible;
height: auto;
}
.ant-tabs-tabpane {
overflow: visible;
height: auto;
}
// Ensure collapse components can expand
.ant-collapse-content {
overflow: visible;
}
.ant-collapse-content-box {
overflow: visible;
}
.Select__menu {
max-width: 100%;
}
@@ -740,37 +767,89 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
props.errorMessage,
]);
const showCustomizeTab = customizeSections.length > 0;
const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify);
// Check if matrixify sections have validation errors
const matrixifyHasErrors = useMemo(() => {
if (!showMatrixifyTab) return false;
return matrixifySections.some(section =>
section.controlSetRows.some(rows =>
rows.some(item => {
const controlName =
typeof item === 'string'
? item
: item && 'name' in item
? item.name
: null;
return (
controlName &&
controlName in props.controls &&
props.controls[controlName].validationErrors &&
props.controls[controlName].validationErrors.length > 0
);
}),
),
);
}, [showMatrixifyTab, matrixifySections, props.controls]);
// Create Matrixify tab label with Beta tag and validation errors
const matrixifyTabLabel = useMemo(
() => (
<>
<span>{t('Matrixify')}</span>
{matrixifyHasErrors && (
<span
css={(theme: SupersetTheme) => css`
margin-left: ${theme.sizeUnit * 2}px;
`}
>
{' '}
<Tooltip
id="matrixify-validation-error-tooltip"
placement="right"
title={t('This section contains validation errors')}
>
<Icons.InfoCircleOutlined
data-test="matrixify-validation-error-tooltip-trigger"
iconColor={theme.colorErrorText}
iconSize="s"
/>
</Tooltip>
</span>
)}{' '}
<Tooltip
title={t(
'This feature is experimental and may change or have limitations',
)}
placement="top"
>
<Label
type="info"
css={css`
margin-left: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
`}
>
{t('beta')}
</Label>
</Tooltip>
</>
),
[
matrixifyHasErrors,
theme.colorErrorText,
theme.sizeUnit,
theme.fontSizeSM,
],
);
const controlPanelRegistry = getChartControlPanelRegistry();
if (!controlPanelRegistry.has(form_data.viz_type) && pluginContext.loading) {
return <Loading />;
}
const showCustomizeTab = customizeSections.length > 0;
const showMatrixifyTab = isFeatureEnabled(FeatureFlag.Matrixify);
// Create Matrixify tab label with Beta tag
const matrixifyTabLabel = (
<>
{t('Matrixify')}{' '}
<Tooltip
title={t(
'This feature is experimental and may change or have limitations',
)}
placement="top"
>
<Label
type="info"
css={css`
margin-left: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
`}
>
{t('beta')}
</Label>
</Tooltip>
</>
);
return (
<Styles ref={containerRef}>
<Tabs
@@ -868,7 +947,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
<RunQueryButton
onQuery={props.onQuery}
onStop={props.onStop}
errorMessage={props.errorMessage}
errorMessage={props.buttonErrorMessage || props.errorMessage}
loading={props.chart.chartStatus === 'loading'}
isNewChart={!props.chart.queriesResponse}
canStopQuery={props.canStopQuery}

View File

@@ -395,7 +395,10 @@ const ExploreChartPanel = ({
queriesResponse: chart.queriesResponse,
})}
{...(chart.chartStatus && { chartStatus: chart.chartStatus })}
hideRowCount={formData?.matrixify_enabled === true}
hideRowCount={
formData?.matrixify_enable_vertical_layout === true ||
formData?.matrixify_enable_horizontal_layout === true
}
/>
</ChartHeaderExtension>
{renderChart()}
@@ -412,7 +415,8 @@ const ExploreChartPanel = ({
chart.chartUpdateEndTime,
refreshCachedQuery,
formData?.row_limit,
formData?.matrixify_enabled,
formData?.matrixify_enable_vertical_layout,
formData?.matrixify_enable_horizontal_layout,
renderChart,
],
);

View File

@@ -247,6 +247,28 @@ function setSidebarWidths(key, dimension) {
setItem(key, newDimension);
}
// Chart types that use aggregation and can have multiple values in tooltips
const AGGREGATED_CHART_TYPES = [
// Deck.gl aggregated charts
'deck_screengrid',
'deck_heatmap',
'deck_contour',
'deck_hex',
'deck_grid',
// Other aggregated chart types can be added here
'heatmap',
'treemap',
'sunburst',
'pie',
'donut',
'histogram',
'table',
];
function isAggregatedChartType(vizType) {
return AGGREGATED_CHART_TYPES.includes(vizType);
}
function ExploreViewContainer(props) {
const dynamicPluginContext = usePluginContext();
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
@@ -349,6 +371,15 @@ function ExploreViewContainer(props) {
props.form_data,
]);
// Simple debounced auto-query for non-renderTrigger controls
const debouncedAutoQuery = useMemo(
() =>
debounce(() => {
onQuery();
}, 1000), // 1 second delay
[onQuery],
);
const handleKeydown = useCallback(
event => {
const controlOrCommand = event.ctrlKey || event.metaKey;
@@ -488,6 +519,53 @@ function ExploreViewContainer(props) {
),
);
if (changedControlKeys.includes('tooltip_contents')) {
const tooltipContents = props.controls.tooltip_contents?.value || [];
const currentTemplate = props.controls.tooltip_template?.value || '';
if (tooltipContents.length > 0) {
const getFieldName = item => {
if (typeof item === 'string') return item;
if (item?.item_type === 'column') return item.column_name;
if (item?.item_type === 'metric') {
return item.metric_name || item.label;
}
return null;
};
const vizType = props.form_data?.viz_type || '';
const isAggregatedChart = isAggregatedChartType(vizType);
const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips
const fieldNames = tooltipContents.map(getFieldName).filter(Boolean);
const missingVariables = fieldNames.filter(
fieldName =>
!currentTemplate.includes(`{{ ${fieldName} }}`) &&
!currentTemplate.includes(`{{ limit ${fieldName}`),
);
if (missingVariables.length > 0) {
const newVariables = missingVariables.map(fieldName => {
const item = tooltipContents[fieldNames.indexOf(fieldName)];
const isColumn =
item?.item_type === 'column' || typeof item === 'string';
if (isAggregatedChart && isColumn) {
return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`;
}
return `{{ ${fieldName} }}`;
});
const updatedTemplate =
currentTemplate +
(currentTemplate ? ' ' : '') +
newVariables.join(' ');
props.actions.setControlValue('tooltip_template', updatedTemplate);
}
}
}
// this should also be handled by the actions that are actually changing the controls
const displayControlsChanged = changedControlKeys.filter(
key => props.controls[key].renderTrigger,
@@ -495,8 +573,25 @@ function ExploreViewContainer(props) {
if (displayControlsChanged.length > 0) {
reRenderChart(displayControlsChanged);
}
// Auto-update for non-renderTrigger controls
const queryControlsChanged = changedControlKeys.filter(
key =>
!props.controls[key].renderTrigger &&
!props.controls[key].dontRefreshOnChange,
);
if (queryControlsChanged.length > 0) {
// Check if there are no validation errors before auto-updating
const hasErrors = Object.values(props.controls).some(
control =>
control.validationErrors && control.validationErrors.length > 0,
);
if (!hasErrors) {
debouncedAutoQuery();
}
}
}
}, [props.controls, props.ownState]);
}, [props.controls, props.ownState, debouncedAutoQuery]);
const chartIsStale = useMemo(() => {
if (lastQueriedControls) {
@@ -545,6 +640,7 @@ function ExploreViewContainer(props) {
}
const errorMessage = useMemo(() => {
// Include all controls with validation errors (for button disabling)
const controlsWithErrors = Object.values(props.controls).filter(
control =>
control.validationErrors && control.validationErrors.length > 0,
@@ -584,11 +680,62 @@ function ExploreViewContainer(props) {
return errorMessage;
}, [props.controls]);
// Error message for Data tab only (excludes matrixify controls)
const dataTabErrorMessage = useMemo(() => {
const controlsWithErrors = Object.values(props.controls).filter(
control =>
control.validationErrors &&
control.validationErrors.length > 0 &&
control.tabOverride !== 'matrixify', // Exclude matrixify controls from Data tab
);
if (controlsWithErrors.length === 0) {
return null;
}
const errorMessages = controlsWithErrors.map(
control => control.validationErrors,
);
const uniqueErrorMessages = [...new Set(errorMessages.flat())];
const errors = uniqueErrorMessages
.map(message => {
const matchingLabels = controlsWithErrors
.filter(control => control.validationErrors?.includes(message))
.map(control =>
typeof control.label === 'function'
? control.label(props.exploreState)
: control.label,
);
return [matchingLabels, message];
})
.map(([labels, message]) => (
<div key={message}>
{labels.length > 1 ? t('Controls labeled ') : t('Control labeled ')}
<strong>{` ${labels.join(', ')}`}</strong>
<span>: {message}</span>
</div>
));
let dataTabErrorMessage;
if (errors.length > 0) {
dataTabErrorMessage = (
<div
css={css`
text-align: 'left';
`}
>
{errors}
</div>
);
}
return dataTabErrorMessage;
}, [props.controls]);
function renderChartContainer() {
return (
<ExploreChartPanel
{...props}
errorMessage={errorMessage}
errorMessage={dataTabErrorMessage}
chartIsStale={chartIsStale}
onQuery={onQuery}
/>
@@ -734,7 +881,8 @@ function ExploreViewContainer(props) {
onQuery={onQuery}
onStop={onStop}
canStopQuery={props.can_add || props.can_overwrite}
errorMessage={errorMessage}
errorMessage={dataTabErrorMessage}
buttonErrorMessage={errorMessage}
chartIsStale={chartIsStale}
/>
</Resizable>
@@ -790,7 +938,7 @@ function mapStateToProps(state) {
saveModal,
} = state;
const { controls, slice, datasource, metadata, hiddenFormData } = explore;
const hasQueryMode = !!controls.query_mode?.value;
const hasQueryMode = !!controls?.query_mode?.value;
const fieldsToOmit = hasQueryMode
? retainQueryModeRequirements(hiddenFormData)
: Object.keys(hiddenFormData ?? {});
@@ -835,6 +983,7 @@ function mapStateToProps(state) {
}
if (
controls &&
form_data.viz_type === 'big_number_total' &&
slice?.form_data?.subheader &&
(!controls.subtitle?.value || controls.subtitle.value === '')

View File

@@ -31,6 +31,7 @@ export type RunQueryButtonProps = {
canStopQuery: boolean;
chartIsStale: boolean;
};
export const RunQueryButton = ({
loading,
onQuery,

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