mirror of
https://github.com/apache/superset.git
synced 2026-07-18 20:55:47 +00:00
Compare commits
8 Commits
mcp-v1
...
fix-doc-bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ca0067ea | ||
|
|
1187902e68 | ||
|
|
ad3eff9e90 | ||
|
|
3e554674ff | ||
|
|
dced2f8564 | ||
|
|
05c6a1bf20 | ||
|
|
c193d6d6a1 | ||
|
|
fb840b8e71 |
70
.github/workflows/bashlib.sh
vendored
70
.github/workflows/bashlib.sh
vendored
@@ -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
|
||||
|
||||
141
.github/workflows/superset-playwright.yml
vendored
Normal file
141
.github/workflows/superset-playwright.yml
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
name: Playwright E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'The branch or tag to checkout'
|
||||
required: false
|
||||
default: ''
|
||||
pr_id:
|
||||
description: 'The pull request ID to checkout'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
playwright-tests:
|
||||
runs-on: ubuntu-22.04
|
||||
# Allow workflow to succeed even if tests fail during shadow mode
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: ["chromium"]
|
||||
app_root: ["", "/app/prefix"]
|
||||
env:
|
||||
SUPERSET_ENV: development
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
REDIS_PORT: 16379
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
# -------------------------------------------------------
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
submodules: recursive
|
||||
# -------------------------------------------------------
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
uses: ./.github/actions/change-detector/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
- name: Setup postgres
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Import test data
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
- name: Install npm dependencies
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: npm-install
|
||||
- name: Build javascript packages
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: build-instrumented-assets
|
||||
- name: Install Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: playwright-install
|
||||
- name: Run Playwright
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
with:
|
||||
run: playwright-run ${{ matrix.app_root }}
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
run: |
|
||||
APP_ROOT="${{ matrix.app_root }}"
|
||||
SAFE_APP_ROOT=${APP_ROOT//\//_}
|
||||
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
|
||||
- name: Upload Playwright Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
17
LLMS.md
17
LLMS.md
@@ -15,8 +15,9 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
|
||||
|
||||
### Testing Strategy Migration
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
64
superset-frontend/package-lock.json
generated
64
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
90
superset-frontend/playwright.config.ts
Normal file
90
superset-frontend/playwright.config.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/// <reference types="node" />
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
// Test directory
|
||||
testDir: './playwright/tests',
|
||||
|
||||
// Timeout settings
|
||||
timeout: 30000,
|
||||
expect: { timeout: 8000 },
|
||||
|
||||
// Parallel execution
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 2 : 1,
|
||||
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
['github'], // GitHub Actions annotations
|
||||
['list'], // Detailed output with summary table
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }], // Interactive report
|
||||
['json', { outputFile: 'test-results/results.json' }], // Machine-readable
|
||||
]
|
||||
: [
|
||||
['list'], // Shows summary table locally
|
||||
['html', { outputFolder: 'playwright-report', open: 'on-failure' }], // Auto-open on failure
|
||||
],
|
||||
|
||||
// Global test setup
|
||||
use: {
|
||||
// Use environment variable for base URL in CI, default to localhost:8088 for local
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
|
||||
|
||||
// Browser settings
|
||||
headless: !!process.env.CI,
|
||||
|
||||
viewport: { width: 1280, height: 1024 },
|
||||
|
||||
// Screenshots and videos on failure
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
|
||||
// Trace collection for debugging
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
testIdAttribute: 'data-test',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: 'curl -f http://localhost:8088/health',
|
||||
url: 'http://localhost:8088/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5000,
|
||||
},
|
||||
});
|
||||
218
superset-frontend/playwright/README.md
Normal file
218
superset-frontend/playwright/README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Playwright E2E Tests for Superset
|
||||
|
||||
This directory contains Playwright end-to-end tests for Apache Superset, designed as a replacement for the existing Cypress tests during the migration to Playwright.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
playwright/
|
||||
├── components/core/ # Reusable UI components
|
||||
├── pages/ # Page Object Models
|
||||
├── tests/ # Test files organized by feature
|
||||
├── utils/ # Shared constants and utilities
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
We follow **YAGNI** (You Aren't Gonna Need It), **DRY** (Don't Repeat Yourself), and **KISS** (Keep It Simple, Stupid) principles:
|
||||
|
||||
- Build only what's needed now
|
||||
- Reuse existing patterns and components
|
||||
- Keep solutions simple and maintainable
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Core Components (`components/core/`)
|
||||
|
||||
Reusable UI interaction classes for common elements:
|
||||
|
||||
- **Form**: Container with properly scoped child element access
|
||||
- **Input**: Supports `fill()`, `type()`, and `pressSequentially()` methods
|
||||
- **Button**: Standard click, hover, focus interactions
|
||||
|
||||
**Usage Example:**
|
||||
```typescript
|
||||
import { Form } from '../components/core';
|
||||
|
||||
const loginForm = new Form(page, '[data-test="login-form"]');
|
||||
const usernameInput = loginForm.getInput('[data-test="username-input"]');
|
||||
await usernameInput.fill('admin');
|
||||
```
|
||||
|
||||
### Page Objects (`pages/`)
|
||||
|
||||
Each page object encapsulates:
|
||||
- **Actions**: What you can do on the page
|
||||
- **Queries**: Information you can get from the page
|
||||
- **Selectors**: Centralized in private static SELECTORS constant
|
||||
- **NO Assertions**: Keep assertions in test files
|
||||
|
||||
**Page Object Pattern:**
|
||||
```typescript
|
||||
export class AuthPage {
|
||||
// Selectors centralized in the page object
|
||||
private static readonly SELECTORS = {
|
||||
LOGIN_FORM: '[data-test="login-form"]',
|
||||
USERNAME_INPUT: '[data-test="username-input"]',
|
||||
} as const;
|
||||
|
||||
// Actions - what you can do
|
||||
async loginWithCredentials(username: string, password: string) { }
|
||||
|
||||
// Queries - information you can get
|
||||
async getCurrentUrl(): Promise<string> { }
|
||||
|
||||
// NO assertions - those belong in tests
|
||||
}
|
||||
```
|
||||
|
||||
### Tests (`tests/`)
|
||||
|
||||
Organized by feature/area (auth, dashboard, charts, etc.):
|
||||
- Use page objects for actions
|
||||
- Keep assertions in test files
|
||||
- Import shared constants from `utils/`
|
||||
|
||||
**Test Pattern:**
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AuthPage } from '../../pages/AuthPage';
|
||||
import { LOGIN } from '../../utils/urls';
|
||||
|
||||
test('should login with correct credentials', async ({ page }) => {
|
||||
const authPage = new AuthPage(page);
|
||||
await authPage.goto();
|
||||
await authPage.loginWithCredentials('admin', 'general');
|
||||
|
||||
// Assertions belong in tests, not page objects
|
||||
expect(await authPage.getCurrentUrl()).not.toContain(LOGIN);
|
||||
});
|
||||
```
|
||||
|
||||
### Utilities (`utils/`)
|
||||
|
||||
Shared constants and utilities:
|
||||
- **urls.ts**: URL paths and request patterns
|
||||
- Keep flat exports (no premature namespacing)
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Adding New Tests
|
||||
|
||||
1. **Check existing components** before creating new ones
|
||||
2. **Use page objects** for page interactions
|
||||
3. **Keep assertions in tests**, not page objects
|
||||
4. **Follow naming conventions**: `feature.spec.ts`
|
||||
|
||||
### Adding New Components
|
||||
|
||||
1. **Follow YAGNI**: Only build what's immediately needed
|
||||
2. **Use Locator-based scoping** for proper element isolation
|
||||
3. **Support both string selectors and Locator objects** via constructor overloads
|
||||
4. **Add to `components/core/index.ts`** for easy importing
|
||||
|
||||
### Adding New Page Objects
|
||||
|
||||
1. **Centralize selectors** in private static SELECTORS constant
|
||||
2. **Import shared constants** from `utils/urls.ts`
|
||||
3. **Actions and queries only** - no assertions
|
||||
4. **Use existing components** for DOM interactions
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run playwright:test
|
||||
# or: npx playwright test
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test tests/auth/login.spec.ts
|
||||
|
||||
# Run with UI mode for debugging
|
||||
npm run playwright:ui
|
||||
# or: npx playwright test --ui
|
||||
|
||||
# Run in headed mode (see browser)
|
||||
npm run playwright:headed
|
||||
# or: npx playwright test --headed
|
||||
|
||||
# Debug specific test file
|
||||
npm run playwright:debug tests/auth/login.spec.ts
|
||||
# or: npx playwright test --debug tests/auth/login.spec.ts
|
||||
```
|
||||
|
||||
## Test Reports
|
||||
|
||||
Playwright generates multiple reports for better visibility:
|
||||
|
||||
```bash
|
||||
# View interactive HTML report (opens automatically on failure)
|
||||
npm run playwright:report
|
||||
# or: npx playwright show-report
|
||||
|
||||
# View test trace for debugging failures
|
||||
npx playwright show-trace test-results/[test-name]/trace.zip
|
||||
```
|
||||
|
||||
### Report Types
|
||||
|
||||
- **List Reporter**: Shows progress and summary table in terminal
|
||||
- **HTML Report**: Interactive web interface with screenshots, videos, and traces
|
||||
- **JSON Report**: Machine-readable format in `test-results/results.json`
|
||||
- **GitHub Actions**: Annotations in CI for failed tests
|
||||
|
||||
### Debugging Failed Tests
|
||||
|
||||
When tests fail, Playwright automatically captures:
|
||||
- **Screenshots** at the point of failure
|
||||
- **Videos** of the entire test run
|
||||
- **Traces** with timeline and network activity
|
||||
- **Error context** with detailed debugging information
|
||||
|
||||
All debugging artifacts are available in the HTML report for easy analysis.
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Config**: `playwright.config.ts` - matches Cypress settings
|
||||
- **Base URL**: `http://localhost:8088` (assumes Superset running)
|
||||
- **Browsers**: Chrome only for Phase 1 (YAGNI)
|
||||
- **Retries**: 2 in CI, 0 locally (matches Cypress)
|
||||
|
||||
## Migration from Cypress
|
||||
|
||||
When porting Cypress tests:
|
||||
|
||||
1. **Port the logic**, not the implementation
|
||||
2. **Use page objects** instead of inline selectors
|
||||
3. **Replace `cy.intercept/cy.wait`** with `page.waitForRequest()`
|
||||
4. **Use shared constants** from `utils/urls.ts`
|
||||
5. **Follow the established patterns** shown in `tests/auth/login.spec.ts`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Centralize selectors** in page objects
|
||||
- **Centralize URLs** in `utils/urls.ts`
|
||||
- **Use meaningful test descriptions**
|
||||
- **Keep page objects action-focused**
|
||||
- **Put assertions in tests, not page objects**
|
||||
- **Follow the existing patterns** for consistency
|
||||
119
superset-frontend/playwright/components/core/Button.ts
Normal file
119
superset-frontend/playwright/components/core/Button.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class Button {
|
||||
private readonly locator: Locator;
|
||||
|
||||
constructor(page: Page, selector: string);
|
||||
|
||||
constructor(page: Page, locator: Locator);
|
||||
|
||||
constructor(page: Page, selectorOrLocator: string | Locator) {
|
||||
if (typeof selectorOrLocator === 'string') {
|
||||
this.locator = page.locator(selectorOrLocator);
|
||||
} else {
|
||||
this.locator = selectorOrLocator;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the button element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the button
|
||||
* @param options - Optional click options
|
||||
*/
|
||||
async click(options?: {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
}): Promise<void> {
|
||||
await this.element.click(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the button text content
|
||||
*/
|
||||
async getText(): Promise<string> {
|
||||
return (await this.element.textContent()) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific attribute value from the button
|
||||
* @param attribute - The attribute name to retrieve
|
||||
*/
|
||||
async getAttribute(attribute: string): Promise<string | null> {
|
||||
return this.element.getAttribute(attribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the button is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.element.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the button is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return this.element.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the button is disabled
|
||||
*/
|
||||
async isDisabled(): Promise<boolean> {
|
||||
return this.element.isDisabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hovers over the button
|
||||
* @param options - Optional hover options
|
||||
*/
|
||||
async hover(options?: { timeout?: number; force?: boolean }): Promise<void> {
|
||||
await this.element.hover(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses on the button
|
||||
*/
|
||||
async focus(): Promise<void> {
|
||||
await this.element.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Double clicks the button
|
||||
* @param options - Optional click options
|
||||
*/
|
||||
async doubleClick(options?: {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.element.dblclick(options);
|
||||
}
|
||||
}
|
||||
110
superset-frontend/playwright/components/core/Form.ts
Normal file
110
superset-frontend/playwright/components/core/Form.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import { Input } from './Input';
|
||||
import { Button } from './Button';
|
||||
|
||||
export class Form {
|
||||
private readonly page: Page;
|
||||
|
||||
private readonly locator: Locator;
|
||||
|
||||
constructor(page: Page, selector: string);
|
||||
|
||||
constructor(page: Page, locator: Locator);
|
||||
|
||||
constructor(page: Page, selectorOrLocator: string | Locator) {
|
||||
this.page = page;
|
||||
if (typeof selectorOrLocator === 'string') {
|
||||
this.locator = page.locator(selectorOrLocator);
|
||||
} else {
|
||||
this.locator = selectorOrLocator;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the form element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an input field within the form (properly scoped)
|
||||
* @param inputSelector - Selector for the input field
|
||||
*/
|
||||
getInput(inputSelector: string): Input {
|
||||
const scopedLocator = this.locator.locator(inputSelector);
|
||||
return new Input(this.page, scopedLocator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a button within the form (properly scoped)
|
||||
* @param buttonSelector - Selector for the button
|
||||
*/
|
||||
getButton(buttonSelector: string): Button {
|
||||
const scopedLocator = this.locator.locator(buttonSelector);
|
||||
return new Button(this.page, scopedLocator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the form is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.locator.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form (triggers submit event)
|
||||
*/
|
||||
async submit(): Promise<void> {
|
||||
await this.locator.evaluate((form: HTMLElement) => {
|
||||
if (form instanceof HTMLFormElement) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the form to be visible
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForVisible(options?: { timeout?: number }): Promise<void> {
|
||||
await this.locator.waitFor({ state: 'visible', ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all form data as key-value pairs
|
||||
* Useful for validation and debugging
|
||||
*/
|
||||
async getFormData(): Promise<Record<string, string>> {
|
||||
return this.locator.evaluate((form: HTMLElement) => {
|
||||
if (form instanceof HTMLFormElement) {
|
||||
const formData = new FormData(form);
|
||||
const result: Record<string, string> = {};
|
||||
formData.forEach((value, key) => {
|
||||
result[key] = value.toString();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
}
|
||||
111
superset-frontend/playwright/components/core/Input.ts
Normal file
111
superset-frontend/playwright/components/core/Input.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class Input {
|
||||
private readonly locator: Locator;
|
||||
|
||||
constructor(page: Page, selector: string);
|
||||
|
||||
constructor(page: Page, locator: Locator);
|
||||
|
||||
constructor(page: Page, selectorOrLocator: string | Locator) {
|
||||
if (typeof selectorOrLocator === 'string') {
|
||||
this.locator = page.locator(selectorOrLocator);
|
||||
} else {
|
||||
this.locator = selectorOrLocator;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the input element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.locator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast fill - clears the input and sets the value directly
|
||||
* @param value - The value to fill
|
||||
* @param options - Optional fill options
|
||||
*/
|
||||
async fill(
|
||||
value: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.element.fill(value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types text character by character (simulates real typing)
|
||||
* @param text - The text to type
|
||||
* @param options - Optional typing options
|
||||
*/
|
||||
async type(text: string, options?: { delay?: number }): Promise<void> {
|
||||
await this.element.type(text, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types text sequentially with more control over timing
|
||||
* @param text - The text to type
|
||||
* @param options - Optional sequential typing options
|
||||
*/
|
||||
async pressSequentially(
|
||||
text: string,
|
||||
options?: { delay?: number },
|
||||
): Promise<void> {
|
||||
await this.element.pressSequentially(text, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current value of the input
|
||||
*/
|
||||
async getValue(): Promise<string> {
|
||||
return this.element.inputValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the input field
|
||||
*/
|
||||
async clear(): Promise<void> {
|
||||
await this.element.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input is visible
|
||||
*/
|
||||
async isVisible(): Promise<boolean> {
|
||||
return this.element.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input is enabled
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
return this.element.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses on the input field
|
||||
*/
|
||||
async focus(): Promise<void> {
|
||||
await this.element.focus();
|
||||
}
|
||||
}
|
||||
23
superset-frontend/playwright/components/core/index.ts
Normal file
23
superset-frontend/playwright/components/core/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Core Playwright Components for Superset
|
||||
export { Button } from './Button';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
122
superset-frontend/playwright/pages/AuthPage.ts
Normal file
122
superset-frontend/playwright/pages/AuthPage.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Response } from '@playwright/test';
|
||||
import { Form } from '../components/core';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
export class AuthPage {
|
||||
private readonly page: Page;
|
||||
|
||||
private readonly loginForm: Form;
|
||||
|
||||
// Selectors specific to the auth/login page
|
||||
private static readonly SELECTORS = {
|
||||
LOGIN_FORM: '[data-test="login-form"]',
|
||||
USERNAME_INPUT: '[data-test="username-input"]',
|
||||
PASSWORD_INPUT: '[data-test="password-input"]',
|
||||
LOGIN_BUTTON: '[data-test="login-button"]',
|
||||
ERROR_SELECTORS: [
|
||||
'[role="alert"]',
|
||||
'.ant-form-item-explain-error',
|
||||
'.ant-form-item-explain.ant-form-item-explain-error',
|
||||
'.alert-danger',
|
||||
],
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.loginForm = new Form(page, AuthPage.SELECTORS.LOGIN_FORM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the login page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(URL.LOGIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for login form to be visible
|
||||
*/
|
||||
async waitForLoginForm(): Promise<void> {
|
||||
await this.loginForm.waitForVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with provided credentials
|
||||
* @param username - Username to enter
|
||||
* @param password - Password to enter
|
||||
*/
|
||||
async loginWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const usernameInput = this.loginForm.getInput(
|
||||
AuthPage.SELECTORS.USERNAME_INPUT,
|
||||
);
|
||||
const passwordInput = this.loginForm.getInput(
|
||||
AuthPage.SELECTORS.PASSWORD_INPUT,
|
||||
);
|
||||
const loginButton = this.loginForm.getButton(
|
||||
AuthPage.SELECTORS.LOGIN_BUTTON,
|
||||
);
|
||||
|
||||
await usernameInput.fill(username);
|
||||
await passwordInput.fill(password);
|
||||
await loginButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page URL
|
||||
*/
|
||||
async getCurrentUrl(): Promise<string> {
|
||||
return this.page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session cookie specifically
|
||||
*/
|
||||
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
|
||||
const cookies = await this.page.context().cookies();
|
||||
return cookies.find((c: any) => c.name === 'session') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login form has validation errors
|
||||
*/
|
||||
async hasLoginError(): Promise<boolean> {
|
||||
const visibilityPromises = AuthPage.SELECTORS.ERROR_SELECTORS.map(
|
||||
selector => this.page.locator(selector).isVisible(),
|
||||
);
|
||||
const visibilityResults = await Promise.all(visibilityPromises);
|
||||
return visibilityResults.some((isVisible: any) => isVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a login request to be made and return the response
|
||||
*/
|
||||
async waitForLoginRequest(): Promise<Response> {
|
||||
return this.page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/login/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
}
|
||||
}
|
||||
88
superset-frontend/playwright/tests/auth/login.spec.ts
Normal file
88
superset-frontend/playwright/tests/auth/login.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AuthPage } from '../../pages/AuthPage';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
test.describe('Login view', () => {
|
||||
let authPage: AuthPage;
|
||||
|
||||
test.beforeEach(async ({ page }: any) => {
|
||||
authPage = new AuthPage(page);
|
||||
await authPage.goto();
|
||||
await authPage.waitForLoginForm();
|
||||
});
|
||||
|
||||
test('should redirect to login with incorrect username and password', async ({
|
||||
page,
|
||||
}: any) => {
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Attempt login with incorrect credentials
|
||||
await authPage.loginWithCredentials('admin', 'wrongpassword');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Failed login returns 401 Unauthorized or 302 redirect to login
|
||||
expect([401, 302]).toContain(loginResponse.status());
|
||||
|
||||
// Wait for redirect to complete before checking URL
|
||||
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we stay on login page
|
||||
const currentUrl = await authPage.getCurrentUrl();
|
||||
expect(currentUrl).toContain(URL.LOGIN);
|
||||
|
||||
// Verify error message is shown
|
||||
const hasError = await authPage.hasLoginError();
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
|
||||
test('should login with correct username and password', async ({
|
||||
page,
|
||||
}: any) => {
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Login with correct credentials
|
||||
await authPage.loginWithCredentials('admin', 'general');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Successful login returns 302 redirect
|
||||
expect(loginResponse.status()).toBe(302);
|
||||
|
||||
// Wait for successful redirect to welcome page
|
||||
await page.waitForURL(
|
||||
(url: any) => url.pathname.endsWith('superset/welcome/'),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify specific session cookie exists
|
||||
const sessionCookie = await authPage.getSessionCookie();
|
||||
expect(sessionCookie).not.toBeNull();
|
||||
expect(sessionCookie?.value).toBeTruthy();
|
||||
});
|
||||
});
|
||||
23
superset-frontend/playwright/utils/urls.ts
Normal file
23
superset-frontend/playwright/utils/urls.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export const URL = {
|
||||
LOGIN: 'login/',
|
||||
WELCOME: 'superset/welcome/',
|
||||
} as const;
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface TextControlProps<T extends InputValueType = InputValueType> {
|
||||
value?: T | null;
|
||||
controlId?: string;
|
||||
renderTrigger?: boolean;
|
||||
validationErrors?: string[];
|
||||
}
|
||||
|
||||
export interface TextControlState {
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
1613
tests/integration_tests/dao/base_dao_test.py
Normal file
1613
tests/integration_tests/dao/base_dao_test.py
Normal file
File diff suppressed because it is too large
Load Diff
143
tests/integration_tests/dao/conftest.py
Normal file
143
tests/integration_tests/dao/conftest.py
Normal 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
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user