Compare commits

..

36 Commits

Author SHA1 Message Date
Joe Li
b08b8e41e8 fix(test): fix 3 failing tests in DatasetList.listview.test.tsx
**Problem**: 3 tests failing in CI (sharded-jest-tests shard 4):
1. "selecting all datasets shows correct count" - Multiple "Select all" elements found
2. "duplicate...403 forbidden" - Modal not closing before toast assertion
3. "duplicate...500 error" - Modal not closing before toast assertion

**Root Causes**:

**Issue 1**: Ant Design 5.27.6 renders multiple checkboxes with `aria-label="Select all"`
**Issue 2 & 3**: Modal close animations exceed default 8s waitFor timeout

**Solutions**:

1. Use getAllByLabelText and select first checkbox
2. Increase modal close timeout to 10s (lines 1032, 1097)
3. Add async afterEach cleanup for local development

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:00:15 -08:00
Joe Li
89b0ca0905 fix(playwright): use exact cell match in Table.getRow() to prevent substring collisions
**Problem**: Duplicate dataset test failing with strict mode violation:
```
Error: locator().filter({ hasText: 'members_channels_2' }) resolved to 2 elements:
  1) duplicate_members_channels_2_1763101756082
  2) members_channels_2
```

**Root Cause**: The `filter({ hasText: rowText })` uses substring matching,
so searching for 'members_channels_2' matches both the original dataset
AND any duplicates that contain that substring in their name.

**Solution**: Use exact cell match instead of substring text match

Changed `Table.getRow()` from:
```typescript
.filter({ hasText: rowText })
```

To:
```typescript
.filter({ has: this.page.getByRole('cell', { name: rowText, exact: true }) })
```

**Why this works**:
- Matches the cell with EXACT text (not substring)
- Won't match 'duplicate_members_channels_2_XXX' when looking for 'members_channels_2'
- More precise and resilient to leftover test data
- Uses semantic selector (role='cell') + exact match

**Impact**: All tests using `getDatasetRow()` will now use exact matching:
- Navigate test (finds specific dataset in list)
- Delete test (finds correct dataset to delete)
- Duplicate test (distinguishes original from duplicate)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:09:40 -08:00
Joe Li
7f057b1ef8 fix(ci): update experimental Playwright workflow to load all examples
**Problem**: Experimental Playwright tests still failing after commit 8eb510f.

**Root Cause**: There are TWO Playwright workflow files:
- `.github/workflows/superset-e2e.yml` - Updated in commit 8eb510f 
- `.github/workflows/superset-playwright.yml` - NOT updated 

The experimental tests (`playwright-tests-experimental` job) run from
the `superset-playwright.yml` file which was still using `testdata`
instead of `playwright_testdata`, causing `members_channels_2` dataset
to not be loaded.

**Solution**: Update `superset-playwright.yml` to use `playwright_testdata`

Changed line 100 from:
  run: testdata
To:
  run: playwright_testdata

This matches the fix in commit 8eb510f and ensures experimental tests
load all 17 example datasets including virtual datasets.

**Testing**: Will be validated by CI run after push

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 22:17:49 -08:00
Joe Li
8eb510f21a fix(ci): load all examples for Playwright E2E tests
**Problem**: Playwright duplicate test fails in CI because
`members_channels_2` dataset is not loaded with `--load-test-data` flag.

The `--load-test-data` flag only loads files matching `*.test.*` pattern
(e.g., `unicode_test.test.yaml`). Regular example datasets like
`members_channels_2.yaml` are skipped, causing the duplicate dataset
test to fail with null.

**Root Cause**:
- CI uses `superset load_examples --load-test-data` (line 115 in bashlib.sh)
- This loads ONLY `*.test.*` files (1 dataset: unicode_test)
- Playwright test expects `members_channels_2` virtual dataset
- Ephemeral environments use `superset load_examples` (all datasets)

**Solution**: Create separate data loading function for Playwright

1. Added `playwright_testdata()` function in bashlib.sh:
   - Loads ALL examples via `superset load_examples` (no flag)
   - Includes all 17 datasets (16 virtual + 1 test)
   - Matches ephemeral environment behavior

2. Updated superset-e2e.yml:
   - Playwright job now uses `playwright_testdata` instead of `testdata`
   - Cypress tests still use `testdata` (unchanged)

**Impact**:
-  Playwright tests can use realistic example datasets
-  Matches ephemeral environment data availability
-  No impact on Cypress tests (still use --load-test-data)
- ⏱️ CI data loading ~30-60s slower (acceptable tradeoff)

**Testing**: Will be validated by CI run after push

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 21:45:42 -08:00
Joe Li
022cd3c2e4 fix(test): use semantic selectors for antd 5.27.6 compatibility
**Problem**: Two tests timing out after antd upgrade (5.25.4 → 5.27.6)
1. "selecting all datasets shows correct count in toolbar"
2. "duplicate action shows error toast on 403/500"

**Root Causes**:

Test 1: Using unreliable DOM order assumption
- Used checkboxes[0] assuming it's select-all (breaks with DOM changes)
- Searched for /selected/i text instead of using data-test

Test 2: Not waiting for async modal closure
- Clicked submit, immediately checked toast
- Modal closes asynchronously on error, causing timing issues

**Solutions**:

Test 1: Use semantic selectors (like ChartList tests do)
- Changed: checkboxes[0] → screen.getByLabelText('Select all')
- Changed: getByText(/selected/i) → getByTestId('bulk-select-copy')
- Pattern matches commit 825b7edcc0 (table header fix)

Test 2: Wait for modal to close before checking toast
- Added: waitFor modal dialog not.toBeInTheDocument()
- Ensures all async operations complete before assertion

**Benefits**:
-  No timeout hacks (proper fix, not masking)
-  More resilient to DOM structure changes
-  Uses semantic selectors (aria-label, data-test)
-  Matches established patterns in codebase
2025-11-13 16:16:17 -08:00
Joe Li
6210bfa8b5 fix(playwright): use virtual example dataset for duplicate test
**Problem**: Test created physical dataset, but duplicate button only shows
for virtual datasets (those with SQL queries).

**Solution**: Use existing 'members_channels_2' virtual example dataset

**Changes**:

1. Added getDatasetByName() helper (dataset.ts):
   - Searches datasets by table_name using filter API
   - Returns { id, data } - both ID and full payload in one call
   - No hardcoded IDs, works in any environment
   - Reusable for future tests

2. Updated duplicate test to use example dataset:
   - Gets members_channels_2 by name (ID varies by environment)
   - Uses returned data for verification (no extra API call)
   - Expects dataset to exist (fails gracefully if example data not loaded)
   - Only cleans up duplicate (original is example data)
   - Simpler: no dataset/database creation needed

**Benefits**:
-  Works with virtual datasets (duplicate button appears)
-  No hardcoded dataset IDs
-  Optimized: One less API call (uses data from getDatasetByName)
-  Fewer API calls overall (no dataset/database creation)
-  More realistic (uses actual Superset data)
-  Still hermetic (only duplicate gets cleaned up)

**Testing**: CI will validate with loaded example data
2025-11-13 14:55:18 -08:00
Joe Li
f726f26a16 refactor(playwright): simplify testResources assignment
Before (3 lines):
  const { datasetId, dbId } = await createTestDataset(page, name);
  testResources.datasetIds.push(datasetId);
  testResources.dbId = dbId;

After (2 lines):
  const result = await createTestDataset(page, name);
  testResources = { datasetIds: [result.datasetId], dbId: result.dbId };

Benefits:
- Single assignment instead of mutation
- Fewer lines (2 vs 3)
- More explicit about array creation
- Easier to read
2025-11-13 11:06:52 -08:00
Joe Li
3fae07398d refactor(playwright): improve duplicate test with response interception and API verification
**Problem 1: Delete dependency**
- Previous version deleted duplicate via UI
- If delete failed, couldn't tell if duplicate or delete was broken
- Violated "one test, one concern" principle

**Problem 2: Missing usability verification**
- Only verified duplicate appeared in list
- Didn't verify SQL/schema/database were actually copied
- No proof duplicate was usable, just that it existed

**Solutions:**

1. Response interception (like Cypress intercept):
   - Use page.waitForResponse() to capture duplicate API response
   - Extract duplicate dataset ID directly from backend
   - Track in testResources for cleanup

2. Multi-dataset cleanup pattern:
   - Changed testResources from { datasetId } to { datasetIds: [] }
   - Updated cleanup to loop through all dataset IDs
   - All tests now push to array instead of replacing

3. API verification of duplicate:
   - Added apiGetDataset() helper to fetch dataset details
   - Compare original vs duplicate: sql, database.id, schema
   - Verify table_name changed to new duplicate name
   - Proves duplicate is a true copy, not just a shell

4. Removed UI delete from test:
   - No dependency on delete test
   - Relies on afterEach cleanup via API
   - Clean separation of concerns

**Benefits:**
-  No test interdependencies
-  Verifies duplicate is actually usable
-  Cleaner, more focused test
-  Reusable multi-dataset cleanup pattern
2025-11-12 18:11:57 -08:00
Joe Li
078a57dc2d test(playwright): add duplicate dataset E2E test
- Creates test dataset via API (hermetic)
- Clicks duplicate action button
- Fills DuplicateDatasetModal with new name
- Verifies success toast appears
- Verifies both original and duplicate exist in list
- Cleans up duplicate via UI delete
- Tests real backend dataset creation (not mocked)

Closes gap: Component tests mock API, E2E verifies actual creation
2025-11-12 17:12:28 -08:00
Joe Li
13513c35c4 fix(test): use getByRole for table header in integration test
Fix antd 5.27.6 measurement cell issue in DatasetList.integration.test.tsx.
The getByText() call was finding multiple "Name" elements (real header +
measurement cell), causing Jest worker crashes with "Found multiple elements".

Changed to getByRole('columnheader', { name: /Name/i }) for semantic query.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:28:16 -08:00
Joe Li
46404692d1 fix(playwright): prevent database leak in createTestDataset on failure
Wrap dataset creation in try/catch to clean up orphaned database if
apiPostDataset fails. Without this, failed test runs accumulate GSheets
database connections that never get deleted by afterEach cleanup.

The issue: createGsheetsDatabase succeeds and creates dbId, but if
apiPostDataset throws before returning, the test's testResources never
receives dbId, so afterEach can't clean it up.

The fix: catch errors during dataset creation, delete the database with
failOnStatusCode: false, then rethrow the original error for visibility.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:05:06 -08:00
Joe Li
8e3164a4f2 refactor(playwright): harden dataset E2E tests
- Remove hardcoded birth_names dependency (create test data via API)
- Fix JSON template injection in createGsheetsDatabase (use JSON.stringify)
- Add TypeScript interfaces for API payloads (DatabaseCreatePayload, DatasetCreatePayload)

Tests remain in experimental/ directory until proven stable. Keeps test.describe
structure per Playwright best practices.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 13:13:02 -08:00
Joe Li
825b7edcc0 fix(test): use getByRole for table headers to fix CI failures with antd 5.27.6
Ant Design Table v5.27.6 renders measurement cells containing duplicate
header text, breaking tests using getByText(). Changed to getByRole('columnheader')
for semantic, robust queries.

Fixes:
- DatasetList.permissions.test.tsx: 2 locations (lines 67, 137)
- DatasetList.listview.test.tsx: 12 locations (lines 158-178, 276, 309, 335, 1115)

All 50 tests now passing with antd@5.27.6.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 11:31:19 -08:00
Joe Li
8952e80ffd refactor(playwright): reorganize dataset tests to experimental directory
After rebasing onto master, reorganize Playwright tests to follow the
experimental pattern that was adopted in master:

- Move dataset E2E tests to tests/experimental/ directory
- Update import paths to reflect new directory structure (../../ → ../../../)
- Update chromium project testIgnore to respect experimental pattern
- Add comprehensive README explaining experimental test workflow
- Auth tests remain stable (always run by default)
- Dataset tests are now opt-in (require INCLUDE_EXPERIMENTAL=true)

Test organization:
- tests/auth/ - Stable auth tests (2 tests)
- tests/experimental/dataset/ - Experimental dataset tests (2 tests)

All supporting infrastructure remains in stable locations:
- playwright/components/ - Modal, Table, Toast, etc.
- playwright/pages/ - AuthPage, DatasetListPage, ExplorePage
- playwright/helpers/api/ - Full CRUD + factories

Verification:
- Without INCLUDE_EXPERIMENTAL: 2 tests (auth only)
- With INCLUDE_EXPERIMENTAL=true: 4 tests (auth + dataset)
2025-11-11 12:27:18 -08:00
Joe Li
e5ecf755fd fix(test): correct ThemeProvider import path in DuplicateDatasetModal test
Changed import from deprecated @superset-ui/core to @apache-superset/core
to fix TypeScript compilation error. The theme exports were moved to the
new package during the Ant Design v5 refactor.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:28:36 -08:00
Joe Li
c4369f907f fix(playwright): use relative API paths to support APP_ROOT prefix
Convert absolute API paths (/api/v1/...) to relative paths (api/v1/...)
in Playwright API helpers to properly handle baseURL with path prefix.

Problem:
- CI test "should delete a dataset" was failing in app_prefix configuration
- Absolute paths starting with / bypass Playwright's baseURL
- With baseURL=http://localhost:8081/app/prefix/, requests went to
  http://localhost:8081/api/v1/... (missing /app/prefix/)

Solution:
- Remove leading / from all API endpoint constants
- Playwright now correctly prepends baseURL to relative paths
- Works in both standard (no prefix) and app_prefix configurations

Files changed:
- playwright/helpers/api/database.ts - DATABASE endpoint
- playwright/helpers/api/dataset.ts - DATASET endpoint
- playwright/helpers/api/requests.ts - CSRF token endpoint + comment

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:28:36 -08:00
Joe Li
f51f1cdd89 feat(playwright): implement dataset delete E2E test with API infrastructure
Add comprehensive API infrastructure and delete dataset E2E test:

**API Infrastructure:**
- Generic request helpers with CSRF token handling (requests.ts)
- DRY header building for all mutation requests
- Database and dataset CRUD operations
- Google Sheets database factory for test data isolation
- Response validation with clear error messages

**Toast Component:**
- Reusable toast verification component
- Type-specific getters (success, danger, warning, info)
- SELECTORS constants (no magic strings)

**Delete Dataset Test:**
- Creates test dataset via API (Google Sheets)
- Tests full delete flow: click → modal → confirm → verify removal
- Validates success toast appears with correct message
- Clean afterEach cleanup pattern (no race conditions)

**Code Quality:**
- No private Playwright APIs (uses env variables)
- Path prefix support for prefixed deployments
- All selectors in constants
- Type-safe throughout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:28:36 -08:00
Joe Li
d65f5ac719 fix(playwright): isolate auth tests from global authentication
Root cause: Auth tests inherited global storageState, causing immediate
redirect to /welcome and login form never rendering (100% failure rate).

Problem:
- Auth tests ran in BOTH chromium and chromium-unauth projects (4 runs instead of 2)
- Global storageState at config root inherited by all projects
- chromium-unauth override to undefined didn't prevent cookie inheritance
- Worker loads config-level storageState before project overrides apply
- Tests navigate to /login but redirect to /welcome (already authenticated)
- waitForLoginForm() times out after 5000ms (form never renders)

