Compare commits
34 Commits
6.0-bug-fi
...
custom-dri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d2cee1a7 | ||
|
|
02790b750f | ||
|
|
dd3b89620e | ||
|
|
d4d02c5104 | ||
|
|
41e7f51f4b | ||
|
|
843b81c34d | ||
|
|
cf4fd3e389 | ||
|
|
abb6069d22 | ||
|
|
e05061bdb2 | ||
|
|
d8ee744365 | ||
|
|
06e64115aa | ||
|
|
56152c2366 | ||
|
|
a891ccdb10 | ||
|
|
fd6da21ce0 | ||
|
|
1bf112a57a | ||
|
|
1f530d45cb | ||
|
|
1187902e68 | ||
|
|
ad3eff9e90 | ||
|
|
3e554674ff | ||
|
|
dced2f8564 | ||
|
|
05c6a1bf20 | ||
|
|
c193d6d6a1 | ||
|
|
fb840b8e71 | ||
|
|
d0cc6f115b | ||
|
|
966e231f94 | ||
|
|
a66737cb05 | ||
|
|
bc6859a99d | ||
|
|
133e686224 | ||
|
|
7d0a472d1e | ||
|
|
c2534f9155 | ||
|
|
088ecdd0bf | ||
|
|
e1a2e4843a | ||
|
|
15e8ffee1e | ||
|
|
19ddcb7e5c |
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
@@ -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 }}
|
||||
30
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
|
||||
@@ -136,6 +149,19 @@ curl -f http://localhost:8088/health || echo "❌ Setup required - see https://s
|
||||
- **Use negation operator**: `~Model.field` instead of `== False` to avoid ruff E712 errors
|
||||
- **Example**: `~Model.is_active` instead of `Model.is_active == False`
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
**When creating pull requests:**
|
||||
|
||||
1. **Read the current PR template**: Always check `.github/PULL_REQUEST_TEMPLATE.md` for the latest format
|
||||
2. **Use the template sections**: Include all sections from the template (SUMMARY, BEFORE/AFTER, TESTING INSTRUCTIONS, ADDITIONAL INFORMATION)
|
||||
3. **Follow PR title conventions**: Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
|
||||
- Format: `type(scope): description`
|
||||
- Example: `fix(dashboard): load charts correctly`
|
||||
- Types: `fix`, `feat`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
|
||||
|
||||
**Important**: Always reference the actual template file at `.github/PULL_REQUEST_TEMPLATE.md` instead of using cached content, as the template may be updated over time.
|
||||
|
||||
## Pre-commit Validation
|
||||
|
||||
**Use pre-commit hooks for quality validation:**
|
||||
|
||||
@@ -162,8 +162,11 @@ services:
|
||||
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
|
||||
# configuring the dev-server to use the host.docker.internal to connect to the backend
|
||||
superset: "http://superset-light:8088"
|
||||
# Webpack dev server configuration
|
||||
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
|
||||
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
|
||||
ports:
|
||||
- "127.0.0.1:${NODE_PORT:-9001}:9000" # Parameterized port
|
||||
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces
|
||||
command: ["/app/docker/docker-frontend.sh"]
|
||||
env_file:
|
||||
- path: docker/.env # default
|
||||
|
||||
@@ -138,7 +138,7 @@ try:
|
||||
from superset_config_docker import * # noqa: F403
|
||||
|
||||
logger.info(
|
||||
f"Loaded your Docker configuration at [{superset_config_docker.__file__}]"
|
||||
"Loaded your Docker configuration at [%s]", superset_config_docker.__file__
|
||||
)
|
||||
except ImportError:
|
||||
logger.info("Using default Docker config...")
|
||||
|
||||
@@ -165,6 +165,206 @@ Or in the CRUD interface theme JSON:
|
||||
|
||||
This feature works with the stock Docker image - no custom build required!
|
||||
|
||||
## ECharts Configuration Overrides
|
||||
|
||||
:::note
|
||||
Available since Superset 6.0
|
||||
:::
|
||||
|
||||
Superset provides fine-grained control over ECharts visualizations through theme-level configuration overrides. This allows you to customize the appearance and behavior of all ECharts-based charts without modifying individual chart configurations.
|
||||
|
||||
### Global ECharts Overrides
|
||||
|
||||
Apply settings to all ECharts visualizations using `echartsOptionsOverrides`:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"colorPrimary": "#2893B3",
|
||||
# ... other Ant Design tokens
|
||||
},
|
||||
"echartsOptionsOverrides": {
|
||||
"grid": {
|
||||
"left": "10%",
|
||||
"right": "10%",
|
||||
"top": "15%",
|
||||
"bottom": "15%"
|
||||
},
|
||||
"tooltip": {
|
||||
"backgroundColor": "rgba(0, 0, 0, 0.8)",
|
||||
"borderColor": "#ccc",
|
||||
"textStyle": {
|
||||
"color": "#fff"
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"textStyle": {
|
||||
"fontSize": 14,
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Chart-Specific Overrides
|
||||
|
||||
Target specific chart types using `echartsOptionsOverridesByChartType`:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"colorPrimary": "#2893B3",
|
||||
# ... other tokens
|
||||
},
|
||||
"echartsOptionsOverridesByChartType": {
|
||||
"echarts_pie": {
|
||||
"legend": {
|
||||
"orient": "vertical",
|
||||
"right": 10,
|
||||
"top": "center"
|
||||
}
|
||||
},
|
||||
"echarts_timeseries": {
|
||||
"xAxis": {
|
||||
"axisLabel": {
|
||||
"rotate": 45,
|
||||
"fontSize": 12
|
||||
}
|
||||
},
|
||||
"dataZoom": [{
|
||||
"type": "slider",
|
||||
"show": True,
|
||||
"start": 0,
|
||||
"end": 100
|
||||
}]
|
||||
},
|
||||
"echarts_bubble": {
|
||||
"grid": {
|
||||
"left": "15%",
|
||||
"bottom": "20%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UI Configuration
|
||||
|
||||
You can also configure ECharts overrides through the theme CRUD interface:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"colorPrimary": "#2893B3"
|
||||
},
|
||||
"echartsOptionsOverrides": {
|
||||
"grid": {
|
||||
"left": "10%",
|
||||
"right": "10%"
|
||||
},
|
||||
"tooltip": {
|
||||
"backgroundColor": "rgba(0, 0, 0, 0.8)"
|
||||
}
|
||||
},
|
||||
"echartsOptionsOverridesByChartType": {
|
||||
"echarts_pie": {
|
||||
"legend": {
|
||||
"orient": "vertical",
|
||||
"right": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Override Precedence
|
||||
|
||||
The system applies overrides in the following order (last wins):
|
||||
|
||||
1. **Base ECharts theme** - Default Superset styling
|
||||
2. **Plugin options** - Chart-specific configurations
|
||||
3. **Global overrides** - `echartsOptionsOverrides`
|
||||
4. **Chart-specific overrides** - `echartsOptionsOverridesByChartType[chartType]`
|
||||
|
||||
This ensures chart-specific overrides take precedence over global ones.
|
||||
|
||||
### Common Chart Types
|
||||
|
||||
Available chart types for `echartsOptionsOverridesByChartType`:
|
||||
|
||||
- `echarts_timeseries` - Time series/line charts
|
||||
- `echarts_pie` - Pie and donut charts
|
||||
- `echarts_bubble` - Bubble/scatter charts
|
||||
- `echarts_funnel` - Funnel charts
|
||||
- `echarts_gauge` - Gauge charts
|
||||
- `echarts_radar` - Radar charts
|
||||
- `echarts_boxplot` - Box plot charts
|
||||
- `echarts_treemap` - Treemap charts
|
||||
- `echarts_sunburst` - Sunburst charts
|
||||
- `echarts_graph` - Network/graph charts
|
||||
- `echarts_sankey` - Sankey diagrams
|
||||
- `echarts_heatmap` - Heatmaps
|
||||
- `echarts_mixed_timeseries` - Mixed time series
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Start with global overrides** for consistent styling across all charts
|
||||
2. **Use chart-specific overrides** for unique requirements per visualization type
|
||||
3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
|
||||
4. **Document your overrides** to help team members understand custom styling
|
||||
5. **Consider performance** - complex overrides may impact chart rendering speed
|
||||
|
||||
### Example: Corporate Branding
|
||||
|
||||
```python
|
||||
# Complete corporate theme with ECharts customization
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"colorPrimary": "#1B4D3E",
|
||||
"fontFamily": "Corporate Sans, Arial, sans-serif"
|
||||
},
|
||||
"echartsOptionsOverrides": {
|
||||
"grid": {
|
||||
"left": "8%",
|
||||
"right": "8%",
|
||||
"top": "12%",
|
||||
"bottom": "12%"
|
||||
},
|
||||
"textStyle": {
|
||||
"fontFamily": "Corporate Sans, Arial, sans-serif"
|
||||
},
|
||||
"title": {
|
||||
"textStyle": {
|
||||
"color": "#1B4D3E",
|
||||
"fontSize": 18,
|
||||
"fontWeight": "bold"
|
||||
}
|
||||
}
|
||||
},
|
||||
"echartsOptionsOverridesByChartType": {
|
||||
"echarts_timeseries": {
|
||||
"xAxis": {
|
||||
"axisLabel": {
|
||||
"color": "#666",
|
||||
"fontSize": 11
|
||||
}
|
||||
}
|
||||
},
|
||||
"echarts_pie": {
|
||||
"legend": {
|
||||
"textStyle": {
|
||||
"fontSize": 12
|
||||
},
|
||||
"itemGap": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Manage system-wide default and dark themes via UI or configuration
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -4766,9 +4766,9 @@ available-typed-arrays@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
axios@^1.9.0:
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.11.0.tgz#c2ec219e35e414c025b2095e8b8280278478fdb6"
|
||||
integrity sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
|
||||
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.6"
|
||||
form-data "^4.0.4"
|
||||
|
||||
@@ -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
|
||||
@@ -313,6 +314,7 @@ select = [
|
||||
"E",
|
||||
"F",
|
||||
"F",
|
||||
"G",
|
||||
"I",
|
||||
"N",
|
||||
"PT",
|
||||
@@ -328,6 +330,7 @@ ignore = [
|
||||
"PT006",
|
||||
"T201",
|
||||
"N999",
|
||||
"G201",
|
||||
]
|
||||
extend-select = ["I"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -160,7 +160,9 @@ def test_validate_npm_handles_file_not_found_exception(mock_run, mock_which):
|
||||
def test_validate_npm_does_not_catch_other_subprocess_exceptions(
|
||||
mock_run, mock_which, exception_type
|
||||
):
|
||||
"""Test validate_npm does not catch OSError and PermissionError (they propagate up)."""
|
||||
"""
|
||||
Test validate_npm does not catch OSError and PermissionError (they propagate up).
|
||||
"""
|
||||
mock_which.return_value = "/usr/bin/npm"
|
||||
mock_run.side_effect = exception_type("Test error")
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -124,10 +124,13 @@ describe('VizType control', () => {
|
||||
});
|
||||
|
||||
it('Can change vizType', () => {
|
||||
cy.visitChartByName('Daily Totals');
|
||||
cy.visitChartByName('Daily Totals').then(() => {
|
||||
cy.get('.slice_container').should('be.visible');
|
||||
});
|
||||
|
||||
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
|
||||
|
||||
cy.contains('View all charts').click();
|
||||
cy.contains('View all charts').should('be.visible').click();
|
||||
|
||||
cy.get('.ant-modal-content').within(() => {
|
||||
cy.get('button').contains('KPI').click(); // change categories
|
||||
@@ -176,8 +179,12 @@ describe('Groupby control', () => {
|
||||
.contains('Drop columns here or click')
|
||||
.click();
|
||||
cy.get('[id="adhoc-metric-edit-tabs-tab-simple"]').click();
|
||||
cy.get('input[aria-label="Column"]').click();
|
||||
cy.get('input[aria-label="Column"]').type('state{enter}');
|
||||
|
||||
cy.get('input[aria-label="Columns and metrics"]', { timeout: 10000 })
|
||||
.should('be.visible')
|
||||
.click();
|
||||
cy.get('input[aria-label="Columns and metrics"]').type('state{enter}');
|
||||
|
||||
cy.get('[data-test="ColumnEdit#save"]').contains('Save').click();
|
||||
|
||||
cy.get('button[data-test="run-query-button"]').click();
|
||||
|
||||
104
superset-frontend/package-lock.json
generated
@@ -64,7 +64,6 @@
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"emotion-rgba": "0.0.12",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -160,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",
|
||||
@@ -6781,6 +6781,16 @@
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/source-map/node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@jest/test-result": {
|
||||
"version": "30.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.2.tgz",
|
||||
@@ -10110,6 +10120,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",
|
||||
@@ -20779,15 +20805,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camel-case": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||
@@ -24466,12 +24483,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/emotion-rgba": {
|
||||
"version": "0.0.12",
|
||||
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.12.tgz",
|
||||
"integrity": "sha512-lvtZ52BWisYDtis+HctQMkxcHwmFbzTiZhgMJGFfWXLsBYEzthfKE7nlysOiUwmmAdTM/8YBAPfwQ4MEDwiaWw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodable": {
|
||||
"version": "0.7.8",
|
||||
"resolved": "https://registry.npmjs.org/encodable/-/encodable-0.7.8.tgz",
|
||||
@@ -44935,6 +44946,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module/node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-conflict-json": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz",
|
||||
@@ -45524,6 +45544,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",
|
||||
@@ -63330,8 +63397,7 @@
|
||||
"dependencies": {
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1"
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -65234,6 +65300,8 @@
|
||||
"d3-array": "^1.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.3",
|
||||
|
||||
@@ -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",
|
||||
@@ -132,7 +137,6 @@
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"emotion-rgba": "0.0.12",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -228,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apache-superset/core",
|
||||
"version": "0.0.1-rc3",
|
||||
"version": "0.0.1-rc4",
|
||||
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
|
||||
"sideEffects": false,
|
||||
"main": "lib/index.js",
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
"dependencies": {
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"prop-types": "^15.8.1"
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ interface LookupTable {
|
||||
|
||||
export interface ExampleImage {
|
||||
url: string;
|
||||
urlDark?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ export interface ChartMetadataConfig {
|
||||
enableNoResults?: boolean;
|
||||
supportedAnnotationTypes?: string[];
|
||||
thumbnail: string;
|
||||
thumbnailDark?: string;
|
||||
useLegacyApi?: boolean;
|
||||
behaviors?: Behavior[];
|
||||
exampleGallery?: ExampleImage[];
|
||||
@@ -71,6 +73,8 @@ export default class ChartMetadata {
|
||||
|
||||
thumbnail: string;
|
||||
|
||||
thumbnailDark?: string;
|
||||
|
||||
useLegacyApi: boolean;
|
||||
|
||||
behaviors: Behavior[];
|
||||
@@ -107,6 +111,7 @@ export default class ChartMetadata {
|
||||
description = '',
|
||||
supportedAnnotationTypes = [],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi = false,
|
||||
behaviors = [],
|
||||
datasourceCount = 1,
|
||||
@@ -138,6 +143,7 @@ export default class ChartMetadata {
|
||||
);
|
||||
this.supportedAnnotationTypes = supportedAnnotationTypes;
|
||||
this.thumbnail = thumbnail;
|
||||
this.thumbnailDark = thumbnailDark;
|
||||
this.useLegacyApi = useLegacyApi;
|
||||
this.behaviors = behaviors;
|
||||
this.datasourceCount = datasourceCount;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,21 +123,33 @@ export function AsyncAceEditor(
|
||||
const cssWorkerUrlPromise = import(
|
||||
'ace-builds/src-min-noconflict/worker-css'
|
||||
);
|
||||
const javascriptWorkerUrlPromise = import(
|
||||
'ace-builds/src-min-noconflict/worker-javascript'
|
||||
);
|
||||
const htmlWorkerUrlPromise = import(
|
||||
'ace-builds/src-min-noconflict/worker-html'
|
||||
);
|
||||
const acequirePromise = import('ace-builds/src-min-noconflict/ace');
|
||||
|
||||
const [
|
||||
{ default: ReactAceEditor },
|
||||
{ config },
|
||||
{ default: cssWorkerUrl },
|
||||
{ default: javascriptWorkerUrl },
|
||||
{ default: htmlWorkerUrl },
|
||||
{ require: acequire },
|
||||
] = await Promise.all([
|
||||
reactAcePromise,
|
||||
aceBuildsConfigPromise,
|
||||
cssWorkerUrlPromise,
|
||||
javascriptWorkerUrlPromise,
|
||||
htmlWorkerUrlPromise,
|
||||
acequirePromise,
|
||||
]);
|
||||
|
||||
config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
|
||||
config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
|
||||
config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
|
||||
|
||||
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* eslint-disable import/first */
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { FC } from 'react';
|
||||
import AceEditor, { IAceEditorProps } from 'react-ace';
|
||||
import ace from 'ace-builds/src-noconflict/ace';
|
||||
|
||||
// Disable workers to avoid localhost loading issues
|
||||
ace.config.set('useWorker', false);
|
||||
|
||||
// Import required modes and themes after ace is loaded
|
||||
import 'ace-builds/src-min-noconflict/mode-handlebars';
|
||||
import 'ace-builds/src-min-noconflict/mode-css';
|
||||
import 'ace-builds/src-min-noconflict/mode-json';
|
||||
import 'ace-builds/src-min-noconflict/mode-sql';
|
||||
import 'ace-builds/src-min-noconflict/mode-markdown';
|
||||
import 'ace-builds/src-min-noconflict/mode-javascript';
|
||||
import 'ace-builds/src-min-noconflict/mode-html';
|
||||
import 'ace-builds/src-noconflict/theme-github';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
|
||||
export type CodeEditorMode =
|
||||
| 'handlebars'
|
||||
| 'css'
|
||||
| 'json'
|
||||
| 'sql'
|
||||
| 'markdown'
|
||||
| 'javascript'
|
||||
| 'html';
|
||||
|
||||
export type CodeEditorTheme = 'light' | 'dark';
|
||||
|
||||
export interface CodeEditorProps
|
||||
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
|
||||
mode?: CodeEditorMode;
|
||||
theme?: CodeEditorTheme;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const CodeEditor: FC<CodeEditorProps> = ({
|
||||
mode = 'handlebars',
|
||||
theme = 'dark',
|
||||
name,
|
||||
width = '100%',
|
||||
height = '300px',
|
||||
value,
|
||||
fontSize = 14,
|
||||
showPrintMargin = true,
|
||||
focus = true,
|
||||
wrapEnabled = true,
|
||||
highlightActiveLine = true,
|
||||
editorProps = { $blockScrolling: true },
|
||||
setOptions,
|
||||
...rest
|
||||
}: CodeEditorProps) => {
|
||||
const editorName = name || Math.random().toString(36).substring(7);
|
||||
const aceTheme = theme === 'light' ? 'github' : 'monokai';
|
||||
|
||||
return (
|
||||
<AceEditor
|
||||
mode={mode}
|
||||
theme={aceTheme}
|
||||
name={editorName}
|
||||
height={height}
|
||||
width={width}
|
||||
value={value}
|
||||
fontSize={fontSize}
|
||||
showPrintMargin={showPrintMargin}
|
||||
focus={focus}
|
||||
editorProps={editorProps}
|
||||
wrapEnabled={wrapEnabled}
|
||||
highlightActiveLine={highlightActiveLine}
|
||||
setOptions={{
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
enableSnippets: true,
|
||||
showLineNumbers: true,
|
||||
tabSize: 2,
|
||||
showGutter: true,
|
||||
fontFamily:
|
||||
'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
|
||||
...setOptions,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -181,3 +181,9 @@ export {
|
||||
type ThemedAgGridReactProps,
|
||||
setupAGGridModules,
|
||||
} from './ThemedAgGridReact';
|
||||
export {
|
||||
CodeEditor,
|
||||
type CodeEditorProps,
|
||||
type CodeEditorMode,
|
||||
type CodeEditorTheme,
|
||||
} from './CodeEditor';
|
||||
|
||||
@@ -60,10 +60,7 @@ export function useTheme() {
|
||||
|
||||
const styled: CreateStyled = emotionStyled;
|
||||
|
||||
// launching in in dark mode for now while iterating
|
||||
const themeObject: Theme = Theme.fromConfig({
|
||||
algorithm: ThemeAlgorithm.DEFAULT,
|
||||
});
|
||||
const themeObject: Theme = Theme.fromConfig();
|
||||
|
||||
const { theme } = themeObject;
|
||||
const supersetTheme = theme;
|
||||
|
||||
@@ -127,6 +127,15 @@ export interface SupersetSpecificTokens {
|
||||
// Spinner-related
|
||||
brandSpinnerUrl?: string;
|
||||
brandSpinnerSvg?: string;
|
||||
|
||||
// ECharts-related
|
||||
/** Global ECharts configuration overrides applied to all chart types */
|
||||
echartsOptionsOverrides?: any;
|
||||
|
||||
/** Chart-specific ECharts configuration overrides keyed by viz_type */
|
||||
echartsOptionsOverridesByChartType?: {
|
||||
[chartType: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum FeatureFlag {
|
||||
AlertReports = 'ALERT_REPORTS',
|
||||
AlertReportTabs = 'ALERT_REPORT_TABS',
|
||||
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
|
||||
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
|
||||
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
|
||||
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
|
||||
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
|
||||
|
||||
@@ -33,3 +33,4 @@ export * from './random';
|
||||
export * from './typedMemo';
|
||||
export * from './html';
|
||||
export * from './tooltip';
|
||||
export * from './merge';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 { mergeReplaceArrays } from './merge';
|
||||
|
||||
describe('lodash utilities', () => {
|
||||
describe('mergeReplaceArrays', () => {
|
||||
it('should merge objects and replace arrays', () => {
|
||||
const obj1 = { a: [1, 2], b: { c: 3 } };
|
||||
const obj2 = { a: [4, 5], b: { d: 6 } };
|
||||
|
||||
const result = mergeReplaceArrays(obj1, obj2);
|
||||
|
||||
expect(result).toEqual({
|
||||
a: [4, 5], // array replaced
|
||||
b: { c: 3, d: 6 }, // objects merged
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle precedence with multiple sources', () => {
|
||||
const base = { x: { y: 1 }, z: [1] };
|
||||
const override1 = { x: { y: 2 }, z: [2, 3] };
|
||||
const override2 = { x: { y: 3 }, z: [4] };
|
||||
|
||||
const result = mergeReplaceArrays(base, override1, override2);
|
||||
|
||||
expect(result).toEqual({
|
||||
x: { y: 3 }, // last wins
|
||||
z: [4], // array replaced by last
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty and null values', () => {
|
||||
const base = { a: [1], b: { x: 1 } };
|
||||
const override = { a: [], b: { x: null } };
|
||||
|
||||
const result = mergeReplaceArrays(base, override);
|
||||
|
||||
expect(result).toEqual({
|
||||
a: [], // empty array replaces
|
||||
b: { x: null }, // null overrides
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 { mergeWith } from 'lodash';
|
||||
|
||||
/**
|
||||
* Merges objects using lodash.mergeWith, but replaces arrays instead of concatenating them.
|
||||
* This is useful for configuration objects where you want to completely override array values
|
||||
* rather than merge them by index.
|
||||
*
|
||||
* @example
|
||||
* const obj1 = { a: [1, 2], b: { c: 3 } };
|
||||
* const obj2 = { a: [4, 5], b: { d: 6 } };
|
||||
* mergeReplaceArrays(obj1, obj2);
|
||||
* // Result: { a: [4, 5], b: { c: 3, d: 6 } }
|
||||
*
|
||||
* @example
|
||||
* // ECharts configuration merging
|
||||
* const baseConfig = { series: [{ type: 'line' }], grid: { left: '10%' } };
|
||||
* const overrides = { series: [{ type: 'bar' }], grid: { right: '10%' } };
|
||||
* mergeReplaceArrays(baseConfig, overrides);
|
||||
* // Result: { series: [{ type: 'bar' }], grid: { left: '10%', right: '10%' } }
|
||||
*
|
||||
* @param sources - Objects to merge (rightmost wins for arrays, deep merge for objects)
|
||||
* @returns Merged object with arrays replaced, not concatenated
|
||||
*/
|
||||
export function mergeReplaceArrays<T = any>(...sources: any[]): T {
|
||||
const replaceArrays = (objValue: any, srcValue: any) => {
|
||||
if (Array.isArray(srcValue)) {
|
||||
return srcValue; // Replace arrays entirely
|
||||
}
|
||||
return undefined; // Let lodash handle object merging for non-arrays
|
||||
};
|
||||
|
||||
return mergeWith({}, ...sources, replaceArrays);
|
||||
}
|
||||
@@ -17,4 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
declare module 'ace-builds/src-min-noconflict/worker-css';
|
||||
declare module 'ace-builds/src-min-noconflict/worker-javascript';
|
||||
declare module 'ace-builds/src-min-noconflict/worker-html';
|
||||
declare module 'ace-builds/src-min-noconflict/ace';
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -19,8 +19,10 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import example from './images/example.jpg';
|
||||
import exampleDark from './images/example-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
category: t('Correlation'),
|
||||
@@ -28,7 +30,7 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
"Visualizes how a metric has changed over a time using a color scale and a calendar view. Gray values are used to indicate missing values and the linear color scheme is used to encode the magnitude of each day's value.",
|
||||
),
|
||||
exampleGallery: [{ url: example }],
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
name: t('Calendar Heatmap'),
|
||||
tags: [
|
||||
t('Business'),
|
||||
@@ -39,6 +41,7 @@ const metadata = new ChartMetadata({
|
||||
t('Trend'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 106 KiB |
@@ -19,7 +19,9 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import example from './images/chord.jpg';
|
||||
import exampleDark from './images/chord-dark.jpg';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -29,11 +31,16 @@ const metadata = new ChartMetadata({
|
||||
'Showcases the flow or link between categories using thickness of chords. The value and corresponding thickness can be different for each side.',
|
||||
),
|
||||
exampleGallery: [
|
||||
{ url: example, caption: t('Relationships between community channels') },
|
||||
{
|
||||
url: example,
|
||||
urlDark: exampleDark,
|
||||
caption: t('Relationships between community channels'),
|
||||
},
|
||||
],
|
||||
name: t('Chord Diagram'),
|
||||
tags: [t('Circular'), t('Legacy'), t('Proportional'), t('Relational')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -19,8 +19,11 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import exampleUsa from './images/exampleUsa.jpg';
|
||||
import exampleUsaDark from './images/exampleUsa-dark.jpg';
|
||||
import exampleGermany from './images/exampleGermany.jpg';
|
||||
import exampleGermanyDark from './images/exampleGermany-dark.jpg';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -29,7 +32,10 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
"Visualizes how a single metric varies across a country's principal subdivisions (states, provinces, etc) on a choropleth map. Each subdivision's value is elevated when you hover over the corresponding geographic boundary.",
|
||||
),
|
||||
exampleGallery: [{ url: exampleUsa }, { url: exampleGermany }],
|
||||
exampleGallery: [
|
||||
{ url: exampleUsa, urlDark: exampleUsaDark },
|
||||
{ url: exampleGermany, urlDark: exampleGermanyDark },
|
||||
],
|
||||
name: t('Country Map'),
|
||||
tags: [
|
||||
t('2D'),
|
||||
@@ -40,6 +46,7 @@ const metadata = new ChartMetadata({
|
||||
t('Stacked'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -19,7 +19,9 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import example from './images/Horizon_Chart.jpg';
|
||||
import exampleDark from './images/Horizon_Chart-dark.jpg';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -28,10 +30,11 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'Compares how a metric changes over time between different groups. Each group is mapped to a row and change over time is visualized bar lengths and color.',
|
||||
),
|
||||
exampleGallery: [{ url: example }],
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
name: t('Horizon Chart'),
|
||||
tags: [t('Legacy')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 125 KiB |
@@ -18,8 +18,11 @@
|
||||
*/
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example1 from './images/MapBox.jpg';
|
||||
import example1Dark from './images/MapBox-dark.jpg';
|
||||
import example2 from './images/MapBox2.jpg';
|
||||
import example2Dark from './images/MapBox2-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -27,8 +30,8 @@ const metadata = new ChartMetadata({
|
||||
credits: ['https://www.mapbox.com/mapbox-gl-js/api/'],
|
||||
description: '',
|
||||
exampleGallery: [
|
||||
{ url: example1, description: t('Light mode') },
|
||||
{ url: example2, description: t('Dark mode') },
|
||||
{ url: example1, urlDark: example1Dark, description: t('Light mode') },
|
||||
{ url: example2, urlDark: example2Dark, description: t('Dark mode') },
|
||||
],
|
||||
name: t('MapBox'),
|
||||
tags: [
|
||||
@@ -40,6 +43,7 @@ const metadata = new ChartMetadata({
|
||||
t('Transformable'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import example from './images/example.jpg';
|
||||
import exampleDark from './images/example-dark.jpg';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -26,9 +29,11 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'Table that visualizes paired t-tests, which are used to understand statistical differences between groups.',
|
||||
),
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
name: t('Paired t-test Table'),
|
||||
tags: [t('Legacy'), t('Statistical'), t('Tabular')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -19,8 +19,11 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example1 from './images/example1.jpg';
|
||||
import example1Dark from './images/example1-dark.jpg';
|
||||
import example2 from './images/example2.jpg';
|
||||
import example2Dark from './images/example2-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -29,10 +32,14 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'Plots the individual metrics for each row in the data vertically and links them together as a line. This chart is useful for comparing multiple metrics across all of the samples or rows in the data.',
|
||||
),
|
||||
exampleGallery: [{ url: example1 }, { url: example2 }],
|
||||
exampleGallery: [
|
||||
{ url: example1, urlDark: example1Dark },
|
||||
{ url: example2, urlDark: example2Dark },
|
||||
],
|
||||
name: t('Parallel Coordinates'),
|
||||
tags: [t('Directional'), t('Legacy'), t('Relational')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -19,16 +19,19 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example from './images/example.jpg';
|
||||
import exampleDark from './images/example-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
category: t('Part of a Whole'),
|
||||
description: t('Compare the same summarized metric across multiple groups.'),
|
||||
exampleGallery: [{ url: example }],
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
name: t('Partition Chart'),
|
||||
tags: [t('Categorical'), t('Comparison'), t('Legacy'), t('Proportional')],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -19,8 +19,11 @@
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example1 from './images/example1.jpg';
|
||||
import example1Dark from './images/example1-dark.jpg';
|
||||
import example2 from './images/example2.jpg';
|
||||
import example2Dark from './images/example2-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -28,7 +31,10 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'A polar coordinate chart where the circle is broken into wedges of equal angle, and the value represented by any wedge is illustrated by its area, rather than its radius or sweep angle.',
|
||||
),
|
||||
exampleGallery: [{ url: example1 }, { url: example2 }],
|
||||
exampleGallery: [
|
||||
{ url: example1, urlDark: example1Dark },
|
||||
{ url: example2, urlDark: example2Dark },
|
||||
],
|
||||
name: t('Nightingale Rose Chart'),
|
||||
tags: [
|
||||
t('Legacy'),
|
||||
@@ -40,6 +46,7 @@ const metadata = new ChartMetadata({
|
||||
t('Trend'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -19,8 +19,11 @@
|
||||
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example1 from './images/WorldMap1.jpg';
|
||||
import example1Dark from './images/WorldMap1-dark.jpg';
|
||||
import example2 from './images/WorldMap2.jpg';
|
||||
import example2Dark from './images/WorldMap2-dark.jpg';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const metadata = new ChartMetadata({
|
||||
@@ -29,7 +32,10 @@ const metadata = new ChartMetadata({
|
||||
description: t(
|
||||
'A map of the world, that can indicate values in different countries.',
|
||||
),
|
||||
exampleGallery: [{ url: example1 }, { url: example2 }],
|
||||
exampleGallery: [
|
||||
{ url: example1, urlDark: example1Dark },
|
||||
{ url: example2, urlDark: example2Dark },
|
||||
],
|
||||
name: t('World Map'),
|
||||
tags: [
|
||||
t('2D'),
|
||||
@@ -43,6 +49,7 @@ const metadata = new ChartMetadata({
|
||||
t('Featured'),
|
||||
],
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
behaviors: [
|
||||
Behavior.InteractiveChart,
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"d3-array": "^1.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
"ngeohash": "^0.6.3",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
isValidElement,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
@@ -110,7 +111,9 @@ export const DeckGLContainer = memo(
|
||||
const layers = useCallback(() => {
|
||||
if (
|
||||
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
|
||||
OSM_LAYER_KEYWORDS.some(tilek => props.mapStyle?.includes(tilek))) &&
|
||||
OSM_LAYER_KEYWORDS.some((tilek: string) =>
|
||||
props.mapStyle?.includes(tilek),
|
||||
)) &&
|
||||
props.layers.some(
|
||||
l => typeof l !== 'function' && l?.id === 'tile-layer',
|
||||
) === false
|
||||
@@ -132,6 +135,20 @@ export const DeckGLContainer = memo(
|
||||
return props.layers as Layer[];
|
||||
}, [props.layers, props.mapStyle]);
|
||||
|
||||
const isCustomTooltip = (content: ReactNode): boolean =>
|
||||
isValidElement(content) &&
|
||||
content.props?.['data-tooltip-type'] === 'custom';
|
||||
|
||||
const renderTooltip = (tooltipState: TooltipProps['tooltip']) => {
|
||||
if (!tooltipState) return null;
|
||||
|
||||
if (isCustomTooltip(tooltipState.content)) {
|
||||
return <Tooltip tooltip={tooltipState} variant="custom" />;
|
||||
}
|
||||
|
||||
return <Tooltip tooltip={tooltipState} />;
|
||||
};
|
||||
|
||||
const { children = null, height, width } = props;
|
||||
|
||||
return (
|
||||
@@ -150,7 +167,7 @@ export const DeckGLContainer = memo(
|
||||
layers={layers()}
|
||||
viewState={viewState}
|
||||
onViewStateChange={onViewStateChange}
|
||||
onAfterRender={context => {
|
||||
onAfterRender={(context: any) => {
|
||||
glContextRef.current = context.gl;
|
||||
}}
|
||||
>
|
||||
@@ -164,7 +181,7 @@ export const DeckGLContainer = memo(
|
||||
</DeckGL>
|
||||
{children}
|
||||
</div>
|
||||
<Tooltip tooltip={tooltip} />
|
||||
{renderTooltip(tooltip)}
|
||||
</>
|
||||
);
|
||||
}),
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 256 KiB |
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
import { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example from './images/example.png';
|
||||
import exampleDark from './images/example-dark.png';
|
||||
import transformProps from '../transformProps';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
@@ -26,9 +28,10 @@ const metadata = new ChartMetadata({
|
||||
category: t('Map'),
|
||||
credits: ['https://uber.github.io/deck.gl'],
|
||||
description: t('Compose multiple layers together to form complex visuals.'),
|
||||
exampleGallery: [{ url: example }],
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
name: t('deck.gl Multiple Layers'),
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
tags: [t('deckGL'), t('Multi-Layers')],
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ const StyledLegend = styled.div`
|
||||
outline: none;
|
||||
overflow-y: scroll;
|
||||
max-height: 200px;
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
|
||||
& ul {
|
||||
list-style: none;
|
||||
|
||||
@@ -29,26 +29,42 @@ export type TooltipProps = {
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
variant?: 'default' | 'custom';
|
||||
};
|
||||
|
||||
const StyledDiv = styled.div<{ top: number; left: number }>`
|
||||
${({ theme, top, left }) => `
|
||||
const StyledDiv = styled.div<{
|
||||
top: number;
|
||||
left: number;
|
||||
variant: 'default' | 'custom';
|
||||
}>`
|
||||
${({ theme, top, left, variant }) => `
|
||||
position: absolute;
|
||||
top: ${top}px;
|
||||
left: ${left}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
margin: ${theme.sizeUnit * 2}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
color: ${theme.colorText};
|
||||
maxWidth: 300px;
|
||||
fontSize: ${theme.fontSizeSM}px;
|
||||
zIndex: 9;
|
||||
pointerEvents: none;
|
||||
${
|
||||
variant === 'default'
|
||||
? `
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
margin: ${theme.sizeUnit * 2}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
color: ${theme.colorText};
|
||||
maxWidth: 300px;
|
||||
fontSize: ${theme.fontSizeSM}px;
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
`
|
||||
: `
|
||||
margin: ${theme.sizeUnit * 3}px;
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function Tooltip(props: TooltipProps) {
|
||||
const { tooltip } = props;
|
||||
const { tooltip, variant = 'default' } = props;
|
||||
if (typeof tooltip === 'undefined' || tooltip === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -58,7 +74,7 @@ export default function Tooltip(props: TooltipProps) {
|
||||
typeof content === 'string' ? safeHtmlSpan(content) : content;
|
||||
|
||||
return (
|
||||
<StyledDiv top={y} left={x}>
|
||||
<StyledDiv top={y} left={x} variant={variant}>
|
||||
{safeContent}
|
||||
</StyledDiv>
|
||||
);
|
||||
|
||||
@@ -17,14 +17,26 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ArcLayer } from '@deck.gl/layers';
|
||||
import { JsonObject, QueryFormData, t } from '@superset-ui/core';
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
import { commonLayerProps } from '../common';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { Point } from '../../types';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
interface ArcDataItem {
|
||||
sourceColor?: number[];
|
||||
targetColor?: number[];
|
||||
color?: number[];
|
||||
sourcePosition: number[];
|
||||
targetPosition: number[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
const points: Point[] = [];
|
||||
data.forEach(d => {
|
||||
@@ -36,24 +48,14 @@ export function getPoints(data: JsonObject[]) {
|
||||
}
|
||||
|
||||
function setTooltipContent(formData: QueryFormData) {
|
||||
return (o: JsonObject) => (
|
||||
const defaultTooltipGenerator = (o: JsonObject) => (
|
||||
<div className="deckgl-tooltip">
|
||||
<TooltipRow
|
||||
label={t('Start (Longitude, Latitude): ')}
|
||||
value={`${o.object?.sourcePosition?.[0]}, ${o.object?.sourcePosition?.[1]}`}
|
||||
/>
|
||||
<TooltipRow
|
||||
label={t('End (Longitude, Latitude): ')}
|
||||
value={`${o.object?.targetPosition?.[0]}, ${o.object?.targetPosition?.[1]}`}
|
||||
/>
|
||||
{formData.dimension && (
|
||||
<TooltipRow
|
||||
label={`${formData?.dimension}: `}
|
||||
value={`${o.object?.cat_color}`}
|
||||
/>
|
||||
)}
|
||||
{CommonTooltipRows.arcPositions(o)}
|
||||
{CommonTooltipRows.category(o)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createTooltipContent(formData, defaultTooltipGenerator);
|
||||
}
|
||||
|
||||
export const getLayer: GetLayerType<ArcLayer> = function ({
|
||||
@@ -74,19 +76,27 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
|
||||
|
||||
return new ArcLayer({
|
||||
data,
|
||||
getSourceColor: (d: JsonObject) => {
|
||||
getSourceColor: (d: ArcDataItem): [number, number, number, number] => {
|
||||
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
||||
return [sc.r, sc.g, sc.b, 255 * sc.a];
|
||||
}
|
||||
|
||||
return d.targetColor || d.color;
|
||||
return (d.sourceColor || d.color || [sc.r, sc.g, sc.b, 255 * sc.a]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
getTargetColor: (d: any) => {
|
||||
getTargetColor: (d: ArcDataItem): [number, number, number, number] => {
|
||||
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
||||
return [tc.r, tc.g, tc.b, 255 * tc.a];
|
||||
}
|
||||
|
||||
return d.targetColor || d.color;
|
||||
return (d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a]) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
},
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
getWidth: fd.stroke_width ? fd.stroke_width : 3,
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
legendPosition,
|
||||
viewport,
|
||||
mapboxStyle,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
deckGLCategoricalColor,
|
||||
deckGLCategoricalColorSchemeSelect,
|
||||
deckGLCategoricalColorSchemeTypeSelect,
|
||||
@@ -77,6 +79,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['row_limit', filterNulls],
|
||||
['adhoc_filters'],
|
||||
[tooltipContents],
|
||||
[tooltipTemplate],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
|
||||
import thumbnail from './images/thumbnail.png';
|
||||
import thumbnailDark from './images/thumbnail-dark.png';
|
||||
import example from './images/example.png';
|
||||
import exampleDark from './images/example-dark.png';
|
||||
import transformProps from '../../transformProps';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
@@ -35,7 +37,8 @@ const metadata = new ChartMetadata({
|
||||
),
|
||||
name: t('deck.gl Arc'),
|
||||
thumbnail,
|
||||
exampleGallery: [{ url: example }],
|
||||
thumbnailDark,
|
||||
exampleGallery: [{ url: example, urlDark: exampleDark }],
|
||||
useLegacyApi: true,
|
||||
tags: [t('deckGL'), t('Geo'), t('3D'), t('Relational'), t('Web')],
|
||||
});
|
||||
|
||||
@@ -25,8 +25,24 @@ import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { ColorType } from '../../types';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import {
|
||||
createTooltipContent,
|
||||
CommonTooltipRows,
|
||||
} from '../../utilities/tooltipUtils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function defaultTooltipGenerator(o: any) {
|
||||
return (
|
||||
<div className="deckgl-tooltip">
|
||||
{CommonTooltipRows.centroid(o)}
|
||||
<TooltipRow
|
||||
label={t('Threshold: ')}
|
||||
value={`${o?.object?.contour?.threshold}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function setTooltipContent(o: any) {
|
||||
return (
|
||||
<div className="deckgl-tooltip">
|
||||
@@ -41,6 +57,7 @@ function setTooltipContent(o: any) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getLayer: GetLayerType<ContourLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
@@ -59,6 +76,18 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
||||
} = fd;
|
||||
let data = payload.data.features;
|
||||
|
||||
// Store original data for tooltip access
|
||||
const originalDataMap = new Map();
|
||||
data.forEach((d: any) => {
|
||||
if (d.position) {
|
||||
const key = `${Math.floor(d.position[0] * 1000)},${Math.floor(d.position[1] * 1000)}`;
|
||||
if (!originalDataMap.has(key)) {
|
||||
originalDataMap.set(key, []);
|
||||
}
|
||||
originalDataMap.get(key)?.push(d.originalData || d);
|
||||
}
|
||||
});
|
||||
|
||||
const contours = rawContours?.map(
|
||||
(contour: {
|
||||
color: ColorType;
|
||||
@@ -89,6 +118,47 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
||||
data = jsFnMutatorFunction(data);
|
||||
}
|
||||
|
||||
// Create wrapper for tooltip content that adds nearby points
|
||||
const tooltipContentGenerator = (o: any) => {
|
||||
// Find nearby points based on hover coordinate
|
||||
const nearbyPoints: any[] = [];
|
||||
if (o.coordinate) {
|
||||
const searchKey = `${Math.floor(o.coordinate[0] * 1000)},${Math.floor(o.coordinate[1] * 1000)}`;
|
||||
const points = originalDataMap.get(searchKey) || [];
|
||||
nearbyPoints.push(...points);
|
||||
|
||||
// Also check neighboring cells for better coverage
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const neighborKey = `${Math.floor(o.coordinate[0] * 1000) + dx},${Math.floor(o.coordinate[1] * 1000) + dy}`;
|
||||
const neighborPoints = originalDataMap.get(neighborKey) || [];
|
||||
nearbyPoints.push(...neighborPoints);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance the object with nearby points data
|
||||
if (nearbyPoints.length > 0) {
|
||||
const enhancedObject = {
|
||||
...o.object,
|
||||
nearbyPoints: nearbyPoints.slice(0, 5), // Limit to first 5 points
|
||||
totalPoints: nearbyPoints.length,
|
||||
// Add first point's data at top level for easy access
|
||||
...nearbyPoints[0],
|
||||
};
|
||||
Object.assign(o, { object: enhancedObject });
|
||||
}
|
||||
}
|
||||
|
||||
// Use createTooltipContent with the enhanced object
|
||||
const baseTooltipContent = createTooltipContent(
|
||||
fd,
|
||||
defaultTooltipGenerator,
|
||||
);
|
||||
return baseTooltipContent(o);
|
||||
};
|
||||
|
||||
return new ContourLayer({
|
||||
id: `contourLayer-${fd.slice_id}`,
|
||||
data,
|
||||
@@ -101,7 +171,7 @@ export const getLayer: GetLayerType<ContourLayer> = function ({
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
setTooltipContent,
|
||||
setTooltipContent: tooltipContentGenerator,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
filterState,
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
mapboxStyle,
|
||||
spatial,
|
||||
viewport,
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
@@ -44,6 +46,8 @@ const config: ControlPanelConfig = {
|
||||
['size'],
|
||||
[filterNulls],
|
||||
['adhoc_filters'],
|
||||
[tooltipContents],
|
||||
[tooltipTemplate],
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 65 KiB |