Compare commits

...
Author SHA1 Message Date
Joe LiandClaude 31dcc1d954 fix(dataset): add mount guards to DatasourceModal async save operation
Prevents async state updates after component unmount in the modal's save flow by adding isMountedRef pattern to guard all operations that execute after async API calls.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 21:37:02 -07:00
Joe LiandClaude eea7402e4e fix(dataset): add comprehensive mount guards to DatasourceEditor async methods
Extends the isMountedRef pattern to all async methods in DatasourceEditor
to prevent state updates and toast notifications after component unmount.

**Methods Protected**:
- onQueryFormat: Guards SQL formatting state updates and toasts
- formatSql: Guards SQL formatting via SupersetClient.post
- syncMetadata: Guards metadata sync state updates and toasts
- fetchUsageData: Guards usage data state updates and toasts (already had partial guards)

**Pattern Applied**:
- Initialize: this.isComponentMounted = false (constructor)
- Set true: componentDidMount
- Set false: componentWillUnmount
- Guard all post-await state updates and toast calls with if (this.isComponentMounted)

**Why This Matters**:
The parent DatasourceEditor contains the child DatasetUsageTab. Both components
make async calls that can resolve after unmount:
- Parent: SupersetClient.get, formatQuery, fetchSyncedColumns
- Child: onFetchCharts (calls parent's fetchUsageData)

Both need mount guards because they manage independent state. This commit
completes the pattern in the parent, matching the fix applied to the child
in the previous commit.

**Benefits**:
- Eliminates potential intermittent test failures in parent component
- Prevents memory leaks and stale state updates
- Consistent async safety pattern across parent and child
- Production safety for all SQL formatting and metadata operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 16:58:32 -07:00
Joe LiandClaude 813d184ea2 fix(dataset): prevent async state updates after unmount in DatasetUsageTab
Fixes intermittent test error: "The document global was defined when React
was initialized, but is not defined anymore."

**Root Cause**:
handleFetchCharts awaited onFetchCharts() then unconditionally updated state
(setCurrentPage, setSortColumn, setSortDirection, setLoading). If the component
unmounted before the Promise resolved, these updates would execute on an
unmounted component, causing the jsdom crash.

**Fix**:
- Added isMountedRef to track component mount state
- Guard all post-await state updates with isMountedRef.current checks
- Set isMountedRef.current = true on mount (handles React Strict Mode double-mount)
- Set isMountedRef.current = false on unmount to prevent stale updates

**Test Changes**:
- Updated mock to simulate Table wrapper's pagination override behavior
- Mock now merges recordCount → total with pagination prop
- Ensures tests validate actual runtime behavior

**Benefits**:
- Eliminates intermittent test failures
- Prevents memory leaks in production
- React Strict Mode compatible
- Better UX (no stale state updates if user navigates during fetch)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 15:47:06 -07:00
Joe LiandClaude 13553a7205 fix(dataset): add pagination props to DatasetUsageTab Table component
Fixes pagination display issue where navigating to page 2+ would show
incorrect page counts (e.g., "1 page" instead of "3 pages").

**Root cause**: Table component was missing `current`, `total`, and
`pageSize` props in the pagination config, causing it to calculate
total pages from `data.length` instead of `totalCount`.

**Changes**:
- Add `current`, `total`, and `pageSize` to pagination config
- Update test assertions from `aria-current` to `ant-pagination-item-active`
- Update test queries from `getByRole('link')` to `getByRole('listitem')`
  (Ant Design 5 renders pagination items as <li>, not <a>)
- Add TypeScript type guard for pagination prop access
- Fix type compatibility for pagination.onChange (undefined → null)

**Test results**: All 16 tests passing (pagination navigation, state
persistence, edge cases, and async cleanup tests)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 10:17:27 -07:00
Joe LiandClaude ecce53e12f test(dataset): add comprehensive tests for DatasetUsageTab pagination and async cleanup
Add TDD tests to verify:
- Timeout cleanup on component unmount (prevents async updates after unmount)
- Rapid pagination click handling (clears pending timeouts)
- Scroll behavior after pagination
- Pagination state across multiple page changes
- Edge cases (single page, partial pages)

Tests currently fail as expected, exposing bugs:
- Missing `current` and `total` in pagination config
- Unmanaged setTimeout causing race conditions

Improvements:
- Use typed TableProps instead of `any` for mock
- Use aria-current instead of Ant Design class names for robustness
- Use userEvent for realistic user interactions
- Combine DOM assertions with soft prop assertions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 22:30:20 -07:00
Joe Li bd35faa8a0 style: fix eslint arrow-body-style warning in DatasetUsageTab
Move eslint-disable comment to correct line to suppress arrow-body-style
warning for useEffect cleanup function.
2025-10-02 22:30:20 -07:00
Joe LiandClaude 69879986e6 fix(dataset): add timeout cleanup in DatasetUsageTab to prevent async updates after unmount
Add proper setTimeout cleanup using useRef + useEffect to prevent intermittent
"document global was defined..." errors during test execution.

Component changes:
- Add scrollTimeoutRef to track timeout IDs with proper TypeScript typing
- Add cleanup useEffect to clear timeout on component unmount
- Update handlePageChange to clear pending timeouts before setting new ones
- Set scrollTimeoutRef to null after timeout executes

Test changes:
- Add Table component mock to capture onChange handlers for pagination tests
- Add 3 comprehensive test cases validating timeout cleanup:
  * Cleanup on unmount prevents async updates after component removal
  * Rapid pagination clears pending timeouts (only last scroll executes)
  * Normal pagination scrolls to top with 100ms delay
- Use fake timers with proper cleanup in afterEach
- Mock Element.prototype.scrollTo for JSDOM compatibility

Benefits:
- Prevents memory leaks from lingering timeouts
- Prevents stale DOM references in production
- Fixes test flakiness from async callback race conditions
- Prevents duplicate scroll operations on rapid pagination clicks

All tests passing (12/12). No intermittent errors when running with
ExploreChartPanel tests (20/20 total).

Fixes #34707

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 22:30:20 -07:00
Joe LiandClaude 41fd703a8c test(dataset): add TDD tests for async timeout cleanup in DatasetUsageTab
Add comprehensive test coverage for setTimeout cleanup to prevent
intermittent "document global was defined..." errors during test execution.

Test improvements:
- Add scrollTo mock lifecycle management (save/restore in beforeEach/afterEach)
- Add 3 TDD test cases for timeout cleanup scenarios:
  * Cleanup on unmount to prevent async updates
  * Clear pending timeout on rapid pagination clicks
  * Scroll to top after pagination with delay
- Use RTL-compliant getByRole('link') for pagination interaction
- Optimize mock reuse via Element.prototype.scrollTo reference
- Centralize timer cleanup in afterEach for guaranteed reset

Component changes:
- Update Table pagination to use recordCount/usePagination pattern
- Align with @superset-ui/core Table component API

Tests are currently failing (TDD) - component fix pending.

Related to #34707

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 22:30:20 -07:00
amaannawab923andGitHub 96170e43c0 fix(ag-grid-table): remove enterprise features to use community version (#35453) 2025-10-02 22:45:34 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8fde970b6b chore(deps): bump @ant-design/icons from 6.0.0 to 6.1.0 in /docs (#35439)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:28:20 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
38cb577fc6 chore(deps): bump swagger-ui-react from 5.27.1 to 5.29.1 in /docs (#35437)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:27:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8ebe045846 chore(deps): bump antd from 5.27.1 to 5.27.4 in /docs (#35432)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:26:13 -07:00
Beto DealmeidaandGitHub 62dc5c0306 fix(cache): ensure SQL is sanitized before cache key generation (#35419) 2025-10-02 13:25:54 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6ad8d29fcd chore(deps-dev): bump globals from 16.3.0 to 16.4.0 in /superset-websocket (#35431)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:25:38 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d83a88d5d9 chore(deps-dev): bump typescript-eslint from 8.19.0 to 8.45.0 in /superset-websocket (#35430)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:24:47 -07:00
SBIN2010andGitHub a7cc48dcda fix(test): changed test use unsaved changes prompt (#35447) 2025-10-02 10:20:30 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e2a7dc9256 chore(deps): bump ioredis and @types/ioredis in /superset-websocket (#35428)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 10:19:28 -07:00
Kamil GabryjelskiandGitHub 0364933e8b fix(explore): Remove query autotrigger (#35418) 2025-10-02 19:08:31 +02:00
Beto DealmeidaandGitHub 3202ff4b3f fix(pinot): more functions (#35451) 2025-10-02 13:01:47 -04:00
Gabriel Torres RuizandGitHub 553204e613 fix(dashboard): exit markdown edit mode when clicking outside of element (#35336) 2025-10-02 19:42:16 +03:00
Rafael BenitezandGitHub fe8348c03a fix(dataset): sort by database in Dataset and Saved queries Issue (#35277) 2025-10-02 16:44:19 +02:00
Beto DealmeidaandGitHub 30021f8ede fix(pinot): SUBSTR function (#35427) 2025-10-02 10:29:18 -04:00
Beto DealmeidaandGitHub f3349388d0 fix(pinot): DATE_SUB function (#35426) 2025-10-02 10:12:19 -04:00
Antonio RiveroandGitHub 449a89c214 fix(slice): Fix using isdigit when id passed as int (#35452) 2025-10-02 16:04:54 +02:00
Beto DealmeidaandGitHub 5428376662 fix(pinot): DATE_ADD function (#35424) 2025-10-02 09:56:20 -04:00
Evan RusackasandGitHub 5493e2c96d chore(dependabot): switch back to daily cadence (a trickle rather than a flood) (#35421) 2025-10-01 14:53:11 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2f8657f122 chore(deps-dev): bump eslint from 9.34.0 to 9.36.0 in /superset-websocket (#35359)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:26:06 -07:00
Beto DealmeidaGitHubbito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
aa97d2fe03 fix(pinot): dialect date truncation (#35420)
Co-authored-by: bito-code-review[bot] <188872107+bito-code-review[bot]@users.noreply.github.com>
2025-10-01 13:16:46 -04:00
28389de93e fix(ci): fix GHA for docs during PRs (#35186)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2025-10-01 10:13:47 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7c415c7cd9 chore(deps): bump hot-shots from 11.1.0 to 11.2.0 in /superset-websocket (#35358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:12:30 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f6b2abee81 chore(deps-dev): bump webpack from 5.101.3 to 5.102.0 in /docs (#35368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:11:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2ef7232959 chore(deps-dev): bump eslint from 9.34.0 to 9.36.0 in /docs (#35377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:10:45 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2991f60368 chore(deps-dev): bump @types/react-json-tree from 0.6.11 to 0.13.0 in /superset-frontend (#35406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:10:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
85ec3b1901 chore(deps-dev): bump @applitools/eyes-storybook from 3.55.6 to 3.60.0 in /superset-frontend (#35407)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:10:05 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0c639abe17 chore(deps-dev): bump jsdom from 26.0.0 to 27.0.0 in /superset-frontend (#35409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:09:38 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
50ed9b9e0e chore(deps-dev): bump @types/jquery from 3.5.32 to 3.5.33 in /superset-frontend (#35410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:08:36 -07:00
Beto DealmeidaandGitHub 13a164dd63 fix: table quoting in DBs with supports_cross_catalog_queries=True (#35350) 2025-10-01 12:17:46 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6b0b13e4e7 chore(deps-dev): bump typescript-eslint from 8.40.0 to 8.45.0 in /docs (#35381)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 08:49:51 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c188952de2 chore(deps-dev): bump webpack from 5.99.9 to 5.102.0 in /superset-frontend (#35402)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 08:48:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a3b91f924d chore(deps): bump @babel/runtime from 7.28.2 to 7.28.4 in /superset-frontend (#35403)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 08:47:11 -07:00
Rafael BenitezandGitHub d8688cf8b1 fix(explore): close unsaved changes modal when discarding changes (#35307) 2025-10-01 17:43:02 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
40378afbf8 chore(deps): bump actions/labeler from 5 to 6 (#35386)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 07:02:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
84e21434b3 chore(deps): bump aws-actions/configure-aws-credentials from 4 to 5 (#35387)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 07:02:26 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d27210794c chore(deps): bump actions/setup-python from 5 to 6 (#35390)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 07:01:48 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5a90a84685 chore(deps): bump actions/github-script from 7 to 8 (#35385)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 07:00:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6fbbc85df7 chore(deps): bump actions/setup-node from 4 to 5 (#35394)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 06:59:44 -07:00
GeidōandGitHub a66c230058 fix(SqlLab): Hit tableschemaview with a valid queryEditorId (#35341) 2025-10-01 14:39:02 +03:00
Damian PendrakandGitHub 19db0353a9 feat(db): custom database error messages (#34674) 2025-10-01 14:29:57 +03:00
Kamil GabryjelskiandGitHub 88e5581d04 fix: Broken splitter in sql lab and some minor visual fixes (#35416) 2025-10-01 13:11:50 +02:00
Beto DealmeidaandGitHub bf88d9bb1c fix(pinot): restrict types in dialect (#35337) 2025-09-30 16:34:53 -04:00
Beto DealmeidaandGitHub d51b35f61b fix: adhoc orderby in explore (#35342) 2025-09-30 16:34:44 -04:00
Gabriel Torres RuizandGitHub 220480b627 feat(theming): add base theme config (#35220) 2025-09-30 11:01:31 -07:00
ef78d2af06 fix(doris): Don't set supports_cross_catalog_queries to true (#35332)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
2025-09-30 14:42:03 +03:00
7deed00def chore(frontend): Consolidate ESLint configurations in superset-frontend (#35318)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-29 23:52:25 -07:00
Beto DealmeidaandGitHub 4e093a8e2a feat: sqlglot dialect for Pinot (#35333) 2025-09-29 21:56:35 -04:00
0b73b4842f fix: Enable DuckDB examples loading in showtime ephemeral environments (#35294)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-29 13:21:33 -07:00
113 changed files with 7428 additions and 2742 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
# Notify PMC members of changes to GitHub Actions
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe
# Notify PMC members of changes to required GitHub Actions
+35 -35
View File
@@ -5,7 +5,7 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
interval: "daily"
- package-ecosystem: "npm"
ignore:
@@ -18,7 +18,7 @@ updates:
- dependency-name: "jest-environment-jsdom"
directory: "/superset-frontend/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -40,21 +40,21 @@ updates:
- package-ecosystem: "npm"
directory: ".github/actions"
schedule:
interval: "monthly"
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
- package-ecosystem: "npm"
directory: "/docs/"
schedule:
interval: "monthly"
interval: "daily"
open-pull-requests-limit: 10
versioning-strategy: increase
- package-ecosystem: "npm"
directory: "/superset-websocket/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -63,7 +63,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -75,7 +75,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-calendar/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -85,7 +85,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-histogram/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -95,7 +95,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -105,7 +105,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -115,7 +115,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -125,7 +125,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -135,7 +135,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -145,7 +145,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -155,7 +155,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -165,7 +165,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -175,7 +175,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -185,7 +185,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -195,7 +195,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-sankey/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -205,7 +205,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -215,7 +215,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -225,7 +225,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-event-flow/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -235,7 +235,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -245,7 +245,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-sankey-loop/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -255,7 +255,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -265,7 +265,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/preset-chart-xy/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -275,7 +275,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-heatmap/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -285,7 +285,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -295,7 +295,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-sunburst/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -305,7 +305,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -315,7 +315,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -325,7 +325,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -339,7 +339,7 @@ updates:
- dependency-name: "react-markdown"
- dependency-name: "remark-gfm"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -349,7 +349,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-demo/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
@@ -359,7 +359,7 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
schedule:
interval: "monthly"
interval: "daily"
labels:
- npm
- dependabot
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
uses: ./.github/actions/setup-supersetbot/
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
@@ -27,7 +27,7 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@v5
- name: Check and notify
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ github.token }}
script: |
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
pull-requests: write
steps:
- name: Comment access denied
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const message = `👋 Hi @${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}!
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
- uses: actions/setup-node@v5
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -69,7 +69,7 @@ jobs:
- name: Comment (success)
if: steps.describe-services.outputs.active == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{github.token}}
script: |
+8 -8
View File
@@ -63,7 +63,7 @@ jobs:
- name: Get event SHA
id: get-sha
if: steps.eval-label.outputs.result == 'up'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -94,7 +94,7 @@ jobs:
core.setOutput("sha", prSha);
- name: Looking for feature flags in PR description
uses: actions/github-script@v7
uses: actions/github-script@v8
id: eval-feature-flags
if: steps.eval-label.outputs.result == 'up'
with:
@@ -116,7 +116,7 @@ jobs:
return results;
- name: Reply with confirmation comment
uses: actions/github-script@v7
uses: actions/github-script@v8
if: steps.eval-label.outputs.result == 'up'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -189,7 +189,7 @@ jobs:
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -225,7 +225,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -248,7 +248,7 @@ jobs:
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{ github.token }}
script: |
@@ -318,7 +318,7 @@ jobs:
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
- name: Comment (success)
if: ${{ success() }}
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{github.token}}
script: |
@@ -331,7 +331,7 @@ jobs:
});
- name: Comment (failure)
if: ${{ failure() }}
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{github.token}}
script: |
@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6
with:
sync-labels: true
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check for 'hold' label
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
echo "HOMEBREW_REPOSITORY=$HOMEBREW_REPOSITORY" >>"${GITHUB_ENV}"
brew install norwoodj/tap/helm-docs
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Install Node.js
if: env.HAS_TAGS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Security Check - Authorize Maintainers Only
id: auth
uses: actions/github-script@v7
uses: actions/github-script@v8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -63,7 +63,7 @@ jobs:
with:
run: testdata
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
@@ -36,7 +36,7 @@ jobs:
submodules: recursive
ref: master
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install eyes-storybook dependencies
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './docs/.nvmrc'
- name: Setup Python
+12 -10
View File
@@ -21,12 +21,14 @@ jobs:
- uses: actions/checkout@v5
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@v1.11.0
- uses: JustinBeckwith/linkinator-action@3d5ba091319fa7b0ac14703761eebb7d100e6f6d # v1.11.0
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx, !superset-frontend/CHANGELOG.md"
paths: "**/*.md, **/*.mdx"
linksToSkip: >-
^https://github.com/apache/(superset|incubator-superset)/(pull|issue)/\d+,
^https://github.com/apache/(superset|incubator-superset)/(pull|issues)/\d+,
^https://github.com/apache/(superset|incubator-superset)/commit/[a-f0-9]+,
superset-frontend/.*CHANGELOG\.md,
http://localhost:8088/,
http://127.0.0.1:3000/,
http://localhost:9001/,
@@ -41,12 +43,12 @@ jobs:
http://theiconic.com.au/,
https://dev.mysql.com/doc/refman/5.7/en/innodb-limits.html,
^https://img\.shields\.io/.*,
https://vkusvill.ru/
https://www.linkedin.com/in/mark-thomas-b16751158/
https://theiconic.com.au/
https://wattbewerb.de/
https://timbr.ai/
https://opensource.org/license/apache-2-0
https://vkusvill.ru/,
https://www.linkedin.com/in/mark-thomas-b16751158/,
https://theiconic.com.au/,
https://wattbewerb.de/,
https://timbr.ai/,
https://opensource.org/license/apache-2-0,
https://www.plaidcloud.com/
build-deploy:
name: Build & Deploy
@@ -61,7 +63,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './docs/.nvmrc'
- name: yarn install
+1 -1
View File
@@ -109,7 +109,7 @@ jobs:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
+1 -1
View File
@@ -101,7 +101,7 @@ jobs:
CR_RELEASE_NAME_TEMPLATE: "superset-helm-chart-{{ .Version }}"
- name: Open Pull Request
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const branchName = '${{ env.branch_name }}';
+1 -1
View File
@@ -99,7 +99,7 @@ jobs:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
if: steps.check.outputs.frontend
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install dependencies
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Quickly add thumbs up!
if: github.event_name == 'issue_comment' && contains(github.event.comment.body, '@supersetbot')
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/')
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
@@ -112,7 +112,7 @@ jobs:
fetch-depth: 0
- name: Use Node.js 20
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
+5 -2
View File
@@ -26,6 +26,9 @@ ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}
# Include translations in the final build
ARG BUILD_TRANSLATIONS="false"
# Build arg to pre-populate examples DuckDB file
ARG LOAD_EXAMPLES_DUCKDB="false"
######################################################################
# superset-node-ci used as a base for building frontend assets and CI
######################################################################
@@ -143,8 +146,8 @@ RUN if [ "${BUILD_TRANSLATIONS}" = "true" ]; then \
######################################################################
FROM python-base AS python-common
# Build arg to pre-populate examples DuckDB file
ARG LOAD_EXAMPLES_DUCKDB="false"
# Re-declare build arg to receive it in this stage
ARG LOAD_EXAMPLES_DUCKDB
ENV SUPERSET_HOME="/app/superset_home" \
HOME="/app/superset_home" \
+42
View File
@@ -1769,6 +1769,48 @@ You can use the `Extra` field in the **Edit Databases** form to configure SSL:
}
}
```
##### Custom Error Messages
You can use the `CUSTOM_DATABASE_ERRORS` in the `superset/custom_database_errors.py` file or overwrite it in your config file to configure custom error messages for database exceptions.
This feature lets you transform raw database errors into user-friendly messages, optionally including documentation links and hiding default error codes.
Provide an empty string as the first value to keep the original error message. This way, you can add just a link to the documentation
**Example usage:**
```Python
CUSTOM_DATABASE_ERRORS = {
"database_name": {
re.compile('permission denied for view'): (
__(
'Permission denied'
),
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{
"custom_doc_links": [
{
"url": "https://example.com/docs/1",
"label": "Check documentation"
},
],
"show_issue_info": False,
}
)
},
"examples": {
re.compile(r'message="(?P<message>[^"]*)"'): (
__(
'Unexpected error: "%(message)s"'
),
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{}
)
}
}
```
**Options:**
- ``custom_doc_links``: List of documentation links to display with the error.
- ``show_issue_info``: Set to ``False`` to hide default error codes.
## Misc
+6 -6
View File
@@ -27,7 +27,7 @@
"version:remove:components": "node scripts/manage-versions.mjs remove components"
},
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@ant-design/icons": "^6.1.0",
"@docusaurus/core": "3.8.1",
"@docusaurus/plugin-client-redirects": "3.8.1",
"@docusaurus/preset-classic": "3.8.1",
@@ -49,7 +49,7 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^5.26.7",
"antd": "^5.27.4",
"caniuse-lite": "^1.0.30001739",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
@@ -63,7 +63,7 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.11",
"swagger-ui-react": "^5.27.1",
"swagger-ui-react": "^5.29.1",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4"
},
@@ -74,15 +74,15 @@
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.42.0",
"eslint": "^9.34.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"prettier": "^3.6.2",
"typescript": "~5.9.2",
"typescript-eslint": "^8.39.0",
"webpack": "^5.101.0"
"typescript-eslint": "^8.45.0",
"webpack": "^5.102.0"
},
"browserslist": {
"production": [
@@ -16,10 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
{
"settings": {
"import/resolver": {
"typescript": {}
}
declare module '@theme/Layout' {
import type { ReactNode } from 'react';
export interface Props {
readonly children?: ReactNode;
readonly noFooter?: boolean;
readonly wrapperClassName?: string;
readonly title?: string;
readonly description?: string;
}
export default function Layout(props: Props): ReactNode;
}
+2 -2
View File
@@ -23,8 +23,8 @@ import type { Plugin } from '@docusaurus/types';
export default function webpackExtendPlugin(): Plugin<void> {
return {
name: 'custom-webpack-plugin',
configureWebpack(config, isServer, utils) {
const { isDev } = utils;
configureWebpack(config) {
const isDev = process.env.NODE_ENV === 'development';
return {
devtool: isDev ? 'eval-source-map' : config.devtool,
...(isDev && {
+17 -2
View File
@@ -2,12 +2,27 @@
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
"baseUrl": ".",
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"types": ["@docusaurus/module-type-aliases"]
},
"jsx": "react-jsx",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@superset-ui/core": ["../superset-frontend/packages/superset-ui-core/src"],
"@superset-ui/core/*": ["../superset-frontend/packages/superset-ui-core/src/*"],
"*": ["src/*", "node_modules/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"../superset-frontend/**/*",
"src/webpack.extend.ts"
]
}
+336 -242
View File
@@ -232,15 +232,15 @@
classnames "^2.2.6"
rc-util "^5.31.1"
"@ant-design/icons@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.0.0.tgz#302c935b8b0b429e4444cbc45809247276186d94"
integrity sha512-o0aCCAlHc1o4CQcapAwWzHeaW2x9F49g7P3IDtvtNXgHowtRWYb7kiubt8sQPFvfVIVU/jLw2hzeSlNt0FU+Uw==
"@ant-design/icons@^6.1.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.1.0.tgz#97cc14a3c0528b8e2b37f41f232b019f2ca38c2c"
integrity sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg==
dependencies:
"@ant-design/colors" "^8.0.0"
"@ant-design/icons-svg" "^4.4.0"
"@rc-component/util" "^1.2.1"
classnames "^2.2.6"
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@ant-design/react-slick@~1.1.2":
version "1.1.2"
@@ -2385,10 +2385,10 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f"
integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0":
version "4.9.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3"
integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==
dependencies:
eslint-visitor-keys "^3.4.3"
@@ -2433,10 +2433,10 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.34.0", "@eslint/js@^9.32.0":
version "9.34.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.34.0.tgz#fc423168b9d10e08dea9088d083788ec6442996b"
integrity sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==
"@eslint/js@9.36.0", "@eslint/js@^9.32.0":
version "9.36.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef"
integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==
"@eslint/object-schema@^2.1.6":
version "2.1.6"
@@ -2746,10 +2746,10 @@
rc-resize-observer "^1.3.1"
rc-util "^5.44.0"
"@rc-component/util@^1.2.1":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.2.2.tgz#f8363b0e1cc78af3ec56e2235cc3438eb8832040"
integrity sha512-p3zQr9Wu8BKncqmuW23olzBoAFsN8PYMS9FaI4JwJLwknH7DvfHAr1fwbfl9aAWw4Jva64ucpenbgG4fznLUSw==
"@rc-component/util@^1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.3.0.tgz#fc8e1ce1e5292592ef7f45839a3c07366288275c"
integrity sha512-hfXE04CVsxI/slmWKeSh6du7sSKpbvVdVEZCa8A+2QWDlL97EsCYme2c3ZWLn1uC9FR21JoewlrhUPWO4QgO8w==
dependencies:
is-mobile "^5.0.0"
react-is "^18.2.0"
@@ -3050,7 +3050,34 @@
ramda-adjunct "^5.0.0"
unraw "^3.0.0"
"@swagger-api/apidom-core@>=1.0.0-beta.41 <1.0.0-rc.0", "@swagger-api/apidom-core@^1.0.0-beta.46":
"@swagger-api/apidom-ast@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.50.tgz#9e0f4132ea54dd4fca0f31f919a0c5aad12d93c1"
integrity sha512-uUBUm6J6KlyKppyfS7DIW37De6oyMVIpHYmaNV3YAaDMuRMov5KHHWXKbqWlI+l493OljOcXEqDIPeLzm6B5PQ==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
unraw "^3.0.0"
"@swagger-api/apidom-core@>=1.0.0-beta.50 <1.0.0-rc.0", "@swagger-api/apidom-core@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.50.tgz#d9ed6821d1b38b66ed8cd0b8d8bf5de997354178"
integrity sha512-9N7ySdyzx/3kUnprAi63GQNt+Kq8VUvErwDgPcMRAsZX8jUhk9KLJ9N0fup4mWm6+xGs0JH35wxBxnanS6aiqw==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-ast" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
minim "~0.23.8"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
short-unique-id "^5.3.2"
ts-mixer "^6.0.3"
"@swagger-api/apidom-core@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-core/-/apidom-core-1.0.0-beta.46.tgz#6c879f6cf9e67fa0cc36955d3a7f0dd185e36b4f"
integrity sha512-CC8SdAkjSA/l31xfg5l4860cixbYABIXamJeNcXJr+O1z9YNKHIIENeI0zIwORk/TYUaSOg1KUjDnV2Pdctufg==
@@ -3065,14 +3092,31 @@
short-unique-id "^5.3.2"
ts-mixer "^6.0.3"
"@swagger-api/apidom-error@>=1.0.0-beta.41 <1.0.0-rc.0", "@swagger-api/apidom-error@^1.0.0-beta.46":
"@swagger-api/apidom-error@>=1.0.0-beta.50 <1.0.0-rc.0", "@swagger-api/apidom-error@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.50.tgz#4354c4ade0482824ec7b17667390057f87838ddd"
integrity sha512-vdpi2nRVcxXLGc68JPNwTcKrCKl8PnOEPuykZSxeNbDKnZY80APbsoLDX+1gdRgafK/7k5XdsBkpDQscsTkDng==
dependencies:
"@babel/runtime-corejs3" "^7.20.7"
"@swagger-api/apidom-error@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-error/-/apidom-error-1.0.0-beta.46.tgz#fe9039c23ac655930e1f06d99b8ea28bc40d905d"
integrity sha512-AN2ZOHp7gtVsCa6hWxH3nk/1PS7bRtqDVCih3C5qA8k5JfHjP2wfks7b14Ns971SUvDD/SubaCmXmu0CRBHLag==
dependencies:
"@babel/runtime-corejs3" "^7.20.7"
"@swagger-api/apidom-json-pointer@>=1.0.0-beta.41 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.40 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.46":
"@swagger-api/apidom-json-pointer@>=1.0.0-beta.50 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.50.tgz#8d2c4129c0d59cf059df3f0e40703114a5c3b49a"
integrity sha512-2TgFKHlZ/SlnTZzY7EwE8xx5Pr2BYePX52xZJFqWnueSAIcCcsrqZeazWIAaDe/gXd47CDqU95nDChMECERspA==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swaggerexpert/json-pointer" "^2.10.1"
"@swagger-api/apidom-json-pointer@^1.0.0-beta.40 <1.0.0-rc.0", "@swagger-api/apidom-json-pointer@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-beta.46.tgz#40edcaf5fddc5c748ed4084c9042a2669cbf507c"
integrity sha512-JsbCkYDG7rbf1QmEhtXgbuCGJ4oW+uWDOEnW+Gar49sjk/OKX/2GSGUpywRstnZcopjNarLcEx/H1mHRLoL45A==
@@ -3136,6 +3180,20 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-2019-09@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2019-09/-/apidom-ns-json-schema-2019-09-1.0.0-beta.50.tgz#83c0eae65b2c4563c5233acdfbccb66c07065e93"
integrity sha512-QP6DuthV8ZWQnthYbPEVikK5rTN4T5lhnAnmO1v6zOCS9B1heKCFcIYgBhcqCnuZ0Tt8kGOfLyqGMb57lPkCdw==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-draft-7" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-2020-12@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.0-beta.46.tgz#779dbfb80ad10c3d6b2fd182a35231803e9ee387"
@@ -3150,6 +3208,20 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-2020-12@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-2020-12/-/apidom-ns-json-schema-2020-12-1.0.0-beta.50.tgz#0310fc351efd42695227c012b1407728e1400e92"
integrity sha512-ZaqrtZEXUx35x66ND8sc5vf1sIuWPERA15EdRHeca56E09RnjZMUHkiDvdx78165h31QmM67YLi04zEBYhQS0g==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-2019-09" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-4@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.46.tgz#f17bb2010dd933fb6875c82d83826eda62f87bb6"
@@ -3163,6 +3235,19 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-4@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-beta.50.tgz#43b0e37d135b805ab3ea7efce0c5fc5678e95abc"
integrity sha512-aqCwW+iuN7RokH10vDp/eEwlrT4LAlHGy1pLzAS9aFVJyUutfm0I4fxLfddOKD2yd04z858zhLwOVSo4BjrLHg==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-ast" "^1.0.0-beta.50"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-6@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.46.tgz#6629cb35e9e10e670e34a4fd50b6d159f5666766"
@@ -3177,6 +3262,20 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-6@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-beta.50.tgz#a311488627a12953a4a331d3b4231c20311aed9c"
integrity sha512-trF1TZZ79WJOjQw3C1Y7wcqNMxxgHMZtJW2/tP5MwII1hqsExGzmGyUuNlVuSC9k9v/9sCj85hQlJ4TW6HFciQ==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-draft-4" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-7@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.46.tgz#04fa39cfe0f869e0c7be0f70ceb34ffb6cc830ad"
@@ -3191,6 +3290,20 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-json-schema-draft-7@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-beta.50.tgz#eb99fdecf9746905331f281578e924082659aef0"
integrity sha512-g9VscnMwjPUYCfR6UxUwsLiIKnyXy2W28J+zN0rbijoSEtUdakcrxwdPhqwgJZHPci8NHNE8574zaocqKBiqSg==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-draft-6" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.4"
"@swagger-api/apidom-ns-openapi-2@^1.0.0-beta.40 <1.0.0-rc.0", "@swagger-api/apidom-ns-openapi-2@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.0-beta.46.tgz#448f5b7bacd29a3dc37124a7a62f154c94cb818d"
@@ -3219,7 +3332,37 @@
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.3"
"@swagger-api/apidom-ns-openapi-3-1@>=1.0.0-beta.41 <1.0.0-rc.0", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.40 <1.0.0-rc.0", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.46":
"@swagger-api/apidom-ns-openapi-3-0@^1.0.0-beta.50":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.0-beta.50.tgz#1d9631c0c7504a8985671f2f8e5ca435235b1a54"
integrity sha512-I4GHyNILNxDsYKYeG1+ZA3rnfU1RAYtNp3dA+G8LCX5AB/2N7dT2VPK8HS4cj9m3ZVz7dl1o+X6tpaJIN5kDsA==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-draft-4" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.3"
"@swagger-api/apidom-ns-openapi-3-1@>=1.0.0-beta.50 <1.0.0-rc.0":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.50.tgz#8b9a0c97849cc107228e66dd746bed858b78c50d"
integrity sha512-kxwuaFl1kQddk/RBS5Mz3rE/6v5mXggqhzVwDBObGjgkRmDRVF5nUalziBRNg6A3NcpYbsjNMU/OCA1JihFkrg==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-ast" "^1.0.0-beta.50"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-json-pointer" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-json-schema-2020-12" "^1.0.0-beta.50"
"@swagger-api/apidom-ns-openapi-3-0" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
ramda "~0.30.0"
ramda-adjunct "^5.0.0"
ts-mixer "^6.0.3"
"@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.40 <1.0.0-rc.0", "@swagger-api/apidom-ns-openapi-3-1@^1.0.0-beta.46":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-beta.46.tgz#87056343a5e2ff48992a27af99611fcbdfbc0f22"
integrity sha512-dSs9h4YEty7h8HJDtWSeC0cg0O2XUTwTVbnOylHX4Z7tXYub8TJ2LEbngRic/R4T58xt76Ro5rZqGUcrt5qXDQ==
@@ -3423,16 +3566,16 @@
tree-sitter "=0.22.4"
web-tree-sitter "=0.24.5"
"@swagger-api/apidom-reference@>=1.0.0-beta.41 <1.0.0-rc.0":
version "1.0.0-beta.46"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.46.tgz#c9cb8edfc1099f05e19075c880c4e7c4a87d5015"
integrity sha512-OpJ72k17hyrUJYvj5HgNg93uUsk/28cBWkDAna/cZtCiTdz9zji31JiDUr5GoUd81rpVGuxo4AuUvxXVsH35tA==
"@swagger-api/apidom-reference@>=1.0.0-beta.50 <1.0.0-rc.0":
version "1.0.0-beta.50"
resolved "https://registry.yarnpkg.com/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-beta.50.tgz#d03f5506327e92dff81a4fac6460edcd2bf3989e"
integrity sha512-aD7gTWPgkJb9oYaC4jZPvxb7YbQKG9pWDYZigAkVGqOAbeYxUXeI00XyCLj/cH8l7KwyhTZNX70F7VnfxOkq7w==
dependencies:
"@babel/runtime-corejs3" "^7.26.10"
"@swagger-api/apidom-core" "^1.0.0-beta.46"
"@swagger-api/apidom-error" "^1.0.0-beta.46"
"@swagger-api/apidom-core" "^1.0.0-beta.50"
"@swagger-api/apidom-error" "^1.0.0-beta.50"
"@types/ramda" "~0.30.0"
axios "^1.9.0"
axios "^1.12.2"
minimatch "^7.4.3"
process "^0.11.10"
ramda "~0.30.0"
@@ -4087,117 +4230,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.40.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz#19f959f273b32f5082c891903645e6a85328db4e"
integrity sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==
"@typescript-eslint/eslint-plugin@8.45.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz#9f251d4e85ec5089e7cccb09257ce93dbf0d7744"
integrity sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.40.0"
"@typescript-eslint/type-utils" "8.40.0"
"@typescript-eslint/utils" "8.40.0"
"@typescript-eslint/visitor-keys" "8.40.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/type-utils" "8.45.0"
"@typescript-eslint/utils" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.40.0.tgz#1bc9f3701ced29540eb76ff2d95ce0d52ddc7e69"
integrity sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==
"@typescript-eslint/parser@8.45.0", "@typescript-eslint/parser@^8.42.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.45.0.tgz#571660c98824aefb4a6ec3b3766655d1348520a4"
integrity sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==
dependencies:
"@typescript-eslint/scope-manager" "8.40.0"
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/typescript-estree" "8.40.0"
"@typescript-eslint/visitor-keys" "8.40.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
debug "^4.3.4"
"@typescript-eslint/parser@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.42.0.tgz#20ea66f4867981fb5bb62cbe1454250fc4a440ab"
integrity sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==
"@typescript-eslint/project-service@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.45.0.tgz#f83dda1bca31dae2fd6821f9131daf1edebfd46c"
integrity sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==
dependencies:
"@typescript-eslint/scope-manager" "8.42.0"
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/typescript-estree" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
"@typescript-eslint/tsconfig-utils" "^8.45.0"
"@typescript-eslint/types" "^8.45.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.40.0.tgz#1b7ba6079ff580c3215882fe75a43e5d3ed166b9"
integrity sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==
"@typescript-eslint/scope-manager@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz#59615ba506a9e3479d1efb0d09d6ab52f2a19142"
integrity sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.40.0"
"@typescript-eslint/types" "^8.40.0"
debug "^4.3.4"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
"@typescript-eslint/project-service@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.42.0.tgz#636eb3418b6c42c98554dce884943708bf41a583"
integrity sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==
"@typescript-eslint/tsconfig-utils@8.45.0", "@typescript-eslint/tsconfig-utils@^8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz#63d38282790a2566c571bad423e7c1cad1f3d64c"
integrity sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==
"@typescript-eslint/type-utils@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz#04004bdf2598844faa29fb936fb6b0ee10d6d3f3"
integrity sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.42.0"
"@typescript-eslint/types" "^8.42.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz#2fbfcc8643340d8cd692267e61548b946190be8a"
integrity sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==
dependencies:
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/visitor-keys" "8.40.0"
"@typescript-eslint/scope-manager@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz#36016757bc85b46ea42bae47b61f9421eddedde3"
integrity sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==
dependencies:
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
"@typescript-eslint/tsconfig-utils@8.40.0", "@typescript-eslint/tsconfig-utils@^8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz#8e8fdb9b988854aedd04abdde3239c4bdd2d26e4"
integrity sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==
"@typescript-eslint/tsconfig-utils@8.42.0", "@typescript-eslint/tsconfig-utils@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz#21a3e74396fd7443ff930bc41b27789ba7e9236e"
integrity sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==
"@typescript-eslint/type-utils@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz#a7e4a1f0815dd0ba3e4eef945cc87193ca32c422"
integrity sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==
dependencies:
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/typescript-estree" "8.40.0"
"@typescript-eslint/utils" "8.40.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/utils" "8.45.0"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.40.0.tgz#0b580fdf643737aa5c01285314b5c6e9543846a9"
integrity sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==
"@typescript-eslint/types@8.45.0", "@typescript-eslint/types@^8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.45.0.tgz#fc01cd2a4690b9713b02f895e82fb43f7d960684"
integrity sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==
"@typescript-eslint/types@8.42.0", "@typescript-eslint/types@^8.40.0", "@typescript-eslint/types@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.42.0.tgz#ae15c09cebda20473772902033328e87372db008"
integrity sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==
"@typescript-eslint/typescript-estree@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz#295149440ce7da81c790a4e14e327599a3a1e5c9"
integrity sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==
"@typescript-eslint/typescript-estree@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz#3498500f109a89b104d2770497c707e56dfe062d"
integrity sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==
dependencies:
"@typescript-eslint/project-service" "8.40.0"
"@typescript-eslint/tsconfig-utils" "8.40.0"
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/visitor-keys" "8.40.0"
"@typescript-eslint/project-service" "8.45.0"
"@typescript-eslint/tsconfig-utils" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/visitor-keys" "8.45.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -4205,46 +4310,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/typescript-estree@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz#593c3af87d4462252c0d7239d1720b84a1b56864"
integrity sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==
dependencies:
"@typescript-eslint/project-service" "8.42.0"
"@typescript-eslint/tsconfig-utils" "8.42.0"
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.40.0.tgz#8d0c6430ed2f5dc350784bb0d8be514da1e54054"
integrity sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==
"@typescript-eslint/utils@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.45.0.tgz#6e68e92d99019fdf56018d0e6664c76a70470c95"
integrity sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.40.0"
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/typescript-estree" "8.40.0"
"@typescript-eslint/scope-manager" "8.45.0"
"@typescript-eslint/types" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/visitor-keys@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz#c1b45196981311fed7256863be4bfb2d3eda332a"
integrity sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==
"@typescript-eslint/visitor-keys@8.45.0":
version "8.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz#4e3bcc55da64ac61069ebfe62ca240567ac7d784"
integrity sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==
dependencies:
"@typescript-eslint/types" "8.40.0"
eslint-visitor-keys "^4.2.1"
"@typescript-eslint/visitor-keys@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz#87c6caaa1ac307bc73a87c1fc469f88f0162f27e"
integrity sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==
dependencies:
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/types" "8.45.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -4544,10 +4625,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.26.7:
version "5.27.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.1.tgz#5378fc017cb4057ffefe2a670f20e54b924d897d"
integrity sha512-jGMSdBN7hAMvPV27B4RhzZfL6n6yu8yDbo7oXrlJasaOqB7bSDPcjdEy1kXy3JPsny/Qazb1ykzRI4EfcByAPQ==
antd@^5.27.4:
version "5.27.4"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.4.tgz#13c97deb12e6aeb43adecd23f3dbe3139a62e579"
integrity sha512-rhArohoAUCxhkPjGI/BXthOrrjaElL4Fb7d4vEHnIR3DpxFXfegd4rN21IgGdiF+Iz4EFuUZu8MdS8NuJHLSVQ==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -4585,10 +4666,10 @@ antd@^5.26.7:
rc-resize-observer "^1.4.3"
rc-segmented "~2.7.0"
rc-select "~14.16.8"
rc-slider "~11.1.8"
rc-slider "~11.1.9"
rc-steps "~6.0.1"
rc-switch "~4.1.0"
rc-table "~7.51.1"
rc-table "~7.53.0"
rc-tabs "~15.7.0"
rc-textarea "~1.10.2"
rc-tooltip "~6.4.0"
@@ -4765,10 +4846,10 @@ available-typed-arrays@^1.0.7:
dependencies:
possible-typed-array-names "^1.0.0"
axios@^1.9.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
axios@^1.12.2:
version "1.12.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7"
integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.4"
@@ -4862,11 +4943,16 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.5.1:
base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.8.9:
version "2.8.10"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz#32eb5e253d633fa3fa3ffb1685fabf41680d9e8a"
integrity sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==
batch@0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
@@ -4980,14 +5066,15 @@ browser-assert@^1.2.1:
resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200"
integrity sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.25.0, browserslist@^4.25.3:
version "4.25.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.3.tgz#9167c9cbb40473f15f75f85189290678b99b16c5"
integrity sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.24.5, browserslist@^4.25.0, browserslist@^4.25.3:
version "4.26.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.26.3.tgz#40fbfe2d1cd420281ce5b1caa8840049c79afb56"
integrity sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==
dependencies:
caniuse-lite "^1.0.30001735"
electron-to-chromium "^1.5.204"
node-releases "^2.0.19"
baseline-browser-mapping "^2.8.9"
caniuse-lite "^1.0.30001746"
electron-to-chromium "^1.5.227"
node-releases "^2.0.21"
update-browserslist-db "^1.1.3"
buffer-from@^1.0.0:
@@ -4995,6 +5082,14 @@ buffer-from@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
dependencies:
base64-js "^1.3.1"
ieee754 "^1.2.1"
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@@ -5082,11 +5177,16 @@ 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.30001735, caniuse-lite@^1.0.30001739:
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001739:
version "1.0.30001739"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz#b34ce2d56bfc22f4352b2af0144102d623a124f4"
integrity sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==
caniuse-lite@^1.0.30001746:
version "1.0.30001746"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz#199d20f04f5369825e00ff7067d45d5dfa03aee7"
integrity sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==
ccount@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
@@ -5254,7 +5354,7 @@ clone-deep@^4.0.1:
kind-of "^6.0.2"
shallow-clone "^3.0.0"
clsx@^2.0.0:
clsx@^2.0.0, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
@@ -6340,14 +6440,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@=3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.4.tgz#af5a5a11407524431456cf18836c55d13441cd8e"
integrity sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
dompurify@^3.2.5:
dompurify@=3.2.6, dompurify@^3.2.5:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
@@ -6416,10 +6509,10 @@ ee-first@1.1.1:
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
electron-to-chromium@^1.5.204:
version "1.5.207"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz#0fedde3eec615065ee95531c09a10578644c5552"
integrity sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==
electron-to-chromium@^1.5.227:
version "1.5.228"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz#38b849bc8714bd21fb64f5ad56bf8cfd8638e1e9"
integrity sha512-nxkiyuqAn4MJ1QbobwqJILiDtu/jk14hEAWaMiJmNPh1Z+jqoFlBFZjdXwLWGeVSeu9hGLg6+2G9yJaW8rBIFA==
emoji-regex@^8.0.0:
version "8.0.0"
@@ -6775,18 +6868,18 @@ 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.34.0:
version "9.34.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.34.0.tgz#0ea1f2c1b5d1671db8f01aa6b8ce722302016f7b"
integrity sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==
eslint@^9.36.0:
version "9.36.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088"
integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
"@eslint/config-array" "^0.21.0"
"@eslint/config-helpers" "^0.3.1"
"@eslint/core" "^0.15.2"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.34.0"
"@eslint/js" "9.36.0"
"@eslint/plugin-kit" "^0.3.5"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
@@ -9983,10 +10076,10 @@ node-gyp-build@^4.8.0, node-gyp-build@^4.8.2, node-gyp-build@^4.8.4:
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8"
integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==
node-releases@^2.0.19:
version "2.0.19"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314"
integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==
node-releases@^2.0.21:
version "2.0.21"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c"
integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
@@ -11455,10 +11548,10 @@ rc-select@~14.16.2, rc-select@~14.16.8:
rc-util "^5.16.1"
rc-virtual-list "^3.5.2"
rc-slider@~11.1.8:
version "11.1.8"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-11.1.8.tgz#cf3b30dacac8f98d44f7685f733f6f7da146fc06"
integrity sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==
rc-slider@~11.1.9:
version "11.1.9"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-11.1.9.tgz#d872130fbf4ec51f28543d62e90451091d6f5208"
integrity sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "^2.2.5"
@@ -11482,10 +11575,10 @@ rc-switch@~4.1.0:
classnames "^2.2.1"
rc-util "^5.30.0"
rc-table@~7.51.1:
version "7.51.1"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.51.1.tgz#cd69ae3262d3b61e4c93c979c12786906e944691"
integrity sha512-5iq15mTHhvC42TlBLRCoCBLoCmGlbRZAlyF21FonFnS/DIC8DeRqnmdyVREwt2CFbPceM0zSNdEeVfiGaqYsKw==
rc-table@~7.53.0:
version "7.53.1"
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.53.1.tgz#b891aa39e9d1d944711f018692d2c52013afc90f"
integrity sha512-firAd7Z+liqIDS5TubJ1qqcoBd6YcANLKWQDZhFf3rfoOTt/UNPj4n3O+2vhl+z4QMqwPEUVAil661WHA8H8Aw==
dependencies:
"@babel/runtime" "^7.10.1"
"@rc-component/context" "^1.4.0"
@@ -12515,7 +12608,7 @@ setprototypeof@1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
sha.js@^2.4.11:
sha.js@^2.4.12:
version "2.4.12"
resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f"
integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==
@@ -12990,18 +13083,18 @@ svgo@^3.0.2, svgo@^3.2.0:
csso "^5.0.5"
picocolors "^1.0.0"
swagger-client@^3.35.5:
version "3.35.6"
resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.35.6.tgz#03892a1ec9995db44d7b49feb7aafafc06d9ed51"
integrity sha512-OgwNneIdC45KXwOfwrlkwgWPeAKiV4K75mOnZioTddo1mpp9dTboCDVJas7185Ww1ziBwzShBqXpNGmyha9ZQg==
swagger-client@^3.35.7:
version "3.35.7"
resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.35.7.tgz#2f649c1875cb88a747254963ea85dad09605f238"
integrity sha512-AAVk7lBFIw41wI0tsqyh/l4dwJ0/eslHL2Ex4hmsGtuKcD6/wXunetO8AsmE5MptK4YgRvpmUDvKnF1TaGzdiQ==
dependencies:
"@babel/runtime-corejs3" "^7.22.15"
"@scarf/scarf" "=1.4.0"
"@swagger-api/apidom-core" ">=1.0.0-beta.41 <1.0.0-rc.0"
"@swagger-api/apidom-error" ">=1.0.0-beta.41 <1.0.0-rc.0"
"@swagger-api/apidom-json-pointer" ">=1.0.0-beta.41 <1.0.0-rc.0"
"@swagger-api/apidom-ns-openapi-3-1" ">=1.0.0-beta.41 <1.0.0-rc.0"
"@swagger-api/apidom-reference" ">=1.0.0-beta.41 <1.0.0-rc.0"
"@swagger-api/apidom-core" ">=1.0.0-beta.50 <1.0.0-rc.0"
"@swagger-api/apidom-error" ">=1.0.0-beta.50 <1.0.0-rc.0"
"@swagger-api/apidom-json-pointer" ">=1.0.0-beta.50 <1.0.0-rc.0"
"@swagger-api/apidom-ns-openapi-3-1" ">=1.0.0-beta.50 <1.0.0-rc.0"
"@swagger-api/apidom-reference" ">=1.0.0-beta.50 <1.0.0-rc.0"
"@swaggerexpert/cookie" "^2.0.2"
deepmerge "~4.3.0"
fast-json-patch "^3.0.0-1"
@@ -13014,18 +13107,19 @@ swagger-client@^3.35.5:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
swagger-ui-react@^5.27.1:
version "5.27.1"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.27.1.tgz#315b59970c33933a5f62ca0f702789741dcedc7c"
integrity sha512-wwDoavIeJI/Pwiavn32FMJ5dfptz0BAOKjSrj7EdU22QdP3gdk9+MZHdzzjxWURmVj0kc0XoQfsFgjln0toJaw==
swagger-ui-react@^5.29.1:
version "5.29.1"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.1.tgz#90875ef551f0fc271e369db60e4a8cd369ce70f9"
integrity sha512-simkO3VoHrWIQWLH2vCpOJSPVbocV+RLGvHzzh+jhqs5heWsA1cnadx31BbKJVajfxIO4dC3lclxgMYbLUk3fQ==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
base64-js "^1.5.1"
buffer "^6.0.3"
classnames "^2.5.1"
css.escape "1.5.1"
deep-extend "0.6.0"
dompurify "=3.2.4"
dompurify "=3.2.6"
ieee754 "^1.2.1"
immutable "^3.x.x"
js-file-download "^0.4.12"
@@ -13046,8 +13140,8 @@ swagger-ui-react@^5.27.1:
remarkable "^2.0.1"
reselect "^5.1.1"
serialize-error "^8.1.0"
sha.js "^2.4.11"
swagger-client "^3.35.5"
sha.js "^2.4.12"
swagger-client "^3.35.7"
url-parse "^1.5.10"
xml "=1.0.1"
xml-but-prettier "^1.0.1"
@@ -13060,10 +13154,10 @@ synckit@^0.11.7:
dependencies:
"@pkgr/core" "^0.2.9"
tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.2.tgz#ab4984340d30cb9989a490032f086dbb8b56d872"
integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==
tapable@^2.0.0, tapable@^2.2.0, tapable@^2.2.1, tapable@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b"
integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==
terser-webpack-plugin@^5.3.11, terser-webpack-plugin@^5.3.9:
version "5.3.14"
@@ -13321,15 +13415,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.39.0:
version "8.40.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.40.0.tgz#27541748f3ca889c9698327bdacf815f7dc61804"
integrity sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q==
typescript-eslint@^8.45.0:
version "8.45.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.45.0.tgz#98ab164234dc04c112747ec0a4ae29a94efe123b"
integrity sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==
dependencies:
"@typescript-eslint/eslint-plugin" "8.40.0"
"@typescript-eslint/parser" "8.40.0"
"@typescript-eslint/typescript-estree" "8.40.0"
"@typescript-eslint/utils" "8.40.0"
"@typescript-eslint/eslint-plugin" "8.45.0"
"@typescript-eslint/parser" "8.45.0"
"@typescript-eslint/typescript-estree" "8.45.0"
"@typescript-eslint/utils" "8.45.0"
typescript@~5.9.2:
version "5.9.2"
@@ -13745,7 +13839,7 @@ vscode-uri@~3.0.8:
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
watchpack@^2.4.1:
watchpack@^2.4.4:
version "2.4.4"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947"
integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==
@@ -13868,10 +13962,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.101.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.101.3"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.3.tgz#3633b2375bb29ea4b06ffb1902734d977bc44346"
integrity sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==
webpack@^5.102.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.102.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.102.0.tgz#7a2416e6da356c35f1fb35333d2f5cee0133e953"
integrity sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
@@ -13881,7 +13975,7 @@ webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
"@webassemblyjs/wasm-parser" "^1.14.1"
acorn "^8.15.0"
acorn-import-phases "^1.0.3"
browserslist "^4.24.0"
browserslist "^4.24.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.17.3"
es-module-lexer "^1.2.1"
@@ -13894,9 +13988,9 @@ webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
mime-types "^2.1.27"
neo-async "^2.6.2"
schema-utils "^4.3.2"
tapable "^2.1.1"
tapable "^2.2.3"
terser-webpack-plugin "^5.3.11"
watchpack "^2.4.1"
watchpack "^2.4.4"
webpack-sources "^3.3.3"
webpackbar@^6.0.1:
+2 -1
View File
@@ -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-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"]
+4 -2
View File
@@ -181,8 +181,10 @@ dnspython==2.7.0
# email-validator
docker==7.0.0
# via apache-superset
duckdb==1.3.2
# via duckdb-engine
duckdb==0.10.3
# via
# apache-superset
# duckdb-engine
duckdb-engine==0.17.0
# via apache-superset
email-validator==2.2.0
+45 -6
View File
@@ -83,7 +83,6 @@ module.exports = {
'plugin:react-hooks/recommended',
'plugin:react-prefer-function-component/recommended',
'plugin:storybook/recommended',
'plugin:react-you-might-not-need-an-effect/legacy-recommended',
],
parser: '@babel/eslint-parser',
parserOptions: {
@@ -273,6 +272,10 @@ module.exports = {
{
files: ['packages/**'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{ devDependencies: true },
],
'no-restricted-imports': [
'error',
{
@@ -324,7 +327,12 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'playwright/**/*',
'**/test/**/*',
'**/tests/**/*',
'spec/**/*',
'**/fixtures/**/*',
'**/__mocks__/**/*',
'**/spec/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
@@ -348,7 +356,9 @@ module.exports = {
devDependencies: true,
},
],
'jest/consistent-test-it': 'error',
'no-only-tests/no-only-tests': 'error',
'prefer-promise-reject-errors': 0,
'max-classes-per-file': 0,
// temporary rules to help with migration - please re-enable!
'testing-library/await-async-queries': 0,
@@ -387,6 +397,12 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'**/test/**/*',
'**/tests/**/*',
'spec/**/*',
'**/fixtures/**/*',
'**/__mocks__/**/*',
'**/spec/**/*',
'cypress-base/cypress/**/*',
'Stories.tsx',
'packages/superset-ui-core/src/theme/index.tsx',
@@ -400,10 +416,27 @@ module.exports = {
},
},
{
files: ['playwright/**/*'],
// Override specifically for packages stories and overview files
// This must come LAST to override other rules
files: [
'packages/**/*.stories.*',
'packages/**/*.overview.*',
'packages/**/fixtures.*',
],
rules: {
'import/no-unresolved': 0, // Playwright is not installed in main build
'import/no-extraneous-dependencies': 0, // Playwright is not installed in main build
'import/no-extraneous-dependencies': 'off',
},
},
{
// Allow @playwright/test imports in Playwright test files
files: ['playwright/**/*.ts', 'playwright/**/*.js'],
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
},
],
},
},
],
@@ -412,7 +445,13 @@ module.exports = {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/sentence-case-buttons': 'error',
camelcase: [
'error',
{
allow: ['^UNSAFE_'],
properties: 'never',
},
],
'class-methods-use-this': 0,
curly: 2,
'func-names': 0,
+919 -393
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -129,7 +129,7 @@
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"antd": "^5.24.9",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
"content-disposition": "^0.5.4",
@@ -211,7 +211,7 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.55.6",
"@applitools/eyes-storybook": "^3.60.0",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.28.0",
"@babel/core": "^7.28.3",
@@ -225,7 +225,7 @@
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.28.2",
"@babel/runtime": "^7.28.4",
"@babel/runtime-corejs3": "^7.28.2",
"@babel/types": "^7.26.9",
"@cypress/react": "^8.0.2",
@@ -260,7 +260,7 @@
"@types/node": "^22.12.0",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-json-tree": "^0.6.11",
"@types/react-json-tree": "^0.13.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
@@ -318,7 +318,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^26.0.0",
"jsdom": "^27.0.0",
"lerna": "^8.2.3",
"mini-css-extract-plugin": "^2.9.0",
"open-cli": "^8.0.0",
@@ -341,7 +341,7 @@
"tsx": "^4.20.3",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.99.9",
"webpack": "^5.102.0",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
@@ -22,7 +22,7 @@
"typescript": "^5.0.0"
},
"peerDependencies": {
"antd": "^5.24.6",
"antd": "^5.24.9",
"react": "^17.0.2"
},
"scripts": {
@@ -40,7 +40,7 @@ describe('aggregationOperator', () => {
granularity: 'month',
};
test('should return undefined for LAST_VALUE aggregation', () => {
it('should return undefined for LAST_VALUE aggregation', () => {
const formDataWithLastValue = {
...formData,
aggregation: 'LAST_VALUE',
@@ -51,7 +51,7 @@ describe('aggregationOperator', () => {
).toBeUndefined();
});
test('should return undefined when metrics is empty', () => {
it('should return undefined when metrics is empty', () => {
const queryObjectWithoutMetrics = {
...queryObject,
metrics: [],
@@ -67,7 +67,7 @@ describe('aggregationOperator', () => {
).toBeUndefined();
});
test('should apply sum aggregation to all metrics', () => {
it('should apply sum aggregation to all metrics', () => {
const formDataWithSum = {
...formData,
aggregation: 'sum',
@@ -91,7 +91,7 @@ describe('aggregationOperator', () => {
});
});
test('should apply mean aggregation to all metrics', () => {
it('should apply mean aggregation to all metrics', () => {
const formDataWithMean = {
...formData,
aggregation: 'mean',
@@ -115,7 +115,7 @@ describe('aggregationOperator', () => {
});
});
test('should use default aggregation when not specified', () => {
it('should use default aggregation when not specified', () => {
expect(aggregationOperator(formData, queryObject)).toBeUndefined();
});
});
@@ -1,68 +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.
*/
{
"plugins": ["jest", "jest-dom", "no-only-tests", "testing-library"],
"env": {
"jest/globals": true
},
"settings": {
"jest": {
"version": "detect"
}
},
"extends": [
"plugin:jest/recommended",
"plugin:jest-dom/recommended",
"plugin:testing-library/react"
],
"overrides": [
{
"files": [
"**/*.stories.*",
"**/*.overview.*",
"**/fixtures.*"
],
"rules": {
"import/no-extraneous-dependencies": "off"
}
}
],
"rules": {
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
"jest/consistent-test-it": "error",
"no-only-tests/no-only-tests": "error",
"prefer-promise-reject-errors": 0,
"testing-library/no-node-access": "off",
"testing-library/prefer-screen-queries": "off",
"testing-library/no-container": "off",
"testing-library/await-async-queries": "off",
"testing-library/await-async-utils": "off",
"testing-library/no-await-sync-events": "off",
"testing-library/no-render-in-lifecycle": "off",
"testing-library/no-unnecessary-act": "off",
"testing-library/no-wait-for-multiple-assertions": "off",
"testing-library/await-async-events": "off",
"testing-library/no-wait-for-side-effects": "off",
"testing-library/prefer-presence-queries": "off",
"testing-library/render-result-naming-convention": "off",
"testing-library/prefer-find-by": "off",
"testing-library/no-manual-cleanup": "off"
}
}
@@ -26,7 +26,7 @@
"dependencies": {
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.2",
"@babel/runtime": "^7.28.4",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
@@ -78,7 +78,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.8",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.20",
"@types/math-expression-evaluator": "^1.3.3",
"@types/node": "^22.10.3",
@@ -91,7 +91,7 @@
"timezone-mock": "1.3.6"
},
"peerDependencies": {
"antd": "^5.24.6",
"antd": "^5.24.9",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@superset-ui/core/spec';
import { render, screen, fireEvent } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks';
import { TableInstance, useTable } from 'react-table';
import TableCollection from '.';
@@ -36,19 +36,28 @@ beforeEach(() => {
accessor: 'col2',
id: 'col2',
},
{
Header: 'Nested Field',
accessor: 'parent.child',
id: 'parent.child',
dataIndex: ['parent', 'child'],
},
];
const data = [
{
col1: 'Line 01 - Col 01',
col2: 'Line 01 - Col 02',
parent: { child: 'Nested Value 1' },
},
{
col1: 'Line 02 - Col 01',
col2: 'Line 02 - Col 02',
parent: { child: 'Nested Value 2' },
},
{
col1: 'Line 03 - Col 01',
col2: 'Line 03 - Col 02',
parent: { child: 'Nested Value 3' },
},
];
// @ts-ignore
@@ -206,3 +215,28 @@ test('Bulk selection should work with pagination', () => {
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
test('should call setSortBy when clicking sortable column header', () => {
const setSortBy = jest.fn();
const sortingProps = {
...defaultProps,
setSortBy,
};
render(<TableCollection {...sortingProps} />);
// Target the nested field column (the column that needs the array-to-dot conversion)
const nestedFieldHeader = screen.getByText('Nested Field');
expect(nestedFieldHeader).toBeInTheDocument();
// Click on the nested field column header to trigger sorting
fireEvent.click(nestedFieldHeader);
// Verify setSortBy was called with the correct arguments and dot notation conversion
expect(setSortBy).toHaveBeenCalledWith([
{
id: 'parent.child',
desc: expect.any(Boolean),
},
]);
});
@@ -215,9 +215,14 @@ function TableCollection<T extends object>({
const handleTableChange = useCallback(
(_pagination: any, _filters: any, sorter: SorterResult) => {
if (sorter && sorter.field) {
// Convert array field back to dot notation for nested fields
const fieldId = Array.isArray(sorter.field)
? sorter.field.join('.')
: sorter.field;
setSortBy?.([
{
id: sorter.field,
id: fieldId,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
@@ -50,7 +50,7 @@ describe('Typography Component', () => {
it('renders strong text', () => {
render(<Typography.Text strong>Strong Text</Typography.Text>);
expect(screen.getByText('Strong Text')).toHaveStyle('font-weight: 500');
expect(screen.getByText('Strong Text')).toHaveStyle('font-weight: 600');
});
it('renders underlined text', () => {
@@ -16,6 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
/* eslint-disable import/extensions */
import '@fontsource/inter/200.css';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/fira-code/400.css';
import '@fontsource/fira-code/500.css';
import '@fontsource/fira-code/600.css';
/* eslint-enable import/extensions */
import { css, useTheme, Global } from '@emotion/react';
export const GlobalStyles = () => {
@@ -49,14 +49,12 @@ describe('Theme', () => {
});
describe('fromConfig', () => {
it('creates a theme with default tokens when no config is provided', () => {
it('creates a theme with Ant Design defaults when no config is provided', () => {
const theme = Theme.fromConfig();
// Verify default primary color is set
expect(theme.theme.colorPrimary).toBe('#2893b3');
// Verify default font family is set
expect(theme.theme.fontFamily).toContain('Inter');
// Verify Ant Design default tokens are set
expect(theme.theme.colorPrimary).toBeDefined();
expect(theme.theme.fontFamily).toBeDefined();
// Verify the theme is initialized with semantic color tokens
expect(theme.theme.colorText).toBeDefined();
@@ -79,8 +77,8 @@ describe('Theme', () => {
// Verify custom font family is set
expect(theme.theme.fontFamily).toBe('CustomFont, sans-serif');
// But default tokens should still be preserved for unspecified values
expect(theme.theme.colorError).toBe('#e04355');
// Unspecified values will use Ant Design defaults
expect(theme.theme.colorError).toBeDefined();
});
it('creates a theme with dark mode when dark algorithm is specified', () => {
@@ -205,4 +203,586 @@ describe('Theme', () => {
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
describe('fromConfig with baseTheme', () => {
it('applies base theme tokens under the main config', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#ff0000',
colorError: '#00ff00',
fontFamily: 'BaseFont',
},
};
const userConfig: AnyThemeConfig = {
token: {
colorPrimary: '#0000ff',
},
};
const theme = Theme.fromConfig(userConfig, baseTheme);
// User config overrides base theme
expect(theme.theme.colorPrimary).toBe('#0000ff');
// Base theme tokens are preserved when not overridden
expect(theme.theme.colorError).toBe('#00ff00');
expect(theme.theme.fontFamily).toBe('BaseFont');
});
it('applies base theme when no user config is provided', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#ff0000',
fontFamily: 'TestFont',
},
algorithm: antdThemeImport.darkAlgorithm,
};
const theme = Theme.fromConfig(undefined, baseTheme);
// Color may be transformed by dark algorithm, check fontFamily instead
expect(theme.theme.fontFamily).toBe('TestFont');
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('handles empty config with base theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#ff0000',
},
algorithm: antdThemeImport.defaultAlgorithm,
};
const emptyConfig: AnyThemeConfig = {};
const theme = Theme.fromConfig(emptyConfig, baseTheme);
// Base theme tokens should be applied
expect(theme.theme.colorPrimary).toBe('#ff0000');
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
});
it('merges algorithms correctly with base theme', () => {
const baseTheme: AnyThemeConfig = {
algorithm: antdThemeImport.compactAlgorithm,
};
const userConfig: AnyThemeConfig = {
algorithm: antdThemeImport.darkAlgorithm,
};
const theme = Theme.fromConfig(userConfig, baseTheme);
// User algorithm should override base algorithm
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('merges component overrides with base theme', () => {
const baseTheme: AnyThemeConfig = {
components: {
Button: {
colorPrimary: '#basebutton',
},
Input: {
colorBorder: '#baseinput',
},
},
};
const userConfig: AnyThemeConfig = {
components: {
Button: {
colorPrimary: '#userbutton',
},
},
};
const theme = Theme.fromConfig(userConfig, baseTheme);
const serialized = theme.toSerializedConfig();
// User component config overrides base
expect(serialized.components?.Button?.colorPrimary).toBe('#userbutton');
// Base component config preserved when not overridden
expect(serialized.components?.Input?.colorBorder).toBe('#baseinput');
});
it('handles undefined config and undefined base theme', () => {
const theme = Theme.fromConfig(undefined, undefined);
// Should get Ant Design defaults
expect(theme.theme.colorPrimary).toBeDefined();
expect(theme.theme.fontFamily).toBeDefined();
});
it('preserves custom tokens in base theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#ff0000',
// Custom superset-specific tokens
brandLogoAlt: 'CustomLogo',
menuHoverBackgroundColor: '#00ff00',
} as Record<string, any>,
};
const theme = Theme.fromConfig({}, baseTheme);
// Standard token
expect(theme.theme.colorPrimary).toBe('#ff0000');
// Custom tokens should be preserved
expect((theme.theme as any).brandLogoAlt).toBe('CustomLogo');
expect((theme.theme as any).menuHoverBackgroundColor).toBe('#00ff00');
});
});
describe('edge cases with base theme and dark mode', () => {
it('correctly applies base theme tokens in dark mode', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
fontFamily: 'TestFont',
},
algorithm: antdThemeImport.defaultAlgorithm,
};
const baseThemeDark: AnyThemeConfig = {
...baseTheme,
algorithm: antdThemeImport.darkAlgorithm,
};
// Simulate light mode with base theme
const lightTheme = Theme.fromConfig({}, baseTheme);
expect(lightTheme.theme.colorPrimary).toBe('#1890ff');
expect(lightTheme.theme.fontFamily).toBe('TestFont');
// Simulate dark mode with base theme dark
const darkTheme = Theme.fromConfig({}, baseThemeDark);
// Dark algorithm transforms colors, but fontFamily should be preserved
expect(darkTheme.theme.fontFamily).toBe('TestFont');
// Verify the algorithm is different
const lightSerialized = lightTheme.toSerializedConfig();
const darkSerialized = darkTheme.toSerializedConfig();
expect(lightSerialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
expect(darkSerialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('handles switching from custom theme back to base theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
},
algorithm: antdThemeImport.defaultAlgorithm,
};
// First apply custom theme
const customConfig: AnyThemeConfig = {
token: {
colorPrimary: '#52c41a',
},
};
const themeWithCustom = Theme.fromConfig(customConfig, baseTheme);
expect(themeWithCustom.theme.colorPrimary).toBe('#52c41a');
// Then switch back to empty config (simulating removal of custom theme)
const themeWithEmpty = Theme.fromConfig({}, baseTheme);
expect(themeWithEmpty.theme.colorPrimary).toBe('#1890ff');
// Verify they produce different outputs
expect(themeWithCustom.theme.colorPrimary).not.toBe(
themeWithEmpty.theme.colorPrimary,
);
});
it('handles algorithm-only config with base theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
fontFamily: 'TestFont',
borderRadius: 8,
},
algorithm: antdThemeImport.defaultAlgorithm,
};
// Config that only specifies algorithm (common for THEME_DARK)
const algorithmOnlyConfig: AnyThemeConfig = {
algorithm: antdThemeImport.darkAlgorithm,
};
const theme = Theme.fromConfig(algorithmOnlyConfig, baseTheme);
// Should have base theme tokens
expect(theme.theme.fontFamily).toBe('TestFont');
expect(theme.theme.borderRadius).toBe(8);
// Should have user's algorithm
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
});
describe('base theme integration tests', () => {
it('merges base theme tokens with empty user theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
colorError: '#e04355',
fontFamily: 'Inter, Helvetica',
},
};
const userTheme: AnyThemeConfig = {
algorithm: antdThemeImport.defaultAlgorithm,
};
const theme = Theme.fromConfig(userTheme, baseTheme);
expect(theme.theme.colorPrimary).toBe('#2893B3');
expect(theme.theme.colorError).toBe('#e04355');
expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
// Should have user's algorithm
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
});
it('allows user theme to override specific base theme tokens', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
colorError: '#e04355',
fontFamily: 'Inter, Helvetica',
borderRadius: 4,
},
};
const userTheme: AnyThemeConfig = {
algorithm: antdThemeImport.defaultAlgorithm,
token: {
colorPrimary: '#123456', // Override primary color
// Leave other tokens from base
},
};
const theme = Theme.fromConfig(userTheme, baseTheme);
// User override should win
expect(theme.theme.colorPrimary).toBe('#123456');
// Base theme tokens should be preserved
expect(theme.theme.colorError).toBe('#e04355');
expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
expect(theme.theme.borderRadius).toBe(4);
});
it('handles base theme with dark algorithm correctly', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
fontFamily: 'Inter, Helvetica',
},
};
const baseThemeDark: AnyThemeConfig = {
...baseTheme,
algorithm: antdThemeImport.darkAlgorithm,
};
const userDarkTheme: AnyThemeConfig = {
algorithm: antdThemeImport.darkAlgorithm,
};
const theme = Theme.fromConfig(userDarkTheme, baseThemeDark);
// Should have base tokens
expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
// Should be in dark mode
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('works with real-world Superset base theme configuration', () => {
// Simulate actual Superset base theme (THEME_DEFAULT/THEME_DARK from config)
const supersetBaseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
colorError: '#e04355',
colorWarning: '#fcc700',
colorSuccess: '#5ac189',
colorInfo: '#66bcfe',
fontFamily: "'Inter', Helvetica, Arial",
fontFamilyCode: "'Fira Code', 'Courier New', monospace",
},
};
// Simulate THEME_DEFAULT from config
const themeDefault: AnyThemeConfig = {
algorithm: antdThemeImport.defaultAlgorithm,
};
// Simulate THEME_DARK from config
const themeDark: AnyThemeConfig = {
algorithm: antdThemeImport.darkAlgorithm,
};
// Test light mode
const lightTheme = Theme.fromConfig(themeDefault, supersetBaseTheme);
expect(lightTheme.theme.colorPrimary).toBe('#2893B3');
expect(lightTheme.theme.fontFamily).toBe("'Inter', Helvetica, Arial");
// Test dark mode
const darkTheme = Theme.fromConfig(themeDark, {
...supersetBaseTheme,
algorithm: antdThemeImport.darkAlgorithm,
});
expect(darkTheme.theme.fontFamily).toBe("'Inter', Helvetica, Arial");
const darkSerialized = darkTheme.toSerializedConfig();
expect(darkSerialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('handles component overrides in base theme', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
},
components: {
Button: {
primaryColor: '#custom-button',
borderRadius: 8,
},
},
};
const userTheme: AnyThemeConfig = {
algorithm: antdThemeImport.defaultAlgorithm,
};
const theme = Theme.fromConfig(userTheme, baseTheme);
// Should preserve component overrides
const serialized = theme.toSerializedConfig();
expect(serialized.components?.Button?.primaryColor).toBe(
'#custom-button',
);
expect(serialized.components?.Button?.borderRadius).toBe(8);
});
it('properly handles algorithm property override', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
},
algorithm: antdThemeImport.defaultAlgorithm,
};
const userTheme: AnyThemeConfig = {
algorithm: antdThemeImport.darkAlgorithm,
token: {
borderRadius: 8,
},
};
const theme = Theme.fromConfig(userTheme, baseTheme);
const serialized = theme.toSerializedConfig();
// User algorithm should override base algorithm
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
// Both base and user tokens should be merged
expect(serialized.token?.colorPrimary).toBeTruthy();
expect(serialized.token?.borderRadius).toBe(8);
});
it('handles cssVar, hashed and inherit properties correctly', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
},
cssVar: true,
hashed: false,
};
const userTheme: AnyThemeConfig = {
token: {
borderRadius: 8,
},
inherit: true,
};
const theme = Theme.fromConfig(userTheme, baseTheme);
const serialized = theme.toSerializedConfig();
// User properties override/add to base
expect(serialized.inherit).toBe(true);
expect(serialized.cssVar).toBe(true);
expect(serialized.hashed).toBe(false);
// Tokens are still merged
expect(serialized.token?.colorPrimary).toBeTruthy();
expect(serialized.token?.borderRadius).toBe(8);
});
it('merges nested component styles correctly', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
fontFamily: 'BaseFont',
},
components: {
Button: {
colorPrimary: '#basebutton',
fontSize: 14,
},
Input: {
colorBorder: '#baseinput',
},
},
};
const userTheme: AnyThemeConfig = {
token: {
borderRadius: 8,
},
components: {
Button: {
fontSize: 16, // Override Button fontSize
},
Select: {
colorBorder: '#userselect', // Add new component
},
},
};
const theme = Theme.fromConfig(userTheme, baseTheme);
const serialized = theme.toSerializedConfig();
// Tokens should be merged
// Note: components present may affect color transformation
expect(serialized.token?.colorPrimary).toBeTruthy();
expect(serialized.token?.borderRadius).toBe(8);
expect(serialized.token?.fontFamily).toBe('BaseFont');
// Components should be merged (shallow merge per component)
expect(serialized.components?.Button?.colorPrimary).toBe('#basebutton');
expect(serialized.components?.Button?.fontSize).toBe(16); // User override
expect(serialized.components?.Input?.colorBorder).toBe('#baseinput');
expect(serialized.components?.Select?.colorBorder).toBe('#userselect');
});
it('setConfig replaces theme config entirely (does not preserve base theme)', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
fontFamily: 'Inter',
},
};
const theme = Theme.fromConfig({}, baseTheme);
expect(theme.theme.colorPrimary).toBe('#2893B3');
expect(theme.theme.fontFamily).toBe('Inter');
// Update config (simulating theme change)
theme.setConfig({
token: {
colorPrimary: '#654321',
},
algorithm: antdThemeImport.darkAlgorithm,
});
// setConfig replaces the entire config, so base theme is NOT preserved
// This is expected behavior - setConfig is for complete replacement
expect(theme.theme.colorPrimary).toBeTruthy();
// fontFamily reverts to Ant Design default since base theme is not reapplied
expect(theme.theme.fontFamily).not.toBe('Inter');
});
it('minimal theme preserves ALL base theme tokens except overridden ones', () => {
// Simulate a comprehensive base theme with many tokens
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
colorError: '#e04355',
colorWarning: '#fcc700',
colorSuccess: '#5ac189',
colorInfo: '#66bcfe',
fontFamily: 'Inter, Helvetica',
fontSize: 14,
borderRadius: 4,
lineWidth: 1,
controlHeight: 32,
// Custom Superset tokens
brandLogoAlt: 'CustomLogo',
menuHoverBackgroundColor: '#eeeeee',
} as Record<string, any>,
algorithm: antdThemeImport.defaultAlgorithm,
};
// Minimal theme that only overrides primary color and algorithm
const minimalTheme: AnyThemeConfig = {
token: {
colorPrimary: '#ff05dd', // Only override this
},
algorithm: antdThemeImport.darkAlgorithm, // Change to dark
};
const theme = Theme.fromConfig(minimalTheme, baseTheme);
// User's override should apply
expect(theme.theme.colorPrimary).toBe('#ff05dd');
// ALL base theme tokens should be preserved
expect(theme.theme.colorError).toBe('#e04355');
expect(theme.theme.colorWarning).toBe('#fcc700');
expect(theme.theme.colorSuccess).toBe('#5ac189');
expect(theme.theme.colorInfo).toBe('#66bcfe');
expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
expect(theme.theme.fontSize).toBe(14);
expect(theme.theme.borderRadius).toBe(4);
expect(theme.theme.lineWidth).toBe(1);
expect(theme.theme.controlHeight).toBe(32);
// Custom tokens should also be preserved
expect((theme.theme as any).brandLogoAlt).toBe('CustomLogo');
expect((theme.theme as any).menuHoverBackgroundColor).toBe('#eeeeee');
// Algorithm should be updated
const serialized = theme.toSerializedConfig();
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
});
it('arrays in themes are replaced entirely, not merged by index', () => {
const baseTheme: AnyThemeConfig = {
token: {
colorPrimary: '#2893B3',
},
algorithm: [
antdThemeImport.compactAlgorithm,
antdThemeImport.defaultAlgorithm,
],
};
const userTheme: AnyThemeConfig = {
algorithm: [antdThemeImport.darkAlgorithm], // Replace with single item array
};
const theme = Theme.fromConfig(userTheme, baseTheme);
const serialized = theme.toSerializedConfig();
// User's array should completely replace base array
expect(Array.isArray(serialized.algorithm)).toBe(true);
expect(serialized.algorithm).toHaveLength(1);
expect(serialized.algorithm).toContain(ThemeAlgorithm.DARK);
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.COMPACT);
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DEFAULT);
});
});
});
@@ -20,31 +20,13 @@
// eslint-disable-next-line no-restricted-syntax
import React from 'react';
import { theme as antdThemeImport, ConfigProvider } from 'antd';
// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
/* eslint-disable import/extensions */
import '@fontsource/inter/200.css';
/* eslint-disable import/extensions */
import '@fontsource/inter/400.css';
/* eslint-disable import/extensions */
import '@fontsource/inter/500.css';
/* eslint-disable import/extensions */
import '@fontsource/inter/600.css';
/* eslint-disable import/extensions */
import '@fontsource/fira-code/400.css';
/* eslint-disable import/extensions */
import '@fontsource/fira-code/500.css';
/* eslint-disable import/extensions */
import '@fontsource/fira-code/600.css';
import {
ThemeProvider,
CacheProvider as EmotionCacheProvider,
} from '@emotion/react';
import createCache from '@emotion/cache';
import { noop } from 'lodash';
import { noop, mergeWith } from 'lodash';
import { GlobalStyles } from './GlobalStyles';
import {
AntdThemeConfig,
AnyThemeConfig,
@@ -53,63 +35,16 @@ import {
allowedAntdTokens,
SharedAntdTokens,
} from './types';
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
/* eslint-disable theme-colors/no-literal-colors */
export class Theme {
theme: SupersetTheme;
private static readonly defaultTokens = {
// Brand
brandLogoAlt: 'Apache Superset',
brandLogoUrl: '/static/assets/images/superset-logo-horiz.png',
brandLogoMargin: '18px',
brandLogoHref: '/',
brandLogoHeight: '24px',
// Spinner
brandSpinnerUrl: undefined,
brandSpinnerSvg: undefined,
// Default colors
colorPrimary: '#2893B3', // NOTE: previous lighter primary color was #20a7c9
colorLink: '#2893B3',
colorError: '#e04355',
colorWarning: '#fcc700',
colorSuccess: '#5ac189',
colorInfo: '#66bcfe',
// Forcing some default tokens
fontFamily: `'Inter', Helvetica, Arial`,
fontFamilyCode: `'Fira Code', 'Courier New', monospace`,
// Extra tokens
transitionTiming: 0.3,
brandIconMaxWidth: 37,
fontSizeXS: '8',
fontSizeXXL: '28',
fontWeightNormal: '400',
fontWeightLight: '300',
fontWeightStrong: 500,
};
private antdConfig: AntdThemeConfig;
private constructor({ config }: { config?: AnyThemeConfig }) {
this.SupersetThemeProvider = this.SupersetThemeProvider.bind(this);
// Create a new config object with default tokens
const newConfig: AnyThemeConfig = config ? { ...config } : {};
// Ensure token property exists with defaults
newConfig.token = {
...Theme.defaultTokens,
...(config?.token || {}),
};
this.setConfig(newConfig);
this.setConfig(config || {});
}
/**
@@ -118,9 +53,24 @@ export class Theme {
* If simple tokens are provided as { token: {...} }, they will be applied with defaults
* If no config is provided, uses default tokens
* Dark mode can be set via the algorithm property in the config
* @param config - The theme configuration
* @param baseTheme - Optional base theme to apply under the config
*/
static fromConfig(config?: AnyThemeConfig): Theme {
return new Theme({ config });
static fromConfig(
config?: AnyThemeConfig,
baseTheme?: AnyThemeConfig,
): Theme {
let mergedConfig: AnyThemeConfig | undefined = config;
if (baseTheme && config) {
mergedConfig = mergeWith({}, baseTheme, config, (objValue, srcValue) =>
Array.isArray(srcValue) ? srcValue : undefined,
);
} else if (baseTheme && !config) {
mergedConfig = baseTheme;
}
return new Theme({ config: mergedConfig });
}
private static getFilteredAntdTheme(
@@ -148,22 +98,15 @@ export class Theme {
setConfig(config: AnyThemeConfig): void {
const antdConfig = normalizeThemeConfig(config);
// Apply default tokens to token property
antdConfig.token = {
...Theme.defaultTokens,
...(antdConfig.token || {}),
};
// First phase: Let Ant Design compute the tokens
const tokens = Theme.getFilteredAntdTheme(antdConfig);
// Set the base theme properties
this.antdConfig = antdConfig;
this.theme = {
...Theme.defaultTokens,
...antdConfig.token, // Passing through the extra, superset-specific tokens
...tokens,
};
...tokens, // First apply Ant Design computed tokens
...(antdConfig.token || {}), // Then override with our custom tokens
} as SupersetTheme;
// Update the providers with the fully formed theme
this.updateProviders(
@@ -250,5 +193,3 @@ export class Theme {
);
}
}
/* eslint-enable theme-colors/no-literal-colors */
@@ -78,6 +78,7 @@ export type SerializableThemeConfig = {
algorithm?: ThemeAlgorithmOption;
hashed?: boolean;
inherit?: boolean;
cssVar?: boolean | { key?: string; prefix?: string };
};
/**
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getFontSize, getColorVariants, isThemeDark } from './themeUtils';
import { theme as antdTheme } from 'antd';
import {
getFontSize,
getColorVariants,
isThemeDark,
isThemeConfigDark,
} from './themeUtils';
import { Theme } from '../Theme';
import { ThemeAlgorithm } from '../types';
@@ -71,8 +77,7 @@ describe('themeUtils', () => {
token: { fontSize: '14' },
});
// Ant Design provides fontSizeXS: '8' by default
expect(getFontSize(minimalTheme.theme, 'xs')).toBe('8');
expect(getFontSize(minimalTheme.theme, 'xs')).toBe('14');
expect(getFontSize(minimalTheme.theme, 'm')).toBe('14');
});
});
@@ -131,4 +136,111 @@ describe('themeUtils', () => {
expect(variants.bg).toBeUndefined();
});
});
describe('isThemeConfigDark', () => {
it('returns true for config with dark algorithm', () => {
const config = {
algorithm: antdTheme.darkAlgorithm,
};
expect(isThemeConfigDark(config)).toBe(true);
});
it('returns true for config with dark algorithm in array', () => {
const config = {
algorithm: [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm],
};
expect(isThemeConfigDark(config)).toBe(true);
});
it('returns false for config without dark algorithm', () => {
const config = {
algorithm: antdTheme.defaultAlgorithm,
};
expect(isThemeConfigDark(config)).toBe(false);
});
it('returns false for config with no algorithm', () => {
const config = {
token: {
colorPrimary: '#1890ff',
},
};
expect(isThemeConfigDark(config)).toBe(false);
});
it('detects manually-created dark theme without dark algorithm', () => {
// This is the edge case: dark colors without dark algorithm
const config = {
token: {
colorBgContainer: '#1a1a1a', // Dark background
colorBgBase: '#0a0a0a', // Dark base
colorText: '#ffffff', // Light text
},
};
expect(isThemeConfigDark(config)).toBe(true);
});
it('does not false-positive on light theme with custom colors', () => {
const config = {
token: {
colorBgContainer: '#ffffff', // Light background
colorBgBase: '#f5f5f5', // Light base
colorText: '#000000', // Dark text
},
};
expect(isThemeConfigDark(config)).toBe(false);
});
it('handles partial color tokens gracefully', () => {
// With actual theme computation, a dark colorBgContainer results in a dark theme
const config = {
token: {
colorBgContainer: '#1a1a1a', // Dark background
// Missing other color tokens
},
};
expect(isThemeConfigDark(config)).toBe(true);
});
it('respects colorBgContainer as the primary indicator', () => {
// The computed theme uses colorBgContainer as the main background
const darkConfig = {
token: {
colorBgContainer: '#1a1a1a', // Dark background
colorText: '#000000', // Dark text (unusual but doesn't override)
},
};
expect(isThemeConfigDark(darkConfig)).toBe(true);
const lightConfig = {
token: {
colorBgContainer: '#ffffff', // Light background
colorText: '#ffffff', // Light text (unusual but doesn't override)
},
};
expect(isThemeConfigDark(lightConfig)).toBe(false);
});
it('handles non-string color tokens gracefully', () => {
const config = {
token: {
colorBgContainer: undefined,
colorText: null,
colorBgBase: 123, // Invalid type
},
};
expect(isThemeConfigDark(config)).toBe(false);
});
it('returns false for empty config', () => {
expect(isThemeConfigDark({})).toBe(false);
});
it('returns false for config with empty token object', () => {
const config = {
token: {},
};
expect(isThemeConfigDark(config)).toBe(false);
});
});
});
@@ -18,7 +18,14 @@
*/
import tinycolor from 'tinycolor2';
import { useTheme as useEmotionTheme } from '@emotion/react';
import type { SupersetTheme, FontSizeKey, ColorVariants } from '../types';
import { theme as antdTheme } from 'antd';
import type {
SupersetTheme,
FontSizeKey,
ColorVariants,
AnyThemeConfig,
} from '../types';
import { normalizeThemeConfig } from '../utils';
const fontSizeMap: Record<FontSizeKey, keyof SupersetTheme> = {
xs: 'fontSizeXS',
@@ -113,6 +120,22 @@ export function isThemeDark(theme: SupersetTheme): boolean {
return tinycolor(theme.colorBgContainer).isDark();
}
/**
* Check if a theme configuration results in a dark theme
* @param config - The theme configuration to check
* @returns true if the config results in a dark theme, false otherwise
*/
export function isThemeConfigDark(config: AnyThemeConfig): boolean {
try {
const normalizedConfig = normalizeThemeConfig(config);
const themeConfig = antdTheme.getDesignToken(normalizedConfig);
return tinycolor(themeConfig.colorBgContainer).isDark();
} catch {
return false;
}
}
/**
* Hook to determine if the current theme is dark mode
* @returns true if theme is dark, false if light
@@ -122,7 +122,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
it('should use getBuckets for linear_palette color scheme', () => {
const propsWithLinearPalette = {
...mockProps,
formData: {
@@ -138,7 +138,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for fixed_color color scheme', () => {
it('should use getBuckets for fixed_color color scheme', () => {
const propsWithFixedColor = {
...mockProps,
formData: {
@@ -154,7 +154,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
it('should use getColorBreakpointsBuckets for color_breakpoints scheme', () => {
const propsWithBreakpoints = {
...mockProps,
formData: {
@@ -187,7 +187,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
it('should use getBuckets when color_scheme_type is undefined (backward compatibility)', () => {
const propsWithUndefinedScheme = {
...mockProps,
formData: {
@@ -203,7 +203,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
it('should use getBuckets for unsupported color schemes (categorical_palette)', () => {
const propsWithUnsupportedScheme = {
...mockProps,
formData: {
@@ -230,7 +230,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
it('handles empty features data gracefully', () => {
const propsWithEmptyData = {
...mockProps,
payload: {
@@ -249,7 +249,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
expect(mockGetColorBreakpointsBuckets).not.toHaveBeenCalled();
});
test('handles missing color_breakpoints for color_breakpoints scheme', () => {
it('handles missing color_breakpoints for color_breakpoints scheme', () => {
const propsWithMissingBreakpoints = {
...mockProps,
formData: {
@@ -266,7 +266,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
expect(mockGetBuckets).not.toHaveBeenCalled();
});
test('handles null legend_position correctly', () => {
it('handles null legend_position correctly', () => {
const propsWithNullLegendPosition = {
...mockProps,
formData: {
@@ -294,7 +294,7 @@ describe('DeckGLPolygon Legend Integration', () => {
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {
it('renders legend with non-empty categories when metric and linear_palette are defined', () => {
const { container } = renderWithTheme(<DeckGLPolygon {...mockProps} />);
// Verify the component renders and calls the correct bucket function
@@ -309,7 +309,7 @@ describe('DeckGLPolygon Legend Integration', () => {
expect(Object.keys(categoriesData)).toHaveLength(2);
});
test('does not render legend when metric is null', () => {
it('does not render legend when metric is null', () => {
const propsWithoutMetric = {
...mockProps,
formData: {
@@ -326,7 +326,7 @@ describe('DeckGLPolygon Legend Integration', () => {
});
describe('getPoints utility', () => {
test('extracts points from polygon data', () => {
it('extracts points from polygon data', () => {
const data = [
{
polygon: [
@@ -64,7 +64,7 @@ Object.defineProperty(document, 'getElementById', {
const mockDecode = decode as jest.MockedFunction<typeof decode>;
describe('spatialUtils', () => {
test('getSpatialColumns returns correct columns for latlong type', () => {
it('getSpatialColumns returns correct columns for latlong type', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'latlong',
lonCol: 'longitude',
@@ -75,7 +75,7 @@ describe('spatialUtils', () => {
expect(result).toEqual(['longitude', 'latitude']);
});
test('getSpatialColumns returns correct columns for delimited type', () => {
it('getSpatialColumns returns correct columns for delimited type', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'delimited',
lonlatCol: 'coordinates',
@@ -85,7 +85,7 @@ describe('spatialUtils', () => {
expect(result).toEqual(['coordinates']);
});
test('getSpatialColumns returns correct columns for geohash type', () => {
it('getSpatialColumns returns correct columns for geohash type', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'geohash',
geohashCol: 'geohash_code',
@@ -95,16 +95,16 @@ describe('spatialUtils', () => {
expect(result).toEqual(['geohash_code']);
});
test('getSpatialColumns throws error when spatial is null', () => {
it('getSpatialColumns throws error when spatial is null', () => {
expect(() => getSpatialColumns(null as any)).toThrow('Bad spatial key');
});
test('getSpatialColumns throws error when spatial type is missing', () => {
it('getSpatialColumns throws error when spatial type is missing', () => {
const spatial = {} as SpatialFormData['spatial'];
expect(() => getSpatialColumns(spatial)).toThrow('Bad spatial key');
});
test('getSpatialColumns throws error when latlong columns are missing', () => {
it('getSpatialColumns throws error when latlong columns are missing', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'latlong',
};
@@ -113,7 +113,7 @@ describe('spatialUtils', () => {
);
});
test('getSpatialColumns throws error when delimited column is missing', () => {
it('getSpatialColumns throws error when delimited column is missing', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'delimited',
};
@@ -122,7 +122,7 @@ describe('spatialUtils', () => {
);
});
test('getSpatialColumns throws error when geohash column is missing', () => {
it('getSpatialColumns throws error when geohash column is missing', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'geohash',
};
@@ -131,7 +131,7 @@ describe('spatialUtils', () => {
);
});
test('getSpatialColumns throws error for unknown spatial type', () => {
it('getSpatialColumns throws error for unknown spatial type', () => {
const spatial = {
type: 'unknown',
} as any;
@@ -140,7 +140,7 @@ describe('spatialUtils', () => {
);
});
test('addSpatialNullFilters adds null filters for spatial columns', () => {
it('addSpatialNullFilters adds null filters for spatial columns', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'latlong',
lonCol: 'longitude',
@@ -159,7 +159,7 @@ describe('spatialUtils', () => {
]);
});
test('addSpatialNullFilters returns original filters when spatial is null', () => {
it('addSpatialNullFilters returns original filters when spatial is null', () => {
const existingFilters: QueryObjectFilterClause[] = [
{ col: 'test_col', op: '==', val: 'test' },
];
@@ -168,7 +168,7 @@ describe('spatialUtils', () => {
expect(result).toBe(existingFilters);
});
test('addSpatialNullFilters works with empty filters array', () => {
it('addSpatialNullFilters works with empty filters array', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'delimited',
lonlatCol: 'coordinates',
@@ -181,7 +181,7 @@ describe('spatialUtils', () => {
]);
});
test('buildSpatialQuery throws error when spatial is missing', () => {
it('buildSpatialQuery throws error when spatial is missing', () => {
const formData = {} as SpatialFormData;
expect(() => buildSpatialQuery(formData)).toThrow(
@@ -189,7 +189,7 @@ describe('spatialUtils', () => {
);
});
test('buildSpatialQuery calls buildQueryContext with correct parameters', () => {
it('buildSpatialQuery calls buildQueryContext with correct parameters', () => {
const mockBuildQueryContext =
jest.requireMock('@superset-ui/core').buildQueryContext;
const formData: SpatialFormData = {
@@ -209,7 +209,7 @@ describe('spatialUtils', () => {
});
});
test('processSpatialData processes latlong data correctly', () => {
it('processSpatialData processes latlong data correctly', () => {
const records = [
{ longitude: -122.4, latitude: 37.8, count: 10, extra: 'test1' },
{ longitude: -122.5, latitude: 37.9, count: 20, extra: 'test2' },
@@ -237,7 +237,7 @@ describe('spatialUtils', () => {
});
});
test('processSpatialData processes delimited data correctly', () => {
it('processSpatialData processes delimited data correctly', () => {
const records = [
{ coordinates: '-122.4,37.8', count: 15 },
{ coordinates: '-122.5,37.9', count: 25 },
@@ -257,7 +257,7 @@ describe('spatialUtils', () => {
});
});
test('processSpatialData processes geohash data correctly', () => {
it('processSpatialData processes geohash data correctly', () => {
mockDecode.mockReturnValue({
latitude: 37.8,
longitude: -122.4,
@@ -284,7 +284,7 @@ describe('spatialUtils', () => {
expect(mockDecode).toHaveBeenCalledWith('dr5regw3p');
});
test('processSpatialData reverses coordinates when reverseCheckbox is true', () => {
it('processSpatialData reverses coordinates when reverseCheckbox is true', () => {
const records = [{ longitude: -122.4, latitude: 37.8, count: 10 }];
const spatial: SpatialFormData['spatial'] = {
type: 'latlong',
@@ -298,7 +298,7 @@ describe('spatialUtils', () => {
expect(result[0].position).toEqual([37.8, -122.4]);
});
test('processSpatialData handles invalid coordinates', () => {
it('processSpatialData handles invalid coordinates', () => {
const records = [
{ longitude: 'invalid', latitude: 37.8, count: 10 },
{ longitude: -122.4, latitude: NaN, count: 20 },
@@ -318,7 +318,7 @@ describe('spatialUtils', () => {
expect(result).toHaveLength(0);
});
test('processSpatialData handles missing metric values', () => {
it('processSpatialData handles missing metric values', () => {
const records = [
{ longitude: -122.4, latitude: 37.8, count: null },
{ longitude: -122.5, latitude: 37.9 },
@@ -338,7 +338,7 @@ describe('spatialUtils', () => {
expect(result[2].weight).toBe(1);
});
test('processSpatialData returns empty array for empty records', () => {
it('processSpatialData returns empty array for empty records', () => {
const spatial: SpatialFormData['spatial'] = {
type: 'latlong',
lonCol: 'longitude',
@@ -350,7 +350,7 @@ describe('spatialUtils', () => {
expect(result).toEqual([]);
});
test('processSpatialData returns empty array when spatial is null', () => {
it('processSpatialData returns empty array when spatial is null', () => {
const records = [{ longitude: -122.4, latitude: 37.8 }];
const result = processSpatialData(records, null as any);
@@ -358,7 +358,7 @@ describe('spatialUtils', () => {
expect(result).toEqual([]);
});
test('processSpatialData handles delimited coordinate edge cases', () => {
it('processSpatialData handles delimited coordinate edge cases', () => {
const records = [
{ coordinates: '', count: 10 },
{ coordinates: null, count: 20 },
@@ -382,7 +382,7 @@ describe('spatialUtils', () => {
});
});
test('processSpatialData copies additional properties correctly', () => {
it('processSpatialData copies additional properties correctly', () => {
const records = [
{
longitude: -122.4,
@@ -416,7 +416,7 @@ describe('spatialUtils', () => {
expect(result[0]).not.toHaveProperty('extra_col');
});
test('transformSpatialProps transforms chart props correctly', () => {
it('transformSpatialProps transforms chart props correctly', () => {
const mockGetMetricLabel =
jest.requireMock('@superset-ui/core').getMetricLabel;
mockGetMetricLabel.mockReturnValue('count_label');
@@ -510,7 +510,7 @@ describe('spatialUtils', () => {
expect(result.payload.data.metricLabels).toEqual(['count_label']);
});
test('transformSpatialProps handles missing hooks gracefully', () => {
it('transformSpatialProps handles missing hooks gracefully', () => {
const chartProps: ChartProps = {
datasource: {
id: 1,
@@ -556,7 +556,7 @@ describe('spatialUtils', () => {
expect(typeof result.setTooltip).toBe('function');
});
test('transformSpatialProps handles missing metric', () => {
it('transformSpatialProps handles missing metric', () => {
const mockGetMetricLabel =
jest.requireMock('@superset-ui/core').getMetricLabel;
mockGetMetricLabel.mockReturnValue(undefined);
@@ -505,6 +505,7 @@ export const tooltipTemplate = {
config: {
type: TooltipTemplateControl,
label: t('Customize tooltips template'),
renderTrigger: true,
debounceDelay: 30,
default: '',
description: '',
@@ -131,10 +131,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
filter: true,
enableRowGroup: true,
enableValue: true,
sortable: true,
resizable: true,
minWidth: 100,
@@ -251,6 +248,12 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
}
}, [hasServerPageLengthChanged]);
useEffect(() => {
if (gridRef.current?.api) {
gridRef.current.api.sizeColumnsToFit();
}
}, [width]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
params.api.sizeColumnsToFit();
@@ -312,7 +315,6 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
onCellClicked={handleCrossFilter}
initialState={gridInitialState}
suppressAggFuncInHeader
rowGroupPanelShow="always"
enableCellTextSelection
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
@@ -19,7 +19,7 @@
import { mergeReplaceArrays } from '@superset-ui/core';
describe('Theme Override Deep Merge Behavior', () => {
test('should merge nested objects correctly', () => {
it('should merge nested objects correctly', () => {
const baseOptions = {
grid: {
left: '5%',
@@ -67,7 +67,7 @@ describe('Theme Override Deep Merge Behavior', () => {
});
});
test('should replace arrays instead of merging them', () => {
it('should replace arrays instead of merging them', () => {
const baseOptions = {
series: [
{ name: 'Series 1', type: 'line' },
@@ -86,7 +86,7 @@ describe('Theme Override Deep Merge Behavior', () => {
expect(result.series).toHaveLength(1);
});
test('should handle null overrides correctly', () => {
it('should handle null overrides correctly', () => {
const baseOptions = {
grid: {
left: '5%',
@@ -127,7 +127,7 @@ describe('Theme Override Deep Merge Behavior', () => {
});
});
test('should handle override precedence correctly', () => {
it('should handle override precedence correctly', () => {
const baseTheme = {
textStyle: { color: '#000', fontSize: 12 },
};
@@ -167,7 +167,7 @@ describe('Theme Override Deep Merge Behavior', () => {
});
});
test('should preserve deep nested structures', () => {
it('should preserve deep nested structures', () => {
const baseOptions = {
xAxis: {
axisLabel: {
@@ -215,7 +215,7 @@ describe('Theme Override Deep Merge Behavior', () => {
});
});
test('should handle function values correctly', () => {
it('should handle function values correctly', () => {
const formatFunction = (value: any) => `${value}%`;
const overrideFunction = (value: any) => `$${value}`;
@@ -241,7 +241,7 @@ describe('Theme Override Deep Merge Behavior', () => {
expect(result.yAxis.axisLabel.formatter('100')).toBe('$100');
});
test('should handle empty objects and arrays', () => {
it('should handle empty objects and arrays', () => {
const baseOptions = {
series: [{ name: 'Test', data: [1, 2, 3] }],
grid: { left: '5%' },
@@ -117,7 +117,7 @@ const chartPropsConfig = {
theme: supersetTheme,
};
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
@@ -158,7 +158,7 @@ it('should transform chart props for viz with showQueryIdentifiers=false', () =>
]);
});
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
test('should transform chart props for viz with showQueryIdentifiers=true', () => {
const chartPropsConfigWithIdentifiers = {
...chartPropsConfig,
formData: {
@@ -259,7 +259,7 @@ describe('legend sorting', () => {
});
});
it('legend margin: top orientation sets grid.top correctly', () => {
test('legend margin: top orientation sets grid.top correctly', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
@@ -274,7 +274,7 @@ it('legend margin: top orientation sets grid.top correctly', () => {
expect((transformed.echartOptions.grid as any).top).toEqual(270);
});
it('legend margin: bottom orientation sets grid.bottom correctly', () => {
test('legend margin: bottom orientation sets grid.bottom correctly', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
@@ -290,7 +290,7 @@ it('legend margin: bottom orientation sets grid.bottom correctly', () => {
expect((transformed.echartOptions.grid as any).bottom).toEqual(270);
});
it('legend margin: left orientation sets grid.left correctly', () => {
test('legend margin: left orientation sets grid.left correctly', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
@@ -306,7 +306,7 @@ it('legend margin: left orientation sets grid.left correctly', () => {
expect((transformed.echartOptions.grid as any).left).toEqual(270);
});
it('legend margin: right orientation sets grid.right correctly', () => {
test('legend margin: right orientation sets grid.right correctly', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
@@ -32,7 +32,7 @@ const mockColorScale = jest.fn(
describe('transformSeries', () => {
const series = { name: 'test-series' };
test('should use the colorScaleKey if timeShiftColor is enabled', () => {
it('should use the colorScaleKey if timeShiftColor is enabled', () => {
const opts = {
timeShiftColor: true,
colorScaleKey: 'test-key',
@@ -44,7 +44,7 @@ describe('transformSeries', () => {
expect((result as any)?.itemStyle.color).toBe('color-for-test-key-1');
});
test('should use seriesKey if timeShiftColor is not enabled', () => {
it('should use seriesKey if timeShiftColor is not enabled', () => {
const opts = {
timeShiftColor: false,
seriesKey: 'series-key',
@@ -56,7 +56,7 @@ describe('transformSeries', () => {
expect((result as any)?.itemStyle.color).toBe('color-for-series-key-2');
});
test('should apply border styles for bar series with connectNulls', () => {
it('should apply border styles for bar series with connectNulls', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
connectNulls: true,
@@ -72,7 +72,7 @@ describe('transformSeries', () => {
);
});
test('should not apply border styles for non-bar series', () => {
it('should not apply border styles for non-bar series', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Line,
connectNulls: true,
@@ -88,7 +88,7 @@ describe('transformSeries', () => {
});
describe('transformNegativeLabelsPosition', () => {
test('label position bottom of negative value no Horizontal', () => {
it('label position bottom of negative value no Horizontal', () => {
const isHorizontal = false;
const series: SeriesOption = {
data: [
@@ -112,7 +112,7 @@ describe('transformNegativeLabelsPosition', () => {
expect((result as any)[4].label).toBe(undefined);
});
test('label position left of negative value is Horizontal', () => {
it('label position left of negative value is Horizontal', () => {
const isHorizontal = true;
const series: SeriesOption = {
data: [
@@ -137,7 +137,7 @@ describe('transformNegativeLabelsPosition', () => {
expect((result as any)[4].label.position).toBe('outside');
});
test('label position to line type', () => {
it('label position to line type', () => {
const isHorizontal = false;
const series: SeriesOption = {
data: [
@@ -165,7 +165,7 @@ describe('transformNegativeLabelsPosition', () => {
expect((result as any)[4].label).toBe(undefined);
});
test('label position to bar type and stack', () => {
it('label position to bar type and stack', () => {
const isHorizontal = false;
const series: SeriesOption = {
data: [
@@ -1,30 +0,0 @@
{
"extends": "prettier",
"plugins": ["prettier"],
"rules": {
"prefer-template": 2,
"new-cap": 2,
"no-restricted-syntax": 2,
"guard-for-in": 2,
"prefer-arrow-callback": 2,
"func-names": 2,
"react/jsx-no-bind": 2,
"no-confusing-arrow": 2,
"jsx-a11y/no-static-element-interactions": 2,
"jsx-a11y/anchor-has-content": 2,
"react/require-default-props": 2,
"no-plusplus": 2,
"no-mixed-operators": 0,
"no-continue": 2,
"no-bitwise": 2,
"no-multi-assign": 2,
"no-restricted-properties": 2,
"no-prototype-builtins": 2,
"class-methods-use-this": 2,
"import/no-named-as-default": 2,
"react/no-unescaped-entities": 2,
"react/no-string-refs": 2,
"react/jsx-indent": 0,
"prettier/prettier": "error"
}
}
@@ -1017,12 +1017,13 @@ export function runTablePreviewQuery(newTable, runPreviewOnly) {
};
}
export function syncTable(table, tableMetadata) {
export function syncTable(table, tableMetadata, finalQueryEditorId) {
return function (dispatch) {
const finalTable = { ...table, queryEditorId: finalQueryEditorId };
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? SupersetClient.post({
endpoint: encodeURI('/tableschemaview/'),
postPayload: { table: { ...tableMetadata, ...table } },
postPayload: { table: { ...tableMetadata, ...finalTable } },
})
: Promise.resolve({ json: { id: table.id } });
@@ -22,6 +22,8 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import TableElement, { Column } from 'src/SqlLab/components/TableElement';
import { table, initialState } from 'src/SqlLab/fixtures';
import { render, waitFor, fireEvent } from 'spec/helpers/testing-library';
import * as sqlLabActions from 'src/SqlLab/actions/sqlLab';
import { QueryEditor } from 'src/SqlLab/types';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
@@ -79,6 +81,27 @@ const mockedProps = {
activeKey: [table.id],
};
const createStateWithQueryEditor = (queryEditor: Partial<QueryEditor>) => ({
...initialState,
sqlLab: {
...initialState.sqlLab,
queryEditors: [queryEditor],
},
});
const setupSyncTableTest = () => {
const spy = jest.spyOn(sqlLabActions, 'syncTable');
mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
fetchMock.post(
updateTableSchemaEndpoint,
{ id: 100 },
{ overwriteRoutes: true },
);
return spy;
};
test('renders', () => {
expect(isValidElement(<TableElement table={table} />)).toBe(true);
});
@@ -207,3 +230,212 @@ test('refreshes table metadata when triggered', async () => {
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1),
);
});
test('calls syncTable with valid backend ID when query editor has tabViewId', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'temp-id-123',
};
const state = createStateWithQueryEditor({
id: 'temp-id-123',
tabViewId: '42',
inLocalStorage: false,
name: 'Test Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'42', // finalQueryEditorId
);
});
syncTableSpy.mockRestore();
});
test('does not call syncTable when query editor is in localStorage', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'local-id',
};
const state = createStateWithQueryEditor({
id: 'local-id',
tabViewId: undefined,
inLocalStorage: true,
name: 'Local Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('does not call syncTable with non-numeric queryEditorId', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'not-a-number',
};
const state = createStateWithQueryEditor({
id: 'not-a-number',
tabViewId: 'also-not-a-number',
inLocalStorage: false,
name: 'Invalid Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('does not call syncTable for already initialized tables', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: true, // Already initialized
queryEditorId: '789',
};
const state = createStateWithQueryEditor({
id: '789',
tabViewId: '789',
inLocalStorage: false,
name: 'Initialized Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
});
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
syncTableSpy.mockRestore();
});
test('calls syncTable after query editor is migrated from localStorage', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'temp-editor-id',
};
// Start with editor in localStorage
const localState = createStateWithQueryEditor({
id: 'temp-editor-id',
tabViewId: undefined,
inLocalStorage: true,
name: 'Temp Editor',
});
const { rerender } = render(
<TableElement table={testTable} activeKey={[testTable.id]} />,
{
useRedux: true,
initialState: localState,
},
);
await new Promise(resolve => setTimeout(resolve, 100));
expect(syncTableSpy).not.toHaveBeenCalled();
const migratedState = createStateWithQueryEditor({
id: 'temp-editor-id',
tabViewId: '999',
inLocalStorage: false,
name: 'Temp Editor',
});
rerender(<TableElement table={testTable} activeKey={[testTable.id]} />);
const { unmount } = render(
<TableElement table={testTable} activeKey={[testTable.id]} />,
{
useRedux: true,
initialState: migratedState,
},
);
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'999',
);
});
unmount();
syncTableSpy.mockRestore();
});
test('passes numeric queryEditorId validation', async () => {
const syncTableSpy = setupSyncTableTest();
const testTable = {
...table,
initialized: false,
queryEditorId: 'editor-123',
};
const state = createStateWithQueryEditor({
id: 'editor-123',
tabViewId: '456',
inLocalStorage: false,
name: 'Valid Editor',
});
render(<TableElement table={testTable} activeKey={[testTable.id]} />, {
useRedux: true,
initialState: state,
});
await waitFor(() => {
expect(syncTableSpy).toHaveBeenCalled();
const [, , finalQueryEditorId] = syncTableSpy.mock.calls[0];
// Verify it's a valid numeric string
expect(Number.isNaN(Number(finalQueryEditorId))).toBe(false);
expect(typeof finalQueryEditorId).toBe('string');
expect(finalQueryEditorId).toMatch(/^\d+$/);
});
syncTableSpy.mockRestore();
});
@@ -17,8 +17,8 @@
* under the License.
*/
import { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import type { Table } from 'src/SqlLab/types';
import { useDispatch, useSelector } from 'react-redux';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import {
ButtonGroup,
Card,
@@ -103,6 +103,19 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
},
{ skip: !expanded },
);
const tableData = {
...tableMetadata,
...tableExtendedMetadata,
};
const queryEditors = useSelector<SqlLabRootState, QueryEditor[]>(
state => state.sqlLab.queryEditors,
);
const currentTable = { ...tableData, ...table };
const { queryEditorId } = currentTable;
const queryEditor = queryEditors.find(
qe => qe.id === queryEditorId || qe.tabViewId === queryEditorId,
);
const currentQueryEditorId = queryEditor?.tabViewId || queryEditorId;
useEffect(() => {
if (hasMetadataError || hasExtendedMetadataError) {
@@ -112,16 +125,16 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
}
}, [hasMetadataError, hasExtendedMetadataError, dispatch]);
const tableData = {
...tableMetadata,
...tableExtendedMetadata,
};
// TODO: migrate syncTable logic by SIP-93
const syncTableMetadata = useEffectEvent(() => {
const { initialized } = table;
if (!initialized) {
dispatch(syncTable(table, tableData));
// if not a valid number, wait for backend to assign one
const hasFinalQueryEditorId =
currentQueryEditorId &&
!Number.isNaN(Number(currentQueryEditorId)) &&
currentTable.queryEditorId !== currentQueryEditorId;
if (!initialized && hasFinalQueryEditorId) {
dispatch(syncTable(currentTable, tableData, currentQueryEditorId));
}
});
@@ -129,7 +142,12 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
if (isMetadataSuccess && isExtraMetadataSuccess) {
syncTableMetadata();
}
}, [isMetadataSuccess, isExtraMetadataSuccess, syncTableMetadata]);
}, [
isMetadataSuccess,
isExtraMetadataSuccess,
currentQueryEditorId,
syncTableMetadata,
]);
const [sortColumns, setSortColumns] = useState(false);
const [hovered, setHovered] = useState(false);
@@ -265,7 +265,9 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
{confirmChange && (
<ConfirmModalStyled>
<div className="btn-container">
<Button onClick={handlerCancelConfirm}>{t('Cancel')}</Button>
<Button buttonStyle="secondary" onClick={handlerCancelConfirm}>
{t('Cancel')}
</Button>
<Button
className="proceed-btn"
buttonStyle="primary"
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FunctionComponent, useState, useEffect, useCallback } from 'react';
import {
FunctionComponent,
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { useSelector } from 'react-redux';
import {
styled,
@@ -108,6 +114,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isSaving, setIsSaving] = useState(false);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -185,6 +199,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
};
const onConfirmSave = async () => {
// Pull out extra fields into the extra object
if (!isMountedRef.current) {
return;
}
setIsSaving(true);
try {
await SupersetClient.put({
@@ -195,6 +212,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
if (!isMountedRef.current) {
return;
}
setIsSaving(false);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
json.result.type = 'table';
@@ -204,8 +225,11 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
});
onHide();
} catch (response) {
setIsSaving(false);
const error = await getClientErrorObject(response);
if (!isMountedRef.current) {
return;
}
setIsSaving(false);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
// sip-40 error response
@@ -622,6 +622,7 @@ const ResultTable =
class DatasourceEditor extends PureComponent {
constructor(props) {
super(props);
this.isComponentMounted = false;
this.state = {
datasource: {
...props.datasource,
@@ -760,11 +761,17 @@ class DatasourceEditor extends PureComponent {
try {
const response = await this.props.formatQuery(datasource.sql);
if (!this.isComponentMounted) {
return;
}
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
if (!this.isComponentMounted) {
return;
}
this.props.addDangerToast(
clientError ||
statusText ||
@@ -808,11 +815,17 @@ class DatasourceEditor extends PureComponent {
body: JSON.stringify({ sql: datasource.sql }),
headers: { 'Content-Type': 'application/json' },
});
if (!this.isComponentMounted) {
return;
}
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
if (!this.isComponentMounted) {
return;
}
this.props.addDangerToast(
clientError ||
statusText ||
@@ -823,28 +836,41 @@ class DatasourceEditor extends PureComponent {
async syncMetadata() {
const { datasource } = this.state;
if (!this.isComponentMounted) {
return;
}
this.setState({ metadataLoading: true });
try {
const newCols = await fetchSyncedColumns(datasource);
if (!this.isComponentMounted) {
return;
}
const columnChanges = updateColumns(
datasource.columns,
newCols,
this.props.addSuccessToast,
);
if (!this.isComponentMounted) {
return;
}
this.setColumns({
databaseColumns: columnChanges.finalColumns.filter(
col => !col.expression, // remove calculated columns
),
});
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
if (this.isComponentMounted) {
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
}
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError || statusText || t('An error has occurred'),
);
this.setState({ metadataLoading: false });
if (this.isComponentMounted) {
this.props.addDangerToast(
clientError || statusText || t('An error has occurred'),
);
this.setState({ metadataLoading: false });
}
}
}
@@ -901,10 +927,12 @@ class DatasourceEditor extends PureComponent {
id: ids[index],
}));
this.setState({
usageCharts: chartsWithIds,
usageChartsCount: json?.count || 0,
});
if (this.isComponentMounted) {
this.setState({
usageCharts: chartsWithIds,
usageChartsCount: json?.count || 0,
});
}
return {
charts: chartsWithIds,
@@ -914,15 +942,17 @@ class DatasourceEditor extends PureComponent {
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while fetching usage data'),
);
this.setState({
usageCharts: [],
usageChartsCount: 0,
});
if (this.isComponentMounted) {
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while fetching usage data'),
);
this.setState({
usageCharts: [],
usageChartsCount: 0,
});
}
return {
charts: [],
count: 0,
@@ -1883,6 +1913,7 @@ class DatasourceEditor extends PureComponent {
}
componentDidMount() {
this.isComponentMounted = true;
Mousetrap.bind('ctrl+shift+f', e => {
e.preventDefault();
if (this.state.isEditMode) {
@@ -1894,6 +1925,7 @@ class DatasourceEditor extends PureComponent {
}
componentWillUnmount() {
this.isComponentMounted = false;
Mousetrap.unbind('ctrl+shift+f');
this.props.resetQuery();
}
@@ -17,14 +17,149 @@
* under the License.
*/
import {
act,
createWrapper,
render,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import {
TableProps,
OnChangeFunction,
} from '@superset-ui/core/components/Table';
import DatasetUsageTab from '.';
interface Chart {
id?: number;
slice_name: string;
url: string;
certified_by?: string;
certification_details?: string;
description?: string;
owners: Array<{
first_name: string;
last_name: string;
id: number;
}>;
changed_on_delta_humanized: string;
changed_on?: string;
changed_by: {
first_name: string;
last_name: string;
id: number;
} | null;
dashboards: Array<{
id: number;
dashboard_title: string;
url: string;
}>;
}
jest.mock('@superset-ui/core/components/Table', () => {
const actual = jest.requireActual('@superset-ui/core/components/Table');
const ActualTable = actual.default;
let latestPaginationHandler:
| ((page: number, pageSize?: number) => void)
| null = null;
let latestPaginationPageSize: number | undefined;
let latestPaginationCurrent: number | undefined;
let latestPaginationTotal: number | undefined;
let latestOnChangeHandler: OnChangeFunction<Chart> | null = null;
const MockTable = <RecordType extends object>({
pagination,
onChange,
recordCount,
defaultPageSize = 15,
usePagination = true,
...rest
}: TableProps<RecordType>) => {
// Simulate the Table wrapper's pagination merging logic
let mergedPagination = pagination;
if (usePagination) {
// Build default pagination settings like the wrapper does
const paginationDefaults: {
hideOnSinglePage: boolean;
pageSize: number;
total?: number;
} = {
hideOnSinglePage: true,
pageSize: defaultPageSize,
};
// Add total from recordCount if provided
if (recordCount) {
paginationDefaults.total = recordCount;
}
// Merge with pagination overrides
if (pagination && typeof pagination !== 'boolean') {
mergedPagination = { ...paginationDefaults, ...pagination };
} else if (!pagination) {
mergedPagination = paginationDefaults;
}
}
// Capture the merged pagination for assertions
if (mergedPagination && typeof mergedPagination !== 'boolean') {
latestPaginationPageSize = mergedPagination.pageSize;
latestPaginationCurrent = mergedPagination.current;
latestPaginationTotal = mergedPagination.total;
latestPaginationHandler = mergedPagination.onChange || null;
}
// Cast to Chart type since we know DatasetUsageTab uses Chart
latestOnChangeHandler =
(onChange as OnChangeFunction<Chart> | undefined) || null;
return (
<ActualTable
pagination={mergedPagination}
onChange={onChange}
recordCount={recordCount}
defaultPageSize={defaultPageSize}
usePagination={usePagination}
{...rest}
/>
);
};
return {
__esModule: true,
...actual,
default: MockTable,
__getLatestPaginationHandler: () => latestPaginationHandler,
__getLatestPaginationPageSize: () => latestPaginationPageSize,
__getLatestPaginationCurrent: () => latestPaginationCurrent,
__getLatestPaginationTotal: () => latestPaginationTotal,
__getLatestOnChangeHandler: () => latestOnChangeHandler,
__resetPaginationHandler: () => {
latestPaginationHandler = null;
latestPaginationPageSize = undefined;
latestPaginationCurrent = undefined;
latestPaginationTotal = undefined;
latestOnChangeHandler = null;
},
};
});
const tableMock = jest.requireMock('@superset-ui/core/components/Table') as {
__getLatestPaginationHandler: () =>
| ((page: number, pageSize?: number) => void)
| null;
__getLatestPaginationPageSize: () => number | undefined;
__getLatestPaginationCurrent: () => number | undefined;
__getLatestPaginationTotal: () => number | undefined;
__resetPaginationHandler: () => void;
__getLatestOnChangeHandler: () =>
| ((pagination: { current: number; pageSize?: number }) => void)
| null;
};
const mockChartsResponse = {
result: [
{
@@ -83,6 +218,9 @@ const mockChartsResponse = {
ids: [1, 2],
};
// Store original scrollTo to restore after tests
let originalScrollTo: typeof Element.prototype.scrollTo;
const setupTest = (props = {}) => {
const mockOnFetchCharts = jest.fn(() =>
Promise.resolve({
@@ -101,21 +239,48 @@ const setupTest = (props = {}) => {
...props,
};
return render(<DatasetUsageTab {...defaultProps} />, {
const result = render(<DatasetUsageTab {...defaultProps} />, {
wrapper: createWrapper({
useRedux: true,
useRouter: true,
}),
});
return { ...result, mockOnFetchCharts };
};
beforeEach(() => {
fetchMock.reset();
jest.clearAllMocks();
// Save original scrollTo and mock it for JSDOM compatibility
originalScrollTo = Element.prototype.scrollTo;
Object.defineProperty(Element.prototype, 'scrollTo', {
value: jest.fn(),
configurable: true,
writable: true,
});
// eslint-disable-next-line no-underscore-dangle
tableMock.__resetPaginationHandler();
});
afterEach(() => {
fetchMock.restore();
// Restore real timers in case a test with fakeTimers failed mid-test
jest.useRealTimers();
// Restore original scrollTo to avoid test pollution
if (originalScrollTo) {
Object.defineProperty(Element.prototype, 'scrollTo', {
value: originalScrollTo,
configurable: true,
writable: true,
});
} else {
delete (Element.prototype as any).scrollTo;
}
});
test('renders empty state when no charts provided', () => {
@@ -212,3 +377,363 @@ test('enables sorting for Chart and Last modified columns', async () => {
expect(dashboardHeader).not.toHaveClass('ant-table-column-has-sorters');
});
});
test('clears scroll timeout on unmount to prevent async updates', async () => {
jest.useFakeTimers();
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
scrollToMock.mockClear();
const manyCharts = Array.from({ length: 51 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
const { unmount, mockOnFetchCharts } = setupTest({
charts: manyCharts.slice(0, 25),
totalCount: 51,
});
// eslint-disable-next-line no-underscore-dangle
const onChangeHandler = tableMock.__getLatestOnChangeHandler();
// eslint-disable-next-line no-underscore-dangle
const pageSize = tableMock.__getLatestPaginationPageSize();
expect(onChangeHandler).toBeDefined();
onChangeHandler?.({ current: 2, pageSize } as {
current: number;
pageSize?: number;
});
await waitFor(() =>
expect(mockOnFetchCharts).toHaveBeenCalledWith(
2,
25,
expect.any(String),
expect.any(String),
),
);
unmount();
act(() => {
jest.runAllTimers();
});
expect(scrollToMock).not.toHaveBeenCalled();
});
test('clears pending scroll timeout on rapid pagination clicks', async () => {
jest.useFakeTimers();
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
scrollToMock.mockClear();
const manyCharts = Array.from({ length: 60 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
const { mockOnFetchCharts } = setupTest({
charts: manyCharts.slice(0, 25),
totalCount: 60,
});
// eslint-disable-next-line no-underscore-dangle
const firstOnChange = tableMock.__getLatestOnChangeHandler();
// eslint-disable-next-line no-underscore-dangle
const pageSize = tableMock.__getLatestPaginationPageSize();
expect(firstOnChange).toBeDefined();
firstOnChange?.({ current: 2, pageSize } as {
current: number;
pageSize?: number;
});
await waitFor(() =>
expect(mockOnFetchCharts).toHaveBeenCalledWith(
2,
25,
expect.any(String),
expect.any(String),
),
);
act(() => {
jest.advanceTimersByTime(50);
});
// eslint-disable-next-line no-underscore-dangle
const secondOnChange = tableMock.__getLatestOnChangeHandler();
expect(secondOnChange).toBeDefined();
secondOnChange?.({ current: 3, pageSize } as {
current: number;
pageSize?: number;
});
await waitFor(() =>
expect(mockOnFetchCharts).toHaveBeenCalledWith(
3,
25,
expect.any(String),
expect.any(String),
),
);
act(() => {
jest.runAllTimers();
});
expect(scrollToMock).toHaveBeenCalledTimes(1);
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
test('scrolls table to top after pagination with delay', async () => {
jest.useFakeTimers();
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
scrollToMock.mockClear();
const manyCharts = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
const { mockOnFetchCharts } = setupTest({
charts: manyCharts.slice(0, 25),
totalCount: 30,
});
// eslint-disable-next-line no-underscore-dangle
const onChangeHandler = tableMock.__getLatestOnChangeHandler();
// eslint-disable-next-line no-underscore-dangle
const pageSize = tableMock.__getLatestPaginationPageSize();
expect(onChangeHandler).toBeDefined();
onChangeHandler?.({ current: 2, pageSize } as {
current: number;
pageSize?: number;
});
await waitFor(() =>
expect(mockOnFetchCharts).toHaveBeenCalledWith(
2,
25,
expect.any(String),
expect.any(String),
),
);
expect(scrollToMock).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(100);
});
expect(scrollToMock).toHaveBeenCalledWith({
top: 0,
behavior: 'smooth',
});
});
test('displays correct pagination and navigates between pages', async () => {
const manyCharts = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
const { mockOnFetchCharts } = setupTest({
charts: manyCharts.slice(0, 25),
totalCount: 30,
});
// Page 1: Verify UI shows page 1 is active (using Ant Design active class)
await waitFor(() => {
const page1Item = screen.getByRole('listitem', { name: '1' });
expect(page1Item).toHaveClass('ant-pagination-item-active');
});
// Soft assertion: verify component passes correct props to Table
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationCurrent()).toBe(1);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(30);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
// User clicks page 2 (using userEvent for realistic interaction)
const page2Item = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2Item);
// Data fetch called for page 2
await waitFor(() =>
expect(mockOnFetchCharts).toHaveBeenCalledWith(
2,
25,
expect.any(String),
expect.any(String),
),
);
// Verify UI reflects page 2 is now active (using Ant Design active class)
await waitFor(() => {
const page2Item = screen.getByRole('listitem', { name: '2' });
expect(page2Item).toHaveClass('ant-pagination-item-active');
});
// Soft assertion: Table still receives correct total (catches the bug!)
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationCurrent()).toBe(2);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(30);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
});
test('maintains pagination state across multiple page changes', async () => {
const manyCharts = Array.from({ length: 60 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
const { mockOnFetchCharts } = setupTest({
charts: manyCharts.slice(0, 25),
totalCount: 60,
});
// Verify initial state (page 1) using Ant Design active class
await waitFor(() => {
const page1Item = screen.getByRole('listitem', { name: '1' });
expect(page1Item).toHaveClass('ant-pagination-item-active');
});
// Soft assertion: initial props are correct
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
// User navigates to page 2
await userEvent.click(screen.getByRole('listitem', { name: '2' }));
await waitFor(() => {
expect(mockOnFetchCharts).toHaveBeenCalledWith(
2,
25,
expect.any(String),
expect.any(String),
);
const page2Item = screen.getByRole('listitem', { name: '2' });
expect(page2Item).toHaveClass('ant-pagination-item-active');
});
// Soft assertion: props update correctly after navigation
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationCurrent()).toBe(2);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
// User navigates to page 3
await userEvent.click(screen.getByRole('listitem', { name: '3' }));
await waitFor(() => {
expect(mockOnFetchCharts).toHaveBeenCalledWith(
3,
25,
expect.any(String),
expect.any(String),
);
const page3Item = screen.getByRole('listitem', { name: '3' });
expect(page3Item).toHaveClass('ant-pagination-item-active');
});
// Soft assertion: total remains consistent across all navigations
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationCurrent()).toBe(3);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
});
test('handles total count below one page (no pagination needed)', async () => {
const fewCharts = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
setupTest({
charts: fewCharts,
totalCount: 5,
});
// Pagination should not show page 2 item since only 1 page exists
await waitFor(() => {
expect(
screen.queryByRole('listitem', { name: '2' }),
).not.toBeInTheDocument();
});
// Table receives correct props
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(5);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationCurrent()).toBe(1);
});
test('handles server returning fewer rows than page size', async () => {
const partialPage = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
slice_name: `Test Chart ${i + 1}`,
url: `/explore/${i + 1}/`,
owners: [],
changed_on_delta_humanized: '1 day ago',
changed_by: null,
dashboards: [],
}));
setupTest({
charts: partialPage,
totalCount: 12,
});
// Only page 1 should exist
await waitFor(() => {
expect(
screen.queryByRole('listitem', { name: '2' }),
).not.toBeInTheDocument();
});
// Table receives correct total (not data.length!)
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationTotal()).toBe(12);
// eslint-disable-next-line no-underscore-dangle
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
});
@@ -99,6 +99,8 @@ const DatasetUsageTab = ({
}: DatasetUsageTabProps) => {
const addDangerToastRef = useRef(addDangerToast);
const tableContainerRef = useRef<HTMLDivElement>(null);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMountedRef = useRef(true);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -115,14 +117,19 @@ const DatasetUsageTab = ({
try {
await onFetchCharts(page, PAGE_SIZE, column, direction);
setCurrentPage(page);
setSortColumn(column);
setSortDirection(direction);
// Only update state if component is still mounted
if (isMountedRef.current) {
setCurrentPage(page);
setSortColumn(column);
setSortDirection(direction);
}
} catch (error) {
if (addDangerToastRef.current)
if (isMountedRef.current && addDangerToastRef.current)
addDangerToastRef.current(t('Error fetching charts'));
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
},
[datasourceId, onFetchCharts, sortColumn, sortDirection],
@@ -132,11 +139,31 @@ const DatasetUsageTab = ({
addDangerToastRef.current = addDangerToast;
}, [addDangerToast]);
// Cleanup on unmount
useEffect(() => {
// Set mounted flag to true on mount (important for Strict Mode double-mount)
isMountedRef.current = true;
return () => {
// Mark component as unmounted to prevent state updates
isMountedRef.current = false;
// Clear pending scroll timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
const handlePageChange = useCallback(
(page: number) => {
handleFetchCharts(page);
setTimeout(() => {
// Clear any pending scroll timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => {
const tableBody =
tableContainerRef.current?.querySelector('.ant-table-body');
if (tableBody) {
@@ -145,6 +172,7 @@ const DatasetUsageTab = ({
behavior: 'smooth',
});
}
scrollTimeoutRef.current = null;
}, 100);
},
[handleFetchCharts],
@@ -261,14 +289,9 @@ const DatasetUsageTab = ({
sticky
columns={columns}
data={charts}
pagination={{
current: currentPage,
total: totalCount,
pageSize: PAGE_SIZE,
onChange: handlePageChange,
showSizeChanger: false,
size: 'default',
}}
recordCount={totalCount}
usePagination
defaultPageSize={PAGE_SIZE}
loading={loading}
size={TableSize.Middle}
rowKey={(record: Chart) =>
@@ -276,6 +299,12 @@ const DatasetUsageTab = ({
}
tableLayout="fixed"
scroll={{ y: 293, x: '100%' }}
pagination={{
current: currentPage,
total: totalCount,
pageSize: PAGE_SIZE,
hideOnSinglePage: false,
}}
css={css`
.ant-table-pagination.ant-pagination {
margin-bottom: 0;
@@ -0,0 +1,39 @@
/**
* 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, screen } from 'spec/helpers/testing-library';
import { CustomDocLink } from './CustomDocLink';
const mockedProps = {
url: 'https://superset.apache.org/docs/',
label: 'Superset Docs',
};
test('should render the label', () => {
render(<CustomDocLink {...mockedProps} />);
expect(screen.getByText('Superset Docs')).toBeInTheDocument();
});
test('should render the link with correct attributes', () => {
render(<CustomDocLink {...mockedProps} />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', mockedProps.url);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
@@ -16,25 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
{
"plugins": ["jest", "jest-dom", "no-only-tests", "testing-library"],
"env": {
"jest/globals": true
},
"settings": {
"jest": {
"version": "detect"
}
},
"extends": [
"plugin:jest/recommended",
"plugin:jest-dom/recommended",
"plugin:testing-library/react"
],
"rules": {
"import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
"jest/consistent-test-it": "error",
"no-only-tests/no-only-tests": "error",
"prefer-promise-reject-errors": 0
}
}
import { Flex, Icons } from '@superset-ui/core/components';
export type CustomDocLinkProps = {
url: string;
label: string;
};
export const CustomDocLink = ({ url, label }: CustomDocLinkProps) => (
<a href={url} target="_blank" rel="noopener noreferrer">
<Flex align="center" gap={4}>
{label} <Icons.Full iconSize="m" />
</Flex>
</a>
);
@@ -53,6 +53,38 @@ const mockedProps = {
subtitle: 'Error message',
};
const mockedPropsWithCustomError = {
...mockedProps,
error: {
...mockedProps.error,
extra: {
...mockedProps.error.extra,
custom_doc_links: [
{
label: 'Custom Doc Link 1',
url: 'https://example.com/custom-doc-1',
},
{
label: 'Custom Doc Link 2',
url: 'https://example.com/custom-doc-2',
},
],
show_issue_info: false,
},
},
};
const mockedPropsWithCustomErrorAndBadLinks = {
...mockedProps,
error: {
...mockedProps.error,
extra: {
...mockedProps.error.extra,
custom_doc_links: true,
},
},
};
test('should render', () => {
const nullExtraProps = {
...mockedProps,
@@ -112,3 +144,28 @@ test('should NOT render the owners', () => {
screen.queryByText('Chart Owners: Owner A, Owner B'),
).not.toBeInTheDocument();
});
test('should render custom documentation links when provided', () => {
render(<DatabaseErrorMessage {...mockedPropsWithCustomError} />, {
useRedux: true,
});
expect(screen.getByText('Custom Doc Link 1')).toBeInTheDocument();
expect(screen.getByText('Custom Doc Link 2')).toBeInTheDocument();
});
test('should NOT render see more button when show_issue_info is false', () => {
render(<DatabaseErrorMessage {...mockedPropsWithCustomError} />, {
useRedux: true,
});
const button = screen.queryByText('See more');
expect(button).not.toBeInTheDocument();
});
test('should render message when wrong value provided for custom_doc_urls', () => {
// @ts-ignore
render(<DatabaseErrorMessage {...mockedPropsWithCustomErrorAndBadLinks} />, {
useRedux: true,
});
const button = screen.queryByText('Error message');
expect(button).toBeInTheDocument();
});
@@ -22,6 +22,7 @@ import { t, tn } from '@superset-ui/core';
import type { ErrorMessageComponentProps } from './types';
import { IssueCode } from './IssueCode';
import { ErrorAlert } from './ErrorAlert';
import { CustomDocLink, CustomDocLinkProps } from './CustomDocLink';
interface DatabaseErrorExtra {
owners?: string[];
@@ -30,6 +31,8 @@ interface DatabaseErrorExtra {
message: string;
}[];
engine_name: string | null;
custom_doc_links?: CustomDocLinkProps[];
show_issue_info?: boolean;
}
export function DatabaseErrorMessage({
@@ -40,20 +43,32 @@ export function DatabaseErrorMessage({
const isVisualization = ['dashboard', 'explore'].includes(source || '');
const [firstLine, ...remainingLines] = message.split('\n');
const alertMessage = firstLine;
const alertDescription =
remainingLines.length > 0 ? remainingLines.join('\n') : null;
let alertMessage: ReactNode = firstLine;
const body = extra && (
if (Array.isArray(extra?.custom_doc_links)) {
alertMessage = (
<>
{firstLine}
{extra.custom_doc_links.map(link => (
<div key={link.url}>
<CustomDocLink {...link} />
</div>
))}
</>
);
}
const body = extra && extra.show_issue_info !== false && (
<>
<p>
{t('This may be triggered by:')}
<br />
{extra.issue_codes
?.map<ReactNode>(issueCode => (
<IssueCode {...issueCode} key={issueCode.code} />
))
.reduce((prev, curr) => [prev, <br />, curr])}
{extra.issue_codes?.flatMap((issueCode, idx, arr) => [
<IssueCode {...issueCode} key={issueCode.code} />,
idx < arr.length - 1 ? <br key={`br-${issueCode.code}`} /> : null,
])}
</p>
{isVisualization && extra.owners && (
<>
@@ -100,6 +100,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
</span>
</div>
)}
{children}
</div>
);
const renderAlert = (closable: boolean) => (
@@ -129,7 +130,6 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
footer={null}
>
{renderAlert(false)}
{children}
</Modal>
</>
);
@@ -132,6 +132,7 @@ class Markdown extends PureComponent {
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleResizeStart = this.handleResizeStart.bind(this);
this.setEditor = this.setEditor.bind(this);
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
}
componentDidMount() {
@@ -268,6 +269,13 @@ class Markdown extends PureComponent {
}
}
shouldFocusMarkdown(event, container, menuRef) {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
return false;
}
renderEditMode() {
return (
<MarkdownEditor
@@ -343,6 +351,7 @@ class Markdown extends PureComponent {
{({ dragSourceRef }) => (
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
id={`${component.id}-mode`}
@@ -17,7 +17,13 @@
* under the License.
*/
import { Provider } from 'react-redux';
import { act, render, screen, fireEvent } from 'spec/helpers/testing-library';
import {
act,
render,
screen,
fireEvent,
userEvent,
} from 'spec/helpers/testing-library';
import { mockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
import MarkdownConnected from './Markdown';
@@ -45,6 +51,7 @@ describe('Markdown', () => {
};
beforeAll(() => {
const originalError = console.error;
jest.spyOn(console, 'error').mockImplementation(msg => {
if (
typeof msg === 'string' &&
@@ -53,7 +60,7 @@ describe('Markdown', () => {
!msg.includes('Warning: React does not recognize') &&
!msg.includes("Warning: Can't perform a React state update")
) {
console.error(msg);
originalError.call(console, msg);
}
});
});
@@ -335,4 +342,75 @@ describe('Markdown', () => {
// Check that width is no longer 248px
expect(updatedParent).not.toHaveStyle('width: 248px');
});
test('shouldFocusMarkdown returns true when clicking inside markdown container', async () => {
await setup({ editMode: true });
const markdownContainer = screen.getByTestId(
'dashboard-component-chart-holder',
);
userEvent.click(markdownContainer);
expect(await screen.findByRole('textbox')).toBeInTheDocument();
});
test('shouldFocusMarkdown returns false when clicking outside markdown container', async () => {
await setup({ editMode: true });
const markdownContainer = screen.getByTestId(
'dashboard-component-chart-holder',
);
userEvent.click(markdownContainer);
expect(await screen.findByRole('textbox')).toBeInTheDocument();
userEvent.click(document.body);
await new Promise(resolve => setTimeout(resolve, 50));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
test('shouldFocusMarkdown keeps focus when clicking on menu items', async () => {
await setup({ editMode: true });
const markdownContainer = screen.getByTestId(
'dashboard-component-chart-holder',
);
userEvent.click(markdownContainer);
expect(await screen.findByRole('textbox')).toBeInTheDocument();
const editButton = screen.getByText('Edit');
userEvent.click(editButton);
await new Promise(resolve => setTimeout(resolve, 50));
expect(screen.queryByRole('textbox')).toBeInTheDocument();
});
test('should exit edit mode when clicking outside in same row', async () => {
await setup({ editMode: true });
const markdownContainer = screen.getByTestId(
'dashboard-component-chart-holder',
);
userEvent.click(markdownContainer);
expect(await screen.findByRole('textbox')).toBeInTheDocument();
const outsideElement = document.createElement('div');
outsideElement.className = 'grid-row';
document.body.appendChild(outsideElement);
userEvent.click(outsideElement);
await new Promise(resolve => setTimeout(resolve, 50));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
document.body.removeChild(outsideElement);
});
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { render, userEvent } from 'spec/helpers/testing-library';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
@@ -44,40 +44,102 @@ test('should render the passed children', () => {
expect(container.querySelector('#child')).toBeInTheDocument();
});
test('should focus on click in editMode', () => {
test('should focus on click in editMode', async () => {
const { container } = setup({ editMode: true });
fireEvent.click(container.querySelector('.with-popover-menu'));
await userEvent.click(container.querySelector('.with-popover-menu'));
expect(
container.querySelector('.with-popover-menu--focused'),
).toBeInTheDocument();
});
test('should render menuItems when focused', () => {
test('should render menuItems when focused', async () => {
const { container } = setup({ editMode: true });
expect(container.querySelector('#menu1')).not.toBeInTheDocument();
expect(container.querySelector('#menu2')).not.toBeInTheDocument();
fireEvent.click(container.querySelector('.with-popover-menu'));
await userEvent.click(container.querySelector('.with-popover-menu'));
expect(container.querySelector('#menu1')).toBeInTheDocument();
expect(container.querySelector('#menu2')).toBeInTheDocument();
});
test('should not focus when disableClick=true', () => {
test('should not focus when disableClick=true', async () => {
const { container } = setup({ disableClick: true, editMode: true });
fireEvent.click(container.querySelector('.with-popover-menu'));
await userEvent.click(container.querySelector('.with-popover-menu'));
expect(
container.querySelector('.with-popover-menu--focused'),
).not.toBeInTheDocument();
});
test('should use the passed shouldFocus func to determine if it should focus', () => {
test('should use the passed shouldFocus func to determine if it should focus', async () => {
const { container } = setup({ editMode: true, shouldFocus: () => false });
expect(
container.querySelector('.with-popover-menu--focused'),
).not.toBeInTheDocument();
fireEvent.click(container.querySelector('.with-popover-menu'));
await userEvent.click(container.querySelector('.with-popover-menu'));
expect(
container.querySelector('.with-popover-menu--focused'),
).not.toBeInTheDocument();
});
test('should allow event propagation to enable multiple components to work independently', async () => {
const onChangeFocus = jest.fn();
const { container } = setup({
editMode: true,
disableClick: false,
shouldFocus: () => true,
onChangeFocus,
});
const menuComponent = container.querySelector('.with-popover-menu');
await userEvent.click(menuComponent);
expect(onChangeFocus).toHaveBeenCalledWith(true);
});
test('should unfocus when another component is clicked', async () => {
const onChangeFocusA = jest.fn();
const onChangeFocusB = jest.fn();
const componentA = render(
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
onChangeFocus={onChangeFocusA}
>
<div id="child-a" />
</WithPopoverMenu>,
);
const componentB = render(
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
onChangeFocus={onChangeFocusB}
>
<div id="child-b" />
</WithPopoverMenu>,
);
const menuA = componentA.container.querySelector('.with-popover-menu');
const menuB = componentB.container.querySelector('.with-popover-menu');
await userEvent.click(menuA);
expect(onChangeFocusA).toHaveBeenCalledWith(true);
expect(
componentA.container.querySelector('.with-popover-menu--focused'),
).toBeInTheDocument();
await userEvent.click(menuB);
expect(onChangeFocusB).toHaveBeenCalledWith(true);
expect(onChangeFocusA).toHaveBeenCalledWith(false);
expect(
componentA.container.querySelector('.with-popover-menu--focused'),
).not.toBeInTheDocument();
expect(
componentB.container.querySelector('.with-popover-menu--focused'),
).toBeInTheDocument();
});
@@ -33,7 +33,11 @@ interface WithPopoverMenuProps {
// Event argument is left as "any" because of the clash. In defaultProps it seems
// like it should be React.FocusEvent<>, however from handleClick() we can also
// derive that type is EventListenerOrEventListenerObject.
shouldFocus: (event: any, container: ShouldFocusContainer) => Boolean;
shouldFocus: (
event: any,
container: ShouldFocusContainer,
menuRef: HTMLDivElement | null,
) => Boolean;
editMode: Boolean;
style: CSSProperties;
}
@@ -105,16 +109,21 @@ export default class WithPopoverMenu extends PureComponent<
> {
container: ShouldFocusContainer;
menuRef: HTMLDivElement | null;
static defaultProps = {
children: null,
disableClick: false,
onChangeFocus: null,
menuItems: [],
isFocused: false,
shouldFocus: (event: any, container: ShouldFocusContainer) => {
shouldFocus: (
event: any,
container: ShouldFocusContainer,
menuRef: HTMLDivElement | null,
) => {
if (container?.contains(event.target)) return true;
if (event.target.id === 'menu-item') return true;
if (event.target.parentNode?.id === 'menu-item') return true;
if (menuRef?.contains(event.target)) return true;
return false;
},
style: null,
@@ -125,7 +134,9 @@ export default class WithPopoverMenu extends PureComponent<
this.state = {
isFocused: props.isFocused!,
};
this.menuRef = null;
this.setRef = this.setRef.bind(this);
this.setMenuRef = this.setMenuRef.bind(this);
this.handleClick = this.handleClick.bind(this);
}
@@ -150,37 +161,49 @@ export default class WithPopoverMenu extends PureComponent<
this.container = ref;
}
setMenuRef(ref: HTMLDivElement | null) {
this.menuRef = ref;
}
shouldHandleFocusChange(shouldFocus: Boolean): boolean {
const { disableClick } = this.props;
const { isFocused } = this.state;
return (
(!disableClick && shouldFocus && !isFocused) ||
(!shouldFocus && isFocused)
);
}
handleClick(event: any) {
if (!this.props.editMode) {
return;
}
event.stopPropagation();
const {
onChangeFocus,
shouldFocus: shouldFocusFunc,
disableClick,
} = this.props;
const shouldFocus = shouldFocusFunc(event, this.container);
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
if (shouldFocus === this.state.isFocused) return;
if (!disableClick && shouldFocus && !this.state.isFocused) {
// if not focused, set focus and add a window event listener to capture outside clicks
// this enables us to not set a click listener for ever item on a dashboard
document.addEventListener('click', this.handleClick);
document.addEventListener('drag', this.handleClick);
this.setState(() => ({ isFocused: true }));
if (onChangeFocus) {
onChangeFocus(true);
}
if (onChangeFocus) onChangeFocus(true);
} else if (!shouldFocus && this.state.isFocused) {
document.removeEventListener('click', this.handleClick);
document.removeEventListener('drag', this.handleClick);
this.setState(() => ({ isFocused: false }));
if (onChangeFocus) {
onChangeFocus(false);
}
if (onChangeFocus) onChangeFocus(false);
}
}
@@ -201,7 +224,7 @@ export default class WithPopoverMenu extends PureComponent<
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles>
<PopoverMenuStyles ref={this.setMenuRef}>
{menuItems.map((node: ReactNode, i: Number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
@@ -0,0 +1,248 @@
/**
* 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, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { ExploreAlert } from './ExploreAlert';
test('renders with title and body text', () => {
render(
<ExploreAlert title="Test Title" bodyText="Test body text" type="info" />,
);
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test body text')).toBeInTheDocument();
});
test('renders info type alert', () => {
const { container } = render(
<ExploreAlert title="Info Alert" bodyText="Info message" type="info" />,
);
expect(container.querySelector('.ant-alert-info')).toBeInTheDocument();
});
test('renders warning type alert', () => {
const { container } = render(
<ExploreAlert
title="Warning Alert"
bodyText="Warning message"
type="warning"
/>,
);
expect(container.querySelector('.ant-alert-warning')).toBeInTheDocument();
});
test('renders error type alert', () => {
const { container } = render(
<ExploreAlert title="Error Alert" bodyText="Error message" type="error" />,
);
expect(container.querySelector('.ant-alert-error')).toBeInTheDocument();
});
test('renders primary button when text and action provided', () => {
const primaryAction = jest.fn();
render(
<ExploreAlert
title="Alert with button"
bodyText="Body text"
type="info"
primaryButtonText="Primary Action"
primaryButtonAction={primaryAction}
/>,
);
expect(screen.getByText('Primary Action')).toBeInTheDocument();
});
test('calls primary button action when clicked', async () => {
const primaryAction = jest.fn();
render(
<ExploreAlert
title="Alert with button"
bodyText="Body text"
type="info"
primaryButtonText="Click Me"
primaryButtonAction={primaryAction}
/>,
);
await userEvent.click(screen.getByText('Click Me'));
expect(primaryAction).toHaveBeenCalledTimes(1);
});
test('renders both primary and secondary buttons when provided', () => {
const primaryAction = jest.fn();
const secondaryAction = jest.fn();
render(
<ExploreAlert
title="Alert with buttons"
bodyText="Body text"
type="info"
primaryButtonText="Primary"
primaryButtonAction={primaryAction}
secondaryButtonText="Secondary"
secondaryButtonAction={secondaryAction}
/>,
);
expect(screen.getByText('Primary')).toBeInTheDocument();
expect(screen.getByText('Secondary')).toBeInTheDocument();
});
test('calls secondary button action when clicked', async () => {
const primaryAction = jest.fn();
const secondaryAction = jest.fn();
render(
<ExploreAlert
title="Alert with buttons"
bodyText="Body text"
type="info"
primaryButtonText="Primary"
primaryButtonAction={primaryAction}
secondaryButtonText="Secondary"
secondaryButtonAction={secondaryAction}
/>,
);
await userEvent.click(screen.getByText('Secondary'));
expect(secondaryAction).toHaveBeenCalledTimes(1);
expect(primaryAction).not.toHaveBeenCalled();
});
test('does not render buttons when only text is provided without action', () => {
render(
<ExploreAlert
title="Alert without action"
bodyText="Body text"
type="info"
primaryButtonText="Primary"
/>,
);
expect(screen.queryByText('Primary')).not.toBeInTheDocument();
});
test('does not render buttons when only action is provided without text', () => {
const primaryAction = jest.fn();
render(
<ExploreAlert
title="Alert without text"
bodyText="Body text"
type="info"
primaryButtonAction={primaryAction}
/>,
);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
test('does not render secondary button when secondary action is missing', () => {
const primaryAction = jest.fn();
render(
<ExploreAlert
title="Alert"
bodyText="Body text"
type="info"
primaryButtonText="Primary"
primaryButtonAction={primaryAction}
secondaryButtonText="Secondary"
/>,
);
expect(screen.getByText('Primary')).toBeInTheDocument();
expect(screen.queryByText('Secondary')).not.toBeInTheDocument();
});
test('does not render secondary button when secondary text is missing', () => {
const primaryAction = jest.fn();
const secondaryAction = jest.fn();
render(
<ExploreAlert
title="Alert"
bodyText="Body text"
type="info"
primaryButtonText="Primary"
primaryButtonAction={primaryAction}
secondaryButtonAction={secondaryAction}
/>,
);
expect(screen.getByText('Primary')).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /secondary/i }),
).not.toBeInTheDocument();
});
test('applies custom className', () => {
const { container } = render(
<ExploreAlert
title="Alert"
bodyText="Body text"
type="info"
className="custom-class"
/>,
);
expect(container.querySelector('.custom-class')).toBeInTheDocument();
});
test('renders with ReactNode as bodyText', () => {
render(
<ExploreAlert
title="Alert"
bodyText={
<div>
<span>Line 1</span>
<span>Line 2</span>
</div>
}
type="info"
/>,
);
expect(screen.getByText('Line 1')).toBeInTheDocument();
expect(screen.getByText('Line 2')).toBeInTheDocument();
});
test('is not closable by default', () => {
const { container } = render(
<ExploreAlert title="Alert" bodyText="Body text" type="info" />,
);
expect(
container.querySelector('.ant-alert-close-icon'),
).not.toBeInTheDocument();
});
test('shows icon by default', () => {
const { container } = render(
<ExploreAlert title="Alert" bodyText="Body text" type="info" />,
);
expect(container.querySelector('.anticon')).toBeInTheDocument();
});
@@ -20,6 +20,7 @@
import { forwardRef, RefObject, MouseEvent } from 'react';
import { Button } from '@superset-ui/core/components';
import { ErrorAlert } from 'src/components';
import { styled } from '@superset-ui/core';
interface ControlPanelAlertProps {
title: string;
@@ -32,6 +33,12 @@ interface ControlPanelAlertProps {
className?: string;
}
const ButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
`;
export const ExploreAlert = forwardRef(
(
{
@@ -55,14 +62,16 @@ export const ExploreAlert = forwardRef(
showIcon
>
{primaryButtonText && primaryButtonAction && (
<div>
<ButtonContainer>
{secondaryButtonAction && secondaryButtonText && (
<Button onClick={secondaryButtonAction}>
<Button buttonStyle="secondary" onClick={secondaryButtonAction}>
{secondaryButtonText}
</Button>
)}
<Button onClick={primaryButtonAction}>{primaryButtonText}</Button>
</div>
<Button buttonStyle="secondary" onClick={primaryButtonAction}>
{primaryButtonText}
</Button>
</ButtonContainer>
)}
</ErrorAlert>
),
@@ -371,15 +371,6 @@ function ExploreViewContainer(props) {
props.form_data,
]);
// Simple debounced auto-query for non-renderTrigger controls
const debouncedAutoQuery = useMemo(
() =>
debounce(() => {
onQuery();
}, 1000), // 1 second delay
[onQuery],
);
const handleKeydown = useCallback(
event => {
const controlOrCommand = event.ctrlKey || event.metaKey;
@@ -573,25 +564,8 @@ function ExploreViewContainer(props) {
if (displayControlsChanged.length > 0) {
reRenderChart(displayControlsChanged);
}
// Auto-update for non-renderTrigger controls
const queryControlsChanged = changedControlKeys.filter(
key =>
!props.controls[key].renderTrigger &&
!props.controls[key].dontRefreshOnChange,
);
if (queryControlsChanged.length > 0) {
// Check if there are no validation errors before auto-updating
const hasErrors = Object.values(props.controls).some(
control =>
control.validationErrors && control.validationErrors.length > 0,
);
if (!hasErrors) {
debouncedAutoQuery();
}
}
}
}, [props.controls, props.ownState, debouncedAutoQuery]);
}, [props.controls, props.ownState]);
const chartIsStale = useMemo(() => {
if (lastQueriedControls) {
@@ -41,6 +41,7 @@ export const useUnsavedChangesPrompt = ({
const manualSaveRef = useRef(false); // Track if save was user-initiated (not via navigation)
const handleConfirmNavigation = useCallback(() => {
setShowModal(false);
confirmNavigationRef.current?.();
}, []);
@@ -22,86 +22,116 @@ import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'spec/helpers/testing-library';
const history = createMemoryHistory({
let history = createMemoryHistory({
initialEntries: ['/dashboard'],
});
beforeEach(() => {
history = createMemoryHistory({ initialEntries: ['/dashboard'] });
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<Router history={history}>{children}</Router>
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('useUnsavedChangesPrompt', () => {
test('should not show modal initially', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
test('should not show modal initially', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
expect(result.current.showModal).toBe(false);
expect(result.current.showModal).toBe(false);
});
test('should block navigation and show modal if there are unsaved changes', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
// Simulate blocked navigation
act(() => {
const unblock = history.block((tx: any) => tx);
unblock();
history.push('/another-page');
});
expect(result.current.showModal).toBe(true);
});
test('should trigger onSave and hide modal on handleSaveAndCloseModal', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
test('should block navigation and show modal if there are unsaved changes', () => {
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave: jest.fn(),
}),
{ wrapper },
);
// Simulate blocked navigation
act(() => {
const unblock = history.block((tx: any) => tx);
unblock();
history.push('/another-page');
});
expect(result.current.showModal).toBe(true);
await act(async () => {
await result.current.handleSaveAndCloseModal();
});
test('should trigger onSave and hide modal on handleSaveAndCloseModal', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
});
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
test('should trigger manual save and not show modal again', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
await act(async () => {
await result.current.handleSaveAndCloseModal();
});
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
act(() => {
result.current.triggerManualSave();
});
test('should trigger manual save and not show modal again', async () => {
const onSave = jest.fn().mockResolvedValue(undefined);
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
});
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
test('should close modal when handleConfirmNavigation is called', () => {
const onSave = jest.fn();
act(() => {
result.current.triggerManualSave();
});
const { result } = renderHook(
() =>
useUnsavedChangesPrompt({
hasUnsavedChanges: true,
onSave,
}),
{ wrapper },
);
expect(onSave).toHaveBeenCalled();
expect(result.current.showModal).toBe(false);
// First, trigger navigation to show the modal
act(() => {
const unblock = history.block((tx: any) => tx);
unblock();
history.push('/another-page');
});
expect(result.current.showModal).toBe(true);
// Then call handleConfirmNavigation to discard changes
act(() => {
result.current.handleConfirmNavigation();
});
expect(result.current.showModal).toBe(false);
});
+137 -75
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { t, SupersetClient, styled } from '@superset-ui/core';
import {
Tag,
@@ -72,12 +72,26 @@ const FlexRowContainer = styled.div`
}
`;
const IconTag = styled(Tag)`
display: inline-flex;
align-items: center;
`;
const CONFIRM_OVERWRITE_MESSAGE = t(
'You are importing one or more themes that already exist. ' +
'Overwriting might cause you to lose some of your work. Are you ' +
'sure you want to overwrite?',
);
interface ConfirmModalConfig {
visible: boolean;
title: string;
message: string;
onConfirm: () => Promise<any>;
successMessage: string;
errorMessage: string;
}
interface ThemesListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -112,6 +126,10 @@ function ThemesList({
const [importingTheme, showImportModal] = useState<boolean>(false);
const [appliedThemeId, setAppliedThemeId] = useState<number | null>(null);
// State for confirmation modal
const [confirmModalConfig, setConfirmModalConfig] =
useState<ConfirmModalConfig | null>(null);
const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
@@ -189,20 +207,23 @@ function ThemesList({
setThemeModalOpen(true);
}
function handleThemeApply(themeObj: ThemeObject) {
if (themeObj.json_data) {
try {
const themeConfig = JSON.parse(themeObj.json_data);
setTemporaryTheme(themeConfig);
setAppliedThemeId(themeObj.id || null);
addSuccessToast(t('Local theme set to "%s"', themeObj.theme_name));
} catch (error) {
addDangerToast(
t('Failed to set local theme: Invalid JSON configuration'),
);
const handleThemeApply = useCallback(
(themeObj: ThemeObject) => {
if (themeObj.json_data) {
try {
const themeConfig = JSON.parse(themeObj.json_data);
setTemporaryTheme(themeConfig);
setAppliedThemeId(themeObj.id || null);
addSuccessToast(t('Local theme set to "%s"', themeObj.theme_name));
} catch (error) {
addDangerToast(
t('Failed to set local theme: Invalid JSON configuration'),
);
}
}
}
}
},
[setTemporaryTheme, addSuccessToast, addDangerToast],
);
function handleThemeModalApply() {
// Clear any previously applied theme ID when applying from modal
@@ -235,60 +256,83 @@ function ThemesList({
};
// Generic confirmation modal utility to reduce code duplication
const showThemeConfirmation = (config: {
title: string;
content: string;
onConfirm: () => Promise<any>;
successMessage: string;
errorMessage: string;
}) => {
Modal.confirm({
title: config.title,
content: config.content,
onOk: () => {
config
.onConfirm()
.then(() => {
refreshData();
addSuccessToast(config.successMessage);
})
.catch(err => {
addDangerToast(t(config.errorMessage, err.message));
});
},
});
const showThemeConfirmation = useCallback(
(config: {
title: string;
content: string;
onConfirm: () => Promise<any>;
successMessage: string;
errorMessage: string;
}) => {
setConfirmModalConfig({
visible: true,
title: config.title,
message: config.content,
onConfirm: config.onConfirm,
successMessage: config.successMessage,
errorMessage: config.errorMessage,
});
},
[],
);
const handleConfirmModalOk = async () => {
if (!confirmModalConfig) return;
try {
await confirmModalConfig.onConfirm();
refreshData();
addSuccessToast(confirmModalConfig.successMessage);
setConfirmModalConfig(null);
} catch (err: any) {
addDangerToast(t(confirmModalConfig.errorMessage, err.message));
}
};
const handleSetSystemDefault = (theme: ThemeObject) => {
showThemeConfirmation({
title: t('Set System Default Theme'),
content: t(
'Are you sure you want to set "%s" as the system default theme? This will apply to all users who haven\'t set a personal preference.',
theme.theme_name,
),
onConfirm: () => setSystemDefaultTheme(theme.id!),
successMessage: t(
'"%s" is now the system default theme',
theme.theme_name,
),
errorMessage: 'Failed to set system default theme: %s',
});
const handleConfirmModalCancel = () => {
setConfirmModalConfig(null);
};
const handleSetSystemDark = (theme: ThemeObject) => {
showThemeConfirmation({
title: t('Set System Dark Theme'),
content: t(
'Are you sure you want to set "%s" as the system dark theme? This will apply to all users who haven\'t set a personal preference.',
theme.theme_name,
),
onConfirm: () => setSystemDarkTheme(theme.id!),
successMessage: t('"%s" is now the system dark theme', theme.theme_name),
errorMessage: 'Failed to set system dark theme: %s',
});
};
const handleSetSystemDefault = useCallback(
(theme: ThemeObject) => {
showThemeConfirmation({
title: t('Set System Default Theme'),
content: t(
'Are you sure you want to set "%s" as the system default theme? This will apply to all users who haven\'t set a personal preference.',
theme.theme_name,
),
onConfirm: () => setSystemDefaultTheme(theme.id!),
successMessage: t(
'"%s" is now the system default theme',
theme.theme_name,
),
errorMessage: 'Failed to set system default theme: %s',
});
},
[showThemeConfirmation],
);
const handleUnsetSystemDefault = () => {
const handleSetSystemDark = useCallback(
(theme: ThemeObject) => {
showThemeConfirmation({
title: t('Set System Dark Theme'),
content: t(
'Are you sure you want to set "%s" as the system dark theme? This will apply to all users who haven\'t set a personal preference.',
theme.theme_name,
),
onConfirm: () => setSystemDarkTheme(theme.id!),
successMessage: t(
'"%s" is now the system dark theme',
theme.theme_name,
theme.theme_name,
),
errorMessage: 'Failed to set system dark theme: %s',
});
},
[showThemeConfirmation],
);
const handleUnsetSystemDefault = useCallback(() => {
showThemeConfirmation({
title: t('Remove System Default Theme'),
content: t(
@@ -298,9 +342,9 @@ function ThemesList({
successMessage: t('System default theme removed'),
errorMessage: 'Failed to remove system default theme: %s',
});
};
}, [showThemeConfirmation]);
const handleUnsetSystemDark = () => {
const handleUnsetSystemDark = useCallback(() => {
showThemeConfirmation({
title: t('Remove System Dark Theme'),
content: t(
@@ -310,7 +354,7 @@ function ThemesList({
successMessage: t('System dark theme removed'),
errorMessage: 'Failed to remove system dark theme: %s',
});
};
}, [showThemeConfirmation]);
const initialSort = [{ id: 'theme_name', desc: true }];
const columns = useMemo(
@@ -340,16 +384,16 @@ function ThemesList({
)}
{original.is_system_default && (
<Tooltip title={t('This is the system default theme')}>
<Tag color="warning">
<Icons.SunOutlined /> {t('Default')}
</Tag>
<IconTag color="warning" icon={<Icons.SunOutlined />}>
{t('Default')}
</IconTag>
</Tooltip>
)}
{original.is_system_dark && (
<Tooltip title={t('This is the system dark theme')}>
<Tag color="default">
<Icons.MoonOutlined /> {t('Dark')}
</Tag>
<IconTag color="default" icon={<Icons.MoonOutlined />}>
{t('Dark')}
</IconTag>
</Tooltip>
)}
</FlexRowContainer>
@@ -487,12 +531,19 @@ function ThemesList({
},
],
[
canEdit,
canDelete,
canCreate,
canApply,
canExport,
canSetSystemThemes,
getCurrentCrudThemeId,
appliedThemeId,
canSetSystemThemes,
addDangerToast,
handleThemeApply,
handleSetSystemDefault,
handleUnsetSystemDefault,
handleSetSystemDark,
handleUnsetSystemDark,
],
);
@@ -570,7 +621,7 @@ function ThemesList({
paginate: true,
},
],
[],
[user],
);
return (
@@ -681,6 +732,17 @@ function ThemesList({
}}
</ConfirmStatusChange>
{preparingExport && <Loading />}
{confirmModalConfig?.visible && (
<Modal
title={confirmModalConfig.title}
show={confirmModalConfig.visible}
onHide={handleConfirmModalCancel}
onHandledPrimaryAction={handleConfirmModalOk}
primaryButtonName={t('Yes')}
>
{confirmModalConfig.message}
</Modal>
)}
</>
);
}
+96 -64
View File
@@ -18,18 +18,15 @@
*/
import {
type AnyThemeConfig,
type SupersetTheme,
type SupersetThemeConfig,
type ThemeControllerOptions,
type ThemeStorage,
isThemeConfigDark,
Theme,
ThemeMode,
themeObject as supersetThemeObject,
} from '@superset-ui/core';
import {
getAntdConfig,
normalizeThemeConfig,
} from '@superset-ui/core/theme/utils';
import { normalizeThemeConfig } from '@superset-ui/core/theme/utils';
import type {
BootstrapThemeData,
BootstrapThemeDataConfig,
@@ -79,7 +76,7 @@ export class ThemeController {
private modeStorageKey: string;
private defaultTheme: AnyThemeConfig;
private defaultTheme: AnyThemeConfig | null;
private darkTheme: AnyThemeConfig | null;
@@ -87,8 +84,6 @@ export class ThemeController {
private currentMode: ThemeMode;
private hasCustomThemes: boolean;
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
private mediaQuery: MediaQueryList;
@@ -116,22 +111,13 @@ export class ThemeController {
this.globalTheme = themeObject;
// Initialize bootstrap data and themes
const {
bootstrapDefaultTheme,
bootstrapDarkTheme,
hasCustomThemes,
}: BootstrapThemeData = this.loadBootstrapData();
const { bootstrapDefaultTheme, bootstrapDarkTheme }: BootstrapThemeData =
this.loadBootstrapData();
this.hasCustomThemes = hasCustomThemes;
// Set themes based on bootstrap data availability
if (this.hasCustomThemes) {
this.darkTheme = bootstrapDarkTheme;
this.defaultTheme = bootstrapDefaultTheme || defaultTheme;
} else {
this.darkTheme = null;
this.defaultTheme = defaultTheme;
}
// Set themes from bootstrap data
// These will be the THEME_DEFAULT and THEME_DARK from config
this.defaultTheme = bootstrapDefaultTheme || defaultTheme || null;
this.darkTheme = bootstrapDarkTheme;
// Initialize system theme detection
this.systemMode = ThemeController.getSystemPreferredMode();
@@ -147,7 +133,7 @@ export class ThemeController {
// Initialize theme and mode
this.currentMode = this.determineInitialMode();
const initialTheme =
this.getThemeForMode(this.currentMode) || this.defaultTheme;
this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
// Setup change callback
if (onChange) this.onChangeCallbacks.add(onChange);
@@ -197,6 +183,7 @@ export class ThemeController {
/**
* Gets the theme configuration for a specific context (global vs dashboard).
* Dashboard themes are always merged with base theme.
* @param forDashboard - Whether to get the dashboard theme or global theme
* @returns The theme configuration for the specified context
*/
@@ -205,7 +192,16 @@ export class ThemeController {
): AnyThemeConfig | null {
// For dashboard context, prioritize dashboard CRUD theme
if (forDashboard && this.dashboardCrudTheme) {
return this.dashboardCrudTheme;
// Dashboard CRUD themes should be merged with base theme
const normalizedTheme = this.normalizeTheme(this.dashboardCrudTheme);
const isDarkMode = isThemeConfigDark(normalizedTheme);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
if (baseTheme) {
const mergedTheme = Theme.fromConfig(normalizedTheme, baseTheme);
return mergedTheme.toSerializedConfig();
}
return normalizedTheme;
}
// For global context or when no dashboard theme, use mode-based theme
@@ -241,7 +237,15 @@ export class ThemeController {
// Controller creates and owns the dashboard theme
const { Theme } = await import('@superset-ui/core');
const normalizedConfig = this.normalizeTheme(themeConfig);
const dashboardTheme = Theme.fromConfig(normalizedConfig);
// Determine if this is a dark theme and get appropriate base
const isDarkMode = isThemeConfigDark(normalizedConfig);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
const dashboardTheme = Theme.fromConfig(
normalizedConfig,
baseTheme || undefined,
);
// Cache the theme for reuse
this.dashboardThemes.set(themeId, dashboardTheme);
@@ -325,7 +329,7 @@ export class ThemeController {
public resetTheme(): void {
this.currentMode = ThemeMode.DEFAULT;
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme;
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
this.updateTheme(defaultTheme);
}
@@ -373,8 +377,8 @@ export class ThemeController {
JSON.stringify(theme),
);
const normalizedTheme = this.normalizeTheme(theme);
this.updateTheme(normalizedTheme);
const mergedTheme = this.getThemeForMode(this.currentMode);
if (mergedTheme) this.updateTheme(mergedTheme);
}
/**
@@ -384,10 +388,14 @@ export class ThemeController {
public clearLocalOverrides(): void {
this.devThemeOverride = null;
this.crudThemeId = null;
this.dashboardCrudTheme = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
// Clear dashboard themes cache
this.dashboardThemes.clear();
this.resetTheme();
}
@@ -407,7 +415,7 @@ export class ThemeController {
/**
* Checks if OS preference detection is allowed.
* Allowed when both themes are available
* Allowed when dark theme is available (including base dark theme)
*/
public canDetectOSPreference(): boolean {
return this.darkTheme !== null;
@@ -422,7 +430,6 @@ export class ThemeController {
public setThemeConfig(config: SupersetThemeConfig): void {
this.defaultTheme = config.theme_default;
this.darkTheme = config.theme_dark || null;
this.hasCustomThemes = true;
let newMode: ThemeMode;
try {
@@ -478,13 +485,16 @@ export class ThemeController {
private updateTheme(theme?: AnyThemeConfig): void {
try {
// If no config provided, use current mode to get theme
const config: AnyThemeConfig =
theme || this.getThemeForMode(this.currentMode) || this.defaultTheme;
if (!theme) {
// No theme provided, use the current mode's theme
const modeTheme =
this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
this.applyTheme(modeTheme);
} else {
// Theme provided, apply it directly
this.applyTheme(theme);
}
// Normalize the theme
const normalizedTheme = this.normalizeTheme(config);
this.applyTheme(normalizedTheme);
this.persistMode();
this.notifyListeners();
} catch (error) {
@@ -501,7 +511,7 @@ export class ThemeController {
// Get the default theme which will have the correct algorithm
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme;
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
this.applyTheme(defaultTheme);
this.persistMode();
@@ -554,10 +564,15 @@ export class ThemeController {
const hasValidDefault: boolean = this.isNonEmptyObject(defaultTheme);
const hasValidDark: boolean = this.isNonEmptyObject(darkTheme);
// Check if themes have actual custom tokens (not just empty or algorithm-only)
const hasCustomDefault =
hasValidDefault && !this.isEmptyTheme(defaultTheme);
const hasCustomDark = hasValidDark && !this.isEmptyTheme(darkTheme);
return {
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
hasCustomThemes: hasValidDefault || hasValidDark,
bootstrapDefaultTheme: hasCustomDefault ? defaultTheme : null,
bootstrapDarkTheme: hasCustomDark ? darkTheme : null,
hasCustomThemes: hasCustomDefault || hasCustomDark,
};
}
@@ -572,6 +587,20 @@ export class ThemeController {
);
}
/**
* Checks if a theme is truly empty (not even an algorithm).
* A theme with just an algorithm is still valid and should be used.
*/
private isEmptyTheme(theme: AnyThemeConfig | undefined): boolean {
if (!theme) return true;
return !(
theme.algorithm ||
(theme.token && Object.keys(theme.token).length > 0) ||
(theme.components && Object.keys(theme.components).length > 0)
);
}
/**
* Normalizes the theme configuration to ensure it has a valid algorithm.
* @param theme - The theme configuration to normalize
@@ -588,49 +617,45 @@ export class ThemeController {
* @returns The theme configuration for the specified mode or null if not available
*/
private getThemeForMode(mode: ThemeMode): AnyThemeConfig | null {
// Priority 1: Dev theme override (highest priority for development)
// Dev overrides affect all contexts
if (this.devThemeOverride) {
return this.devThemeOverride;
const normalizedOverride = this.normalizeTheme(this.devThemeOverride);
const isDarkMode = isThemeConfigDark(normalizedOverride);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
if (baseTheme) {
const mergedTheme = Theme.fromConfig(normalizedOverride, baseTheme);
return mergedTheme.toSerializedConfig();
}
return normalizedOverride;
}
// Priority 2: System theme based on mode (applies to all contexts)
let resolvedMode: ThemeMode = mode;
if (mode === ThemeMode.SYSTEM) {
// OS preference is allowed when dark theme exists
if (this.darkTheme === null) return null;
resolvedMode = ThemeController.getSystemPreferredMode();
}
if (!this.hasCustomThemes) {
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
}
if (resolvedMode === ThemeMode.DARK) return this.darkTheme;
// Handle bootstrap themes using existing normalization
const selectedTheme: AnyThemeConfig =
resolvedMode === ThemeMode.DARK
? this.darkTheme || this.defaultTheme
: this.defaultTheme;
return selectedTheme;
return this.defaultTheme;
}
/**
* Determines the initial theme mode with error recovery.
*/
private determineInitialMode(): ThemeMode {
// Try to restore saved mode first
const savedMode: ThemeMode | null = this.loadSavedMode();
if (savedMode && this.isValidThemeMode(savedMode)) return savedMode;
// If no dark theme is available, force default mode
if (this.darkTheme === null) {
this.storage.removeItem(this.modeStorageKey);
return ThemeMode.DEFAULT;
}
// Try to restore saved mode
const savedMode: ThemeMode | null = this.loadSavedMode();
if (savedMode && this.isValidThemeMode(savedMode)) return savedMode;
// Default to system preference when both themes are available
return ThemeMode.SYSTEM;
}
@@ -663,11 +688,14 @@ export class ThemeController {
// Validate that we have the required theme data for the mode
switch (mode) {
case ThemeMode.DARK:
return !!(this.darkTheme || this.defaultTheme);
// Dark mode is valid if we have a dark theme
return !!this.darkTheme;
case ThemeMode.DEFAULT:
// Default mode is valid if we have a default theme
return !!this.defaultTheme;
case ThemeMode.SYSTEM:
return this.darkTheme !== null;
// System mode is valid if dark mode is available
return !!this.darkTheme;
default:
return true;
}
@@ -698,11 +726,15 @@ export class ThemeController {
* Applies the current theme configuration to the global theme.
* This method sets the theme on the globalTheme and applies it to the Theme.
* It also handles any errors that may occur during the application of the theme.
* @param theme - The theme configuration to apply
* @param theme - The theme configuration to apply (may already include base theme tokens)
*/
private applyTheme(theme: AnyThemeConfig): void {
try {
const normalizedConfig = normalizeThemeConfig(theme);
// Simply apply the theme - it should already be properly merged if needed
// The merging with base theme happens in getThemeForMode() and other methods
// that prepare themes before passing them to applyTheme()
this.globalTheme.setConfig(normalizedConfig);
} catch (error) {
console.error('Failed to apply theme:', error);
+322 -1102
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -18,8 +18,8 @@
"license": "Apache-2.0",
"dependencies": {
"cookie": "^1.0.2",
"hot-shots": "^11.1.0",
"ioredis": "^5.6.1",
"hot-shots": "^11.2.0",
"ioredis": "^5.8.0",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"uuid": "^11.1.0",
@@ -29,7 +29,7 @@
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/ioredis": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash": "^4.17.20",
@@ -38,17 +38,17 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.42.0",
"eslint": "^9.34.0",
"eslint": "^9.36.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.3.0",
"globals": "^16.4.0",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.1",
"ts-node": "^10.9.2",
"tscw-config": "^1.1.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.19.0"
"typescript-eslint": "^8.45.0"
},
"engines": {
"node": "^20.19.4",
@@ -189,7 +189,9 @@ class TestConnectionDatabaseCommand(BaseCommand):
engine=database.db_engine_spec.__name__,
)
# check for custom errors (wrong username, wrong password, etc)
errors = database.db_engine_spec.extract_errors(ex, self._context)
errors = database.db_engine_spec.extract_errors(
ex, self._context, database_name=database.unique_name
)
raise SupersetErrorsException(errors, status=400) from ex
except OAuth2RedirectError:
raise
+3 -1
View File
@@ -124,7 +124,9 @@ class ValidateDatabaseParametersCommand(BaseCommand):
"username": url.username,
"database": url.database,
}
errors = database.db_engine_spec.extract_errors(ex, context)
errors = database.db_engine_spec.extract_errors(
ex, context, database_name=database.unique_name
)
raise DatabaseTestConnectionFailedError(errors, status=400) from ex
if not alive:
+5 -4
View File
@@ -129,6 +129,11 @@ class QueryContextProcessor:
self, query_obj: QueryObject, force_cached: bool | None = False
) -> dict[str, Any]:
"""Handles caching around the df payload retrieval"""
if query_obj:
# Always validate the query object before generating cache key
# This ensures sanitize_clause() is called and extras are normalized
query_obj.validate()
cache_key = self.query_cache_key(query_obj)
timeout = self.get_cache_timeout()
force_query = self._query_context.force or timeout == CACHE_DISABLED_TIMEOUT
@@ -139,10 +144,6 @@ class QueryContextProcessor:
force_cached=force_cached,
)
if query_obj:
# Always validate the query object before processing
query_obj.validate()
if query_obj and cache_key and not cache.is_loaded:
try:
if invalid_columns := [
+67 -36
View File
@@ -36,7 +36,7 @@ from contextlib import contextmanager
from datetime import timedelta
from email.mime.multipart import MIMEMultipart
from importlib.resources import files
from typing import Any, Callable, Iterator, Literal, TYPE_CHECKING, TypedDict
from typing import Any, Callable, Iterator, Literal, Optional, TYPE_CHECKING, TypedDict
import click
from celery.schedules import crontab
@@ -724,46 +724,69 @@ COMMON_BOOTSTRAP_OVERRIDES_FUNC: Callable[ # noqa: E731
# This is merely a default
EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
# ---------------------------------------------------
# Theme Configuration for Superset
# ---------------------------------------------------
# -----------------------------------------------------------------------------
# Theme System Configuration
# -----------------------------------------------------------------------------
# Superset supports custom theming through Ant Design's theme structure.
# This allows users to customize colors, fonts, and other UI elements.
#
# Theme Generation:
# Theme Hierarchy:
# 1. THEME_DEFAULT/THEME_DARK - Base themes defined in config (foundation)
# 2. System themes - Set by admins via UI (when ENABLE_UI_THEME_ADMINISTRATION=True)
# 3. Dashboard themes - Applied per dashboard using the theme bolt button
#
# How it works:
# - Custom themes override base themes for any properties they define
# - Properties not defined in custom themes use the base theme values
# - Admins can set system-wide themes that apply to all users
# - Users can apply specific themes to individual dashboards
#
# Theme Creation:
# - Use the Ant Design theme editor: https://ant.design/theme-editor
# - Export or copy the generated theme JSON and assign to the variables below
# - For detailed instructions: https://superset.apache.org/docs/configuration/theming/
#
# To expose a JSON theme editor modal that can be triggered from the navbar
# set the `ENABLE_THEME_EDITOR` feature flag to True.
#
# Theme Structure:
# Each theme should follow Ant Design's theme format.
# To create custom themes, use the Ant Design Theme Editor at https://ant.design/theme-editor
# and copy the generated JSON configuration.
#
# Example theme definition:
# THEME_DEFAULT = {
# "token": {
# "colorPrimary": "#2893B3",
# "colorSuccess": "#5ac189",
# "colorWarning": "#fcc700",
# "colorError": "#e04355",
# "fontFamily": "'Inter', Helvetica, Arial",
# ... # other tokens
# },
# ... # other theme properties
# }
# - Export the generated JSON and use it in your theme configuration
# -----------------------------------------------------------------------------
# Default theme configuration - foundation for all themes
# This acts as the base theme for all users
THEME_DEFAULT: Theme = {
"token": {
# Brand
"brandLogoAlt": "Apache Superset",
"brandLogoUrl": APP_ICON,
"brandLogoMargin": "18px",
"brandLogoHref": "/",
"brandLogoHeight": "24px",
# Spinner
"brandSpinnerUrl": None,
"brandSpinnerSvg": None,
# Default colors
"colorPrimary": "#2893B3", # NOTE: previous lighter primary color was #20a7c9 # noqa: E501
"colorLink": "#2893B3",
"colorError": "#e04355",
"colorWarning": "#fcc700",
"colorSuccess": "#5ac189",
"colorInfo": "#66bcfe",
# Fonts
"fontFamily": "Inter, Helvetica, Arial",
"fontFamilyCode": "'Fira Code', 'Courier New', monospace",
# Extra tokens
"transitionTiming": 0.3,
"brandIconMaxWidth": 37,
"fontSizeXS": "8",
"fontSizeXXL": "28",
"fontWeightNormal": "400",
"fontWeightLight": "300",
"fontWeightStrong": "500",
},
"algorithm": "default",
}
# Default theme configuration
# Leave empty to use Superset's default theme
THEME_DEFAULT: Theme = {"algorithm": "default"}
# Dark theme configuration
# Applied when user selects dark mode
THEME_DARK: Theme = {"algorithm": "dark"}
# Dark theme configuration - foundation for dark mode
# Inherits all tokens from THEME_DEFAULT and adds dark algorithm
# Set to None to disable dark mode
THEME_DARK: Optional[Theme] = {
**THEME_DEFAULT,
"algorithm": "dark",
}
# Theme behavior and user preference settings
# To force a single theme on all users, set THEME_DARK = None
@@ -2173,6 +2196,14 @@ CATALOGS_SIMPLIFIED_MIGRATION: bool = False
# keeping a web API call open for this long.
SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: bool = False
# CUSTOM_DATABASE_ERRORS: Configure custom error messages for database exceptions
# in superset/custom_database_errors.py.
# Transform raw database errors into user-friendly messages with optional documentation
try:
from superset.custom_database_errors import CUSTOM_DATABASE_ERRORS
except ImportError:
CUSTOM_DATABASE_ERRORS = {}
LOCAL_EXTENSIONS: list[str] = []
EXTENSIONS_PATH: str | None = None
+33 -18
View File
@@ -62,7 +62,7 @@ from sqlalchemy.orm import (
)
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.sql import column, ColumnElement, literal_column, table
from sqlalchemy.sql import column, ColumnElement, literal_column, quoted_name, table
from sqlalchemy.sql.elements import ColumnClause, TextClause
from sqlalchemy.sql.expression import Label
from sqlalchemy.sql.selectable import Alias, TableClause
@@ -1403,13 +1403,19 @@ class SqlaTable(
# project.dataset.table format
if self.catalog and self.database.db_engine_spec.supports_cross_catalog_queries:
# SQLAlchemy doesn't have built-in catalog support for TableClause,
# so we need to construct the full identifier manually
if self.schema:
full_name = f"{self.catalog}.{self.schema}.{self.table_name}"
else:
full_name = f"{self.catalog}.{self.table_name}"
# so we need to construct the full identifier manually with proper quoting
catalog_quoted = self.quote_identifier(self.catalog)
table_quoted = self.quote_identifier(self.table_name)
return table(full_name)
if self.schema:
schema_quoted = self.quote_identifier(self.schema)
full_name = f"{catalog_quoted}.{schema_quoted}.{table_quoted}"
else:
full_name = f"{catalog_quoted}.{table_quoted}"
# Use quoted_name with quote=False to prevent SQLAlchemy from re-quoting
# the already-quoted identifier components
return table(quoted_name(full_name, quote=False))
if self.schema:
return table(self.table_name, schema=self.schema)
@@ -1430,6 +1436,7 @@ class SqlaTable(
metric: AdhocMetric,
columns_by_name: dict[str, TableColumn],
template_processor: BaseTemplateProcessor | None = None,
processed: bool = False,
) -> ColumnElement:
"""
Turn an adhoc metric into a sqlalchemy column.
@@ -1437,6 +1444,7 @@ class SqlaTable(
:param dict metric: Adhoc metric definition
:param dict columns_by_name: Columns for the current table
:param template_processor: template_processor instance
:param bool processed: Whether the sqlExpression has already been processed
:returns: The metric defined as a sqlalchemy column
:rtype: sqlalchemy.sql.column
"""
@@ -1455,16 +1463,20 @@ class SqlaTable(
sqla_column = column(column_name)
sqla_metric = self.sqla_aggregations[metric["aggregate"]](sqla_column)
elif expression_type == utils.AdhocMetricExpressionType.SQL:
try:
expression = self._process_sql_expression(
expression=metric["sqlExpression"],
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
template_processor=template_processor,
)
except SupersetSecurityException as ex:
raise QueryObjectValidationError(ex.message) from ex
expression = metric.get("sqlExpression")
if not processed:
try:
expression = self._process_sql_expression(
expression=expression,
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
template_processor=template_processor,
)
except SupersetSecurityException as ex:
raise QueryObjectValidationError(ex.message) from ex
sqla_metric = literal_column(expression)
else:
raise QueryObjectValidationError("Adhoc metric expressionType is invalid")
@@ -1642,7 +1654,10 @@ class SqlaTable(
)
db_engine_spec = self.db_engine_spec
errors = [
dataclasses.asdict(error) for error in db_engine_spec.extract_errors(ex)
dataclasses.asdict(error)
for error in db_engine_spec.extract_errors(
ex, database_name=self.database.unique_name
)
]
error_message = utils.error_msg_from_exception(ex)
+83
View File
@@ -0,0 +1,83 @@
# 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 re
from typing import Any
from flask_babel import gettext as __
from superset.errors import SupersetErrorType
# CUSTOM_DATABASE_ERRORS: Configure custom error messages for database exceptions.
# Transform raw database errors into user-friendly messages with optional documentation
# links using custom_doc_links. Set show_issue_info=False to hide default error codes.
# Example:
# CUSTOM_DATABASE_ERRORS = {
# "database_name": {
# re.compile('permission denied for view'): (
# __(
# 'Permission denied'
# ),
# SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
# {
# "custom_doc_links": [
# {
# "url": "https://example.com/docs/1",
# "label": "Check documentation"
# },
# ],
# "show_issue_info": False,
# }
# )
# },
# "examples": {
# re.compile(r'message="(?P<message>[^"]*)"'): (
# __(
# 'Unexpected error: "%(message)s"'
# ),
# SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
# {}
# )
# }
# }
CUSTOM_DATABASE_ERRORS: dict[
str, dict[re.Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]]
] = {
"examples": {
re.compile("no such table: a"): (
__("This is custom error message for a"),
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{
"custom_doc_links": [
{
"url": "https://example.com/docs/1",
"label": "Custom documentation link",
},
],
"show_issue_info": False,
},
),
re.compile("no such table: b"): (
__("This is custom error message for b"),
SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
{
"show_issue_info": True,
},
),
}
}
+10 -33
View File
@@ -32,11 +32,11 @@ class ThemeDAO(BaseDAO[Theme]):
@classmethod
def find_system_default(cls) -> Optional[Theme]:
"""Find the current system default theme.
First looks for a theme with is_system_default=True.
If not found or multiple found, falls back to is_system=True theme
with name 'THEME_DEFAULT'.
"""
Find the current system default theme.
Returns the theme with is_system_default=True if exactly one exists.
Returns None if no theme or multiple themes have
is_system_default=True, which triggers fallback to config.py theme.
"""
system_defaults = (
db.session.query(Theme).filter(Theme.is_system_default.is_(True)).all()
@@ -45,27 +45,15 @@ class ThemeDAO(BaseDAO[Theme]):
if len(system_defaults) == 1:
return system_defaults[0]
if len(system_defaults) > 1:
logger.warning(
"Multiple system default themes found (%s), "
"falling back to config theme",
len(system_defaults),
)
# Fallback to is_system=True theme with name 'THEME_DEFAULT'
return (
db.session.query(Theme)
.filter(Theme.is_system.is_(True), Theme.theme_name == "THEME_DEFAULT")
.first()
)
return None
@classmethod
def find_system_dark(cls) -> Optional[Theme]:
"""Find the current system dark theme.
First looks for a theme with is_system_dark=True.
If not found or multiple found, falls back to is_system=True theme
with name 'THEME_DARK'.
Returns the theme with is_system_dark=True if exactly one exists.
Returns None if no theme or multiple themes have is_system_dark=True,
which triggers fallback to config.py theme.
"""
system_darks = (
db.session.query(Theme).filter(Theme.is_system_dark.is_(True)).all()
@@ -74,15 +62,4 @@ class ThemeDAO(BaseDAO[Theme]):
if len(system_darks) == 1:
return system_darks[0]
if len(system_darks) > 1:
logger.warning(
"Multiple system dark themes found (%s), falling back to config theme",
len(system_darks),
)
# Fallback to is_system=True theme with name 'THEME_DARK'
return (
db.session.query(Theme)
.filter(Theme.is_system.is_(True), Theme.theme_name == "THEME_DARK")
.first()
)
return None
+26 -3
View File
@@ -1326,21 +1326,44 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"""Extract error message for queries"""
return utils.error_msg_from_exception(ex)
@classmethod
def get_database_custom_errors(
cls, database_name: str | None
) -> dict[Any, tuple[str, SupersetErrorType, dict[str, Any]]]:
config_custom_errors = app.config.get("CUSTOM_DATABASE_ERRORS", {})
if not isinstance(config_custom_errors, dict):
return {}
if database_name and database_name in config_custom_errors:
database_errors = config_custom_errors[database_name]
if isinstance(database_errors, dict):
return database_errors
return {}
@classmethod
def extract_errors(
cls, ex: Exception, context: dict[str, Any] | None = None
cls,
ex: Exception,
context: dict[str, Any] | None = None,
database_name: str | None = None,
) -> list[SupersetError]:
raw_message = cls._extract_error_message(ex)
context = context or {}
for regex, (message, error_type, extra) in cls.custom_errors.items():
db_engine_custom_errors = cls.get_database_custom_errors(database_name)
for regex, (message, error_type, extra) in [
*db_engine_custom_errors.items(),
*cls.custom_errors.items(),
]:
if match := regex.search(raw_message):
params = {**context, **match.groupdict()}
extra["engine_name"] = cls.engine_name
formatted_message = (message % params) if message else raw_message
return [
SupersetError(
error_type=error_type,
message=message % params,
message=formatted_message,
level=ErrorLevel.ERROR,
extra=extra,
)
+13 -2
View File
@@ -296,11 +296,15 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
@classmethod
def extract_errors(
cls, ex: Exception, context: dict[str, Any] | None = None
cls,
ex: Exception,
context: dict[str, Any] | None = None,
database_name: str | None = None,
) -> list[SupersetError]:
raw_message = cls._extract_error_message(ex)
context = context or {}
# access_token isn't currently parseable from the
# databricks error response, but adding it in here
# for reference if their error message changes
@@ -308,7 +312,14 @@ class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngine
for key, value in cls.context_key_mapping.items():
context[key] = context.get(value)
for regex, (message, error_type, extra) in cls.custom_errors.items():
db_engine_custom_errors = cls.get_database_custom_errors(database_name)
if not isinstance(db_engine_custom_errors, dict):
db_engine_custom_errors = {}
for regex, (message, error_type, extra) in [
*db_engine_custom_errors.items(),
*cls.custom_errors.items(),
]:
match = regex.search(raw_message)
if match:
params = {**context, **match.groupdict()}
+3 -1
View File
@@ -116,7 +116,9 @@ class DorisEngineSpec(MySQLEngineSpec):
)
encryption_parameters = {"ssl": "0"}
supports_dynamic_schema = True
supports_catalog = supports_dynamic_catalog = supports_cross_catalog_queries = True
supports_catalog = supports_dynamic_catalog = True
# while technically supported by Doris, this generates invalid table identifiers
supports_cross_catalog_queries = False
column_type_mappings = ( # type: ignore
(
+58 -12
View File
@@ -871,6 +871,40 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
raise QueryObjectValidationError(ex.message) from ex
return expression
def _process_orderby_expression(
self,
expression: Optional[str],
database_id: int,
engine: str,
schema: str,
template_processor: Optional[BaseTemplateProcessor],
) -> Optional[str]:
"""
Validate and process an ORDER BY clause expression.
This requires prefixing the expression with a dummy SELECT statement, so it can
be properly parsed and validated.
"""
if expression:
expression = f"SELECT 1 ORDER BY {expression}"
if processed := self._process_sql_expression(
expression=expression,
database_id=database_id,
engine=engine,
schema=schema,
template_processor=template_processor,
):
prefix, expression = re.split(
r"ORDER\s+BY",
processed,
maxsplit=1,
flags=re.IGNORECASE,
)
return expression.strip()
return None
def make_sqla_column_compatible(
self, sqla_col: ColumnElement, label: Optional[str] = None
) -> ColumnElement:
@@ -1053,7 +1087,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
db_engine_spec = self.db_engine_spec
errors = [
dataclasses.asdict(error) for error in db_engine_spec.extract_errors(ex)
dataclasses.asdict(error)
for error in db_engine_spec.extract_errors(
ex, database_name=self.database.unique_name
)
]
error_message = utils.error_msg_from_exception(ex)
@@ -1139,6 +1176,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
metric: AdhocMetric,
columns_by_name: dict[str, "TableColumn"], # pylint: disable=unused-argument
template_processor: Optional[BaseTemplateProcessor] = None,
processed: bool = False,
) -> ColumnElement:
"""
Turn an adhoc metric into a sqlalchemy column.
@@ -1146,6 +1184,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
:param dict metric: Adhoc metric definition
:param dict columns_by_name: Columns for the current table
:param template_processor: template_processor instance
:param bool processed: Whether the sqlExpression has already been processed
:returns: The metric defined as a sqlalchemy column
:rtype: sqlalchemy.sql.column
"""
@@ -1158,13 +1197,17 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
sqla_column = sa.column(column_name)
sqla_metric = self.sqla_aggregations[metric["aggregate"]](sqla_column)
elif expression_type == utils.AdhocMetricExpressionType.SQL:
expression = self._process_sql_expression(
expression=metric["sqlExpression"],
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
template_processor=template_processor,
)
expression = metric.get("sqlExpression")
if not processed:
expression = self._process_sql_expression(
expression=metric["sqlExpression"],
database_id=self.database_id,
engine=self.database.backend,
schema=self.schema,
template_processor=template_processor,
)
sqla_metric = literal_column(expression)
else:
raise QueryObjectValidationError("Adhoc metric expressionType is invalid")
@@ -1779,7 +1822,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if isinstance(col, dict):
col = cast(AdhocMetric, col)
if col.get("sqlExpression"):
col["sqlExpression"] = self._process_sql_expression(
col["sqlExpression"] = self._process_orderby_expression(
expression=col["sqlExpression"],
database_id=self.database_id,
engine=self.database.backend,
@@ -1788,9 +1831,12 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
if utils.is_adhoc_metric(col):
# add adhoc sort by column to columns_by_name if not exists
col = self.adhoc_metric_to_sqla(col, columns_by_name)
# if the adhoc metric has been defined before
# use the existing instance.
col = self.adhoc_metric_to_sqla(
col,
columns_by_name,
processed=True,
)
# use the existing instance, if possible
col = metrics_exprs_by_expr.get(str(col), col)
need_groupby = True
elif col in metrics_exprs_by_label:
+3 -1
View File
@@ -367,7 +367,9 @@ class Slice( # pylint: disable=too-many-public-methods
return qry.one_or_none()
def id_or_uuid_filter(id_or_uuid: str) -> BinaryExpression:
def id_or_uuid_filter(id_or_uuid: str | int) -> BinaryExpression:
if isinstance(id_or_uuid, int):
return Slice.id == id_or_uuid
if id_or_uuid.isdigit():
return Slice.id == int(id_or_uuid)
return Slice.uuid == id_or_uuid
+2 -1
View File
@@ -17,5 +17,6 @@
from .dremio import Dremio
from .firebolt import Firebolt, FireboltOld
from .pinot import Pinot
__all__ = ["Dremio", "Firebolt", "FireboltOld"]
__all__ = ["Dremio", "Firebolt", "FireboltOld", "Pinot"]
+172
View File
@@ -0,0 +1,172 @@
# 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.
"""
MySQL ANSI dialect for Apache Pinot.
This dialect is based on MySQL but follows ANSI SQL quoting conventions where
double quotes are used for identifiers instead of string literals.
"""
from __future__ import annotations
from sqlglot import exp
from sqlglot.dialects.mysql import MySQL
from sqlglot.helper import seq_get
from sqlglot.tokens import TokenType
class Pinot(MySQL):
"""
MySQL ANSI dialect used by Apache Pinot.
The main difference from standard MySQL is that double quotes (") are used for
identifiers instead of string literals, following ANSI SQL conventions.
See: https://calcite.apache.org/javadocAggregate/org/apache/calcite/config/Lex.html#MYSQL_ANSI
"""
class Tokenizer(MySQL.Tokenizer):
QUOTES = ["'"] # Only single quotes for strings
IDENTIFIERS = ['"', "`"] # Backticks and double quotes for identifiers
STRING_ESCAPES = ["'", "\\"] # Remove double quote from string escapes
KEYWORDS = {
**MySQL.Tokenizer.KEYWORDS,
"STRING": TokenType.TEXT,
"LONG": TokenType.BIGINT,
"BYTES": TokenType.VARBINARY,
}
class Parser(MySQL.Parser):
FUNCTIONS = {
**MySQL.Parser.FUNCTIONS,
"DATE_ADD": lambda args: exp.DateAdd(
this=seq_get(args, 2),
expression=seq_get(args, 1),
unit=seq_get(args, 0),
),
"DATE_SUB": lambda args: exp.DateSub(
this=seq_get(args, 2),
expression=seq_get(args, 1),
unit=seq_get(args, 0),
),
}
class Generator(MySQL.Generator):
TYPE_MAPPING = {
**MySQL.Generator.TYPE_MAPPING,
exp.DataType.Type.TINYINT: "INT",
exp.DataType.Type.SMALLINT: "INT",
exp.DataType.Type.INT: "INT",
exp.DataType.Type.BIGINT: "LONG",
exp.DataType.Type.FLOAT: "FLOAT",
exp.DataType.Type.DOUBLE: "DOUBLE",
exp.DataType.Type.BOOLEAN: "BOOLEAN",
exp.DataType.Type.TIMESTAMP: "TIMESTAMP",
exp.DataType.Type.TIMESTAMPTZ: "TIMESTAMP",
exp.DataType.Type.VARCHAR: "STRING",
exp.DataType.Type.CHAR: "STRING",
exp.DataType.Type.TEXT: "STRING",
exp.DataType.Type.BINARY: "BYTES",
exp.DataType.Type.VARBINARY: "BYTES",
exp.DataType.Type.JSON: "JSON",
}
# Override MySQL's CAST_MAPPING - don't convert integer or string types
CAST_MAPPING = {
exp.DataType.Type.LONGBLOB: exp.DataType.Type.VARBINARY,
exp.DataType.Type.MEDIUMBLOB: exp.DataType.Type.VARBINARY,
exp.DataType.Type.TINYBLOB: exp.DataType.Type.VARBINARY,
exp.DataType.Type.UBIGINT: "UNSIGNED",
}
TRANSFORMS = {
**MySQL.Generator.TRANSFORMS,
exp.DateAdd: lambda self, e: self.func(
"DATE_ADD",
exp.Literal.string(str(e.args.get("unit").name)),
e.args.get("expression"),
e.this,
),
exp.DateSub: lambda self, e: self.func(
"DATE_SUB",
exp.Literal.string(str(e.args.get("unit").name)),
e.args.get("expression"),
e.this,
),
exp.Substring: lambda self, e: self.func(
"SUBSTR",
e.this,
e.args.get("start"),
e.args.get("length"),
),
exp.StrPosition: lambda self, e: self.func(
"STRPOS",
e.this,
e.args.get("substr"),
e.args.get("position"),
),
exp.StartsWith: lambda self, e: self.func(
"STARTSWITH",
e.this,
e.args.get("expression"),
),
exp.Chr: lambda self, e: self.func(
"CHR",
*e.args.get("expressions", []),
),
exp.Mod: lambda self, e: self.func(
"MOD",
e.this,
e.args.get("expression"),
),
exp.ArrayAgg: lambda self, e: self.func(
"ARRAY_AGG",
e.this,
),
exp.JSONExtractScalar: lambda self, e: self.func(
"JSON_EXTRACT_SCALAR",
e.this,
e.args.get("expression"),
e.args.get("variant"),
),
}
# Remove DATE_TRUNC transformation - Pinot supports standard SQL DATE_TRUNC
TRANSFORMS.pop(exp.DateTrunc, None)
def datatype_sql(self, expression: exp.DataType) -> str:
# Don't use MySQL's VARCHAR size requirement logic
# Just use TYPE_MAPPING for all types
type_value = expression.this
type_sql = (
self.TYPE_MAPPING.get(type_value, type_value.value)
if isinstance(type_value, exp.DataType.Type)
else type_value
)
interior = self.expressions(expression, flat=True)
nested = f"({interior})" if interior else ""
if expression.this in self.UNSIGNED_TYPE_MAPPING:
return f"{type_sql} UNSIGNED{nested}"
return f"{type_sql}{nested}"
def cast_sql(self, expression: exp.Cast, safe_prefix: str | None = None) -> str:
# Pinot doesn't support MySQL's TIMESTAMP() function
# Use standard CAST syntax instead
return super(MySQL.Generator, self).cast_sql(expression, safe_prefix)
+22 -11
View File
@@ -44,7 +44,7 @@ from sqlglot.optimizer.scope import (
)
from superset.exceptions import QueryClauseValidationException, SupersetParseError
from superset.sql.dialects import Dremio, Firebolt
from superset.sql.dialects import Dremio, Firebolt, Pinot
if TYPE_CHECKING:
from superset.models.core import Database
@@ -94,7 +94,7 @@ SQLGLOT_DIALECTS = {
# "odelasticsearch": ???
"oracle": Dialects.ORACLE,
"parseable": Dialects.POSTGRES,
"pinot": Dialects.MYSQL,
"pinot": Pinot,
"postgresql": Dialects.POSTGRES,
"presto": Dialects.PRESTO,
"pydoris": Dialects.DORIS,
@@ -552,14 +552,16 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
try:
statements = sqlglot.parse(script, dialect=dialect)
except sqlglot.errors.ParseError as ex:
error = ex.errors[0]
raise SupersetParseError(
script,
engine,
highlight=error["highlight"],
line=error["line"],
column=error["col"],
) from ex
kwargs = (
{
"highlight": ex.errors[0]["highlight"],
"line": ex.errors[0]["line"],
"column": ex.errors[0]["col"],
}
if ex.errors
else {}
)
raise SupersetParseError(script, engine, **kwargs) from ex
except sqlglot.errors.SqlglotError as ex:
raise SupersetParseError(
script,
@@ -1480,6 +1482,15 @@ def sanitize_clause(clause: str, engine: str) -> str:
Make sure the SQL clause is valid.
"""
try:
return SQLStatement(clause, engine).format()
statement = SQLStatement(clause, engine)
dialect = SQLGLOT_DIALECTS.get(engine)
from sqlglot.dialects.dialect import Dialect
return Dialect.get_or_raise(dialect).generate(
statement._parsed, # pylint: disable=protected-access
copy=True,
comments=False,
pretty=False,
)
except SupersetParseError as ex:
raise QueryClauseValidationException(f"Invalid SQL clause: {clause}") from ex

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