Solution (file-based routing):
- Add testIgnore to chromium project (skip tests/auth/**)
- Move storageState from global use to chromium project only
- chromium-unauth now starts with clean browser (no cached cookies)
- Directory structure enforces auth context (no manual tagging needed)

Changes:
- Remove global storageState from config root (line 67)
- Add testIgnore to chromium project (line 80)
- Move storageState to chromium project only (line 85)
- Remove storageState: undefined override from chromium-unauth (no longer needed)

Expected result:
- Before: Running 4 tests → ××××FF××××FF (4 failures)
- After: Running 2 tests → ✓✓ (2 passes in chromium-unauth only)

Architecture pattern established:
- tests/auth/ → chromium-unauth project (clean browser)
- tests/dataset/ etc → chromium project (global auth, fast E2E)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
67e365b2e3 fix(playwright): implement hybrid auth - per-test login for auth tests
Switch auth tests to per-test login via beforeEach instead of fighting
with storageState configuration. This follows the Cypress pattern and
provides clear separation:

- Auth tests: Per-test login (simple, isolated)
- E2E tests: Global auth (fast, login once)

Changes:
1. login.spec.ts: Add beforeEach hook for navigation
2. playwright.config.ts: Document hybrid approach
3. global-setup.ts: Clarify it's for non-auth tests only
4. client.ts: Prettier formatting (auto-applied)

This eliminates the storageState complexity that was causing
auth test failures while keeping E2E tests fast.

Pattern:
- Auth tests run in chromium-unauth project with beforeEach navigation
- E2E tests run in chromium project with global-setup.ts auth
- Each project has clear purpose documented in config

Benefits:
- Simple, maintainable auth tests (no storageState debugging)
- Fast E2E tests (login once, reuse state)
- Follows proven Cypress approach
- Clear separation of concerns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
71cabc33e0 fix(playwright): correct import paths and baseURL fallback in API helpers
Two critical fixes for Playwright API helper utilities:

1. Fix Import Path (datasets.ts:22):
   - Changed: '../utils/constants'
   - To: '../../utils/constants'
   - Issue: Wrong path resolved to playwright/helpers/utils/constants.ts (doesn't exist)
   - Fix: Correct path resolves to playwright/utils/constants.ts
   - Impact: TypeScript compilation would fail for any test using datasets helper

2. Fix Hardcoded localhost (client.ts:58):
   - Changed: return 'http://localhost:8088'
   - To: return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088'
   - Issue: CI uses http://superset:8088, API calls before first navigation fail
   - Fix: Respect PLAYWRIGHT_BASE_URL environment variable
   - Impact: API helpers work correctly in CI environment

Fallback Chain for baseURL:
1. Extract from page.url() if page has navigated
2. Use process.env.PLAYWRIGHT_BASE_URL (CI: http://superset:8088)
3. Fallback to localhost (local dev: http://localhost:8088)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
468cf9a686 fix(playwright): use separate project for unauthenticated tests
Root Cause:
- playwright.config.ts line 67 hardcodes storageState for ALL tests
- File-scope test.use({ storageState: undefined }) runs AFTER worker
  initialization with cached auth state
- Login tests immediately redirect (already authenticated) → timeout

Solution:
- Create 'chromium-unauth' Playwright project for auth tests
- testMatch routes tests/auth/**/*.spec.ts to unauth project
- Project-level storageState: undefined overrides global setting
- Workers initialize with correct auth state from the start

Changes:
1. playwright.config.ts: Add chromium-unauth project definition
2. login.spec.ts: Remove file-scope test.use() workaround

Why This Works:
- Project-level config overrides global config at worker init time
- testMatch provides declarative routing (no runtime overrides needed)
- Follows Playwright best practices for multiple auth states
- Future-proof: easy to add signup/reset password unauthenticated tests

Fixes failing CI tests from commit 5c9ae89026.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
382928b1e7 fix(playwright): auth test isolation and directory creation
1. Auth State Isolation:
   - Move test.use({ storageState: undefined }) to file scope
   - Previously scoped inside test.describe, which ran after worker
     loaded global auth state, causing login redirects
   - File-scope override creates fresh context for login tests only

2. Directory Creation:
   - Add mkdir for playwright/.auth before saving storageState
   - Prevents failure on first run when directory doesn't exist

3. Code Quality:
   - Remove test.describe nesting (Superset testing guidelines)
   - Create authPage per-test instead of file-scoped variable
   - Ensures tests remain parallel-safe if mode changes later
   - Use TIMEOUT.PAGE_LOAD constant instead of magic 10000
   - Use URL.WELCOME constant instead of hardcoded string
   - Rely on Playwright's inferred fixture types

Fixes broken auth/login tests while keeping fast global auth for
all other tests.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
e830742f36 test(playwright): add E2E infrastructure and dataset list navigation test
Implements Phase 0 and Phase 1 of Playwright E2E test migration:

Infrastructure:
- Global authentication setup with storageState for test performance
- Base Modal component with inheritance pattern for specific modals
- Table component for ListView interactions
- Timeout constants for maintainability
- Enhanced AuthPage with resilient login validation

Components:
- DeleteConfirmationModal and DuplicateDatasetModal
- DatasetListPage and ExplorePage page objects

First E2E test validates dataset → Explore navigation flow using
existing birth_names fixture dataset.

All code follows YAGNI/DRY principles - minimal implementation
for Test 1, with room to grow as additional tests are added.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:58 -08:00
Joe Li
65e71dc429 fix(tests): revert async cleanup and add timeout to slow test
Reverts the async afterEach pattern from commit 69c34d9cfc which caused
CI timeout failures. The async cleanup with setTimeout added cumulative delays
across 36 tests, exceeding test timeouts in slow CI environments.

Root cause analysis:
- Async afterEach with setTimeout(0) adds ~5ms per test
- With 36 tests, this compounds to significant overhead
- Two tests were hitting 20s/30s timeouts in CI

Solution:
1. Restore synchronous afterEach (no delays)
2. Add 30s timeout to "bulk selection clears when filter changes" test

The "document global undefined" error from earlier investigation was likely
transient (Jest cache related), not a systematic issue requiring async cleanup.

Test results: All 36 tests passing (100%), 158s runtime (faster than 171s with async).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
a567408a63 fix(tests): restore async cleanup and fix assertion in DatasetList.listview tests
Fixes two issues in DatasetList.listview.test.tsx:

1. Async cleanup: Reverts commit 2a4263e627 and restores async afterEach with
   setTimeout pattern from commit 47255b285e. Synchronous cleanup was causing
   flaky "document global undefined" errors when React components scheduled
   async updates after test completion.

2. Assertion bug: Fixes "edit action is disabled for non-owner" test at line 857.
   Disabled buttons have BOTH 'action-button' and 'disabled' classes, not just
   'disabled' alone.

Test results: All 36 tests passing (100%), no flaky errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
f8538d104d fix(tests): revert to synchronous afterEach in DatasetList.listview tests
Reverts async afterEach cleanup pattern that was causing CI timeout failures.

**Problem**: Commit 47255b285e introduced async afterEach with act() + setTimeout,
causing 4 tests to timeout in CI:
- "selecting all datasets shows correct count in toolbar" (20s timeout)
- "exit bulk select via close button returns to normal view" (20s timeout)
- "bulk delete refreshes list with updated count" (30s timeout)
- "bulk selection clears when filter changes" (20s timeout)

**Root Cause**: Async afterEach hook introduces timing delays that compound
across 36 tests in the suite, causing CI environment (shard 4/8) to hit
Jest's default timeouts.

**Solution**: Revert to synchronous afterEach pattern used in commit 0d582b71dd.
The async cleanup was intended to fix "document global undefined" crashes,
but those don't occur with synchronous cleanup in practice.

**Verification**:
- Local tests: 35/36 passing (1 unrelated pre-existing failure)
- No timeouts: All 4 previously failing tests now pass
- No leaks: --detectOpenHandles shows clean teardown
- Execution time: 162s (faster than async version)

Note: The remaining failure ("edit action is disabled for non-owner") is a
separate assertion bug unrelated to async cleanup.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
16eb3229c9 test(datasets): add integration tests and fix DatasetList test suite
Adds 2 integration tests and fixes suite-level errors in 46 component tests.

**New Integration Tests** (DatasetList.integration.test.tsx):
- ListView provider state merge: verifies filter + sort + pagination combine correctly
- Bulk delete orchestration: verifies selection → delete → refresh → cleanup cycle
- Both tests passing (24s runtime)

**Async Cleanup Fix**:
- Added async afterEach with act() + setTimeout(0) flush in listview tests
- Prevents "document global undefined" crashes when running full suite
- Ensures React state updates complete before test cleanup

**Code Quality Improvements**:
- Removed unused findFilterByLabel helper (behavior.test.tsx)
- Removed debug console.error statement (behavior.test.tsx)
- Simplified "typing in search" test to verify input value only
- Added comment to DATASET_BULK_DELETE endpoint pattern (testHelpers.tsx)

**Test Reliability Fixes**:
- Increased bulk delete timeout from 20s to 30s (actual time: ~27s)
- Added filter resilience pattern to handle pre-applied state
- Improved assertion timing: capture call counts before operations start
- Fixed integration test to verify "0 selected" state after bulk delete
- Removed conditional expect calls to satisfy eslint rules

**Test Results**:
- DatasetList.behavior.test.tsx: 10/10 passing (33s)
- DatasetList.listview.test.tsx: 36/36 passing (31 baseline + 5 "+1" tests, 167s)
- DatasetList.integration.test.tsx: 2/2 passing (24s)
- Total: 48 tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
e673ffbf6f fix(tests): properly target close button in flaky DatasetList test
The "exit bulk select via close button" test was flaky and timing out because
it used userEvent.click() on a querySelector result. Ant Design Alert close
buttons require fireEvent.click() with DOM elements from querySelector.

Root cause analysis across 3 fix attempts:
1. Initial fix (2025-10-17): Used queryByRole('button') which could grab any
   button (Export, Delete, etc.) instead of the close icon
2. Second fix (2025-10-20): Fixed verification but still used userEvent.click()
   which doesn't work with querySelector DOM elements
3. Final fix (2025-10-20): Scope .querySelector() to testid container + use
   fireEvent.click(), avoiding production component changes

Changes:
- Find bulk-select-controls container via getByTestId (existing testid)
- Scope querySelector('.ant-alert-close-icon') to that container only
- Use fireEvent.click() instead of userEvent.click() for DOM elements
- Update verification to check container disappearance
- Remove TODO comment as issue is resolved
- No production component changes required

Why this approach is better:
- Scoped selector prevents selecting wrong close buttons from other components
- No need to modify ListView.tsx component with custom test props
- More reliable than ChartList tests which use document.querySelector globally
- Pragmatic use of CSS selector for framework internals, scoped to testid

All 31/31 tests now pass reliably in both individual and full suite runs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
b3e8fea741 refactor(tests): reduce boilerplate and fix flaky DatasetList test
Created setupErrorTestScenario helper to eliminate ~40 lines of duplicate code across 4 error toast tests (delete 403/500, duplicate 403/500).

Fixed flaky exit bulk select test by replacing unreliable role query with testId container + labelText pattern for better reliability.

All 31/31 listview tests passing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
aa75b805f7 fix(tests): properly mock SupersetClient errors for toast validation
Fixed the 4 error toast tests by:
- Added module-level mock of MessageToasts/actions to intercept Redux dispatches
- Created buildSupersetClientError helper for proper error structure
- Updated error tests to throw structured errors instead of plain Error objects
- Removed local addDangerToast mocks in favor of module-level mocks

All 31 DatasetList.listview.test.tsx tests now pass.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
bad24486b8 refactor(tests): apply combined waitFor assertion pattern to error toast tests
Applied the combined `waitFor(() => expect(mock).toHaveBeenCalledWith(...))`
pattern to 4 error toast tests as requested, replacing the two-step pattern.

Changes:
- Updated delete 403/500 error tests to use combined assertion
- Updated duplicate 403/500 error tests to use combined assertion

Status: 27 passing, 4 failing (error toast tests broken by this change)

The 4 error toast tests are now failing because addDangerToast is not being
called (0 calls vs expected >= 1). Need to investigate why the combined
pattern breaks these tests when the original two-step pattern worked.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
551f8968c8 fix(tests): resolve bulk action visibility and error toast handling
- Remove premature bulk action button assertions from listview tests
  Bulk export/delete buttons only appear after selecting items, not
  immediately upon entering bulk mode. Updated tests to focus on
  core functionality (checkboxes, close button) without asserting
  on buttons that require additional user interaction.

- Fix error handler in DatasetList fetchDataset to properly call addDangerToast
  Error message was being passed to t() but not wrapped in addDangerToast call,
  preventing error toasts from displaying to users.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
f6e7d7bff5 test(datasets): fix import button query in DatasetList tests
Updated import button queries to use testId instead of semantic role
queries due to missing accessible text on the button element.

The import button only has data-test="import-button" with an icon that
has aria-label="download" and role="img", but no accessible text on the
button itself. Changed from findByRole/getByRole to findByTestId/getByTestId
to make tests pass while documenting the accessibility issue with TODO
comments for future improvement.

Fixes 6 failing tests across DatasetList.test.tsx (2 tests) and
DatasetList.permissions.test.tsx (4 tests).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
51c3e2133d refactor(tests): improve type safety and modernize dataset hook tests
- Add typed response helpers (buildSupersetResponse, buildCachedResponse) to consolidate mocking boilerplate
- Replace waitForNextUpdate() with explicit waitFor(() => expect(result.current.X)...) patterns for better async assertions
- Flatten describe() blocks to standalone test() calls following Kent C. Dodds style
- Add proper TypeScript interfaces for Rison query decoding
- Remove eslint-disable comments for describe blocks
- Use 'as unknown as T' double-cast pattern (matching DashboardCard.test.tsx) for incomplete test mocks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
4cbdff6338 test: add React Testing Library tests for DatasetList
Add comprehensive RTL test coverage for the DatasetList page, with test-local
fixture types that don't modify production code.

**Test Structure**
- Component tests (DatasetList.test.tsx): Basic rendering, UI state
- Integration tests (DatasetList.listview.test.tsx): User interactions, API workflows
- Behavior tests (DatasetList.behavior.test.tsx): Bulk operations, duplication
- Permission tests (DatasetList.permissions.test.tsx): Role-based access control
- Modal tests (DuplicateDatasetModal.test.tsx): Duplicate workflow (9 tests)
- Test helpers (DatasetList.testHelpers.tsx): Shared fixtures and utilities

**Test Quality**
- Test-local fixture types (DatasetFixture, VirtualDatasetFixture)
- No production code modifications required
- Zero type assertions ("as any", "as Dataset")
- Async queries use findBy (no waitFor anti-patterns)
- Observable behavior verification (API calls, DOM state)
- Clean hook tests (removed describe() nesting in useDatasetLists.test.ts)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
Joe Li
c39a2e89ab fix: resolve ESLint and TypeScript issues in dataset unit tests
- Add ESLint disable comments for describe blocks (following ChartList pattern)
- Fix TypeScript mock typing using jest.mocked() helper
- Update supersetGetCache.delete mock to be properly typed
- Fix mockDb.owners type to be tuple [number]
- Add 'as any' casts for test fixtures to avoid strict type checking
- All 30 unit tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 11:27:04 -08:00
305 changed files with 9313 additions and 23444 deletions

View File

@@ -117,6 +117,19 @@ testdata() {
say "::endgroup::"
}
playwright_testdata() {
cd "$GITHUB_WORKSPACE"
say "::group::Load all examples for Playwright tests"
# must specify PYTHONPATH to make `tests.superset_test_config` importable
export PYTHONPATH="$GITHUB_WORKSPACE"
pip install -e .
superset db upgrade
superset load_test_users
superset load_examples
superset init
say "::endgroup::"
}
celery-worker() {
cd "$GITHUB_WORKSPACE"
say "::group::Start Celery worker"

View File

@@ -223,7 +223,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v5

View File

@@ -97,7 +97,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v5

1
.gitignore vendored
View File

@@ -33,7 +33,6 @@ cover
.env
.envrc
.idea
.roo
.mypy_cache
.python-version
.tox

View File

@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
[MESSAGES CONTROL]
disable=all
enable=json-import,disallowed-sql-import,consider-using-transaction
enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
[REPORTS]

View File

@@ -18,7 +18,7 @@
######################################################################
# Node stage to deal with static asset construction
######################################################################
ARG PY_VER=3.11.14-slim-trixie
ARG PY_VER=3.11.13-slim-trixie
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}

View File

@@ -23,107 +23,6 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
### MCP Service
The MCP (Model Context Protocol) service enables AI assistants and automation tools to interact programmatically with Superset.
#### New Features
- MCP service infrastructure with FastMCP framework
- Tools for dashboards, charts, datasets, SQL Lab, and instance metadata
- Optional dependency: install with `pip install apache-superset[fastmcp]`
- Runs as separate process from Superset web server
- JWT-based authentication for production deployments
#### New Configuration Options
**Development** (single-user, local testing):
```python
# superset_config.py
MCP_DEV_USERNAME = "admin" # User for MCP authentication
MCP_SERVICE_HOST = "localhost"
MCP_SERVICE_PORT = 5008
```
**Production** (JWT-based, multi-user):
```python
# superset_config.py
MCP_AUTH_ENABLED = True
MCP_JWT_ISSUER = "https://your-auth-provider.com"
MCP_JWT_AUDIENCE = "superset-mcp"
MCP_JWT_ALGORITHM = "RS256" # or "HS256" for shared secrets
# Option 1: Use JWKS endpoint (recommended for RS256)
MCP_JWKS_URI = "https://auth.example.com/.well-known/jwks.json"
# Option 2: Use static public key (RS256)
MCP_JWT_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----..."
# Option 3: Use shared secret (HS256)
MCP_JWT_ALGORITHM = "HS256"
MCP_JWT_SECRET = "your-shared-secret-key"
# Optional overrides
MCP_SERVICE_HOST = "0.0.0.0"
MCP_SERVICE_PORT = 5008
MCP_SESSION_CONFIG = {
"SESSION_COOKIE_SECURE": True,
"SESSION_COOKIE_HTTPONLY": True,
"SESSION_COOKIE_SAMESITE": "Strict",
}
```
#### Running the MCP Service
```bash
# Development
superset mcp run --port 5008 --debug
# Production
superset mcp run --port 5008
# With factory config
superset mcp run --port 5008 --use-factory-config
```
#### Deployment Considerations
The MCP service runs as a **separate process** from the Superset web server.
**Important**:
- Requires same Python environment and configuration as Superset
- Shares database connections with main Superset app
- Can be scaled independently from web server
- Requires `fastmcp` package (optional dependency)
**Installation**:
```bash
# Install with MCP support
pip install apache-superset[fastmcp]
# Or add to requirements.txt
apache-superset[fastmcp]>=X.Y.Z
```
**Process Management**:
Use systemd, supervisord, or Kubernetes to manage the MCP service process.
See `superset/mcp_service/PRODUCTION.md` for deployment guides.
**Security**:
- Development: Uses `MCP_DEV_USERNAME` for single-user access
- Production: **MUST** configure JWT authentication
- See `superset/mcp_service/SECURITY.md` for details
#### Documentation
- Architecture: `superset/mcp_service/ARCHITECTURE.md`
- Security: `superset/mcp_service/SECURITY.md`
- Production: `superset/mcp_service/PRODUCTION.md`
- Developer Guide: `superset/mcp_service/CLAUDE.md`
- Quick Start: `superset/mcp_service/README.md`
---
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.

View File

@@ -67,7 +67,6 @@ x-superset-volumes: &superset-volumes
- ./superset-frontend:/app/superset-frontend
- superset_home_light:/app/superset_home
- ./tests:/app/tests
- ./extensions:/app/extensions
x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -105,15 +105,7 @@ class CeleryConfig:
CELERY_CONFIG = CeleryConfig
# Extensions configuration
# For local development, point to the extensions directory
# Note: If running in Docker, this path needs to be accessible from inside the container
EXTENSIONS_PATH = os.getenv("EXTENSIONS_PATH", "/app/extensions")
FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"ENABLE_EXTENSIONS": True,
}
FEATURE_FLAGS = {"ALERT_REPORTS": True}
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
# The base URL for the email report hyperlinks.

View File

