Compare commits

...

8 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
60 changed files with 4709 additions and 329 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

@@ -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",

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

@@ -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

@@ -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

@@ -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

@@ -335,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

@@ -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

@@ -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

@@ -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;
@@ -766,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
@@ -894,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

@@ -640,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,
@@ -679,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}
/>
@@ -829,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>

View File

@@ -274,30 +274,6 @@ test('should fetch TopN values when all params are provided', async () => {
});
});
test('should show loading state while fetching TopN values', () => {
const mockFetchTopNValues = fetchTopNValues as jest.MockedFunction<
typeof fetchTopNValues
>;
const value: MatrixifyDimensionControlValue = {
dimension: 'country',
values: [],
};
mockFetchTopNValues.mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<MatrixifyDimensionControl
{...defaultProps}
value={value}
selectionMode="topn"
topNMetric="revenue"
topNValue={5}
/>,
);
expect(screen.getByText('Loading top values...')).toBeInTheDocument();
});
test('should display error when TopN fetch fails', async () => {
const mockFetchTopNValues = fetchTopNValues as jest.MockedFunction<
typeof fetchTopNValues

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useMemo } from 'react';
import { t, SupersetClient, getColumnLabel } from '@superset-ui/core';
import { Select, Space } from '@superset-ui/core/components';
import ControlHeader from 'src/explore/components/ControlHeader';
@@ -40,11 +40,13 @@ interface MatrixifyDimensionControlProps {
label?: string;
description?: string;
hovered?: boolean;
renderTrigger?: boolean;
selectionMode?: 'members' | 'topn';
topNMetric?: string;
topNValue?: number;
topNOrder?: 'ASC' | 'DESC';
formData?: any; // For access to filters and time range
validationErrors?: string[];
}
export default function MatrixifyDimensionControl(
@@ -56,12 +58,13 @@ export default function MatrixifyDimensionControl(
onChange,
label,
description,
hovered,
renderTrigger,
selectionMode = 'members',
topNMetric,
topNValue,
topNOrder = 'DESC',
formData,
validationErrors,
} = props;
const [dimensionOptions, setDimensionOptions] = useState<
@@ -71,8 +74,24 @@ export default function MatrixifyDimensionControl(
Array<{ label: string; value: any }>
>([]);
const [loadingValues, setLoadingValues] = useState(false);
const [loadingTopN, setLoadingTopN] = useState(false);
const [topNError, setTopNError] = useState<string | null>(null);
const [dimensionHovered, setDimensionHovered] = useState(false);
const [valuesHovered, setValuesHovered] = useState(false);
const prevSelectionMode = useRef(selectionMode);
// Reset values when selection mode changes
useEffect(() => {
if (prevSelectionMode.current !== selectionMode) {
prevSelectionMode.current = selectionMode;
if (value?.values && value.values.length > 0) {
onChange({
dimension: value.dimension,
values: [],
topNValues: [],
});
}
}
}, [selectionMode, value]);
// Initialize dimension options from datasource
useEffect(() => {
@@ -140,19 +159,22 @@ export default function MatrixifyDimensionControl(
}, [value?.dimension, datasource, selectionMode]);
// Convert topNValue to number for consistent comparison
const topNValueNum =
typeof topNValue === 'string' ? parseInt(topNValue, 10) : topNValue;
const topNValueNum = useMemo(() => {
if (topNValue === null || topNValue === undefined) {
return null;
}
if (typeof topNValue === 'string') {
if (topNValue === '') return null;
const num = parseInt(topNValue, 10);
return Number.isNaN(num) ? null : num;
}
return typeof topNValue === 'number' ? topNValue : null;
}, [topNValue]);
// Load TopN values when in TopN mode
useEffect(() => {
if (
!value?.dimension ||
!datasource ||
selectionMode !== 'topn' ||
!topNMetric ||
!topNValueNum
) {
// Clear the values when not in topn mode
if (!value?.dimension || !datasource || selectionMode !== 'topn') {
// Clear values when switching away from topn mode
if (
selectionMode !== 'topn' &&
value?.values &&
@@ -167,11 +189,15 @@ export default function MatrixifyDimensionControl(
return undefined;
}
// If we don't have the required topN parameters, just return without loading
if (!topNMetric || !topNValueNum || topNValueNum <= 0) {
return undefined;
}
const controller = new AbortController();
const { signal } = controller;
const loadTopNValues = async () => {
setLoadingTopN(true);
setTopNError(null);
try {
@@ -205,10 +231,6 @@ export default function MatrixifyDimensionControl(
topNValues: [],
});
}
} finally {
if (!signal.aborted) {
setLoadingTopN(false);
}
}
};
@@ -222,11 +244,10 @@ export default function MatrixifyDimensionControl(
datasource,
selectionMode,
topNMetric,
topNValueNum, // Use the converted number
topNValueNum, // Use the converted/validated number
topNOrder,
formData?.adhoc_filters,
formData?.time_range,
onChange, // Add onChange to deps
]);
const handleDimensionChange = (dimension: string) => {
@@ -246,49 +267,60 @@ export default function MatrixifyDimensionControl(
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Select
ariaLabel={t('Select dimension')}
value={value?.dimension || undefined}
header={
<ControlHeader
label={label || t('Dimension')}
description={description || t('Select a dimension')}
hovered={hovered}
/>
}
onChange={handleDimensionChange}
options={dimensionOptions.map(([val, label]) => ({
value: val,
label,
}))}
placeholder={t('Select a dimension')}
allowClear
/>
{value?.dimension && selectionMode === 'members' && (
<div
onMouseEnter={() => setDimensionHovered(true)}
onMouseLeave={() => setDimensionHovered(false)}
>
<Select
ariaLabel={t('Select dimension values')}
value={value?.values || []}
ariaLabel={t('Select dimension')}
value={value?.dimension || undefined}
header={
<ControlHeader
label={t('Dimension values')}
description={t('Select dimension values')}
label={label || t('Dimension')}
description={description || t('Select a dimension')}
hovered={dimensionHovered}
renderTrigger={renderTrigger}
validationErrors={validationErrors}
/>
}
mode="multiple"
onChange={handleValuesChange}
options={valueOptions}
placeholder={t('Select values')}
loading={loadingValues}
onChange={handleDimensionChange}
options={dimensionOptions.map(([val, label]) => ({
value: val,
label,
}))}
placeholder={t('Select a dimension')}
allowClear
showSearch
notFoundContent={t('No results')}
/>
</div>
{value?.dimension && selectionMode === 'members' && (
<div
onMouseEnter={() => setValuesHovered(true)}
onMouseLeave={() => setValuesHovered(false)}
>
<Select
ariaLabel={t('Select dimension values')}
value={value?.values || []}
header={
<ControlHeader
label={t('Dimension values')}
description={t('Select dimension values')}
renderTrigger={renderTrigger}
hovered={valuesHovered}
/>
}
mode="multiple"
onChange={handleValuesChange}
options={valueOptions}
placeholder={t('Select values')}
loading={loadingValues}
allowClear
showSearch
notFoundContent={t('No results')}
/>
</div>
)}
{value?.dimension && selectionMode === 'topn' && loadingTopN && (
<div>{t('Loading top values...')}</div>
)}
{value?.dimension && selectionMode === 'topn' && topNError && (
<div css={theme => ({ color: theme.colorError })}>
{t('Error: %s', topNError)}

View File

@@ -35,6 +35,7 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
value?: T | null;
controlId?: string;
renderTrigger?: boolean;
validationErrors?: string[];
}
export interface TextControlState {

View File

@@ -288,26 +288,30 @@ function buildMatrixifySection(
[`matrixify_topn_order_${axis}`],
];
// Add enable checkbox at the beginning of each section
const enableControl =
axis === 'rows'
? 'matrixify_enable_vertical_layout'
: 'matrixify_enable_horizontal_layout';
baseControls.unshift([enableControl]);
// Add specific controls for each axis
if (axis === 'rows') {
// Add show row labels at the beginning
baseControls.unshift(['matrixify_show_row_labels']);
// Add row height control after show labels
baseControls.splice(1, 0, ['matrixify_row_height']);
// Add show row labels after enable
baseControls.splice(1, 0, ['matrixify_show_row_labels']);
} else if (axis === 'columns') {
// Add show column headers at the beginning
baseControls.unshift(['matrixify_show_column_headers']);
// Add fit columns control after show headers
baseControls.splice(1, 0, ['matrixify_fit_columns_dynamically']);
// Add charts per row after fit columns control
baseControls.splice(2, 0, ['matrixify_charts_per_row']);
// Add show column headers after enable
baseControls.splice(1, 0, ['matrixify_show_column_headers']);
}
return {
label: axis === 'columns' ? t('Horizontal layout') : t('Vertical layout'),
label:
axis === 'columns'
? t('Horizontal layout (columns)')
: t('Vertical layout (rows)'),
expanded: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
controlSetRows: baseControls,
};
}
@@ -315,17 +319,16 @@ function buildMatrixifySection(
export const matrixifyRows = buildMatrixifySection('rows');
export const matrixifyColumns = buildMatrixifySection('columns');
export const matrixifyEnableSection: ControlPanelSectionConfig = {
label: t('Enable Matrixify'),
expanded: true,
tabOverride: 'matrixify',
controlSetRows: [['matrixify_enabled']],
};
export const matrixifyCells: ControlPanelSectionConfig = {
label: t('Cells'),
label: t('Cell layout & styling'),
expanded: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
controlSetRows: [['matrixify_cell_title_template']],
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
controlSetRows: [
['matrixify_row_height', 'matrixify_fit_columns_dynamically'],
['matrixify_charts_per_row'],
['matrixify_cell_title_template'],
],
};

View File

@@ -75,9 +75,9 @@ const getMemoizedSectionsToRender = memoizeOne(
return [
datasourceAndVizType as ControlPanelSectionConfig,
matrixifyEnableSection as ControlPanelSectionConfig,
matrixifyCells as ControlPanelSectionConfig,
matrixifyColumns as ControlPanelSectionConfig,
matrixifyRows as ControlPanelSectionConfig,
matrixifyCells as ControlPanelSectionConfig,
]
.filter(Boolean) // Filter out null/undefined sections
.concat(controlPanelSections.filter(isControlPanelSectionConfig))

View File

@@ -16,22 +16,141 @@
# under the License.
from __future__ import annotations
from typing import Any, Generic, get_args, TypeVar
import logging
import uuid as uuid_lib
from enum import Enum
from typing import (
Any,
Dict,
Generic,
get_args,
List,
Optional,
Sequence,
Tuple,
TypeVar,
)
import sqlalchemy as sa
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_sqlalchemy import BaseQuery
from pydantic import BaseModel, Field
from sqlalchemy import asc, cast, desc, or_, Text
from sqlalchemy.exc import SQLAlchemyError, StatementError
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import ColumnProperty, joinedload, RelationshipProperty
from superset.daos.exceptions import (
DAOFindFailedError,
)
from superset.extensions import db
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=Model)
class ColumnOperatorEnum(str, Enum):
eq = "eq"
ne = "ne"
sw = "sw"
ew = "ew"
in_ = "in"
nin = "nin"
gt = "gt"
gte = "gte"
lt = "lt"
lte = "lte"
like = "like"
ilike = "ilike"
is_null = "is_null"
is_not_null = "is_not_null"
def apply(self, column: Any, value: Any) -> Any:
op_func = operator_map.get(self)
if not op_func:
raise ValueError("Unsupported operator: %s" % self)
return op_func(column, value)
# Define operator_map as a module-level dict after the enum is defined
operator_map: Dict[ColumnOperatorEnum, Any] = {
ColumnOperatorEnum.eq: lambda col, val: col == val,
ColumnOperatorEnum.ne: lambda col, val: col != val,
ColumnOperatorEnum.sw: lambda col, val: col.like(f"{val}%"),
ColumnOperatorEnum.ew: lambda col, val: col.like(f"%{val}"),
ColumnOperatorEnum.in_: lambda col, val: col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
ColumnOperatorEnum.nin: lambda col, val: ~col.in_(
val if isinstance(val, (list, tuple)) else [val]
),
ColumnOperatorEnum.gt: lambda col, val: col > val,
ColumnOperatorEnum.gte: lambda col, val: col >= val,
ColumnOperatorEnum.lt: lambda col, val: col < val,
ColumnOperatorEnum.lte: lambda col, val: col <= val,
ColumnOperatorEnum.like: lambda col, val: col.like(f"%{val}%"),
ColumnOperatorEnum.ilike: lambda col, val: col.ilike(f"%{val}%"),
ColumnOperatorEnum.is_null: lambda col, _: col.is_(None),
ColumnOperatorEnum.is_not_null: lambda col, _: col.isnot(None),
}
# Map SQLAlchemy types to supported operators
TYPE_OPERATOR_MAP = {
"string": [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.sw,
ColumnOperatorEnum.ew,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.like,
ColumnOperatorEnum.ilike,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
],
"boolean": [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
],
"number": [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.gt,
ColumnOperatorEnum.gte,
ColumnOperatorEnum.lt,
ColumnOperatorEnum.lte,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
],
"datetime": [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.gt,
ColumnOperatorEnum.gte,
ColumnOperatorEnum.lt,
ColumnOperatorEnum.lte,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
],
}
class ColumnOperator(BaseModel):
col: str = Field(..., description="Column name to filter on")
opr: ColumnOperatorEnum = Field(..., description="Operator")
value: Any = Field(None, description="Value for the filter")
class BaseDAO(Generic[T]):
"""
Base DAO, implement base CRUD sqlalchemy operations
@@ -83,80 +202,170 @@ class BaseDAO(Generic[T]):
return None
@classmethod
def find_by_id(
cls,
model_id: str | int,
skip_base_filter: bool = False,
) -> T | None:
def _apply_base_filter(
cls, query: Any, skip_base_filter: bool = False, data_model: Any = None
) -> Any:
"""
Find a model by id, if defined applies `base_filter`
Apply the base_filter to the query if it exists and skip_base_filter is False.
"""
query = db.session.query(cls.model_cls)
if cls.base_filter and not skip_base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
if data_model is None:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
id_column = getattr(cls.model_cls, cls.id_column_name)
return query
@classmethod
def _convert_value_for_column(cls, column: Any, value: Any) -> Any:
"""
Convert a value to the appropriate type for a given SQLAlchemy column.
Args:
column: SQLAlchemy column object
value: Value to convert
Returns:
Converted value or None if conversion fails
"""
if (
hasattr(column.type, "python_type")
and column.type.python_type == uuid_lib.UUID
):
if isinstance(value, str):
try:
return uuid_lib.UUID(value)
except (ValueError, AttributeError):
return None
return value
@classmethod
def _find_by_column(
cls,
column_name: str,
value: str | int,
skip_base_filter: bool = False,
) -> T | None:
"""
Private method to find a model by any column value.
Args:
column_name: Name of the column to search by
value: Value to search for
skip_base_filter: Whether to skip base filtering
Returns:
Model instance or None if not found
"""
query = db.session.query(cls.model_cls)
query = cls._apply_base_filter(query, skip_base_filter)
if not hasattr(cls.model_cls, column_name):
return None
column = getattr(cls.model_cls, column_name)
converted_value = cls._convert_value_for_column(column, value)
if converted_value is None:
return None
try:
return query.filter(id_column == model_id).one_or_none()
return query.filter(column == converted_value).one_or_none()
except StatementError:
# can happen if int is passed instead of a string or similar
return None
@classmethod
def find_by_id(
cls,
model_id: str | int,
skip_base_filter: bool = False,
id_column: str | None = None,
) -> T | None:
"""
Find a model by ID using specified or default ID column.
Args:
model_id: ID value to search for
skip_base_filter: Whether to skip base filtering
id_column: Column name to use (defaults to cls.id_column_name)
Returns:
Model instance or None if not found
"""
column = id_column or cls.id_column_name
return cls._find_by_column(column, model_id, skip_base_filter)
@classmethod
def find_by_ids(
cls,
model_ids: list[str] | list[int],
model_ids: Sequence[str | int],
skip_base_filter: bool = False,
id_column: str | None = None,
) -> list[T]:
"""
Find a List of models by a list of ids, if defined applies `base_filter`
:param model_ids: List of IDs to find
:param skip_base_filter: If true, skip applying the base filter
:param id_column: Optional column name to use for ID lookup
(defaults to id_column_name)
"""
id_col = getattr(cls.model_cls, cls.id_column_name, None)
column = id_column or cls.id_column_name
id_col = getattr(cls.model_cls, column, None)
if id_col is None or not model_ids:
return []
query = db.session.query(cls.model_cls).filter(id_col.in_(model_ids))
if cls.base_filter and not skip_base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
# Convert IDs to appropriate types based on column type
converted_ids: list[str | int | uuid_lib.UUID] = []
for id_val in model_ids:
converted_value = cls._convert_value_for_column(id_col, id_val)
if converted_value is not None:
# Only add successfully converted values
converted_ids.append(converted_value)
else:
# Log warning for failed conversions
logger.warning(
"Failed to convert ID '%s' for column %s.%s",
id_val,
cls.model_cls.__name__ if cls.model_cls else "Unknown",
column,
)
# If no valid IDs after conversion, return empty list
if not converted_ids:
return []
query = db.session.query(cls.model_cls).filter(id_col.in_(converted_ids))
query = cls._apply_base_filter(query, skip_base_filter)
try:
results = query.all()
except SQLAlchemyError as ex:
model_name = cls.model_cls.__name__ if cls.model_cls else "Unknown"
raise DAOFindFailedError(
f"Failed to find {model_name} with ids: {model_ids}"
"Failed to find %s with ids: %s" % (model_name, model_ids)
) from ex
return results
@classmethod
def find_all(cls) -> list[T]:
def find_all(cls, skip_base_filter: bool = False) -> list[T]:
"""
Get all that fit the `base_filter`
"""
query = db.session.query(cls.model_cls)
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
query = cls._apply_base_filter(query, skip_base_filter)
return query.all()
@classmethod
def find_one_or_none(cls, **filter_by: Any) -> T | None:
def find_one_or_none(
cls, skip_base_filter: bool = False, **filter_by: Any
) -> T | None:
"""
Get the first that fit the `base_filter`
"""
query = db.session.query(cls.model_cls)
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
cls.id_column_name, data_model
).apply(query, None)
query = cls._apply_base_filter(query, skip_base_filter)
return query.filter_by(**filter_by).one_or_none()
@classmethod
@@ -251,3 +460,229 @@ class BaseDAO(Generic[T]):
cls.id_column_name, data_model
).apply(query, None)
return query.filter_by(**filter_by).all()
@classmethod
def apply_column_operators(
cls, query: Any, column_operators: Optional[List[ColumnOperator]] = None
) -> Any:
"""
Apply column operators (list of ColumnOperator) to the query using
ColumnOperatorEnum logic. Raises ValueError if a filter references a
non-existent column.
"""
if not column_operators:
return query
for c in column_operators:
if not isinstance(c, ColumnOperator):
continue
col, opr, value = c.col, c.opr, c.value
if not col or not hasattr(cls.model_cls, col):
model_name = cls.model_cls.__name__ if cls.model_cls else "Unknown"
logging.error(
"Invalid filter: column '%s' does not exist on %s", col, model_name
)
raise ValueError(
"Invalid filter: column '%s' does not exist on %s"
% (col, model_name)
)
column = getattr(cls.model_cls, col)
try:
# Always use ColumnOperatorEnum's apply method
operator_enum = ColumnOperatorEnum(opr)
query = query.filter(operator_enum.apply(column, value))
except Exception as e:
logging.error("Error applying filter on column '%s': %s", col, e)
raise
return query
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
"""
Returns a dict mapping filterable columns (including hybrid/computed fields if
present) to their supported operators. Used by MCP tools to dynamically expose
filter options. Custom fields supported by the DAO but not present on the model
should be documented here.
"""
mapper = inspect(cls.model_cls)
columns = {c.key: c for c in mapper.columns}
# Add hybrid properties
hybrids = {
name: attr
for name, attr in vars(cls.model_cls).items()
if isinstance(attr, hybrid_property)
}
# You may add custom fields here, e.g.:
# custom_fields = {"tags": ["eq", "in_", "like"], ...}
custom_fields: Dict[str, List[str]] = {}
filterable: Dict[str, Any] = {}
for name, col in columns.items():
if isinstance(col.type, (sa.String, sa.Text)):
filterable[name] = TYPE_OPERATOR_MAP["string"]
elif isinstance(col.type, (sa.Boolean,)):
filterable[name] = TYPE_OPERATOR_MAP["boolean"]
elif isinstance(col.type, (sa.Integer, sa.Float, sa.Numeric)):
filterable[name] = TYPE_OPERATOR_MAP["number"]
elif isinstance(col.type, (sa.DateTime, sa.Date, sa.Time)):
filterable[name] = TYPE_OPERATOR_MAP["datetime"]
else:
# Fallback to eq/ne/null
filterable[name] = [
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
]
# Add hybrid properties as string fields by default
for name in hybrids:
filterable[name] = TYPE_OPERATOR_MAP["string"]
# Add custom fields
filterable.update(custom_fields)
# Convert enum values to strings for the return type
result: Dict[str, List[str]] = {}
for key, operators in filterable.items():
if isinstance(operators, list):
# Convert enums to strings
result[key] = [
op.value if isinstance(op, ColumnOperatorEnum) else op
for op in operators
]
else:
result[key] = operators
return result
@classmethod
def _build_query(
cls,
column_operators: Optional[List[ColumnOperator]] = None,
search: Optional[str] = None,
search_columns: Optional[List[str]] = None,
custom_filters: Optional[Dict[str, BaseFilter]] = None,
skip_base_filter: bool = False,
data_model: Optional[SQLAInterface] = None,
) -> Any:
"""
Build a SQLAlchemy query with base filter, column operators, search, and
custom filters.
"""
if data_model is None:
data_model = SQLAInterface(cls.model_cls, db.session)
query = data_model.session.query(cls.model_cls)
query = cls._apply_base_filter(
query, skip_base_filter=skip_base_filter, data_model=data_model
)
if search and search_columns:
search_filters = []
for column_name in search_columns:
if hasattr(cls.model_cls, column_name):
column = getattr(cls.model_cls, column_name)
search_filters.append(cast(column, Text).ilike(f"%{search}%"))
if search_filters:
query = query.filter(or_(*search_filters))
if custom_filters:
for filter_class in custom_filters.values():
query = filter_class.apply(query, None)
if column_operators:
query = cls.apply_column_operators(query, column_operators)
return query
@classmethod
def list( # noqa: C901
cls,
column_operators: Optional[List[ColumnOperator]] = None,
order_column: str = "changed_on",
order_direction: str = "desc",
page: int = 0,
page_size: int = 100,
search: Optional[str] = None,
search_columns: Optional[List[str]] = None,
custom_filters: Optional[Dict[str, BaseFilter]] = None,
columns: Optional[List[str]] = None,
) -> Tuple[List[Any], int]:
"""
Generic list method for filtered, sorted, and paginated results.
If columns is specified, returns a list of tuples (one per row),
otherwise returns model instances.
"""
data_model = SQLAInterface(cls.model_cls, db.session)
column_attrs = []
relationship_loads = []
if columns is None:
columns = []
for name in columns:
attr = getattr(cls.model_cls, name, None)
if attr is None:
continue
prop = getattr(attr, "property", None)
if isinstance(prop, ColumnProperty):
column_attrs.append(attr)
elif isinstance(prop, RelationshipProperty):
relationship_loads.append(joinedload(attr))
# Ignore properties and other non-queryable attributes
if relationship_loads:
# If any relationships are requested, query the full model
# but don't add the joins yet - we'll add them after counting
query = data_model.session.query(cls.model_cls)
elif column_attrs:
# Only columns requested
query = data_model.session.query(*column_attrs)
else:
# Fallback: query the full model
query = data_model.session.query(cls.model_cls)
query = cls._apply_base_filter(query, data_model=data_model)
if search and search_columns:
search_filters = []
for column_name in search_columns:
if hasattr(cls.model_cls, column_name):
column = getattr(cls.model_cls, column_name)
search_filters.append(cast(column, Text).ilike(f"%{search}%"))
if search_filters:
query = query.filter(or_(*search_filters))
if custom_filters:
for filter_class in custom_filters.values():
query = filter_class.apply(query, None)
if column_operators:
query = cls.apply_column_operators(query, column_operators)
# Count before adding relationship joins to avoid inflated counts
# with one-to-many or many-to-many relationships
total_count = query.count()
# Add relationship joins after counting
if relationship_loads:
for loader in relationship_loads:
query = query.options(loader)
if hasattr(cls.model_cls, order_column):
column = getattr(cls.model_cls, order_column)
if order_direction.lower() == "desc":
query = query.order_by(desc(column))
else:
query = query.order_by(asc(column))
page = page
page_size = max(page_size, 1)
query = query.offset(page * page_size).limit(page_size)
items = query.all()
# If columns are specified, SQLAlchemy returns Row objects (not tuples or
# model instances)
return items, total_count
@classmethod
def count(
cls,
column_operators: Optional[List[ColumnOperator]] = None,
skip_base_filter: bool = False,
) -> int:
"""
Count the number of records for the model, optionally filtered by column
operators.
"""
query = cls._build_query(
column_operators=column_operators, skip_base_filter=skip_base_filter
)
return query.count()

View File

@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from typing import Dict, List, TYPE_CHECKING
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -35,10 +35,23 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Custom filterable fields for charts
CHART_CUSTOM_FIELDS = {
"viz_type": ["eq", "in_", "like"],
"datasource_name": ["eq", "in_", "like"],
}
class ChartDAO(BaseDAO[Slice]):
base_filter = ChartFilter
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields for charts
filterable.update(CHART_CUSTOM_FIELDS)
return filterable
@staticmethod
def get_by_id_or_uuid(id_or_uuid: str) -> Slice:
query = db.session.query(Slice).filter(id_or_uuid_filter(id_or_uuid))

View File

@@ -19,7 +19,7 @@ from __future__ import annotations
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any
from typing import Any, Dict, List
from flask import g
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -45,10 +45,24 @@ from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes
logger = logging.getLogger(__name__)
# Custom filterable fields for dashboards
DASHBOARD_CUSTOM_FIELDS = {
"tags": ["eq", "in_", "like"],
"owners": ["eq", "in_"],
"published": ["eq"],
}
class DashboardDAO(BaseDAO[Dashboard]):
base_filter = DashboardAccessFilter
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields for dashboards
filterable.update(DASHBOARD_CUSTOM_FIELDS)
return filterable
@classmethod
def get_by_id_or_slug(cls, id_or_slug: int | str) -> Dashboard:
if is_uuid(id_or_slug):

View File

@@ -18,7 +18,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from typing import Any, Dict, List
import dateutil.parser
from sqlalchemy.exc import SQLAlchemyError
@@ -35,8 +35,18 @@ from superset.views.base import DatasourceFilter
logger = logging.getLogger(__name__)
# Custom filterable fields for datasets
DATASET_CUSTOM_FIELDS: dict[str, list[str]] = {}
class DatasetDAO(BaseDAO[SqlaTable]):
"""
DAO for datasets. Supports filtering on model fields, hybrid properties, and custom
fields:
- tags: list of tags (eq, in_, like)
- owner: user id (eq, in_)
"""
base_filter = DatasourceFilter
@staticmethod
@@ -351,6 +361,13 @@ class DatasetDAO(BaseDAO[SqlaTable]):
.one_or_none()
)
@classmethod
def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]:
filterable = super().get_filterable_columns_and_operators()
# Add custom fields
filterable.update(DATASET_CUSTOM_FIELDS)
return filterable
class DatasetColumnDAO(BaseDAO[TableColumn]):
pass

View File

@@ -293,6 +293,7 @@ class ImportV1DatasetSchema(Schema):
def fix_extra(self, data: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
"""
Fix for extra initially being exported as a string.
And fixed bug when exporting template_params as empty string.
"""
if isinstance(data.get("extra"), str):
try:
@@ -301,6 +302,9 @@ class ImportV1DatasetSchema(Schema):
except ValueError:
data["extra"] = None
if "template_params" in data and data["template_params"] == "":
data["template_params"] = None
return data
table_name = fields.String(required=True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
# 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.
"""
Fixtures for DAO integration tests.
This module provides fixtures that replicate the unit test behavior by using
an in-memory SQLite database for each test to ensure data isolation and avoid
conflicts between test runs.
Key features:
- In-memory SQLite database created per test
- Proper Flask-SQLAlchemy session patching
- Security manager session handling
- Automatic cleanup after each test
"""
from typing import Generator
from unittest.mock import patch
import pytest
from flask import Flask
from flask_appbuilder.security.sqla.models import User
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from superset.extensions import db
from tests.integration_tests.test_app import app as superset_app
@pytest.fixture(scope="module", autouse=True)
def setup_sample_data() -> None:
"""
Override parent conftest setup_sample_data to prevent loading sample data.
This prevents the parent conftest from loading CSS templates and other
sample data that could interfere with DAO integration tests.
"""
pass
@pytest.fixture
def app() -> Flask:
"""Get the Superset Flask application instance."""
return superset_app
@pytest.fixture
def app_context(app: Flask) -> Generator[Session, None, None]:
"""
Create an in-memory SQLite database for each test.
This fixture replicates the unit test behavior by providing a fresh
in-memory database for each test, ensuring complete data isolation
and avoiding conflicts between test runs.
Args:
app: Flask application instance
Yields:
Session: SQLAlchemy session connected to in-memory database
"""
# Create in-memory SQLite engine with StaticPool to avoid connection issues
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
# Create session bound to in-memory database
session_factory = sessionmaker(bind=engine)
session = session_factory()
# Make session compatible with Flask-SQLAlchemy expectations
session.remove = lambda: None
session.get_bind = lambda *args, **kwargs: engine
with app.app_context():
# Patch db.session to use our in-memory session
with patch.object(db, "session", session):
# Import models to ensure they're registered
from flask_appbuilder.security.sqla.models import User as FABUser
# Create all tables in the in-memory database
# Flask-AppBuilder models use a different metadata object
# We need to create tables from both metadata objects
# First create Flask-AppBuilder tables (User, Role, etc.)
FABUser.metadata.create_all(engine)
# Then create Superset-specific tables
db.metadata.create_all(engine)
try:
yield session
finally:
# Clean up: rollback any pending transactions
session.rollback()
session.close()
engine.dispose()
@pytest.fixture
def user_with_data(app_context: Session) -> Session:
"""
Create a test user in the database.
Some DAO tests expect a user with specific attributes to exist.
This fixture creates that user and returns the database session.
Args:
app_context: Database session from app_context fixture
Returns:
Session: The same database session with test user created
"""
# Create test user with expected attributes
user = User(
username="testuser",
first_name="Test",
last_name="User",
email="testuser@example.com",
active=True,
)
db.session.add(user)
db.session.commit()
return app_context

View File

@@ -14,12 +14,19 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Unit tests for BaseDAO functionality using mocks and no database operations.
"""
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.declarative import declarative_base
from superset.daos.base import BaseDAO
from superset.daos.base import BaseDAO, ColumnOperatorEnum
from superset.daos.exceptions import DAOFindFailedError
@@ -37,6 +44,107 @@ class TestDAOWithNoneModel(BaseDAO[MockModel]):
model_cls = None
# =============================================================================
# Unit Tests - These tests use mocks and don't touch the database
# =============================================================================
def test_column_operator_enum_apply_method() -> None: # noqa: C901
"""
Test that the apply method works correctly for each operator.
This verifies the actual SQL generation for each operator.
"""
Base_test = declarative_base() # noqa: N806
class TestModel(Base_test): # type: ignore
__tablename__ = "test_model"
id = Column(Integer, primary_key=True)
name = Column(String(50))
age = Column(Integer)
active = Column(Boolean)
# Test each operator's apply method
test_cases = [
# (operator, column, value, expected_sql_fragment)
(ColumnOperatorEnum.eq, TestModel.name, "test", "test_model.name = 'test'"),
(ColumnOperatorEnum.ne, TestModel.name, "test", "test_model.name != 'test'"),
(ColumnOperatorEnum.sw, TestModel.name, "test", "test_model.name LIKE 'test%'"),
(ColumnOperatorEnum.ew, TestModel.name, "test", "test_model.name LIKE '%test'"),
(ColumnOperatorEnum.in_, TestModel.id, [1, 2, 3], "test_model.id IN (1, 2, 3)"),
(
ColumnOperatorEnum.nin,
TestModel.id,
[1, 2, 3],
"test_model.id NOT IN (1, 2, 3)",
),
(ColumnOperatorEnum.gt, TestModel.age, 25, "test_model.age > 25"),
(ColumnOperatorEnum.gte, TestModel.age, 25, "test_model.age >= 25"),
(ColumnOperatorEnum.lt, TestModel.age, 25, "test_model.age < 25"),
(ColumnOperatorEnum.lte, TestModel.age, 25, "test_model.age <= 25"),
(
ColumnOperatorEnum.like,
TestModel.name,
"test",
"test_model.name LIKE '%test%'",
),
(
ColumnOperatorEnum.ilike,
TestModel.name,
"test",
"lower(test_model.name) LIKE lower('%test%')",
),
(ColumnOperatorEnum.is_null, TestModel.name, None, "test_model.name IS NULL"),
(
ColumnOperatorEnum.is_not_null,
TestModel.name,
None,
"test_model.name IS NOT NULL",
),
]
for operator, column, value, expected_sql_fragment in test_cases:
# Apply the operator
result = operator.apply(column, value)
# Convert to string to check SQL generation
sql_str = str(result.compile(compile_kwargs={"literal_binds": True}))
# Normalize whitespace for comparison
normalized_sql = " ".join(sql_str.split())
normalized_expected = " ".join(expected_sql_fragment.split())
# Assert the exact SQL fragment is present
assert normalized_expected in normalized_sql, (
f"Expected SQL fragment '{expected_sql_fragment}' not found in generated "
f"SQL: '{sql_str}' "
f"for operator {operator.name}"
)
# Test that all operators are covered
all_operators = set(ColumnOperatorEnum)
tested_operators = {
ColumnOperatorEnum.eq,
ColumnOperatorEnum.ne,
ColumnOperatorEnum.sw,
ColumnOperatorEnum.ew,
ColumnOperatorEnum.in_,
ColumnOperatorEnum.nin,
ColumnOperatorEnum.gt,
ColumnOperatorEnum.gte,
ColumnOperatorEnum.lt,
ColumnOperatorEnum.lte,
ColumnOperatorEnum.like,
ColumnOperatorEnum.ilike,
ColumnOperatorEnum.is_null,
ColumnOperatorEnum.is_not_null,
}
# Ensure we've tested all operators
assert tested_operators == all_operators, (
f"Missing operators: {all_operators - tested_operators}"
)
def test_find_by_ids_sqlalchemy_error_with_model_cls():
"""Test SQLAlchemyError in find_by_ids shows proper model name
when model_cls is set"""
@@ -137,6 +245,6 @@ def test_find_by_ids_none_id_column():
with patch("superset.daos.base.getattr") as mock_getattr:
mock_getattr.return_value = None
results = TestDAO.find_by_ids([1, 2])
results = TestDAO.find_by_ids([1, 2, 3])
assert results == []