@@ -26,8 +26,6 @@ under the License.
Extensions interact with Superset through well-defined, versioned APIs provided by the `@apache-superset/core` (frontend) and `apache-superset-core` (backend) packages. These APIs are designed to be stable, discoverable, and consistent for both built-in and external extensions.
**Note**: The `superset_core.api` module provides abstract classes that are replaced with concrete implementations via dependency injection when Superset initializes. This allows extensions to use the same interfaces as the host application.
**Frontend APIs** (via `@apache-superset/core)`:
The frontend extension APIs in Superset are organized into logical namespaces such as `authentication`, `commands`, `extensions`, `sqlLab`, and others. Each namespace groups related functionality, making it easy for extension authors to discover and use the APIs relevant to their needs. For example, the `sqlLab` namespace provides events and methods specific to SQL Lab, allowing extensions to react to user actions and interact with the SQL Lab environment:
@@ -92,38 +90,31 @@ Backend APIs follow a similar pattern, providing access to Superset's models, se
Extension endpoints are registered under a dedicated `/extensions` namespace to avoid conflicting with built-in endpoints and also because they don't share the same version constraints. By grouping all extension endpoints under `/extensions`, Superset establishes a clear boundary between core and extension functionality, making it easier to manage, document, and secure both types of APIs.
``` python
from superset_core.api.models import Database, get_session
from superset_core.api.daos import DatabaseDAO
from superset_core.api.rest_api import add_extension_api
from superset_core.api import rest_api, models, query
from .api import DatasetReferencesAPI
# Register a new extension REST API
add_extension_api(DatasetReferencesAPI)
rest_api.add_extension_api(DatasetReferencesAPI)
# Fetch Superset entities via the DAO to apply base filters that filter out entities
# that the user doesn't have access to
databases = DatabaseDAO.find_all()
# ..or apply simple filters on top of base filters
databases = DatabaseDAO.filter_by(uuid=database.uuid)
# Access Superset models with simple queries that filter out entities that
# the user doesn't have access to
databases = models.get_databases(id=database_id)
if not databases:
raise Exception("Database not found")
return self.response_404()
return databases[0]
database = databases[0]
# Perform complex queries using SQLAlchemy Query, also filtering out
# inaccessible entities
session = get_session()
databases_query = session.query(Database).filter(
Database.database_name.ilike("%abc%")
)
return DatabaseDAO.query(databases_query)
# Perform complex queries using SQLAlchemy BaseQuery, also filtering
# out inaccessible entities
session = models.get_session()
db_model = models.get_database_model())
database_query = session.query(db_model.database_name.ilike("%abc%")
databases_containing_abc = models.get_databases(query)
# Bypass security model for highly custom use cases
session = get_session()
all_databases_containing_abc = session.query(Database).filter(
Database.database_name.ilike("%abc%")
).all()
session = models.get_session()
db_model = models.get_database_model())
all_databases_containg_abc = session.query(db_model.database_name.ilike("%abc%").all()
```
In the future, we plan to expand the backend APIs to support configuring security models, database engines, SQL Alchemy dialects, etc.

View File

@@ -128,7 +128,7 @@ The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create
```python
from flask import Response
from flask_appbuilder.api import expose, protect, safe
from superset_core.api.rest_api import RestApi
from superset_core.api.types.rest_api import RestApi
class HelloWorldAPI(RestApi):

View File

@@ -1,101 +0,0 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# pkg_resources Deprecation and Migration Guide
## Background
As of setuptools 81.0.0 (scheduled for removal around 2025-11-30), the `pkg_resources` API is deprecated and will be removed. This affects several packages in the Python ecosystem.
## Current Status
### Superset Codebase ✅
The Superset codebase has already migrated away from `pkg_resources` to the modern `importlib.metadata` API:
- `superset/db_engine_specs/__init__.py:36` - Uses `from importlib.metadata import entry_points`
- All entry point loading uses the modern API
### Production Dependencies ⚠️
Some third-party dependencies may still use `pkg_resources`:
- **`clients` package** (Preset-specific): Uses `pkg_resources` in `const.py`
- Error path: `/usr/local/lib/python3.10/site-packages/clients/const.py:1`
## Migration Path
### Short-term Solution (Current)
Pin setuptools to version 80.x to prevent breaking changes:
```python
# requirements/base.in
setuptools<81
```
This prevents the removal of `pkg_resources` while dependent packages are updated.
### Long-term Solution
Update all dependencies to use `importlib.metadata` instead of `pkg_resources`:
#### Migration Example
**Old (deprecated):**
```python
import pkg_resources
version = pkg_resources.get_distribution("package_name").version
entry_points = pkg_resources.iter_entry_points("group_name")
```
**New (recommended):**
```python
from importlib.metadata import version, entry_points
pkg_version = version("package_name")
eps = entry_points(group="group_name")
```
## Action Items
### For Preset Team
1. **Update `clients` package** to use `importlib.metadata` instead of `pkg_resources`
2. **Review other internal packages** for `pkg_resources` usage
3. **Test with setuptools >= 81.0.0** once all packages are migrated
4. **Monitor Datadog logs** for similar deprecation warnings
### For Superset Maintainers
1. ✅ Already using `importlib.metadata`
2. Monitor third-party dependencies for updates
3. Update setuptools pin once ecosystem is ready
## Timeline
- **2025-11-30**: Expected removal of `pkg_resources` from setuptools
- **Before then**: All dependencies must migrate to `importlib.metadata`
## References
- [setuptools pkg_resources deprecation notice](https://setuptools.pypa.io/en/latest/pkg_resources.html)
- [importlib.metadata documentation](https://docs.python.org/3/library/importlib.metadata.html)
- [Migration guide](https://setuptools.pypa.io/en/latest/deprecated/pkg_resources.html)
## Monitoring
Track this issue in production using Datadog:
- Warning pattern: `pkg_resources is deprecated as an API`
- Component: `@component:app`
- Environment: `environment:production`

View File

@@ -487,7 +487,7 @@ const config: Config = {
'data-project-name': 'Apache Superset',
'data-project-color': '#FFFFFF',
'data-project-logo':
'https://superset.apache.org/img/superset-logo-icon-only.png',
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
'data-modal-override-open-id': 'ask-ai-input',
'data-modal-override-open-class': 'search-input',
'data-modal-disclaimer':

View File

@@ -49,8 +49,8 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^5.29.1",
"caniuse-lite": "^1.0.30001756",
"antd": "^5.28.0",
"caniuse-lite": "^1.0.30001754",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
"less": "^4.4.2",
@@ -70,19 +70,19 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.1",
"@docusaurus/tsconfig": "^3.9.2",
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.46.4",
"eslint": "^9.39.1",
"@typescript-eslint/parser": "^8.46.0",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"webpack": "^5.103.0"
"typescript-eslint": "^8.46.2",
"webpack": "^5.102.1"
},
"browserslist": {
"production": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1160,7 +1160,12 @@
dependencies:
core-js-pure "^3.43.0"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
"@babel/runtime@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
@@ -2466,10 +2471,10 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.39.1", "@eslint/js@^9.39.1":
version "9.39.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.1.tgz#0dd59c3a9f40e3f1882975c321470969243e0164"
integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==
"@eslint/js@9.39.0", "@eslint/js@^9.39.0":
version "9.39.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.0.tgz#e1955cefd1d79e80a9557274e9aa9bd3f641be01"
integrity sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==
"@eslint/object-schema@^2.1.7":
version "2.1.7"
@@ -4192,79 +4197,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.47.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz#c53edeec13a79483f4ca79c298d5231b02e9dc17"
integrity sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==
"@typescript-eslint/eslint-plugin@8.46.2", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/type-utils" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/type-utils" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.47.0", "@typescript-eslint/parser@^8.46.4":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.47.0.tgz#51b14ab2be2057ec0f57073b9ff3a9c078b0a964"
integrity sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==
"@typescript-eslint/parser@8.46.2", "@typescript-eslint/parser@^8.46.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
dependencies:
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
"@typescript-eslint/project-service@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.47.0.tgz#b8afc65e0527568018af911b702dcfbfdca16471"
integrity sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==
"@typescript-eslint/project-service@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608"
integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.47.0"
"@typescript-eslint/types" "^8.47.0"
"@typescript-eslint/tsconfig-utils" "^8.46.2"
"@typescript-eslint/types" "^8.46.2"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz#d1c36a973a5499fed3a99e2e6a66aec5c9b1e542"
integrity sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==
"@typescript-eslint/scope-manager@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88"
integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
"@typescript-eslint/tsconfig-utils@8.47.0", "@typescript-eslint/tsconfig-utils@^8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz#4f178b62813538759e0989dd081c5474fad39b84"
integrity sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
"@typescript-eslint/type-utils@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz#b9b0141d99bd5bece3811d7eee68a002597ffa55"
integrity sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==
"@typescript-eslint/type-utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384"
integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.47.0", "@typescript-eslint/types@^8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.47.0.tgz#c7fc9b6642d03505f447a8392934b9d1850de5af"
integrity sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
"@typescript-eslint/typescript-estree@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz#86416dad58db76c4b3bd6a899b1381f9c388489a"
integrity sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==
"@typescript-eslint/typescript-estree@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08"
integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==
dependencies:
"@typescript-eslint/project-service" "8.47.0"
"@typescript-eslint/tsconfig-utils" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/project-service" "8.46.2"
"@typescript-eslint/tsconfig-utils" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -4272,22 +4277,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.47.0.tgz#d6c30690431dbfdab98fc027202af12e77c91419"
integrity sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==
"@typescript-eslint/utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732"
integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz#35f36ed60a170dfc9d4d738e78387e217f24c29f"
integrity sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==
"@typescript-eslint/visitor-keys@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738"
integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/types" "8.46.2"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -4602,10 +4607,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.29.1:
version "5.29.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.29.1.tgz#e124b1aaa709a534816c42d558da02a917b995cc"
integrity sha512-TTFVbpKbyL6cPfEoKq6Ya3BIjTUr7uDW9+7Z+1oysRv1gpcN7kQ4luH8r/+rXXwz4n6BIz1iBJ1ezKCdsdNW0w==
antd@^5.28.0:
version "5.28.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.28.0.tgz#fb5dfc0a2ba5a90ee053c813d71f16e6b66ac994"
integrity sha512-AmCvyhWGHzlDQ6sfnGBBrFm/8sLPbBI8d/NDBsecliKqrTZUMr07TAQldo43iowwKzvgKxxuRoUHaBaYcBMdQA==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -5161,10 +5166,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001756:
version "1.0.30001756"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz#fe80104631102f88e58cad8aa203a2c3e5ec9ebd"
integrity sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001754:
version "1.0.30001754"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz#7758299d9a72cce4e6b038788a15b12b44002759"
integrity sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==
ccount@^2.0.0:
version "2.0.1"
@@ -6833,10 +6838,10 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@^9.39.1:
version "9.39.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5"
integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==
eslint@^9.39.0:
version "9.39.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.0.tgz#33c90ddf62b64e1e3f83b689934b336f21b5f0e5"
integrity sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
@@ -6844,7 +6849,7 @@ eslint@^9.39.1:
"@eslint/config-helpers" "^0.4.2"
"@eslint/core" "^0.17.0"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.39.1"
"@eslint/js" "9.39.0"
"@eslint/plugin-kit" "^0.4.1"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
@@ -8716,10 +8721,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
loader-runner@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3"
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
loader-runner@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0:
version "2.0.4"
@@ -13358,15 +13363,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.47.0:
version "8.47.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.47.0.tgz#bb8fcf4f2c69ffcd5d088f7f30cd52936ff05cbc"
integrity sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==
typescript-eslint@^8.46.2:
version "8.46.2"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.2.tgz#da1adec683ba93a1b6c3850a4efb0922ffbc627d"
integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==
dependencies:
"@typescript-eslint/eslint-plugin" "8.47.0"
"@typescript-eslint/parser" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/eslint-plugin" "8.46.2"
"@typescript-eslint/parser" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
typescript@~5.9.3:
version "5.9.3"
@@ -13904,10 +13909,10 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.103.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.103.0.tgz#17a7c5a5020d5a3a37c118d002eade5ee2c6f3da"
integrity sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==
webpack@^5.102.1, webpack@^5.88.1, webpack@^5.95.0:
version "5.102.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.102.1.tgz#1003a3024741a96ba99c37431938bf61aad3d988"
integrity sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
@@ -13926,7 +13931,7 @@ webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
glob-to-regexp "^0.4.1"
graceful-fs "^4.2.11"
json-parse-even-better-errors "^2.3.1"
loader-runner "^4.3.1"
loader-runner "^4.2.0"
mime-types "^2.1.27"
neo-async "^2.6.2"
schema-utils "^4.3.3"

View File

@@ -1,5 +0,0 @@
# Requirements for the Snowflake Semantic Layer extension
# Install with: pip install -r extensions/requirements-snowflake.txt
snowflake-connector-python>=3.0.0
snowflake-sqlalchemy>=1.5.0

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=5.0.2,<6",
"flask-appbuilder>=5.0.0,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -133,7 +133,8 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
# DuckDB 1.x has type system incompatibilities with duckdb-engine.
duckdb = ["duckdb>=0.10.2,<0.11", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]

View File

@@ -36,9 +36,3 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
# Known affected packages: Preset's 'clients' package
# See docs/docs/contributing/pkg-resources-migration.md for details
setuptools<81

View File

@@ -116,7 +116,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.2
flask-appbuilder==5.0.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
@@ -364,8 +364,6 @@ rsa==4.9.1
# via google-auth
selenium==4.32.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.3
# via apache-superset (pyproject.toml)
simplejson==3.20.1
@@ -386,7 +384,6 @@ sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
@@ -395,12 +392,9 @@ sqlalchemy==1.4.54
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==27.15.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# via apache-superset (pyproject.toml)
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
@@ -415,7 +409,6 @@ typing-extensions==4.15.0
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# cattrs
# limits
# pydantic

View File

@@ -48,7 +48,7 @@ attrs==25.3.0
# referencing
# requests-cache
# trio
authlib==1.6.5
authlib==1.6.4
# via fastmcp
babel==2.17.0
# via
@@ -179,7 +179,7 @@ cryptography==44.0.3
# secretstorage
cycler==0.12.1
# via matplotlib
cyclopts==4.2.4
cyclopts==3.24.0
# via fastmcp
db-dtypes==1.3.1
# via pandas-gbq
@@ -211,7 +211,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.4.2
duckdb==0.10.3
# via
# apache-superset
# duckdb-engine
@@ -228,7 +228,7 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==2.13.1
fastmcp==2.13.0.2
# via apache-superset
filelock==3.12.2
# via virtualenv
@@ -249,7 +249,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.0.2
flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -884,7 +884,7 @@ rsa==4.9.1
# google-auth
ruff==0.8.0
# via apache-superset
secretstorage==3.4.1
secretstorage==3.4.0
# via keyring
selenium==4.32.0
# via
@@ -892,9 +892,8 @@ selenium==4.32.0
# apache-superset
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==80.9.0
setuptools==80.7.1
# via
# -c requirements/base-constraint.txt
# nodeenv
# pandas-gbq
# pydata-google-auth
@@ -933,7 +932,6 @@ sqlalchemy==1.4.54
# -c requirements/base-constraint.txt
# alembic
# apache-superset
# apache-superset-core
# duckdb-engine
# flask-appbuilder
# flask-sqlalchemy
@@ -947,13 +945,11 @@ sqlalchemy-utils==0.38.3
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==27.15.2
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
sqloxide==0.1.51
# via apache-superset
sse-starlette==3.0.2
@@ -993,7 +989,6 @@ typing-extensions==4.15.0
# alembic
# anyio
# apache-superset
# apache-superset-core
# cattrs
# exceptiongroup
# limits
@@ -1030,9 +1025,7 @@ urllib3==2.5.0
# requests-cache
# selenium
uvicorn==0.37.0
# via
# fastmcp
# mcp
# via mcp
vine==5.1.0
# via
# -c requirements/base-constraint.txt

View File

@@ -19,23 +19,6 @@
set -e
# If not already running in Docker, run this script inside Docker
if [ -z "$RUNNING_IN_DOCKER" ]; then
# Extract "current" Python version from CI config (single source of truth)
PYTHON_VERSION=$(grep -A 1 'if.*"current"' .github/actions/setup-backend/action.yml | grep 'PYTHON_VERSION=' | sed 's/.*PYTHON_VERSION=\([0-9.]*\).*/\1/')
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
docker run --rm \
-v "$(pwd)":/app \
-w /app \
-e RUNNING_IN_DOCKER=1 \
python:${PYTHON_VERSION}-slim \
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
exit $?
fi
ADDITIONAL_ARGS="$@"
# Generate the requirements/base.txt file

View File

@@ -49,7 +49,7 @@ The package is organized into logical modules, each providing specific functiona
from flask import request, Response
from flask_appbuilder.api import expose, permission_name, protect, safe
from superset_core.api import models, query, rest_api
from superset_core.api.rest_api import RestApi
from superset_core.api.types.rest_api import RestApi
class DatasetReferencesAPI(RestApi):
"""Example extension API demonstrating core functionality."""

View File

@@ -42,11 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=5.0.2,<6",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0",
"sqlglot>=27.15.2, <28",
"typing-extensions>=4.0.0",
"flask-appbuilder>=5.0.0,<6",
]
[project.urls]

View File

@@ -14,7 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Apache Superset Core - Public API with core functions of Superset
"""

View File

@@ -14,3 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .types.models import CoreModelsApi
from .types.query import CoreQueryApi
from .types.rest_api import CoreRestApi
models: CoreModelsApi
rest_api: CoreRestApi
query: CoreQueryApi

View File

@@ -1,262 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Data Access Object API for superset-core.
Provides dependency-injected DAO classes that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.daos import DatasetDAO, DatabaseDAO
# Use standard BaseDAO methods
datasets = DatasetDAO.find_all()
dataset = DatasetDAO.find_one_or_none(id=123)
DatasetDAO.create(attributes={"name": "New Dataset"})
"""
from abc import ABC, abstractmethod
from typing import Any, ClassVar, Generic, TypeVar
from flask_appbuilder.models.filters import BaseFilter
from sqlalchemy.orm import Query as SQLAQuery
from superset_core.api.models import (
Chart,
CoreModel,
Dashboard,
Database,
Dataset,
KeyValue,
Query,
SavedQuery,
Tag,
User,
)
# Type variable bound to our CoreModel
T = TypeVar("T", bound=CoreModel)
class BaseDAO(Generic[T], ABC):
"""
Abstract base class for DAOs.
This ABC defines the base that all DAOs should implement,
providing consistent CRUD operations across Superset and extensions.
"""
# Due to mypy limitations, we can't have `type[T]` here
model_cls: ClassVar[type[Any] | None]
base_filter: ClassVar[BaseFilter | None]
id_column_name: ClassVar[str]
uuid_column_name: ClassVar[str]
@classmethod
@abstractmethod
def find_all(cls) -> list[T]:
"""Get all entities that fit the base_filter."""
...
@classmethod
@abstractmethod
def find_one_or_none(cls, **filter_by: Any) -> T | None:
"""Get the first entity that fits the base_filter."""
...
@classmethod
@abstractmethod
def create(
cls,
item: T | None = None,
attributes: dict[str, Any] | None = None,
) -> T:
"""Create an object from the specified item and/or attributes."""
...
@classmethod
@abstractmethod
def update(
cls,
item: T | None = None,
attributes: dict[str, Any] | None = None,
) -> T:
"""Update an object from the specified item and/or attributes."""
...
@classmethod
@abstractmethod
def delete(cls, items: list[T]) -> None:
"""Delete the specified items."""
...
@classmethod
@abstractmethod
def query(cls, query: SQLAQuery) -> list[T]:
"""Execute query with base_filter applied."""
...
@classmethod
@abstractmethod
def filter_by(cls, **filter_by: Any) -> list[T]:
"""Get all entries that fit the base_filter."""
...
class DatasetDAO(BaseDAO[Dataset]):
"""
Abstract Dataset DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class DatabaseDAO(BaseDAO[Database]):
"""
Abstract Database DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class ChartDAO(BaseDAO[Chart]):
"""
Abstract Chart DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class DashboardDAO(BaseDAO[Dashboard]):
"""
Abstract Dashboard DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class UserDAO(BaseDAO[User]):
"""
Abstract User DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class QueryDAO(BaseDAO[Query]):
"""
Abstract Query DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class SavedQueryDAO(BaseDAO[SavedQuery]):
"""
Abstract SavedQuery DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class TagDAO(BaseDAO[Tag]):
"""
Abstract Tag DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class KeyValueDAO(BaseDAO[KeyValue]):
"""
Abstract KeyValue DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"BaseDAO",
"DatasetDAO",
"DatabaseDAO",
"ChartDAO",
"DashboardDAO",
"UserDAO",
"QueryDAO",
"SavedQueryDAO",
"TagDAO",
"KeyValueDAO",
]

View File

@@ -1,295 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Model API for superset-core.
Provides model classes that will be replaced by host implementations
during initialization for extension developers to use.
Usage:
from superset_core.api.models import Dataset, Database, get_session
# Use as regular model classes
dataset = Dataset(name="My Dataset")
db = Database(database_name="My DB")
session = get_session()
"""
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_appbuilder import Model
from sqlalchemy.orm import scoped_session
class CoreModel(Model):
"""
Abstract base class that extends Flask-AppBuilder's Model.
This base class provides the interface contract for all Superset models.
The host package provides concrete implementations.
"""
__abstract__ = True
class Database(CoreModel):
"""
Abstract class for Database models.
This abstract class defines the contract that database models should implement,
providing consistent database connectivity and metadata operations.
"""
__abstract__ = True
id: int
verbose_name: str
database_name: str | None
@property
def name(self) -> str:
raise NotImplementedError
@property
def backend(self) -> str:
raise NotImplementedError
@property
def data(self) -> dict[str, Any]:
raise NotImplementedError
class Dataset(CoreModel):
"""
Abstract class for Dataset models.
This abstract class defines the contract that dataset models should implement,
providing consistent data source operations and metadata.
It provides the public API for Datasets implemented by the host application.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
table_name: str | None
main_dttm_col: str | None
database_id: int | None
schema: str | None
catalog: str | None
sql: str | None # For virtual datasets
description: str | None
default_endpoint: str | None
is_featured: bool
filter_select_enabled: bool
offset: int
cache_timeout: int
params: str
perm: str | None
schema_perm: str | None
catalog_perm: str | None
is_managed_externally: bool
external_url: str | None
fetch_values_predicate: str | None
is_sqllab_view: bool
template_params: str | None
extra: str | None # JSON string
normalize_columns: bool
always_filter_main_dttm: bool
folders: str | None # JSON string
class Chart(CoreModel):
"""
Abstract Chart/Slice model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
slice_name: str | None
datasource_id: int | None
datasource_type: str | None
datasource_name: str | None
viz_type: str | None
params: str | None
query_context: str | None
description: str | None
cache_timeout: int
certified_by: str | None
certification_details: str | None
is_managed_externally: bool
external_url: str | None
class Dashboard(CoreModel):
"""
Abstract Dashboard model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
dashboard_title: str | None
position_json: str | None
description: str | None
css: str | None
json_metadata: str | None
slug: str | None
published: bool
certified_by: str | None
certification_details: str | None
is_managed_externally: bool
external_url: str | None
class User(CoreModel):
"""
Abstract User model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
username: str | None
email: str | None
first_name: str | None
last_name: str | None
active: bool
class Query(CoreModel):
"""
Abstract Query model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
client_id: str | None
database_id: int | None
sql: str | None
status: str | None
user_id: int | None
progress: int
error_message: str | None
class SavedQuery(CoreModel):
"""
Abstract SavedQuery model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
label: str | None
sql: str | None
database_id: int | None
description: str | None
user_id: int | None
class Tag(CoreModel):
"""
Abstract Tag model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
name: str | None
type: str | None
class KeyValue(CoreModel):
"""
Abstract KeyValue model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
id: int
uuid: UUID | None
resource: str | None
value: str | None # Encoded value
expires_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
def get_session() -> scoped_session:
"""
Retrieve the SQLAlchemy session to directly interface with the
Superset models.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Query",
"SavedQuery",
"Tag",
"KeyValue",
"CoreModel",
"get_session",
]

View File

@@ -1,51 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Query API for superset-core.
Provides dependency-injected query utility functions that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.query import get_sqlglot_dialect
dialect = get_sqlglot_dialect(database)
"""
from typing import TYPE_CHECKING
from sqlglot import Dialects
if TYPE_CHECKING:
from superset_core.api.models import Database
def get_sqlglot_dialect(database: "Database") -> Dialects:
"""
Get the SQLGlot dialect for the specified database.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param database: The database instance to get the dialect for.
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]

View File

@@ -1,72 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
REST API functions for superset-core.
Provides dependency-injected REST API utility functions that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.rest_api import add_api, add_extension_api
add_api(MyCustomAPI)
add_extension_api(MyExtensionAPI)
"""
from flask_appbuilder.api import BaseApi
class RestApi(BaseApi):
"""
Base REST API class for Superset with browser login support.
This class extends Flask-AppBuilder's BaseApi and enables browser-based
authentication by default.
"""
allow_browser_login = True
def add_api(api: type[RestApi]) -> None:
"""
Add a REST API to the Superset API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param api: A REST API instance.
:returns: None.
"""
raise NotImplementedError("Function will be replaced during initialization")
def add_extension_api(api: type[RestApi]) -> None:
"""
Add an extension REST API to the Superset API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["RestApi", "add_api", "add_extension_api"]

View File

@@ -0,0 +1,90 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any, Type
from flask_sqlalchemy import BaseQuery
from sqlalchemy.orm import scoped_session
class CoreModelsApi(ABC):
"""
Abstract interface for accessing Superset data models.
This class defines the contract for retrieving SQLAlchemy sessions
and model instances for datasets and databases within Superset.
"""
@staticmethod
@abstractmethod
def get_session() -> scoped_session:
"""
Retrieve the SQLAlchemy session to directly interface with the
Superset models.
:returns: The SQLAlchemy scoped session instance.
"""
...
@staticmethod
@abstractmethod
def get_dataset_model() -> Type[Any]:
"""
Retrieve the Dataset (SqlaTable) SQLAlchemy model.
:returns: The Dataset SQLAlchemy model class.
"""
...
@staticmethod
@abstractmethod
def get_database_model() -> Type[Any]:
"""
Retrieve the Database SQLAlchemy model.
:returns: The Database SQLAlchemy model class.
"""
...
@staticmethod
@abstractmethod
def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
"""
Retrieve Dataset (SqlaTable) entities.
:param query: A query with the Dataset model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter datasets using SQLAlchemy's
`filter_by()`.
:returns: SqlaTable entities.
"""
...
@staticmethod
@abstractmethod
def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
"""
Retrieve Database entities.
:param query: A query with the Database model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter databases using SQLAlchemy's
`filter_by()`.
:returns: Database entities.
"""
...

View File

@@ -14,3 +14,28 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any
from sqlglot import Dialects
class CoreQueryApi(ABC):
"""
Abstract interface for query-related operations.
This class defines the contract for database query operations,
including dialect handling and query processing.
"""
@staticmethod
@abstractmethod
def get_sqlglot_dialect(database: Any) -> Dialects:
"""
Get the SQLGlot dialect for the specified database.
:param database: The database instance to get the dialect for.
:returns: The SQLGlot dialect enum corresponding to the database.
"""
...

View File

@@ -0,0 +1,64 @@
# 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.
from abc import ABC, abstractmethod
from typing import Type
from flask_appbuilder.api import BaseApi
class RestApi(BaseApi):
"""
Base REST API class for Superset with browser login support.
This class extends Flask-AppBuilder's BaseApi and enables browser-based
authentication by default.
"""
allow_browser_login = True
class CoreRestApi(ABC):
"""
Abstract interface for managing REST APIs in Superset.
This class defines the contract for adding and managing REST APIs,
including both core APIs and extension APIs.
"""
@staticmethod
@abstractmethod
def add_api(api: Type[RestApi]) -> None:
"""
Add a REST API to the Superset API.
:param api: A REST API instance.
:returns: None.
"""
...
@staticmethod
@abstractmethod
def add_extension_api(api: Type[RestApi]) -> None:
"""
Add an extension REST API to the Superset API.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
"""
...

View File

@@ -6696,9 +6696,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
@@ -12972,9 +12972,9 @@
"dev": true
},
"js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"requires": {
"argparse": "^1.0.7",

View File

@@ -81,8 +81,6 @@ export type ObserveDataMaskCallbackFn = (
nativeFiltersChanged: boolean;
},
) => void;
export type ThemeMode = 'default' | 'dark' | 'system';
export type EmbeddedDashboard = {
getScrollSize: () => Promise<Size>;
unmount: () => void;
@@ -94,7 +92,6 @@ export type EmbeddedDashboard = {
getDataMask: () => Promise<Record<string, any>>;
getChartStates: () => Promise<Record<string, any>>;
setThemeConfig: (themeConfig: Record<string, any>) => void;
setThemeMode: (mode: ThemeMode) => void;
};
/**
@@ -268,18 +265,6 @@ export async function embedDashboard({
}
};
const setThemeMode = (mode: ThemeMode): void => {
try {
ourPort.emit('setThemeMode', { mode });
log(`Theme mode set to: ${mode}`);
} catch (error) {
log(
'Error sending theme mode. Ensure the iframe side implements the "setThemeMode" method.',
);
throw error;
}
};
return {
getScrollSize,
unmount,
@@ -288,7 +273,6 @@ export async function embedDashboard({
observeDataMask,
getDataMask,
getChartStates,
setThemeConfig,
setThemeMode,
setThemeConfig
};
}

View File

@@ -1,6 +1,9 @@
coverage/*
cypress/screenshots
cypress/videos
playwright/.auth
playwright-report/
test-results/
src/temp
.temp_cache/
.tsbuildinfo

View File

@@ -15,9 +15,6 @@
],
"dependencies": {
"@apache-superset/core": "file:packages/superset-core",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
@@ -4294,16 +4291,16 @@
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
@@ -29403,20 +29400,6 @@
"url": "https://opencollective.com/geostyler"
}
},
"node_modules/geostyler/node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/geostyler/node_modules/geostyler-style": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz",
@@ -66264,6 +66247,17 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"packages/superset-ui-demo/node_modules/core-js": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"packages/superset-ui-demo/node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",

View File

@@ -94,9 +94,6 @@
],
"dependencies": {
"@apache-superset/core": "file:packages/superset-core",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -342,31 +342,6 @@ const x_axis_time_format: SharedControlConfig<
option.label.includes(search) || option.value.includes(search),
};
const x_axis_number_format: SharedControlConfig<
'SelectControl',
SelectDefaultOption
> = {
type: 'SelectControl',
freeForm: true,
label: t('X Axis Number Format'),
renderTrigger: true,
default: DEFAULT_NUMBER_FORMAT,
choices: D3_FORMAT_OPTIONS,
description: D3_FORMAT_DOCS,
tokenSeparators: ['\n', '\t', ';'],
filterOption: ({ data: option }, search) =>
option.label.includes(search) || option.value.includes(search),
mapStateToProps: state => {
const isPercentage =
state.controls?.comparison_type?.value === ComparisonType.Percentage;
return {
choices: isPercentage
? D3_FORMAT_OPTIONS.filter(option => option[0].includes('%'))
: D3_FORMAT_OPTIONS,
};
},
};
const color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
type: 'ColorSchemeControl',
label: t('Color Scheme'),
@@ -481,7 +456,6 @@ const sharedControls: Record<string, SharedControlConfig<any>> = {
size: dndSizeControl,
y_axis_format,
x_axis_time_format,
x_axis_number_format,
adhoc_filters: dndAdhocFilterControl,
color_scheme,
time_shift_color,

View File

@@ -38,24 +38,20 @@ export const getOpacity = (
minOpacity = MIN_OPACITY_BOUNDED,
maxOpacity = MAX_OPACITY,
) => {
if (extremeValue === cutoffPoint || typeof value !== 'number') {
if (
extremeValue === cutoffPoint ||
typeof cutoffPoint !== 'number' ||
typeof extremeValue !== 'number' ||
typeof value !== 'number'
) {
return maxOpacity;
}
const numCutoffPoint =
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
const numExtremeValue =
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
if (isNaN(numCutoffPoint) || isNaN(numExtremeValue)) {
return maxOpacity;
}
return Math.min(
maxOpacity,
round(
Math.abs(
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
(value - numCutoffPoint),
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
(value - cutoffPoint),
) + minOpacity,
2,
),

View File

@@ -35,500 +35,487 @@ const countValues = mockData.map(row => row.count);
const strData = [{ name: 'Brian' }, { name: 'Carlos' }, { name: 'Diana' }];
const strValues = strData.map(row => row.name);
test('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
describe('round', () => {
it('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
});
});
test('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
expect(getOpacity('100', 100, 100)).toEqual(1);
expect(getOpacity('75', 50, 100)).toEqual(1);
expect(getOpacity('50', '100', '100')).toEqual(1);
expect(getOpacity('50', '75', '100')).toEqual(1);
expect(getOpacity('50', NaN, '100')).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, 100)).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, NaN)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(100, 50, 100)).toEqual(1);
expect(getOpacity(75, '50', 100)).toEqual(0.53);
expect(getOpacity(75, 50, '100')).toEqual(0.53);
expect(getOpacity(75, '50', '100')).toEqual(0.53);
expect(getOpacity(50, NaN, NaN)).toEqual(1);
expect(getOpacity(50, NaN, 100)).toEqual(1);
expect(getOpacity(50, NaN, '100')).toEqual(1);
expect(getOpacity(50, '75', NaN)).toEqual(1);
expect(getOpacity(50, 75, NaN)).toEqual(1);
describe('getOpacity', () => {
it('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
});
});
test('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
describe('getColorFunction()', () => {
it('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
it('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
it('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
it('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BeginsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BeginsWith,
targetValue: 'C',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Brian')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction EndsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
it('getColorFunction Containing', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction NotContaining', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction Equal', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 'Diana',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Diana')).toEqual('#FF0000FF');
});
it('getColorFunction None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('#FF0000FF');
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
});
test('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
test('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
test('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
test('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BeginsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BeginsWith,
targetValue: 'C',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Brian')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction EndsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
test('getColorFunction Containing', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction NotContaining', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction Equal', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 'Diana',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Diana')).toEqual('#FF0000FF');
});
test('getColorFunction None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('#FF0000FF');
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
test('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
test('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
test('correct column string config', () => {
const columnConfigString = [
{
operator: Comparator.BeginsWith,
targetValue: 'D',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
];
const colorFormatters = getColorFormatters(columnConfigString, strData);
expect(colorFormatters.length).toEqual(4);
expect(colorFormatters[0].column).toEqual('name');
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('name');
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
expect(colorFormatters[2].column).toEqual('name');
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
expect(colorFormatters[3].column).toEqual('name');
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
describe('getColorFormatters()', () => {
it('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
it('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
it('correct column string config', () => {
const columnConfigString = [
{
operator: Comparator.BeginsWith,
targetValue: 'D',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
];
const colorFormatters = getColorFormatters(columnConfigString, strData);
expect(colorFormatters.length).toEqual(4);
expect(colorFormatters[0].column).toEqual('name');
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('name');
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
expect(colorFormatters[2].column).toEqual('name');
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
expect(colorFormatters[3].column).toEqual('name');
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
});
});

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type AceEditor from 'react-ace';
import {
AsyncAceEditor,
SQLEditor,
@@ -101,253 +99,3 @@ test('renders a custom placeholder', () => {
expect(screen.getByRole('paragraph')).toBeInTheDocument();
});
test('registers afterExec event listener for command handling', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Verify the commands object has the 'on' method (confirms event listener capability)
expect(editorInstance.commands).toHaveProperty('on');
expect(typeof editorInstance.commands.on).toBe('function');
});
test('moves autocomplete popup to parent container when triggered', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the editor container
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with insertstring command using _emit
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'insertstring' },
args: ['SELECT'],
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('moves autocomplete popup on startAutocomplete command event', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with startAutocomplete command
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'startAutocomplete' },
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('does not move autocomplete popup on unrelated commands', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the body
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
const originalParent = mockAutocompletePopup.parentElement;
// Simulate an unrelated command (e.g., 'selectall')
editorInstance.commands.exec('selectall', editorInstance, {});
// Wait a bit to ensure no movement happens
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should remain in its original location
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
// Cleanup
document.body.removeChild(mockAutocompletePopup);
});
test('revalidates cached autocomplete popup when detached from DOM', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create first autocomplete popup
const firstPopup = document.createElement('div');
firstPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(firstPopup);
// Trigger command to cache the first popup
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await waitFor(() => {
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
});
// Remove the first popup from DOM (simulating ACE editor replacing it)
firstPopup.remove();
// Create a new autocomplete popup
const secondPopup = document.createElement('div');
secondPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(secondPopup);
// Trigger command again - should find and move the new popup
editorInstance.commands.exec('insertstring', editorInstance, ' ');
await waitFor(() => {
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
expect(parentContainer?.contains(secondPopup)).toBe(true);
});
});
test('cleans up event listeners on unmount', async () => {
const ref = createRef<AceEditor>();
const { container, unmount } = render(
<SQLEditor ref={ref as React.Ref<never>} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Spy on the commands.off method
const offSpy = jest.spyOn(editorInstance.commands, 'off');
// Unmount the component
unmount();
// Verify that the event listener was removed
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
offSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
// Mock the closest method to return null (simulating no #ace-editor parent)
const originalClosest = editorInstance.container?.closest;
if (editorInstance.container) {
editorInstance.container.closest = jest.fn(() => null);
}
// Mock parentElement to be document.body
Object.defineProperty(editorInstance.container, 'parentElement', {
value: document.body,
configurable: true,
});
const initialParent = mockAutocompletePopup.parentElement;
// Trigger command
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should NOT be moved because target container is document.body
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
// Cleanup
if (editorInstance.container && originalClosest) {
editorInstance.container.closest = originalClosest;
}
document.body.removeChild(mockAutocompletePopup);
});

View File

@@ -26,7 +26,6 @@ import type {
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';
import type { Ace } from 'ace-builds';
import {
AsyncEsmComponent,
@@ -208,68 +207,6 @@ export function AsyncAceEditor(
}
}, [keywords, setCompleters]);
// Move autocomplete popup to the nearest parent container with data-ace-container
useEffect(() => {
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
?.editor;
if (!editorInstance) return;
const editorContainer = editorInstance.container;
if (!editorContainer) return;
// Cache DOM elements to avoid repeated queries on every command execution
let cachedAutocompletePopup: HTMLElement | null = null;
let cachedTargetContainer: Element | null = null;
const moveAutocompleteToContainer = () => {
// Revalidate cached popup if missing or detached from DOM
if (
!cachedAutocompletePopup ||
!document.body.contains(cachedAutocompletePopup)
) {
cachedAutocompletePopup =
editorContainer.querySelector<HTMLElement>(
'.ace_autocomplete',
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
}
// Revalidate cached container if missing or detached
if (
!cachedTargetContainer ||
!document.body.contains(cachedTargetContainer)
) {
cachedTargetContainer =
editorContainer.closest('#ace-editor') ??
editorContainer.parentElement;
}
if (
cachedAutocompletePopup &&
cachedTargetContainer &&
cachedTargetContainer !== document.body
) {
cachedTargetContainer.appendChild(cachedAutocompletePopup);
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
}
};
const handleAfterExec = (e: Ace.Operation) => {
const name: string | undefined = e?.command?.name;
if (name === 'insertstring' || name === 'startAutocomplete') {
moveAutocompleteToContainer();
}
};
const { commands } = editorInstance;
commands.on('afterExec', handleAfterExec);
return () => {
commands.off('afterExec', handleAfterExec);
cachedAutocompletePopup = null;
cachedTargetContainer = null;
};
}, [ref]);
return (
<>
<Global
@@ -351,24 +288,14 @@ export function AsyncAceEditor(
border: 1px solid ${token.colorBorderSecondary};
box-shadow: ${token.boxShadow};
border-radius: ${token.borderRadius}px;
padding: ${token.paddingXS}px ${token.paddingXS}px;
}
.ace_tooltip.ace_doc-tooltip {
display: flex !important;
}
&&& .tooltip-detail {
display: flex;
justify-content: center;
flex-direction: row;
gap: ${token.paddingXXS}px;
align-items: center;
& .tooltip-detail {
background-color: ${token.colorBgContainer};
white-space: pre-wrap;
word-break: break-all;
min-width: ${token.sizeXXL * 5}px;
max-width: ${token.sizeXXL * 10}px;
font-size: ${token.fontSize}px;
& .tooltip-detail-head {
background-color: ${token.colorBgElevated};
@@ -391,9 +318,7 @@ export function AsyncAceEditor(
& .tooltip-detail-head,
& .tooltip-detail-body {
background-color: ${token.colorBgLayout};
padding: 0px ${token.paddingXXS}px;
border: 1px ${token.colorSplit} solid;
padding: ${token.padding}px ${token.paddingLG}px;
}
& .tooltip-detail-footer {

View File

@@ -86,7 +86,6 @@ export function EditableTitle({
renderLink,
maxWidth,
autoSize = true,
onEditingChange,
...rest
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(editing);
@@ -132,8 +131,7 @@ export function EditableTitle({
textArea.scrollTop = textArea.scrollHeight;
}
}
onEditingChange?.(isEditing);
}, [isEditing, onEditingChange]);
}, [isEditing]);
function handleClick() {
if (!canEdit || isEditing) return;

View File

@@ -33,5 +33,4 @@ export interface EditableTitleProps {
renderLink?: (title: string) => React.ReactNode;
maxWidth?: number;
autoSize?: boolean;
onEditingChange?: (isEditing: boolean) => void;
}

View File

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

View File

@@ -20,25 +20,25 @@
import { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Tabs', () => {
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
@@ -284,7 +284,6 @@ describe('Tabs', () => {
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
@@ -296,7 +295,6 @@ describe('Tabs', () => {
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} style={customStyle} />,
);
@@ -306,72 +304,3 @@ describe('Tabs', () => {
});
});
});
test('fullHeight prop renders component hierarchy correctly', () => {
const { container } = render(<Tabs items={defaultItems} fullHeight />);
const tabsElement = container.querySelector('.ant-tabs');
const contentHolder = container.querySelector('.ant-tabs-content-holder');
const content = container.querySelector('.ant-tabs-content');
const tabPane = container.querySelector('.ant-tabs-tabpane');
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(tabPane).toBeInTheDocument();
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
expect(contentHolder?.contains(content as Node)).toBe(true);
expect(content?.contains(tabPane as Node)).toBe(true);
});
test('fullHeight prop maintains structure when content updates', () => {
const { container, rerender } = render(
<Tabs items={defaultItems} fullHeight />,
);
const initialTabsElement = container.querySelector('.ant-tabs');
const newItems = [
...defaultItems,
{
key: '4',
label: 'Tab 4',
children: <div data-testid="tab4-content">New tab content</div>,
},
];
rerender(<Tabs items={newItems} fullHeight />);
const updatedTabsElement = container.querySelector('.ant-tabs');
const updatedContentHolder = container.querySelector(
'.ant-tabs-content-holder',
);
expect(updatedTabsElement).toBeInTheDocument();
expect(updatedContentHolder).toBeInTheDocument();
expect(initialTabsElement).toBe(updatedTabsElement);
});
test('fullHeight prop works with allowOverflow to handle tall content', () => {
const { container } = render(
<Tabs items={defaultItems} fullHeight allowOverflow />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
const contentHolder = container.querySelector(
'.ant-tabs-content-holder',
) as HTMLElement;
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
// Verify overflow handling is not restricted
const holderStyles = window.getComputedStyle(contentHolder);
expect(holderStyles.overflow).not.toBe('hidden');
});
test('fullHeight prop handles empty items array', () => {
const { container } = render(<Tabs items={[]} fullHeight />);
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
});

View File

@@ -25,14 +25,12 @@ import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
fullHeight?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
fullHeight = false,
tabBarStyle,
contentStyle,
...props
@@ -48,17 +46,9 @@ const StyledTabs = ({
tabBarStyle={mergedStyle}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};
${fullHeight && 'height: 100%;'}
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
}
.ant-tabs-content {
${fullHeight && 'height: 100%;'}
}
.ant-tabs-tabpane {
${fullHeight && 'height: 100%;'}
${contentStyle}
}
.ant-tabs-tab {

View File

@@ -145,8 +145,6 @@ export {
} from './ListViewCard';
export { Loading, type LoadingProps } from './Loading';
export { Progress, type ProgressProps } from './Progress';
export { Skeleton, type SkeletonProps } from './Skeleton';
export { Switch, type SwitchProps } from './Switch';

View File

@@ -19,27 +19,16 @@
import { DatasourceType } from './types/Datasource';
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
table: DatasourceType.Table,
query: DatasourceType.Query,
dataset: DatasourceType.Dataset,
sl_table: DatasourceType.SlTable,
saved_query: DatasourceType.SavedQuery,
semantic_view: DatasourceType.SemanticView,
};
export default class DatasourceKey {
readonly id: number | string;
readonly id: number;
readonly type: DatasourceType;
constructor(key: string) {
const [idStr, typeStr] = key.split('__');
// Only parse as integer if the entire string is numeric
// (parseInt would incorrectly parse "85d3139f..." as 85)
const isNumeric = /^\d+$/.test(idStr);
this.id = isNumeric ? parseInt(idStr, 10) : idStr;
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
this.id = parseInt(idStr, 10);
this.type = DatasourceType.Table; // default to SqlaTable model
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
}
public toString() {

View File

@@ -67,7 +67,6 @@ export function normalizeTimeColumn(
sqlExpression: formData.x_axis,
label: formData.x_axis,
expressionType: 'SQL',
isColumnReference: true,
};
}

View File

@@ -27,7 +27,6 @@ export interface AdhocColumn {
optionName?: string;
sqlExpression: string;
expressionType: 'SQL';
isColumnReference?: boolean;
columnType?: 'BASE_AXIS' | 'SERIES';
timeGrain?: string;
datasourceWarning?: boolean;
@@ -75,10 +74,6 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
);
}
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
return isAdhocColumn(column) && column?.isColumnReference === true;
}
export function isQueryFormColumn(column: any): column is QueryFormColumn {
return isPhysicalColumn(column) || isAdhocColumn(column);
}

View File

@@ -26,7 +26,6 @@ export enum DatasourceType {
Dataset = 'dataset',
SlTable = 'sl_table',
SavedQuery = 'saved_query',
SemanticView = 'semantic_view',
}
export interface Currency {
@@ -38,7 +37,7 @@ export interface Currency {
* Datasource metadata.
*/
export interface Datasource {
id: number | string;
id: number;
name: string;
type: DatasourceType;
columns: Column[];

View File

@@ -156,7 +156,7 @@ export interface QueryObject
export interface QueryContext {
datasource: {
id: number | string;
id: number;
type: DatasourceType;
};
/** Force refresh of all queries */

View File

@@ -86,7 +86,6 @@ test('should support different columns for x-axis and granularity', () => {
{
timeGrain: 'P1Y',
columnType: 'BASE_AXIS',
isColumnReference: true,
sqlExpression: 'time_column_in_x_axis',
label: 'time_column_in_x_axis',
expressionType: 'SQL',

View File

@@ -33,6 +33,9 @@ export default defineConfig({
? undefined
: '**/experimental/**',
// Global setup - authenticate once before all tests
globalSetup: './playwright/global-setup.ts',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
@@ -77,10 +80,32 @@ export default defineConfig({
projects: [
{
// Default project - uses global authentication for speed
// E2E tests login once via global-setup.ts and reuse auth state
// Explicitly ignore auth tests (they run in chromium-unauth project)
// Also respect the global experimental testIgnore setting
name: 'chromium',
testIgnore: [
'**/tests/auth/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// Reuse authentication state from global setup (fast E2E tests)
storageState: 'playwright/.auth/user.json',
},
},
{
// Separate project for unauthenticated tests (login, signup, etc.)
// These tests use beforeEach for per-test navigation - no global auth
// This hybrid approach: simple auth tests, fast E2E tests
name: 'chromium-unauth',
testMatch: '**/tests/auth/**/*.spec.ts',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// No storageState = clean browser with no cached cookies
},
},
],

View File

@@ -0,0 +1,105 @@
/**
* 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';
/**
* Base Modal component for Ant Design modals.
* Provides minimal primitives - extend this for specific modal types.
* Add methods to this class only when multiple modal types need them (YAGNI).
*
* @example
* class DeleteConfirmationModal extends Modal {
* async clickDelete(): Promise<void> {
* await this.footer.locator('button', { hasText: 'Delete' }).click();
* }
* }
*/
export class Modal {
protected readonly page: Page;
protected readonly modalSelector: string;
// Ant Design modal structure selectors (shared by all modal types)
protected static readonly BASE_SELECTORS = {
FOOTER: '.ant-modal-footer',
BODY: '.ant-modal-body',
};
constructor(page: Page, modalSelector = '[role="dialog"]') {
this.page = page;
this.modalSelector = modalSelector;
}
/**
* Gets the modal element locator
*/
get element(): Locator {
return this.page.locator(this.modalSelector);
}
/**
* Gets the modal footer locator (contains action buttons)
*/
get footer(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.FOOTER);
}
/**
* Gets the modal body locator (contains content)
*/
get body(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.BODY);
}
/**
* Gets a footer button by text content (private helper)
* @param buttonText - The text content of the button
*/
private getFooterButton(buttonText: string): Locator {
return this.footer.locator('button', { hasText: buttonText });
}
/**
* Clicks a footer button by text content
* @param buttonText - The text content of the button to click
* @param options - Optional click options
*/
protected async clickFooterButton(
buttonText: string,
options?: { timeout?: number; force?: boolean; delay?: number },
): Promise<void> {
await this.getFooterButton(buttonText).click(options);
}
/**
* Waits for the modal to become visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
/**
* Waits for the modal to be hidden
* @param options - Optional wait options
*/
async waitForHidden(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'hidden', ...options });
}
}

View File

@@ -0,0 +1,85 @@
/**
* 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';
/**
* Table component for Superset ListView tables.
*/
export class Table {
private readonly page: Page;
private readonly tableSelector: string;
private static readonly SELECTORS = {
TABLE_ROW: '[data-test="table-row"]',
};
constructor(page: Page, tableSelector = '[data-test="listview-table"]') {
this.page = page;
this.tableSelector = tableSelector;
}
/**
* Gets the table element locator
*/
get element(): Locator {
return this.page.locator(this.tableSelector);
}
/**
* Gets a table row by exact text match in the first cell (dataset name column).
* Uses exact match to avoid substring collisions (e.g., 'members_channels_2' vs 'duplicate_members_channels_2_123').
* @param rowText - Exact text to find in the row's first cell
*/
getRow(rowText: string): Locator {
return this.element
.locator(Table.SELECTORS.TABLE_ROW)
.filter({
has: this.page.getByRole('cell', { name: rowText, exact: true }),
});
}
/**
* Clicks a link within a specific row
* @param rowText - Text to identify the row
* @param linkSelector - Selector for the link within the row
*/
async clickRowLink(rowText: string, linkSelector: string): Promise<void> {
const row = this.getRow(rowText);
await row.locator(linkSelector).click();
}
/**
* Waits for the table to be visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
/**
* Clicks an action button in a row by selector
* @param rowText - Text to identify the row
* @param selector - CSS selector for the action element
*/
async clickRowAction(rowText: string, selector: string): Promise<void> {
const row = this.getRow(rowText);
await row.locator(selector).first().click();
}
}

View File

@@ -0,0 +1,105 @@
/**
* 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, Locator } from '@playwright/test';
export type ToastType = 'success' | 'danger' | 'warning' | 'info';
const SELECTORS = {
CONTAINER: '[data-test="toast-container"][role="alert"]',
CONTENT: '.toast__content',
CLOSE_BUTTON: '[data-test="close-button"]',
} as const;
/**
* Toast notification component
* Handles success, danger, warning, and info toasts
*/
export class Toast {
private page: Page;
private container: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.locator(SELECTORS.CONTAINER);
}
/**
* Get the toast container locator
*/
get(): Locator {
return this.container;
}
/**
* Get the toast message text
*/
getMessage(): Locator {
return this.container.locator(SELECTORS.CONTENT);
}
/**
* Wait for a toast to appear
*/
async waitForVisible(): Promise<void> {
await this.container.waitFor({ state: 'visible' });
}
/**
* Wait for toast to disappear
*/
async waitForHidden(): Promise<void> {
await this.container.waitFor({ state: 'hidden' });
}
/**
* Get a success toast
*/
getSuccess(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`);
}
/**
* Get a danger/error toast
*/
getDanger(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`);
}
/**
* Get a warning toast
*/
getWarning(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`);
}
/**
* Get an info toast
*/
getInfo(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`);
}
/**
* Close the toast by clicking the close button
*/
async close(): Promise<void> {
await this.container.locator(SELECTORS.CLOSE_BUTTON).click();
}
}

View File

@@ -21,3 +21,5 @@
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
export { Modal } from './Modal';
export { Table } from './Table';

View File

@@ -0,0 +1,75 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Delete confirmation modal that requires typing "DELETE" to confirm.
* Used throughout Superset for destructive delete operations.
*
* Provides primitives for tests to compose deletion flows.
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: 'input[type="text"]',
};
/**
* Gets the confirmation input component
*/
private get confirmationInput(): Input {
return new Input(
this.page,
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
);
}
/**
* Fills the confirmation input with the specified text.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
*
* @example
* const deleteModal = new DeleteConfirmationModal(page);
* await deleteModal.waitForVisible();
* await deleteModal.fillConfirmationInput('DELETE');
* await deleteModal.clickDelete();
* await deleteModal.waitForHidden();
*/
async fillConfirmationInput(
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDelete(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Delete', options);
}
}

View File

@@ -0,0 +1,73 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Duplicate dataset modal that requires entering a new dataset name.
* Used for duplicating virtual datasets with custom SQL.
*/
export class DuplicateDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="duplicate-modal-input"]',
};
/**
* Gets the new dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fills the new dataset name input
*
* @param datasetName - The new name for the duplicated dataset
* @param options - Optional fill options (timeout, force)
*
* @example
* const duplicateModal = new DuplicateDatasetModal(page);
* await duplicateModal.waitForVisible();
* await duplicateModal.fillDatasetName('my_dataset_copy');
* await duplicateModal.clickDuplicate();
* await duplicateModal.waitForHidden();
*/
async fillDatasetName(
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.nameInput.fill(datasetName, options);
}
/**
* Clicks the Duplicate button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDuplicate(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Duplicate', options);
}
}

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
export { default as StreamingExportModal } from './StreamingExportModal';
export type { StreamingProgress } from './StreamingExportModal';
export { useStreamingExport } from './useStreamingExport';
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';

View File

@@ -0,0 +1,87 @@
/**
* 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 {
chromium,
FullConfig,
Browser,
BrowserContext,
} from '@playwright/test';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
import { AuthPage } from './pages/AuthPage';
/**
* Global setup function that runs once before all tests.
* Authenticates as admin user and saves the authentication state
* to be reused by tests in the 'chromium' project (E2E tests).
*
* Auth tests (chromium-unauth project) don't use this - they login
* per-test via beforeEach for isolation and simplicity.
*/
async function globalSetup(config: FullConfig) {
// Get baseURL with fallback to default
// FullConfig.use doesn't exist in the type - baseURL is only in projects[0].use
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088';
console.log('[Global Setup] Authenticating as admin user...');
let browser: Browser | null = null;
let context: BrowserContext | null = null;
try {
// Launch browser
browser = await chromium.launch();
} catch (error) {
console.error('[Global Setup] Failed to launch browser:', error);
throw new Error('Browser launch failed - check Playwright installation');
}
try {
context = await browser.newContext({ baseURL });
const page = await context.newPage();
// Use AuthPage to handle login logic (DRY principle)
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
await authPage.loginWithCredentials('admin', 'general');
await authPage.waitForLoginSuccess();
// Save authentication state for all tests to reuse
const authStatePath = 'playwright/.auth/user.json';
await mkdir(dirname(authStatePath), { recursive: true });
await context.storageState({
path: authStatePath,
});
console.log(
'[Global Setup] Authentication successful - state saved to playwright/.auth/user.json',
);
} catch (error) {
console.error('[Global Setup] Authentication failed:', error);
throw error;
} finally {
// Ensure cleanup even if auth fails
if (context) await context.close();
if (browser) await browser.close();
}
}
export default globalSetup;

View File

@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page } from '@playwright/test';
import { apiPostDatabase } from './database';
// Public Google Sheets URL for testing
const NETFLIX_TITLES_SHEET =
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
/**
* Create a Google Sheets database connection for testing
* Uses a public Netflix titles dataset by default
* @param page - Playwright page instance
* @param databaseName - Name for the database connection
* @param tableName - Name for the table/dataset
* @returns Database ID from the created database
*/
export async function createGsheetsDatabase(
page: Page,
databaseName: string,
tableName: string,
): Promise<number> {
const requestBody = {
database_name: databaseName,
engine: 'gsheets',
configuration_method: 'dynamic_form',
engine_information: {
disable_ssh_tunneling: true,
supports_dynamic_catalog: false,
supports_file_upload: true,
supports_oauth2: true,
},
driver: 'apsw',
sqlalchemy_uri_placeholder: 'gsheets://',
extra: JSON.stringify({
allows_virtual_table_explore: true,
engine_params: {
catalog: {
[tableName]: NETFLIX_TITLES_SHEET,
},
},
}),
expose_in_sqllab: true,
catalog: [
{
name: tableName,
value: NETFLIX_TITLES_SHEET,
},
],
parameters: {
service_account_info: '',
catalog: {
[tableName]: NETFLIX_TITLES_SHEET,
},
},
masked_encrypted_extra: '{}',
impersonate_user: true,
};
const response = await apiPostDatabase(page, requestBody);
if (!response.ok()) {
const errorText = await response.text();
throw new Error(
`Failed to create database: ${response.status()} ${response.statusText()}\n${errorText}`,
);
}
const body = await response.json();
return body.id;
}

View File

@@ -0,0 +1,79 @@
/**
* 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, APIResponse } from '@playwright/test';
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATABASE: 'api/v1/database/',
} as const;
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
*/
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
driver?: string;
sqlalchemy_uri_placeholder?: string;
extra?: string;
expose_in_sqllab?: boolean;
catalog?: Array<{ name: string; value: string }>;
parameters?: {
service_account_info?: string;
catalog?: Record<string, string>;
};
masked_encrypted_extra?: string;
impersonate_user?: boolean;
}
/**
* POST request to create a database connection
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Database configuration object with type safety
* @returns API response from database creation
*/
export async function apiPostDatabase(
page: Page,
requestBody: DatabaseCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.DATABASE, requestBody);
}
/**
* DELETE request to remove a database connection
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to delete
* @returns API response from database deletion
*/
export async function apiDeleteDatabase(
page: Page,
databaseId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}

View File

@@ -0,0 +1,76 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page } from '@playwright/test';
import { createGsheetsDatabase } from './database.factories';
import { apiPostDataset } from './dataset';
import { apiDeleteDatabase } from './database';
/**
* Create a test dataset with Google Sheets database
* Creates both the database connection and dataset in one call
* @param page - Playwright page instance
* @param datasetName - Name for the dataset/table
* @returns Object containing database ID and dataset ID
*/
export async function createTestDataset(
page: Page,
datasetName: string,
): Promise<{ dbId: number; datasetId: number }> {
// Step 1: Create Google Sheets database with catalog entry
// The tableName in the catalog must match the table_name used when creating the dataset
const dbName = `test_db_${Date.now()}`;
const tableName = datasetName; // Use same name for catalog entry and dataset table_name
const dbId = await createGsheetsDatabase(page, dbName, tableName);
// Step 2: Create dataset using the database
// Wrap in try/finally to ensure database cleanup on failure
try {
// For Google Sheets, table_name must reference the catalog entry name
// catalog: null is required to avoid OAuth validation issues
const datasetRequestBody = {
database: dbId,
catalog: null,
schema: 'main',
table_name: tableName, // Must match the catalog entry name
};
const response = await apiPostDataset(page, datasetRequestBody);
if (!response.ok()) {
const errorText = await response.text();
throw new Error(
`Failed to create dataset: ${response.status()} ${response.statusText()}\n${errorText}`,
);
}
const body = await response.json();
const datasetId = body.id;
return { dbId, datasetId };
} catch (error) {
// Clean up the orphaned database before rethrowing
await apiDeleteDatabase(page, dbId, { failOnStatusCode: false }).catch(
() => {
// Silently ignore cleanup errors - the original error is more important
},
);
throw error;
}
}

View File

@@ -0,0 +1,112 @@
/**
* 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, APIResponse } from '@playwright/test';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATASET: 'api/v1/dataset/',
} as const;
/**
* TypeScript interface for dataset creation API payload
* Provides compile-time safety for required fields
*/
export interface DatasetCreatePayload {
database: number;
catalog: string | null;
schema: string;
table_name: string;
}
/**
* POST request to create a dataset
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Dataset configuration object (database, schema, table_name)
* @returns API response from dataset creation
*/
export async function apiPostDataset(
page: Page,
requestBody: DatasetCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.DATASET, requestBody);
}
/**
* Get a dataset by its table name
* @param page - Playwright page instance (provides authentication context)
* @param tableName - The table_name to search for
* @returns Object with id and data if found, null if not found
*/
export async function getDatasetByName(
page: Page,
tableName: string,
): Promise<{ id: number; data: any } | null> {
// Use Superset's filter API to search by table_name
const filter = {
filters: [
{
col: 'table_name',
opr: 'eq',
value: tableName,
},
],
};
const queryParam = encodeURIComponent(JSON.stringify(filter));
const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`);
if (!response.ok()) {
return null;
}
const body = await response.json();
if (body.result && body.result.length > 0) {
return { id: body.result[0].id, data: body.result[0] };
}
return null;
}
/**
* GET request to fetch a dataset's details
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to fetch
* @returns API response with dataset details
*/
export async function apiGetDataset(
page: Page,
datasetId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* DELETE request to remove a dataset
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to delete
* @returns API response from dataset deletion
*/
export async function apiDeleteDataset(
page: Page,
datasetId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}

View File

@@ -0,0 +1,178 @@
/**
* 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, APIResponse } from '@playwright/test';
export interface ApiRequestOptions {
headers?: Record<string, string>;
params?: Record<string, string>;
failOnStatusCode?: boolean;
}
/**
* Get base URL for Referer header
* Reads from environment variable configured in playwright.config.ts
* Preserves full base URL including path prefix (e.g., /app/prefix)
*/
function getBaseUrl(_page: Page): string {
// Use environment variable which includes path prefix if configured
// This matches playwright.config.ts baseURL setting exactly
return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
}
/**
* Get CSRF token from the API endpoint
* Superset provides a CSRF token via api/v1/security/csrf_token/
* The session cookie is automatically included by page.request
*/
async function getCsrfToken(page: Page): Promise<string> {
try {
const response = await page.request.get('api/v1/security/csrf_token/', {
failOnStatusCode: false,
});
if (!response.ok()) {
console.warn('[CSRF] Failed to fetch CSRF token:', response.status());
return '';
}
const json = await response.json();
return json.result || '';
} catch (error) {
console.warn('[CSRF] Error fetching CSRF token:', error);
return '';
}
}
/**
* Build headers for mutation requests (POST, PUT, PATCH, DELETE)
* Includes CSRF token and Referer for Flask-WTF CSRFProtect
*/
async function buildHeaders(
page: Page,
options?: ApiRequestOptions,
): Promise<Record<string, string>> {
const csrfToken = await getCsrfToken(page);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options?.headers,
};
// Include CSRF token and Referer for Flask-WTF CSRFProtect
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
headers['Referer'] = getBaseUrl(page);
}
return headers;
}
/**
* Send a GET request
* Uses page.request to automatically include browser authentication
*/
export async function apiGet(
page: Page,
url: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return page.request.get(url, {
headers: options?.headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a POST request
* Uses page.request to automatically include browser authentication
*/
export async function apiPost(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.post(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a PUT request
* Uses page.request to automatically include browser authentication
*/
export async function apiPut(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.put(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a PATCH request
* Uses page.request to automatically include browser authentication
*/
export async function apiPatch(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.patch(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a DELETE request
* Uses page.request to automatically include browser authentication
*/
export async function apiDelete(
page: Page,
url: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.delete(url, {
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}

View File

@@ -17,9 +17,10 @@
* under the License.
*/
import { Page, Response } from '@playwright/test';
import { Page, Response, Cookie } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
import { TIMEOUT } from '../utils/constants';
export class AuthPage {
private readonly page: Page;
@@ -56,7 +57,7 @@ export class AuthPage {
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD });
}
/**
@@ -83,6 +84,54 @@ export class AuthPage {
await loginButton.click();
}
/**
* Wait for successful login by verifying the login response and session cookie.
* Call this after loginWithCredentials to ensure authentication completed.
*
* This does NOT assume a specific landing page (which is configurable).
* Instead it:
* 1. Checks if session cookie already exists (guards against race condition)
* 2. Waits for POST /login/ response with redirect status
* 3. Polls for session cookie to appear
*
* @param options - Optional wait options
*/
async waitForLoginSuccess(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout || TIMEOUT.PAGE_LOAD;
const startTime = Date.now();
// 1. Guard: Check if session cookie already exists (race condition protection)
const existingCookie = await this.getSessionCookie();
if (existingCookie?.value) {
// Already authenticated - login completed before we started waiting
return;
}
// 2. Wait for POST /login/ response
const loginResponse = await this.waitForLoginRequest();
// 3. Verify it's a redirect (3xx status code indicates successful login)
const status = loginResponse.status();
if (status < 300 || status >= 400) {
throw new Error(`Login failed: expected redirect (3xx), got ${status}`);
}
// 4. Poll for session cookie to appear (may take a moment after redirect)
const pollInterval = TIMEOUT.API_POLL_INTERVAL;
while (Date.now() - startTime < timeout) {
const sessionCookie = await this.getSessionCookie();
if (sessionCookie && sessionCookie.value) {
// Success - session cookie has landed
return;
}
await this.page.waitForTimeout(pollInterval);
}
throw new Error(
`Login timeout: session cookie did not appear within ${timeout}ms`,
);
}
/**
* Get current page URL
*/
@@ -93,9 +142,9 @@ export class AuthPage {
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
async getSessionCookie(): Promise<Cookie | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
return cookies.find(c => c.name === 'session') || null;
}
/**
@@ -106,7 +155,7 @@ export class AuthPage {
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
return visibilityResults.some(isVisible => isVisible);
}
/**
@@ -114,7 +163,7 @@ export class AuthPage {
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);

View File

@@ -0,0 +1,115 @@
/**
* 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, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { URL } from '../utils/urls';
/**
* Dataset List Page object.
*/
export class DatasetListPage {
private readonly page: Page;
private readonly table: Table;
private static readonly SELECTORS = {
DATASET_LINK: '[data-test="internal-link"]',
DELETE_ACTION: '.action-button svg[data-icon="delete"]',
EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
}
/**
* Navigate to the dataset list page
*/
async goto(): Promise<void> {
await this.page.goto(URL.DATASET_LIST);
}
/**
* Wait for the table to load
* @param options - Optional wait options
*/
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
await this.table.waitForVisible(options);
}
/**
* Gets a dataset row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @param datasetName - The name of the dataset
* @returns Locator for the dataset row
*
* @example
* await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible();
*/
getDatasetRow(datasetName: string): Locator {
return this.table.getRow(datasetName);
}
/**
* Clicks on a dataset name to navigate to Explore
* @param datasetName - The name of the dataset to click
*/
async clickDatasetName(datasetName: string): Promise<void> {
await this.table.clickRowLink(
datasetName,
DatasetListPage.SELECTORS.DATASET_LINK,
);
}
/**
* Clicks the delete action button for a dataset
* @param datasetName - The name of the dataset to delete
*/
async clickDeleteAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DELETE_ACTION,
);
}
/**
* Clicks the export action button for a dataset
* @param datasetName - The name of the dataset to export
*/
async clickExportAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.EXPORT_ACTION,
);
}
/**
* Clicks the duplicate action button for a dataset (virtual datasets only)
* @param datasetName - The name of the dataset to duplicate
*/
async clickDuplicateAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DUPLICATE_ACTION,
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page, Locator } from '@playwright/test';
import { TIMEOUT } from '../utils/constants';
/**
* Explore Page object
*/
export class ExplorePage {
private readonly page: Page;
private static readonly SELECTORS = {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Waits for the Explore page to load.
* Validates URL contains /explore/ and datasource control is visible.
*
* @param options - Optional wait options
*/
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout || TIMEOUT.PAGE_LOAD;
await this.page.waitForURL('**/explore/**', { timeout });
await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, {
state: 'visible',
timeout,
});
}
/**
* Gets the datasource control locator.
* Returns a Locator that tests can use with expect() or to read text.
*
* @returns Locator for the datasource control
*
* @example
* const name = await explorePage.getDatasourceControl().textContent();
*/
getDatasourceControl(): Locator {
return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL);
}
/**
* Gets the currently selected dataset name from the datasource control
*/
async getDatasetName(): Promise<string> {
const text = await this.getDatasourceControl().textContent();
return text?.trim() || '';
}
/**
* Gets the visualization switcher locator.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @returns Locator for the viz switcher
*
* @example
* await expect(explorePage.getVizSwitcher()).toBeVisible();
*/
getVizSwitcher(): Locator {
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
}
}

View File

@@ -20,69 +20,74 @@
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
import { TIMEOUT } from '../../utils/constants';
test.describe('Login view', () => {
let authPage: AuthPage;
/**
* Auth/login tests use per-test navigation via beforeEach.
* Each test starts fresh on the login page without global authentication.
* This follows the Cypress pattern for auth testing - simple and isolated.
*/
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();
});
test.beforeEach(async ({ page }) => {
// Navigate to login page before each test (ensures clean state)
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}) => {
// Create page object (already on login page from beforeEach)
const authPage = new AuthPage(page);
// 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 => url.pathname.endsWith(URL.LOGIN), {
timeout: TIMEOUT.PAGE_LOAD,
});
// 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 }) => {
// Create page object (already on login page from beforeEach)
const authPage = new AuthPage(page);
// 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 => url.pathname.endsWith(URL.WELCOME), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});

View File

@@ -19,52 +19,98 @@ under the License.
# Experimental Playwright Tests
This directory contains Playwright tests that are still under development or validation.
## Purpose
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
- Failures do NOT block PR merges
- Allows tests to run in CI to validate stability before promotion
- Provides visibility into test reliability over time
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
## Promoting Tests to Stable
## How Experimental Tests Work
Once a test has proven stable (no false positives/negatives over sufficient time):
1. Move the test file out of `experimental/` to the appropriate feature directory:
```bash
# From the repository root:
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
superset-frontend/playwright/tests/dashboard/
# Or from the superset-frontend/ directory:
git mv playwright/tests/experimental/dashboard/test.spec.ts \
playwright/tests/dashboard/
```
2. The test will automatically become required for merge
## Test Organization
Organize tests by feature area:
- `auth/` - Authentication and authorization tests
- `dashboard/` - Dashboard functionality tests
- `explore/` - Chart builder tests
- `sqllab/` - SQL Lab tests
- etc.
## Running Tests
### Running Tests
**By default (CI and local), experimental tests are EXCLUDED:**
```bash
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
# Run specific experimental test
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
# Run in UI mode for debugging
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
npm run playwright:test
# Only runs stable tests (tests/auth/*)
```
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
**To include experimental tests, set the environment variable:**
```bash
INCLUDE_EXPERIMENTAL=true npm run playwright:test
# Runs all tests including experimental/
```
### CI Behavior
- **Required CI jobs**: Experimental tests are excluded by default
- Tests in `experimental/` do NOT block merges
- Failures in `experimental/` do NOT fail the build
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
- `.github/workflows/bashlib.sh` sets `INCLUDE_EXPERIMENTAL=true` when `TEST_PATH` is provided
- These jobs can use `continue-on-error: true` for shadow mode
### Configuration
The experimental pattern is configured in `playwright.config.ts`:
```typescript
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
```
This ensures:
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
## When to Use Experimental
Add tests to `experimental/` when:
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
## Moving Tests to Stable
Once an experimental test has proven stable (consistent CI passes over time):
1. **Move the test file** from `experimental/` to the appropriate stable directory:
```bash
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
```
2. **Commit the move** with a clear message:
```bash
git commit -m "test(playwright): promote my-test from experimental to stable"
```
3. **Test will now be required** - It will run by default and block merges on failure
## Current Experimental Tests
### Dataset Tests
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
- Status: Infrastructure complete, validating stability
- Includes: Delete dataset test with API-based test data
- Supporting infrastructure: API helpers, Modal components, page objects
## Infrastructure Location
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
✅ **Correct locations:**
- `playwright/components/` - Components used by any tests
- `playwright/pages/` - Page objects for any features
- `playwright/helpers/api/` - API helpers for test data setup
❌ **Avoid:**
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
## Questions?
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.

View File

@@ -0,0 +1,222 @@
/**
* 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, Page } from '@playwright/test';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { Toast } from '../../../components/core/Toast';
import { createTestDataset } from '../../../helpers/api/dataset.factories';
import {
apiDeleteDataset,
apiGetDataset,
getDatasetByName,
} from '../../../helpers/api/dataset';
import { apiDeleteDatabase } from '../../../helpers/api/database';
test.describe('Dataset List', () => {
let datasetListPage: DatasetListPage;
let explorePage: ExplorePage;
let testResources: { datasetIds: number[]; dbId?: number } = {
datasetIds: [],
};
test.beforeEach(async ({ page }) => {
datasetListPage = new DatasetListPage(page);
explorePage = new ExplorePage(page);
testResources = { datasetIds: [] }; // Reset for each test
// Navigate to dataset list page
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
});
test.afterEach(async ({ page }) => {
// Cleanup any resources created during the test
await cleanupTestAssets(page, testResources);
});
function cleanupTestAssets(
page: Page,
resources: { datasetIds: number[]; dbId?: number },
) {
const promises = [];
// Delete all datasets
for (const datasetId of resources.datasetIds) {
promises.push(
apiDeleteDataset(page, datasetId, {
failOnStatusCode: false,
}).catch(() => {}),
);
}
// Delete database if exists
if (resources.dbId) {
promises.push(
apiDeleteDatabase(page, resources.dbId, {
failOnStatusCode: false,
}).catch(() => {}),
);
}
return Promise.all(promises);
}
test('should navigate to Explore when dataset name is clicked', async ({
page,
}) => {
// Create test dataset (hermetic - no dependency on sample data)
const datasetName = `test_nav_${Date.now()}`;
const result = await createTestDataset(page, datasetName);
testResources = { datasetIds: [result.datasetId], dbId: result.dbId };
// Refresh page to see new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list (uses page object + Playwright auto-wait)
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click on dataset name to navigate to Explore
await datasetListPage.clickDatasetName(datasetName);
// Wait for Explore page to load (validates URL + datasource control)
await explorePage.waitForPageLoad();
// Verify correct dataset is loaded in datasource control
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(datasetName);
// Verify visualization switcher shows default viz type (indicates full page load)
await expect(explorePage.getVizSwitcher()).toBeVisible();
await expect(explorePage.getVizSwitcher()).toContainText('Table');
});
test('should delete a dataset with confirmation', async ({ page }) => {
// Create test dataset (hermetic - creates own test data)
const datasetName = `test_delete_${Date.now()}`;
const result = await createTestDataset(page, datasetName);
testResources = { datasetIds: [result.datasetId], dbId: result.dbId };
// Refresh page to see new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click delete action button
await datasetListPage.clickDeleteAction(datasetName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message
const toast = new Toast(page);
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
});
test('should duplicate a dataset with new name', async ({ page }) => {
// Use virtual example dataset (members_channels_2)
const originalName = 'members_channels_2';
const duplicateName = `duplicate_${originalName}_${Date.now()}`;
// Get the dataset by name (ID varies by environment)
const original = await getDatasetByName(page, originalName);
expect(original).not.toBeNull();
expect(original!.id).toBeGreaterThan(0);
// Verify original dataset is visible in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = page.waitForResponse(
response =>
response.url().includes('/dataset/duplicate') &&
response.status() === 200,
);
// Click duplicate action button
await datasetListPage.clickDuplicateAction(originalName);
// Duplicate modal should appear
const duplicateModal = new DuplicateDatasetModal(page);
await duplicateModal.waitForVisible();
// Fill in new dataset name
await duplicateModal.fillDatasetName(duplicateName);
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response
const duplicateResponse = await duplicateResponsePromise;
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
// Track duplicate for cleanup (original is example data, don't delete it)
testResources = { datasetIds: [duplicateId] };
// Modal should close
await duplicateModal.waitForHidden();
// Verify success toast appears
const toast = new Toast(page);
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
// Refresh to see the duplicated dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Compare original and duplicate datasets
const duplicateResponseData = await apiGetDataset(page, duplicateId);
const duplicateDataFull = await duplicateResponseData.json();
// Verify key properties were copied correctly (original data already fetched)
expect(duplicateDataFull.result.sql).toBe(original!.data.sql);
expect(duplicateDataFull.result.database.id).toBe(
original!.data.database.id,
);
expect(duplicateDataFull.result.schema).toBe(original!.data.schema);
// Name should be different (the duplicate name)
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
});
});

View File

@@ -0,0 +1,47 @@
/**
* 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.
*/
/**
* Timeout constants for Playwright tests.
* Only define timeouts that differ from Playwright defaults or are semantically important.
*
* Default Playwright timeouts (from playwright.config.ts):
* - Test timeout: 30000ms (30s)
* - Expect timeout: 8000ms (8s)
*
* Use these constants instead of magic numbers for better maintainability.
*/
export const TIMEOUT = {
/**
* Page navigation and load timeouts
*/
PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore)
/**
* Form and UI element load timeouts
*/
FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals)
/**
* API polling intervals
*/
API_POLL_INTERVAL: 100, // 100ms between API polling attempts
API_POLL_TIMEOUT: 5000, // 5s total timeout for API state changes
} as const;

View File

@@ -18,6 +18,7 @@
*/
export const URL = {
DATASET_LIST: 'tablemodelview/list',
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;

View File

@@ -31,7 +31,6 @@ const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
country: PropTypes.string,
code: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
name: PropTypes.string,
@@ -117,7 +116,7 @@ function WorldMap(element, props) {
const selected = Object.values(filterState.selectedValues || {});
const key = source.id || source.country;
const country =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
if (!country) {
return undefined;
@@ -171,7 +170,7 @@ function WorldMap(element, props) {
pointerEvent.preventDefault();
const key = source.id || source.country;
const val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
let drillToDetailFilters;
let drillByFilters;
if (val) {

View File

@@ -1,311 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { ReactNode } from 'react';
import {
LegendOrientation,
LegendType,
type EchartsHandler,
type EchartsProps,
} from '../types';
import EchartsTimeseries from './EchartsTimeseries';
import {
EchartsTimeseriesSeriesType,
OrientationType,
type EchartsTimeseriesFormData,
type TimeseriesChartTransformedProps,
} from './types';
const mockEchart = jest.fn();
jest.mock('../components/Echart', () => {
const { forwardRef } = jest.requireActual<typeof import('react')>('react');
const MockEchart = forwardRef<EchartsHandler | null, EchartsProps>(
(props, ref) => {
mockEchart(props);
void ref;
return null;
},
);
MockEchart.displayName = 'MockEchart';
return {
__esModule: true,
default: MockEchart,
};
});
jest.mock('../components/ExtraControls', () => ({
ExtraControls: ({ children }: { children?: ReactNode }) => (
<div data-testid="extra-controls">{children}</div>
),
}));
const originalResizeObserver = globalThis.ResizeObserver;
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
);
let mockOffsetHeight = 0;
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return mockOffsetHeight;
},
});
});
afterAll(() => {
if (offsetHeightDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'offsetHeight',
offsetHeightDescriptor,
);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
});
afterEach(() => {
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
numberFormat: string;
granularitySqla?: string;
} = {
annotationLayers: [],
area: false,
colorScheme: undefined,
timeShiftColor: false,
contributionMode: undefined,
forecastEnabled: false,
forecastPeriods: 0,
forecastInterval: 0,
forecastSeasonalityDaily: null,
forecastSeasonalityWeekly: null,
forecastSeasonalityYearly: null,
logAxis: false,
markerEnabled: false,
markerSize: 1,
metrics: [],
minorSplitLine: false,
minorTicks: false,
opacity: 1,
orderDesc: false,
rowLimit: 0,
seriesType: EchartsTimeseriesSeriesType.Line,
stack: null,
stackDimension: '',
timeCompare: [],
tooltipTimeFormat: undefined,
showTooltipTotal: false,
showTooltipPercentage: false,
truncateXAxis: false,
truncateYAxis: false,
yAxisFormat: undefined,
xAxisForceCategorical: false,
xAxisTimeFormat: undefined,
timeGrainSqla: undefined,
forceMaxInterval: false,
xAxisBounds: [null, null],
yAxisBounds: [null, null],
zoomable: false,
richTooltip: false,
xAxisLabelRotation: 0,
xAxisLabelInterval: 0,
showValue: false,
onlyTotal: false,
showExtraControls: true,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
datasource: '1__table',
viz_type: 'echarts_timeseries',
legendMargin: 0,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: false,
legendSort: null,
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitlePosition: '',
time_range: 'No filter',
granularity: undefined,
granularity_sqla: undefined,
sql: '',
url_params: {},
custom_params: {},
extra_form_data: {},
adhoc_filters: [],
order_desc: false,
row_limit: 0,
row_offset: 0,
time_grain_sqla: undefined,
vizType: 'echarts_timeseries',
dateFormat: 'smart_date',
numberFormat: 'SMART_NUMBER',
};
const defaultProps: TimeseriesChartTransformedProps = {
echartOptions: {} as EChartsCoreOption,
formData: defaultFormData,
height: 400,
width: 800,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
onLegendStateChanged: jest.fn(),
refs: {},
emitCrossFilters: false,
coltypeMapping: {},
onLegendScroll: jest.fn(),
groupby: [],
labelMap: {},
setControlValue: jest.fn(),
selectedValues: {},
legendData: [],
xValueFormatter: String,
xAxis: {
label: 'x',
type: AxisType.Time,
},
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props.height;
}
test('observes extra control height changes when ResizeObserver is available', async () => {
const disconnectSpy = jest.fn();
const observeSpy = jest.fn();
class MockResizeObserver implements ResizeObserver {
private static latestInstance: MockResizeObserver | null = null;
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.latestInstance = this;
}
observe = (target: Element) => {
observeSpy(target);
};
unobserve(_target: Element): void {
void _target;
}
disconnect = () => {
disconnectSpy();
};
trigger(entries: ResizeObserverEntry[] = []) {
this.callback(entries, this);
}
static getLatestInstance() {
return this.latestInstance;
}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
mockOffsetHeight = 42;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(observeSpy).toHaveBeenCalledWith(expect.any(HTMLElement));
mockOffsetHeight = 24;
MockResizeObserver.getLatestInstance()?.trigger();
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(disconnectSpy).not.toHaveBeenCalled();
expect(MockResizeObserver.getLatestInstance()).not.toBeNull();
unmount();
expect(disconnectSpy).toHaveBeenCalled();
});
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
undefined;
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
mockOffsetHeight = 30;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(addEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
mockOffsetHeight = 10;
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

View File

@@ -67,32 +67,8 @@ export default function EchartsTimeseries({
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraControlHeight, setExtraControlHeight] = useState(0);
useEffect(() => {
const element = extraControlRef.current;
if (!element) {
setExtraControlHeight(0);
return;
}
const updateHeight = () => {
setExtraControlHeight(element.offsetHeight || 0);
};
updateHeight();
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
};
const updatedHeight = extraControlRef.current?.offsetHeight || 0;
setExtraControlHeight(updatedHeight);
}, [formData.showExtraControls]);
const hasDimensions = ensureIsArray(groupby).length > 0;

View File

@@ -112,53 +112,6 @@ const config: ControlPanelConfig = {
...sharedControls.x_axis_time_format,
default: 'smart_date',
description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`,
visibility: ({ controls }: ControlPanelsContainerProps) => {
// check if x axis is a time column
const xAxisColumn = controls?.x_axis?.value;
const xAxisOptions = controls?.x_axis?.options;
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
return false;
}
const xAxisType = xAxisOptions.find(
option => option.column_name === xAxisColumn,
)?.type;
return (
typeof xAxisType === 'string' &&
xAxisType.toUpperCase().includes('TIME')
);
},
},
},
{
name: 'x_axis_number_format',
config: {
...sharedControls.x_axis_number_format,
visibility: ({ controls }: ControlPanelsContainerProps) => {
// check if x axis is a floating-point column
const xAxisColumn = controls?.x_axis?.value;
const xAxisOptions = controls?.x_axis?.options;
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
return false;
}
const xAxisType = xAxisOptions.find(
option => option.column_name === xAxisColumn,
)?.type;
if (typeof xAxisType !== 'string') {
return false;
}
const typeUpper = xAxisType.toUpperCase();
return ['FLOAT', 'DOUBLE', 'REAL', 'NUMERIC', 'DECIMAL'].some(
t => typeUpper.includes(t),
);
},
},
},
],

View File

@@ -72,7 +72,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
stack: false,
tooltipTimeFormat: 'smart_date',
xAxisTimeFormat: 'smart_date',
xAxisNumberFormat: 'SMART_NUMBER',
truncateXAxis: true,
truncateYAxis: false,
yAxisBounds: [null, null],

View File

@@ -189,7 +189,6 @@ export default function transformProps(
xAxisSort,
xAxisSortAsc,
xAxisTimeFormat,
xAxisNumberFormat,
xAxisTitle,
xAxisTitleMargin,
yAxisBounds,
@@ -486,9 +485,7 @@ export default function transformProps(
const xAxisFormatter =
xAxisDataType === GenericDataType.Temporal
? getXAxisFormatter(xAxisTimeFormat)
: xAxisDataType === GenericDataType.Numeric
? getNumberFormatter(xAxisNumberFormat)
: String;
: String;
const {
setDataMask = () => {},

View File

@@ -84,7 +84,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
yAxisFormat?: string;
xAxisForceCategorical?: boolean;
xAxisTimeFormat?: string;
xAxisNumberFormat?: string;
timeGrainSqla?: TimeGranularity;
forceMaxInterval?: boolean;
xAxisBounds: [number | undefined | null, number | undefined | null];

View File

@@ -42,5 +42,4 @@ export const DEFAULT_FORM_DATA: Partial<EchartsTreeFormData> = {
nodeLabelPosition: 'left',
childLabelPosition: 'bottom',
emphasis: 'descendant',
initialTreeDepth: 2,
};

View File

@@ -279,23 +279,6 @@ const controlPanel: ControlPanelConfig = {
},
},
],
[
{
name: 'initialTreeDepth',
config: {
type: 'NumberControl',
label: t('Initial tree depth'),
min: -1,
step: 1,
max: 10,
default: DEFAULT_FORM_DATA.initialTreeDepth,
renderTrigger: true,
description: t(
'The initial level (depth) of the tree. If set as -1 all nodes are expanded.',
),
},
},
],
],
},
],

View File

@@ -74,7 +74,6 @@ export default function transformProps(
nodeLabelPosition,
childLabelPosition,
emphasis,
initialTreeDepth,
}: EchartsTreeFormData = { ...DEFAULT_FORM_DATA, ...formData };
const metricLabel = getMetricLabel(metric);
@@ -204,7 +203,6 @@ export default function transformProps(
},
select: DEFAULT_TREE_SERIES_OPTION.select,
leaves: { label: { position: childLabelPosition } },
initialTreeDepth,
},
];

View File

@@ -36,7 +36,6 @@ export type EchartsTreeFormData = QueryFormData & {
nodeLabelPosition: 'top' | 'bottom' | 'left' | 'right';
childLabelPosition: 'top' | 'bottom' | 'left' | 'right';
emphasis: 'none' | 'ancestor' | 'descendant';
initialTreeDepth: number;
};
export interface TreeChartDataResponseResult extends ChartDataResponseResult {

View File

@@ -67,7 +67,7 @@ const legendTypeControl: ControlSetItem = {
label: t('Type'),
choices: [
['scroll', t('Scroll')],
['plain', t('List')],
['plain', t('Plain')],
],
default: legendType,
renderTrigger: true,

View File

@@ -482,17 +482,8 @@ export function getLegendProps(
break;
case LegendOrientation.Bottom:
legend.bottom = 0;
if (padding?.left) {
legend.left = padding.left;
}
break;
case LegendOrientation.Top:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
if (padding?.left) {
legend.left = padding.left;
}
break;
default:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;

View File

@@ -1,156 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types';
import controlPanel from '../../../src/Timeseries/Regular/Scatter/controlPanel';
const config = controlPanel;
const getControl = (controlName: string) => {
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === controlName
) {
return control;
}
}
}
}
}
return null;
};
const mockControls = (
xAxisColumn: string | null,
xAxisType: string | null,
): ControlPanelsContainerProps => {
const options = xAxisType
? [{ column_name: xAxisColumn, type: xAxisType }]
: [];
return {
controls: {
// @ts-ignore
x_axis: {
value: xAxisColumn,
options: options,
},
},
};
};
// tests for x_axis_time_format control
const timeFormatControl: any = getControl('x_axis_time_format');
test('scatter chart control panel should include x_axis_time_format control in the panel', () => {
expect(timeFormatControl).toBeDefined();
});
test('scatter chart control panel should have correct default value for x_axis_time_format', () => {
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config).toBeDefined();
expect(timeFormatControl.config.default).toBe('smart_date');
});
test('scatter chart control panel should have visibility function for x_axis_time_format', () => {
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config.visibility).toBeDefined();
expect(typeof timeFormatControl.config.visibility).toBe('function');
// The visibility function exists - the exact logic is tested implicitly through UI behavior
// The important part is that the control has proper visibility configuration
});
const isTimeVisible = (
xAxisColumn: string | null,
xAxisType: string | null,
): boolean => {
const props = mockControls(xAxisColumn, xAxisType);
const visibilityFn = timeFormatControl?.config?.visibility;
return visibilityFn ? visibilityFn(props) : false;
};
test('x_axis_time_format control should be visible for any data types include TIME', () => {
expect(isTimeVisible('time_column', 'TIME')).toBe(true);
expect(isTimeVisible('time_column', 'TIME WITH TIME ZONE')).toBe(true);
expect(isTimeVisible('time_column', 'TIMESTAMP WITH TIME ZONE')).toBe(true);
expect(isTimeVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
true,
);
});
test('x_axis_time_format control should be hidden for data types that do NOT include TIME', () => {
expect(isTimeVisible('null', 'null')).toBe(false);
expect(isTimeVisible(null, null)).toBe(false);
expect(isTimeVisible('float_column', 'FLOAT')).toBe(false);
});
// tests for x_axis_number_format control
const numberFormatControl: any = getControl('x_axis_number_format');
test('scatter chart control panel should include x_axis_number_format control in the panel', () => {
expect(numberFormatControl).toBeDefined();
});
test('scatter chart control panel should have correct default value for x_axis_number_format', () => {
expect(numberFormatControl).toBeDefined();
expect(numberFormatControl.config).toBeDefined();
expect(numberFormatControl.config.default).toBe('SMART_NUMBER');
});
test('scatter chart control panel should have visibility function for x_axis_number_format', () => {
expect(numberFormatControl).toBeDefined();
expect(numberFormatControl.config.visibility).toBeDefined();
expect(typeof numberFormatControl.config.visibility).toBe('function');
// The visibility function exists - the exact logic is tested implicitly through UI behavior
// The important part is that the control has proper visibility configuration
});
const isNumberVisible = (
xAxisColumn: string | null,
xAxisType: string | null,
): boolean => {
const props = mockControls(xAxisColumn, xAxisType);
const visibilityFn = numberFormatControl?.config?.visibility;
return visibilityFn ? visibilityFn(props) : false;
};
test('x_axis_number_format control should be visible for any floating-point data types', () => {
expect(isNumberVisible('float_column', 'FLOAT')).toBe(true);
expect(isNumberVisible('double_column', 'DOUBLE')).toBe(true);
expect(isNumberVisible('real_column', 'REAL')).toBe(true);
expect(isNumberVisible('numeric_column', 'NUMERIC')).toBe(true);
expect(isNumberVisible('decimal_column', 'DECIMAL')).toBe(true);
});
test('x_axis_number_format control should be hidden for any non-floating-point data types', () => {
expect(isNumberVisible('string_column', 'VARCHAR')).toBe(false);
expect(isNumberVisible('null', 'null')).toBe(false);
expect(isNumberVisible(null, null)).toBe(false);
expect(isNumberVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
false,
);
});

View File

@@ -1,183 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, SMART_DATE_ID } from '@superset-ui/core';
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import {
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps,
} from '../../../src/Timeseries/types';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
D3_FORMAT_OPTIONS,
D3_TIME_FORMAT_OPTIONS,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/ui';
describe('Scatter Chart X-axis Time Formatting', () => {
const baseFormData: EchartsTimeseriesFormData = {
...DEFAULT_FORM_DATA,
colorScheme: 'supersetColors',
datasource: '1__table',
granularity_sqla: '__timestamp',
metric: ['column 1'],
groupby: [],
viz_type: 'echarts_timeseries_scatter',
seriesType: EchartsTimeseriesSeriesType.Scatter,
};
const timeseriesData = [
{
data: [
{ column_1: 0.72099, __timestamp: 1609459200000 },
{ column_1: 0.77954, __timestamp: 1612137600000 },
{ column_1: 2.83434, __timestamp: 1614556800000 },
],
colnames: ['column_1', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
},
];
const baseChartPropsConfig = {
width: 800,
height: 600,
queriesData: timeseriesData,
theme: supersetTheme,
};
test('xAxisTimeFormat has no default formatter', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(xAxis.axisLabel.formatter).toBeUndefined();
});
test.each(D3_TIME_FORMAT_OPTIONS.map(([id]) => id))(
'should handle %s format',
format => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: {
...baseFormData,
xAxisTimeFormat: format,
},
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
if (format === SMART_DATE_ID) {
expect(xAxis.axisLabel.formatter).toBeUndefined();
} else {
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe(format);
}
},
);
});
describe('Scatter Chart X-axis Number Formatting', () => {
const baseFormData: EchartsTimeseriesFormData = {
...DEFAULT_FORM_DATA,
colorScheme: 'supersetColors',
datasource: '1__table',
metric: ['column_1'],
x_axis: 'column_2',
groupby: [],
viz_type: 'echarts_timeseries_scatter',
seriesType: EchartsTimeseriesSeriesType.Scatter,
};
const timeseriesData = [
{
data: [
{ column_1: 0.72099, column_2: 3.01699 },
{ column_1: 0.77954, column_2: 3.44802 },
{ column_1: 2.83434, column_2: 3.58095 },
],
colnames: ['column_1', 'column_2'],
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
},
];
const baseChartPropsConfig = {
width: 800,
height: 600,
queriesData: timeseriesData,
theme: supersetTheme,
};
test('should use SMART_NUMBER as default xAxisNumberFormat', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe('SMART_NUMBER');
});
test.each(D3_FORMAT_OPTIONS.map(([id]) => id))(
'should handle %s format',
format => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: {
...baseFormData,
xAxisNumberFormat: format,
},
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe(format);
},
);
});

View File

@@ -101,35 +101,36 @@ describe('queryObject conversion', () => {
it('should convert queryObject', () => {
const { queries } = buildQuery({ ...formData, x_axis: 'time_column' });
expect(queries[0]).toMatchObject({
granularity: 'time_column',
time_range: '1 year ago : 2013',
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
columns: [
{
columnType: 'BASE_AXIS',
expressionType: 'SQL',
label: 'time_column',
sqlExpression: 'time_column',
timeGrain: 'P1Y',
isColumnReference: true,
},
'col1',
],
series_columns: ['col1'],
metrics: ['count(*)'],
post_processing: [
{
operation: 'pivot',
options: {
aggregates: { 'count(*)': { operator: 'mean' } },
columns: ['col1'],
drop_missing_columns: true,
index: ['time_column'],
expect(queries[0]).toEqual(
expect.objectContaining({
granularity: 'time_column',
time_range: '1 year ago : 2013',
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
columns: [
{
columnType: 'BASE_AXIS',
expressionType: 'SQL',
label: 'time_column',
sqlExpression: 'time_column',
timeGrain: 'P1Y',
},
},
{ operation: 'flatten' },
],
});
'col1',
],
series_columns: ['col1'],
metrics: ['count(*)'],
post_processing: [
{
operation: 'pivot',
options: {
aggregates: { 'count(*)': { operator: 'mean' } },
columns: ['col1'],
drop_missing_columns: true,
index: ['time_column'],
},
},
{ operation: 'flatten' },
],
}),
);
});
});

View File

@@ -166,7 +166,7 @@ function StickyWrap({
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
const scrollBarSize = getScrollBarSize();
const { bodyHeight, columnWidths, hasVerticalScroll } = sticky;
const { bodyHeight, columnWidths } = sticky;
const needSizer =
!columnWidths ||
sticky.width !== maxWidth ||
@@ -283,18 +283,13 @@ function StickyWrap({
</colgroup>
);
const headerContainerWidth = hasVerticalScroll
? maxWidth - scrollBarSize
: maxWidth;
headerTable = (
<div
key="header"
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
width: headerContainerWidth,
boxSizing: 'border-box',
scrollbarGutter: 'stable',
}}
role="presentation"
>
@@ -314,8 +309,7 @@ function StickyWrap({
ref={scrollFooterRef}
style={{
overflow: 'hidden',
width: headerContainerWidth,
boxSizing: 'border-box',
scrollbarGutter: 'stable',
}}
role="presentation"
>
@@ -345,8 +339,6 @@ function StickyWrap({
height: bodyHeight,
overflow: 'auto',
scrollbarGutter: 'stable',
width: maxWidth,
boxSizing: 'border-box',
}}
css={scrollBarStyles}
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}

View File

@@ -139,31 +139,6 @@ function cellWidth({
return perc2;
}
/**
* Sanitize a column identifier for use in HTML id attributes and CSS selectors.
* Replaces characters that are invalid in CSS selectors with safe alternatives.
*
* Note: The returned value should be prefixed with a string (e.g., "header-")
* to ensure it forms a valid HTML ID (IDs cannot start with a digit).
*
* Exported for testing.
*/
export function sanitizeHeaderId(columnId: string): string {
return (
columnId
// Semantic replacements first: preserve meaning in IDs for readability
// (e.g., '%pct_nice' → 'percentpct_nice' instead of '_pct_nice')
.replace(/%/g, 'percent')
.replace(/#/g, 'hash')
.replace(/△/g, 'delta')
// Generic sanitization for remaining special characters
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_') // Collapse consecutive underscores
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
);
}
/**
* Cell left margin (offset) calculation for horizontal bar chart elements
* when alignPositiveNegative is not set
@@ -869,9 +844,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}
// Cache sanitized header ID to avoid recomputing it multiple times
const headerId = sanitizeHeaderId(column.originalLabel ?? column.key);
return {
id: String(i), // to allow duplicate column keys
// must use custom accessor to allow `.` in column names
@@ -997,7 +969,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
const cellProps = {
'aria-labelledby': `header-${headerId}`,
'aria-labelledby': `header-${column.key}`,
role: 'cell',
// show raw number in title in case of numeric values
title: typeof value === 'number' ? String(value) : undefined,
@@ -1084,7 +1056,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
},
Header: ({ column: col, onClick, style, onDragStart, onDrop }) => (
<th
id={`header-${headerId}`}
id={`header-${column.originalLabel}`}
title={t('Shift + Click to sort by multiple columns')}
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
style={{

View File

@@ -18,120 +18,15 @@
*/
import '@testing-library/jest-dom';
import { render, screen } from '@superset-ui/core/spec';
import { cloneDeep } from 'lodash';
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
import TableChart from '../src/TableChart';
import transformProps from '../src/transformProps';
import DateWithFormatter from '../src/utils/DateWithFormatter';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
const expectValidAriaLabels = (container: HTMLElement) => {
const allCells = container.querySelectorAll('tbody td');
const cellsWithLabels = container.querySelectorAll(
'tbody td[aria-labelledby]',
);
// Table must render data cells (catch empty table regression)
expect(allCells.length).toBeGreaterThan(0);
// ALL data cells must have aria-labelledby (no unlabeled cells)
expect(cellsWithLabels.length).toBe(allCells.length);
// ALL aria-labelledby values should be valid
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
expect(labelledBy).not.toBeNull();
expect(labelledBy).toEqual(expect.stringMatching(/\S/));
const labelledByValue = labelledBy as string;
expect(labelledByValue).not.toMatch(/\s/);
expect(labelledByValue).not.toMatch(/[%#△]/);
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledByValue)}`,
);
expect(referencedHeader).toBeTruthy();
});
};
test('sanitizeHeaderId should sanitize percent sign', () => {
expect(sanitizeHeaderId('%pct_nice')).toBe('percentpct_nice');
});
test('sanitizeHeaderId should sanitize hash/pound sign', () => {
expect(sanitizeHeaderId('# metric_1')).toBe('hash_metric_1');
});
test('sanitizeHeaderId should sanitize delta symbol', () => {
expect(sanitizeHeaderId('△ delta')).toBe('delta_delta');
});
test('sanitizeHeaderId should replace spaces with underscores', () => {
expect(sanitizeHeaderId('Main metric_1')).toBe('Main_metric_1');
expect(sanitizeHeaderId('multiple spaces')).toBe('multiple_spaces');
});
test('sanitizeHeaderId should handle multiple special characters', () => {
expect(sanitizeHeaderId('% #△ test')).toBe('percent_hashdelta_test');
expect(sanitizeHeaderId('% # △ test')).toBe('percent_hash_delta_test');
});
test('sanitizeHeaderId should preserve alphanumeric, underscore, and hyphen', () => {
expect(sanitizeHeaderId('valid-name_123')).toBe('valid-name_123');
});
test('sanitizeHeaderId should replace other special characters with underscore', () => {
expect(sanitizeHeaderId('col@name!test')).toBe('col_name_test');
expect(sanitizeHeaderId('test.column')).toBe('test_column');
});
test('sanitizeHeaderId should handle edge cases', () => {
expect(sanitizeHeaderId('')).toBe('');
expect(sanitizeHeaderId('simple')).toBe('simple');
});
test('sanitizeHeaderId should collapse consecutive underscores', () => {
expect(sanitizeHeaderId('test @@ space')).toBe('test_space');
expect(sanitizeHeaderId('col___name')).toBe('col_name');
expect(sanitizeHeaderId('a b c')).toBe('a_b_c');
expect(sanitizeHeaderId('test@@name')).toBe('test_name');
});
test('sanitizeHeaderId should remove leading underscores', () => {
expect(sanitizeHeaderId('@col')).toBe('col');
expect(sanitizeHeaderId('!revenue')).toBe('revenue');
expect(sanitizeHeaderId('@@test')).toBe('test');
expect(sanitizeHeaderId(' leading_spaces')).toBe('leading_spaces');
});
test('sanitizeHeaderId should remove trailing underscores', () => {
expect(sanitizeHeaderId('col@')).toBe('col');
expect(sanitizeHeaderId('revenue!')).toBe('revenue');
expect(sanitizeHeaderId('test@@')).toBe('test');
expect(sanitizeHeaderId('trailing_spaces ')).toBe('trailing_spaces');
});
test('sanitizeHeaderId should remove leading and trailing underscores', () => {
expect(sanitizeHeaderId('@col@')).toBe('col');
expect(sanitizeHeaderId('!test!')).toBe('test');
expect(sanitizeHeaderId(' spaced ')).toBe('spaced');
expect(sanitizeHeaderId('@@multiple@@')).toBe('multiple');
});
test('sanitizeHeaderId should handle inputs with only special characters', () => {
expect(sanitizeHeaderId('@')).toBe('');
expect(sanitizeHeaderId('@@')).toBe('');
expect(sanitizeHeaderId(' ')).toBe('');
expect(sanitizeHeaderId('!@$')).toBe('');
expect(sanitizeHeaderId('!@#$')).toBe('hash'); // # is replaced with 'hash' (semantic replacement)
// Semantic replacements produce readable output even when alone
expect(sanitizeHeaderId('%')).toBe('percent');
expect(sanitizeHeaderId('#')).toBe('hash');
expect(sanitizeHeaderId('△')).toBe('delta');
expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta');
});
describe('plugin-chart-table', () => {
describe('transformProps', () => {
test('should parse pageLength to pageSize', () => {
it('should parse pageLength to pageSize', () => {
expect(transformProps(testData.basic).pageSize).toBe(20);
expect(
transformProps({
@@ -147,13 +42,13 @@ describe('plugin-chart-table', () => {
).toBe(0);
});
test('should memoize data records', () => {
it('should memoize data records', () => {
expect(transformProps(testData.basic).data).toBe(
transformProps(testData.basic).data,
);
});
test('should memoize columns meta', () => {
it('should memoize columns meta', () => {
expect(transformProps(testData.basic).columns).toBe(
transformProps({
...testData.basic,
@@ -162,14 +57,14 @@ describe('plugin-chart-table', () => {
);
});
test('should format timestamp', () => {
it('should format timestamp', () => {
// eslint-disable-next-line no-underscore-dangle
const parsedDate = transformProps(testData.basic).data[0]
.__timestamp as DateWithFormatter;
expect(String(parsedDate)).toBe('2020-01-01 12:34:56');
expect(parsedDate.getTime()).toBe(1577882096000);
});
test('should process comparison columns when time_compare and comparison_type are set', () => {
it('should process comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
const comparisonColumns = transformedProps.columns.filter(
col =>
@@ -191,7 +86,7 @@ describe('plugin-chart-table', () => {
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
});
test('should not process comparison columns when time_compare is empty', () => {
it('should not process comparison columns when time_compare is empty', () => {
const propsWithoutTimeCompare = {
...testData.comparison,
rawFormData: {
@@ -214,7 +109,7 @@ describe('plugin-chart-table', () => {
expect(comparisonColumns.length).toBe(0);
});
test('should correctly apply column configuration for comparison columns', () => {
it('should correctly apply column configuration for comparison columns', () => {
const transformedProps = transformProps(testData.comparisonWithConfig);
const comparisonColumns = transformedProps.columns.filter(
@@ -252,7 +147,7 @@ describe('plugin-chart-table', () => {
expect(percentMetricConfig?.config).toEqual({ d3NumberFormat: '.3f' });
});
test('should correctly format comparison columns using getComparisonColFormatter', () => {
it('should correctly format comparison columns using getComparisonColFormatter', () => {
const transformedProps = transformProps(testData.comparisonWithConfig);
const comparisonColumns = transformedProps.columns.filter(
col =>
@@ -283,7 +178,7 @@ describe('plugin-chart-table', () => {
expect(formattedPercentMetric).toBe('0.123');
});
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
it('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
const transformedProps = transformProps(testData.comparison);
// Check if comparison columns are processed
@@ -370,7 +265,7 @@ describe('plugin-chart-table', () => {
});
describe('TableChart', () => {
test('render basic data', () => {
it('render basic data', () => {
render(
<TableChart {...transformProps(testData.basic)} sticky={false} />,
);
@@ -389,9 +284,12 @@ describe('plugin-chart-table', () => {
expect(cells[8]).toHaveTextContent('N/A');
});
test('render advanced data', () => {
it('render advanced data', () => {
render(
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
<>
<TableChart {...transformProps(testData.advanced)} sticky={false} />
,
</>,
);
const secondColumnHeader = screen.getByText('Sum of Num');
expect(secondColumnHeader).toBeInTheDocument();
@@ -406,7 +304,7 @@ describe('plugin-chart-table', () => {
expect(cells[4]).toHaveTextContent('2.47k');
});
test('render advanced data with currencies', () => {
it('render advanced data with currencies', () => {
render(
ProviderWrapper({
children: (
@@ -426,7 +324,7 @@ describe('plugin-chart-table', () => {
expect(cells[4]).toHaveTextContent('$ 2.47k');
});
test('render data with a bigint value in a raw record mode', () => {
it('render data with a bigint value in a raw record mode', () => {
render(
ProviderWrapper({
children: (
@@ -447,7 +345,7 @@ describe('plugin-chart-table', () => {
expect(cells[3]).toHaveTextContent('1234567890123456789');
});
test('render raw data', () => {
it('render raw data', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
@@ -464,7 +362,7 @@ describe('plugin-chart-table', () => {
expect(cells[1]).toHaveTextContent('0');
});
test('render raw data with currencies', () => {
it('render raw data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
@@ -489,7 +387,7 @@ describe('plugin-chart-table', () => {
expect(cells[2]).toHaveTextContent('$ 0');
});
test('render small formatted data with currencies', () => {
it('render small formatted data with currencies', () => {
const props = transformProps({
...testData.raw,
rawFormData: {
@@ -531,14 +429,14 @@ describe('plugin-chart-table', () => {
expect(cells[2]).toHaveTextContent('$ 0.61');
});
test('render empty data', () => {
it('render empty data', () => {
render(
<TableChart {...transformProps(testData.empty)} sticky={false} />,
);
expect(screen.getByText('No records found')).toBeInTheDocument();
});
test('render color with column color formatter', () => {
it('render color with column color formatter', () => {
render(
ProviderWrapper({
children: (
@@ -568,8 +466,8 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
});
test('render cell without color', () => {
const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]);
it('render cell without color', () => {
const dataWithEmptyCell = testData.advanced.queriesData[0];
dataWithEmptyCell.data.push({
__timestamp: null,
name: 'Noah',
@@ -609,7 +507,7 @@ describe('plugin-chart-table', () => {
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
test('should display original label in grouped headers', () => {
it('should display original label in grouped headers', () => {
const props = transformProps(testData.comparison);
render(<TableChart {...props} sticky={false} />);
@@ -624,128 +522,7 @@ describe('plugin-chart-table', () => {
expect(hasMetricHeaders).toBe(true);
});
test('should set meaningful header IDs for time-comparison columns', () => {
// Test time-comparison columns have proper IDs
// Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety
const props = transformProps(testData.comparison);
render(<TableChart {...props} sticky={false} />);
const headers = screen.getAllByRole('columnheader');
// All headers should have IDs
const headersWithIds = headers.filter(header => header.id);
expect(headersWithIds.length).toBeGreaterThan(0);
// None should have "header-undefined"
const undefinedHeaders = headersWithIds.filter(header =>
header.id.includes('undefined'),
);
expect(undefinedHeaders).toHaveLength(0);
// Should have IDs based on sanitized originalLabel (e.g., "metric_1")
const hasMetricHeaders = headersWithIds.some(
header =>
header.id.includes('metric_1') || header.id.includes('metric_2'),
);
expect(hasMetricHeaders).toBe(true);
// CRITICAL: Verify sanitization - no spaces or special chars in any header ID
headersWithIds.forEach(header => {
// IDs must not contain spaces (would break CSS selectors and ARIA)
expect(header.id).not.toMatch(/\s/);
// IDs must not contain special chars like %, #, △
expect(header.id).not.toMatch(/[%#△]/);
// IDs should only contain valid characters: alphanumeric, underscore, hyphen
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
});
test('should validate ARIA references for time-comparison table cells', () => {
// Test that ALL cells with aria-labelledby have valid references
// This is critical for screen reader accessibility
const props = transformProps(testData.comparison);
const { container } = render(<TableChart {...props} sticky={false} />);
expectValidAriaLabels(container);
});
test('should set meaningful header IDs for regular table columns', () => {
// Test regular (non-time-comparison) columns have proper IDs
// Uses fallback to column.key since originalLabel is undefined
const props = transformProps(testData.advanced);
const { container } = render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
const headers = screen.getAllByRole('columnheader');
// Test 1: "name" column (regular string column)
const nameHeader = headers.find(header =>
header.textContent?.includes('name'),
);
expect(nameHeader).toBeDefined();
expect(nameHeader?.id).toBe('header-name'); // Falls back to column.key
// Verify cells reference this header correctly
const nameCells = container.querySelectorAll(
'td[aria-labelledby="header-name"]',
);
expect(nameCells.length).toBeGreaterThan(0);
// Test 2: "sum__num" column (metric with verbose map "Sum of Num")
const sumHeader = headers.find(header =>
header.textContent?.includes('Sum of Num'),
);
expect(sumHeader).toBeDefined();
expect(sumHeader?.id).toBe('header-sum_num'); // Falls back to column.key, consecutive underscores collapsed
// Verify cells reference this header correctly
const sumCells = container.querySelectorAll(
'td[aria-labelledby="header-sum_num"]',
);
expect(sumCells.length).toBeGreaterThan(0);
// Test 3: Verify NO headers have "undefined" in their ID
const undefinedHeaders = headers.filter(header =>
header.id?.includes('undefined'),
);
expect(undefinedHeaders).toHaveLength(0);
// Test 4: Verify ALL headers have proper IDs (no missing IDs)
const headersWithIds = headers.filter(header => header.id);
expect(headersWithIds.length).toBe(headers.length);
// Test 5: Verify ALL header IDs are properly sanitized
headersWithIds.forEach(header => {
// IDs must not contain spaces
expect(header.id).not.toMatch(/\s/);
// IDs must not contain special chars like % (from %pct_nice column)
expect(header.id).not.toMatch(/[%#△]/);
// IDs should only contain valid CSS selector characters
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
});
test('should validate ARIA references for regular table cells', () => {
// Test that ALL cells with aria-labelledby have valid references
// This is critical for screen reader accessibility
const props = transformProps(testData.advanced);
const { container } = render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
);
expectValidAriaLabels(container);
});
test('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
const props = transformProps({
...testData.raw,
rawFormData: { ...testData.raw.rawFormData },
@@ -795,7 +572,7 @@ describe('plugin-chart-table', () => {
cells = document.querySelectorAll('td');
});
test('render color with string column color formatter(operator begins with)', () => {
it('render color with string column color formatter(operator begins with)', () => {
render(
ProviderWrapper({
children: (
@@ -827,7 +604,7 @@ describe('plugin-chart-table', () => {
);
});
test('render color with string column color formatter (operator ends with)', () => {
it('render color with string column color formatter (operator ends with)', () => {
render(
ProviderWrapper({
children: (
@@ -856,7 +633,7 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
});
test('render color with string column color formatter (operator containing)', () => {
it('render color with string column color formatter (operator containing)', () => {
render(
ProviderWrapper({
children: (
@@ -885,7 +662,7 @@ describe('plugin-chart-table', () => {
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
});
test('render color with string column color formatter (operator not containing)', () => {
it('render color with string column color formatter (operator not containing)', () => {
render(
ProviderWrapper({
children: (
@@ -916,7 +693,7 @@ describe('plugin-chart-table', () => {
);
});
test('render color with string column color formatter (operator =)', () => {
it('render color with string column color formatter (operator =)', () => {
render(
ProviderWrapper({
children: (
@@ -947,7 +724,7 @@ describe('plugin-chart-table', () => {
);
});
test('render color with string column color formatter (operator None)', () => {
it('render color with string column color formatter (operator None)', () => {
render(
ProviderWrapper({
children: (
@@ -980,7 +757,7 @@ describe('plugin-chart-table', () => {
);
});
test('render color with column color formatter to entire row', () => {
it('render color with column color formatter to entire row', () => {
render(
ProviderWrapper({
children: (
@@ -1016,7 +793,7 @@ describe('plugin-chart-table', () => {
);
});
test('display text color using column color formatter', () => {
it('display text color using column color formatter', () => {
render(
ProviderWrapper({
children: (
@@ -1049,7 +826,7 @@ describe('plugin-chart-table', () => {
);
});
test('display text color using column color formatter for entire row', () => {
it('display text color using column color formatter for entire row', () => {
render(
ProviderWrapper({
children: (

View File

@@ -950,13 +950,7 @@ export function mergeTable(table, query, prepend) {
return { type: MERGE_TABLE, table, query, prepend };
}
export function addTable(
queryEditor,
tableName,
catalogName,
schemaName,
expanded = true,
) {
export function addTable(queryEditor, tableName, catalogName, schemaName) {
return function (dispatch, getState) {
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
@@ -970,7 +964,7 @@ export function addTable(
mergeTable({
...table,
id: nanoid(11),
expanded,
expanded: true,
}),
);
};

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