Compare commits

..
Author SHA1 Message Date
Enzo Martellucci 9dd0c3be2a Merge branch 'master' into enxdev/feat/dashboard-excel-export-sync-fallback 2026-09-09 18:13:00 +02:00
Enzo Martellucci 70b52908b9 fix(dashboard): honor default Excel export row limit 2026-09-09 18:05:40 +02:00
Enzo Martellucci 44869b6c96 docs(dashboard): simplify Excel export guidance 2026-09-09 17:54:28 +02:00
dependabot[bot] a78591be60 chore(deps-dev): bump oxfmt from 0.65.0 to 0.66.0 in /superset-websocket (#44049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 22:34:46 +07:00
Evan RusackasandClaude Sonnet 5 4dcc800b58 fix(security): use self-repository syntax for change-detector in integration tests (#44065)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:33:42 +07:00
Evan RusackasandClaude Sonnet 5 9267d8b666 fix(security): suppress zizmor self-repository false positive for playwright experimental step (#44064)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:33:33 +07:00
Evan RusackasandClaude Sonnet 5 b1c6401e82 fix(security): suppress zizmor self-repository false positive for mysql setup step (#44067)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:33:23 +07:00
Evan RusackasandClaude Sonnet 5 196fe87c54 fix(security): use self-repository syntax for setup-backend in mysql job (#44066)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:33:21 +07:00
Evan RusackasandClaude Sonnet 5 09522cc4b8 fix(security): suppress zizmor self-repository false positive for playwright test-data step (#44075)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:32:40 +07:00
Evan RusackasandClaude Sonnet 5 4aca0d7da4 fix(security): suppress zizmor self-repository false positive for playwright install step (#44074)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:32:29 +07:00
Evan RusackasandClaude Sonnet 5 ce700f389c fix(security): suppress zizmor self-repository false positive for postgres setup step (#44084)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:31:57 +07:00
Evan RusackasandClaude Sonnet 5 072a29ddb5 fix(security): suppress zizmor self-repository false positive for playwright build step (#44073)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:31:05 +07:00
ShaitanandClaude Opus 4.8 f73ccee782 fix(couchbase): remove debug prints that emit the connection URI to stdout (#44070)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 22:30:45 +07:00
Evan RusackasandClaude Sonnet 5 0fe58044cb fix(security): use self-repository syntax for setup-backend in Playwright workflow (#44085)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:29:56 +07:00
Evan RusackasandClaude Sonnet 5 4686d13379 fix(security): use self-repository syntax for change-detector in Playwright workflow (#44086)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:28:38 +07:00
dependabot[bot] 68528e3308 chore(deps-dev): bump jest-environment-jsdom from 30.5.0 to 30.5.1 in /superset-frontend (#44055)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 22:26:26 +07:00
Evan RusackasandClaude Sonnet 5 c442180328 fix(security): use self-repository syntax for chart-testing action ref (#44083)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:25:42 +07:00
Evan RusackasandClaude Sonnet 5 80ebfad094 fix(security): suppress zizmor self-repository false positive for npm install step (#44076)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:25:21 +07:00
Joe LiandClaude Fable 5.1 d1ad6aeda6 chore(docker): speed up image builds and the compose dev loop (#43916)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 22:24:37 +07:00
Enzo Martellucci d137fd1c06 fix(dashboard): address Excel export review findings 2026-09-09 17:09:57 +02:00
Michael S. MolinaandClaude Sonnet 5 7f06e4eb60 feat(table): add opt-in migration from Table V1 to Table V2 (#42088)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:08:05 -03:00
dependabot[bot] 07a8f8b984 chore(deps-dev): bump oxlint from 1.80.0 to 1.81.0 in /docs (#44052)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 07:50:48 -07:00
Enzo Martellucci ccb5fb279f Merge branch 'master' into enxdev/feat/dashboard-excel-export-sync-fallback 2026-09-09 16:44:55 +02:00
Amin Ghadersohi 08e686b535 fix(mcp): complete Gauge chart support follow-up (#43943) 2026-09-09 10:34:50 -04:00
Mafi 97329bb1cf fix(reports): detect perceptually blank screenshots (#44034) 2026-09-09 15:18:20 +02:00
Enzo MartellucciandClaude Opus 5 3108f41167 fix(dashboard): keep image exports queued, list skips in the workbook
Follow-up to the inline Excel export, addressing three gaps in it:

An image export draws every chart through the headless webdriver, one browser
session at a time. No row budget bounds that, so serving it inline was exactly
the gateway timeout the budget exists to prevent; without export storage it is
now refused with a message saying image exports run in the background.

The skipped-charts list moves into the workbook, on an "Export Summary" sheet
whenever any chart is skipped rather than only when every chart is. The list
then travels with the file however it is delivered, which is the only way an
export served as a download can report it -- and matches the sheet #43805 adds.

Query contexts are resolved once. The row budget already had to resolve them to
size the export, and the builder resolved them again to run it: with
EXCEL_EXPORT_QUERY_CONTEXT_BUILDER pointed at a service, that is both a doubled
cost and a real hazard, since one set of queries could be measured and a
different set run. The budget now returns an InlineExportPlan carrying what it
resolved, and the builder runs those contexts as-is.

The export action also reports progress: while a request is in flight both
Excel actions are disabled and the one clicked reads "Preparing export…",
restored when the file downloads, the queued message arrives, or it fails. The
server's lock prevents duplicate work but told the user nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:04:00 +02:00
4d8dfdd152 fix(sqllab): restrict TabStateView updates to owned columns and queries (#43992)
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:03:30 +01:00
57a92170bd fix(chart): apply the query-context datasource check on create and update (#43997)
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:03:14 +01:00
954dbc9ff6 fix(dataset): validate SQL table access on virtual dataset import (#43999)
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:02:52 +01:00
ShaitanandClaude Opus 4.8 7b83ed8374 fix(mcp): align execute_sql table-access checks with the SQL Lab path (#43998)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:02:35 +01:00
Enzo MartellucciandClaude Opus 5 49b9347974 fix(charts): preserve dataset conversion when saving SQL Lab charts (#43988)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:07:50 +02:00
dependabot[bot] df95ab23ec chore(deps-dev): bump @types/node from 26.4.0 to 26.4.1 in /superset-websocket (#44046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:47:29 -07:00
dependabot[bot] e43f44e91e chore(deps-dev): bump @types/node from 26.4.0 to 26.4.1 in /superset-embedded-sdk (#44048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:47:24 -07:00
dependabot[bot] d38f20e5ad chore(deps): bump query-string from 9.5.0 to 9.5.1 in /superset-frontend (#44061)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:59 -07:00
dependabot[bot] 0a412b07cb chore(deps-dev): bump webpack from 5.110.2 to 5.110.3 in /superset-embedded-sdk (#44050)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:28 -07:00
dependabot[bot] 5e99f14ac9 chore(deps-dev): bump oxlint from 1.80.0 to 1.81.0 in /superset-websocket (#44051)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:23 -07:00
dependabot[bot] ea287c1ab0 chore(deps-dev): bump webpack from 5.110.2 to 5.110.3 in /docs (#44053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:16 -07:00
dependabot[bot] e170822a28 chore(deps-dev): bump oxfmt from 0.65.0 to 0.66.0 in /docs (#44054)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:13 -07:00
dependabot[bot] 677e9189f1 chore(deps-dev): bump oxfmt from 0.65.0 to 0.66.0 in /superset-frontend (#44056)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:46:02 -07:00
dependabot[bot] 638f87de5f chore(deps-dev): bump jest from 30.5.0 to 30.5.1 in /superset-frontend (#44058)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:45:51 -07:00
dependabot[bot] cb2188aec3 chore(deps-dev): bump babel-jest from 30.5.0 to 30.5.1 in /superset-frontend (#44059)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:45:43 -07:00
dependabot[bot] 176a50d254 chore(deps-dev): bump webpack from 5.110.2 to 5.110.3 in /superset-frontend (#44060)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:45:37 -07:00
dependabot[bot] 010c7a12ed chore(deps): bump query-string from 9.5.0 to 9.5.1 in /superset-frontend (#44061)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:45:27 -07:00
dependabot[bot] de08dc207c chore(deps-dev): bump @types/node from 26.4.0 to 26.4.1 in /superset-frontend (#44062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 04:45:17 -07:00
Enzo MartellucciandClaude Opus 5 f64b45f2c4 feat(dashboard): stream the Excel export inline when no storage is configured
"Export Data to Excel" was only usable where EXCEL_EXPORT_S3_BUCKET was set:
everywhere else the menu item rendered, the endpoint answered 501, and the
click produced nothing but an error toast.

The endpoint now picks its execution path from that same config rather than
refusing. With a bucket it queues the export and answers 202 with a job id,
unchanged. Without one it builds the workbook during the request and returns
it as the response, so the browser downloads the file directly -- no worker,
bucket or email required.

An export served this way has to finish inside one request, so its size is
settled before any query runs: the new EXCEL_EXPORT_SYNC_MAX_ROWS (100,000)
caps the combined row_limit of every query the export would run, and an export
over that total -- or one where any query has no finite limit -- is refused with
a 400 naming the bucket as the fix, rather than being started and left to hit
the request timeout.

Both paths build the same workbook from the same code: the builder moves out of
the Celery task into superset.dashboards.excel_export.workbook, leaving the task
to own only queueing, upload, email and its own error handling. Both acquire the
same per-user+dashboard lock; the inline path releases it, and deletes its temp
file, however the request ends.

Guest, embedded, anonymous and no-email sessions stay blocked on both paths --
guest support is handled separately in #43805 -- and are covered by a regression
test using a guest token that does grant access to the dashboard.

The storage check is isolated in a single helper so it can be repointed at
EXPORT_STORAGE when #43805 lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 12:06:05 +02:00
9079f002ee fix(dashboard): scope tab-diff report lookup to the edited dashboard (#43989)
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:03:32 +02:00
Enzo MartellucciandClaude Sonnet 5 1ab6077e06 fix(filters): clamp filter bar row while remeasuring after items change (#43966)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:52:28 +02:00
Sepuri Sai KrishnaandEvan Rusackas c76e4d98f2 fix(api): advertise only the time grains Prophet can resolve (#43710)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-09-08 22:39:15 -07:00
fc110d8428 fix(versioning): enforce is_managed_externally on version restore (#44013)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 21:55:08 -07:00
KunalandJoe Li 4f26627a6a fix(native-filters): stop Date Range calendar popup from clipping in overflow dropdown (#43978)
Co-authored-by: Joe Li <joe@preset.io>
2026-09-08 21:52:33 -07:00
KunalandClaude Sonnet 5 613871ed36 fix(native-filters): stabilize DefaultValue's filterState object identity (#43859)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 21:51:20 -07:00
Francesco.CastaldiandFrancescoCastaldi c503f92642 fix(charts): restrict Prophet time grain schema validation to supported Prophet grains (#43585)
Co-authored-by: FrancescoCastaldi <francesco.castaldi@mapsgroup.it>
2026-09-08 21:43:33 -07:00
nacretion 50c1504cac fix(db_engine_specs): cast VARCHAR before Postgres DATE_TRUNC (#43167) 2026-09-08 21:38:37 -07:00
ShaitanandClaude Opus 4.8 5d771e9f46 fix(dataset): apply the import overwrite permission check to identity-matched datasets (#43058)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 21:37:14 -07:00
Grégoire Gailly c9965c7bf4 feat(folders): drag and drop visible elements (columns/metrics) of folders in explore view (#42483) 2026-09-08 21:36:55 -07:00
Đỗ Trọng Hải 9669e41471 chore(build): remove legacy re-export shim used for development (#42190)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-08 21:34:40 -07:00
Evan RusackasandClaude Sonnet 5 daf41bdf04 fix(security): suppress zizmor self-repository false positive for sqlite install-deps step (#44002)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 18:25:00 -07:00
986b648f1c fix(versioning): resolve dashboard M2M membership by attach/detach windows, not end_transaction_id (#44010)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:59:04 -07:00
dependabot[bot]andClaude Code acb29937ae chore(deps): bump the security group across 1 directory with 3 updates (#44030)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 17:52:32 -07:00
dependabot[bot]andClaude Code 2875c76d55 chore(deps): bump react-ace from 14.0.1 to 15.0.0 in /superset-frontend (#42833)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-08 17:52:09 -07:00
d0f139c210 feat(deps): bump flask-caching from 2.4.1 to 2.5.0 + bump flask from v2 to v3 (#43875)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-09-08 17:20:53 -07:00
dependabot[bot] 9de50b85e5 chore(deps): bump the security group across 1 directory with 3 updates (#44029)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 17:04:39 -07:00
Joe LiandVitor Avila c510946df5 fix: BigQuery pivot-table Custom SQL row label with a space drops from GROUPING SETS (#43720)
Co-authored-by: Vitor Avila <vitor.avila@preset.io>
2026-09-08 16:58:49 -07:00
Joe LiandClaude Sonnet 5 91f4d01ca2 fix(chart-creation): stop antd v6 Steps from doubling the icon-content gap (#43730)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:57:55 -07:00
Evan Rusackas 3828dcb922 fix(security): suppress zizmor self-repository false positive for cached-dependencies in presto-hive workflow (#43976) 2026-09-08 16:53:30 -07:00
Evan Rusackas 362d3509cb fix(security): suppress zizmor self-repository false positive for cached-dependencies in presto-hive workflow (#43984) 2026-09-08 16:53:23 -07:00
Evan RusackasandClaude Sonnet 5 fa31e9c1c3 fix(security): suppress zizmor self-repository false positive for cached-dependencies (#43972)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:52:42 -07:00
Evan RusackasandClaude Sonnet 5 335a2a78ea fix(dashboard): expand clipped scrollable content before screenshot/PDF export (#43979)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:46:34 -07:00
Evan RusackasandClaude Sonnet 5 3564baabf6 fix(security): suppress zizmor self-repository false positive for setup-backend step (#44018)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:41:46 -07:00
237 changed files with 15845 additions and 3575 deletions
@@ -49,7 +49,7 @@ jobs:
# allowlist (only v8.1.0+ are, at apache/infrastructure-actions'
# actions.yml). Needs an INFRA request before this can de-vendor too.
- name: Set up chart-testing
uses: ./.github/actions/chart-testing-action
uses: $/.github/actions/chart-testing-action
- name: Run chart-testing (list-changed)
id: list-changed
+43 -8
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -109,13 +109,25 @@ jobs:
submodules: recursive
# -------------------------------------------------------
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Setup postgres
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-postgres
- name: Import test data
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright_testdata
- name: Setup Node.js
@@ -125,19 +137,42 @@ jobs:
cache: "npm"
cache-dependency-path: "superset-frontend/package-lock.json"
- name: Install npm dependencies
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: npm-install
- name: Build javascript packages
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: build-instrumented-assets
- name: Install Playwright
uses: ./.github/actions/cached-dependencies
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: playwright-install
- name: Run Playwright (Experimental Tests)
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
@@ -34,7 +34,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
uses: $/.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -75,9 +75,15 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Setup MySQL
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-mysql
- name: Start Celery worker
@@ -169,13 +175,7 @@ jobs:
run: |
setup-postgres
- name: Start Celery worker
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python integration tests (PostgreSQL)
@@ -216,9 +216,15 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: ./.github/actions/setup-backend/
uses: $/.github/actions/setup-backend/
- name: Install dependencies
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: |
# sqlite needs this working directory
@@ -82,7 +82,13 @@ jobs:
- name: Setup Python
uses: $/.github/actions/setup-backend/
- name: Setup Postgres
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: setup-postgres
- name: Start Celery worker
@@ -147,7 +153,13 @@ jobs:
with:
run: setup-postgres
- name: Start Celery worker
uses: ./.github/actions/cached-dependencies
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: celery-worker
- name: Python unit tests (PostgreSQL)
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
with:
run: npm-install
- name: lint
+56 -25
View File
@@ -231,20 +231,6 @@ RUN /app/docker/apt-install.sh \
# The database file will be created at runtime when examples are loaded from Parquet files
RUN mkdir -p /app/data && chown -R superset:superset /app/data
# Copy compiled things from previous stages
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionall as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# --- Realtime WebSocket server (part of the official image) ---------------
# The realtime transport (superset-websocket) is a Node service, bundled by
# esbuild into a single self-contained file. Copy the Node runtime plus that
@@ -267,7 +253,10 @@ EXPOSE ${SUPERSET_PORT}
######################################################################
FROM python-common AS lean
# Install Python dependencies using docker/pip-install.sh
# Install Python dependencies using docker/pip-install.sh.
# Requirements are installed *before* the application source is copied
# below so that source-only changes don't bust this (slow, network-bound)
# cache layer or defeat --cache-from.
COPY requirements/base.txt requirements/
# Copy superset-core package needed for editable install in base.txt
@@ -275,9 +264,27 @@ COPY superset-core superset-core
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
/app/docker/pip-install.sh --requires-build-essential -r requirements/base.txt
# Install the superset package
# Copy compiled frontend assets and application source now that
# dependencies have been resolved and cached above.
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionally as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# Install the superset package itself. --no-deps because its dependencies
# were already installed from requirements/base.txt above, so this layer
# stays fast even though the source copy above changes on every edit.
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
uv pip install -e .
uv pip install -e . --no-deps
RUN python -m compileall /app/superset
USER superset
@@ -293,22 +300,46 @@ RUN /app/docker/apt-install.sh \
pkg-config \
default-libmysqlclient-dev
# Copy development requirements and install them
# Copy development requirements and install them *before* the application
# source is copied below, so source-only edits don't bust this cache layer.
COPY requirements/*.txt requirements/
# Copy local packages needed for editable installs in development.txt
COPY superset-core superset-core
COPY superset-extensions-cli superset-extensions-cli
# Install Python dependencies using docker/pip-install.sh
# requirements/development.txt is generated by `uv pip compile` and embeds
# `-e .` (an editable install of this same package) as its first line. That
# self-reference needs the full superset/ source tree, which hasn't been
# copied in yet at this point, so it's stripped here; the real editable
# install of `.` runs below, once the source is present.
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
/app/docker/pip-install.sh --requires-build-essential -r requirements/development.txt
# Install the superset package
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
uv pip install -e .
grep -vxF -- "-e ." requirements/development.txt > requirements/development-deps.txt && \
/app/docker/pip-install.sh --requires-build-essential -r requirements/development-deps.txt
RUN uv pip install .[postgres]
RUN python -m compileall /app/superset
# Copy compiled frontend assets and application source now that
# dependencies have been resolved and cached above.
COPY --from=superset-node /app/superset/static/assets superset/static/assets
# Copy service.worker.js optionally as it doesn't exist when DEV_MODE=true
COPY --from=superset-node /app/superset/static/service-worker.j[s] superset/static/service-worker.js
# TODO, when the next version comes out, use --exclude superset/translations
COPY superset superset
# TODO in the meantime, remove the .po files
RUN rm superset/translations/*/*/*.po
# Merging translations from backend and frontend stages
COPY --from=superset-node /app/superset/translations superset/translations
COPY --from=python-translation-compiler /app/translations_mo superset/translations
# Install the superset package together with its postgres extra, using the
# same uv cache mount as the requirements install above. --no-deps because
# all dependencies (including the postgres extra's psycopg2-binary) are
# already installed from requirements/development.txt above.
# NOTE: source is bind-mounted over /app/superset in DEV_MODE, so a
# compileall pass here would be wasted work; unlike `lean`, `dev` skips it.
RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \
uv pip install -e .[postgres] --no-deps
USER superset
+29 -20
View File
@@ -230,6 +230,7 @@ before retrying. Preview or recheck failures fail closed rather than treating
unknown impact as zero. Chart and dashboard purge endpoints are unchanged.
- The dashboard datasource-based visibility fallback now fails closed: a dashboard whose member charts datasources cannot be resolved (deleted datasource rows, missing `datasource_id`, or unsupported datasource types) is no longer accessible to users without explicit editor/viewer rights, and a dashboard composed of semantic-view charts now requires `datasource_access` on (at least one of) its semantic views or their parent semantic layer — previously any authenticated user could open such a dashboards shell. Because the fallback now considers every member chart rather than only table-backed ones, a user holding `datasource_access` on any single member datasource — including a semantic view or its parent layer — can open a mixed dashboard that previously denied them. Dashboards with no charts remain accessible, and dashboards with explicit viewers are unaffected. Conversely, holders of `all_datasource_access` now see every published no-viewer dashboard in the dashboard list — including chart-less ones previously hidden by the inner joins — matching what the object-level gate already allowed them to open.
- Version restore (`POST /api/v1/{chart,dashboard,dataset}/<uuid>/versions/<version_uuid>/restore`) now refuses an **externally managed** entity (`is_managed_externally = True`) with HTTP 403, enforcing server-side what the docs already promised. Previously the refusal existed only in the browser, so an otherwise-authorized editor could restore such an entity by calling the endpoint directly and have the restore overwritten on the next external sync. Soft-delete recovery is deliberately unaffected — it changes visibility, not content.
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
@@ -537,31 +538,37 @@ Note that a retried query returns partial data with no truncation indicator
(e.g. a filter dropdown may list only a subset of values on tables above the
row cap).
### Dashboard "Export Data to Excel" requires a Celery worker and S3 bucket
### Dashboard Excel exports support direct downloads
A new dashboard action exports every chart's data to a single multi-sheet
`.xlsx` asynchronously. It is disabled by default and turns on only when
`EXCEL_EXPORT_S3_BUCKET` is set (the endpoint returns `501` otherwise). It also
requires a running Celery worker and a configured SMTP transport, since the task
emails the requesting user a pre-signed download link. New config keys:
`EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
`.xlsx`. Without an export bucket, Superset builds the workbook during the
request and returns it to the browser. When `EXCEL_EXPORT_S3_BUCKET` is set, a
Celery worker builds and uploads the workbook, then emails the user a pre-signed
download link. This queued path also needs a worker and SMTP transport.
Direct downloads are limited by `EXCEL_EXPORT_SYNC_MAX_ROWS` (default
`100_000`), based on the combined `row_limit` of the planned queries. Superset
uses `ROW_LIMIT` when a query omits its limit and returns `400` before querying
if the total exceeds the limit. Image exports also return `400` without an export
bucket because they require background webdriver rendering.
`POST /api/v1/dashboard/<id>/export_xlsx/` returns either `202` with a queued job
id or `200` with the workbook. It no longer returns `501` when no bucket is set.
New config keys: `EXCEL_EXPORT_S3_BUCKET`, `EXCEL_EXPORT_S3_KEY_PREFIX`,
`EXCEL_EXPORT_LINK_TTL_SECONDS`, `EXCEL_EXPORT_S3_CLIENT_KWARGS`,
`EXCEL_EXPORT_TABLE_VIZ_TYPES`, and `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
`EXCEL_EXPORT_SYNC_MAX_ROWS`, `EXCEL_EXPORT_TABLE_VIZ_TYPES`, and
`EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
The feature depends on `boto3`, which is **not** installed by default; install it
with `pip install apache-superset[excel-export]`.
The queued path depends on `boto3`, which is **not** installed by default; install
it with `pip install apache-superset[excel-export]`. The direct-download path
does not use it.
Charts store their `query_context` only once they have been (re-)saved in
Explore, so older charts may have none. For a fixed, conservative set of viz
types (`table`, `big_number_total`, `big_number`, `pie`) the export rebuilds a
query context from the chart's saved form data so those charts still export.
The rebuild is a single-query mapping and does **not** reproduce plugin
post-processing (pivot, rolling, forecast) or multi-query charts, so any chart of
another type without a saved query context is skipped and listed in the email for
the user to re-save. To cover those types, set `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`
to a callable that receives the chart's form data and returns a query-context
payload (or `None` to fall back to the built-in rebuild) — for example one backed
by a service that runs the chart's real frontend `buildQuery`.
For `table`, `big_number_total`, `big_number`, and `pie` charts without a saved
`query_context`, Superset rebuilds a single query from saved form data. Charts
that need post-processing or multiple queries are skipped and listed on the
workbook's "Export Summary" sheet. Use `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` to
support more chart types.
A second mode, **Export Images to Excel**, embeds non-table charts as rendered
images (which viz types stay tabular is controlled by
@@ -1232,6 +1239,8 @@ Custom time ranges that use the "Now" or "Today" anchor (for the Start, End, or
Charts and dashboards using these anchors will compute a different (correct) timestamp after upgrading; if a chart's filters or drill-downs were tuned to compensate for the old offset, review them after upgrading.
- [43916](https://github.com/apache/superset/pull/43916): The `docker-compose` dev loop now skips re-running `superset load_examples` on every `docker compose up` once the example data and dashboards are present in the databases (set `SUPERSET_FORCE_LOAD_EXAMPLES=yes` to reload them anyway), and the `superset-node` service now defaults `DISABLE_TS_CHECKER=true` like `docker-compose-light.yml` already did, skipping webpack's TypeScript type-checking pass in dev by default.
## 6.1.0
### ClickHouse minimum driver version bump
+1
View File
@@ -138,6 +138,7 @@ services:
condition: service_started
volumes: *superset-volumes
environment:
SUPERSET_FORCE_LOAD_EXAMPLES: "${SUPERSET_FORCE_LOAD_EXAMPLES:-}"
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
+3
View File
@@ -183,6 +183,8 @@ services:
condition: service_started
user: *superset-user
volumes: *superset-volumes
environment:
SUPERSET_FORCE_LOAD_EXAMPLES: "${SUPERSET_FORCE_LOAD_EXAMPLES:-}"
healthcheck:
disable: true
@@ -202,6 +204,7 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
+6
View File
@@ -73,6 +73,12 @@ SUPERSET_ENV=development
# Swagger UI is opt-in (off by default); enable it for local development.
SUPERSET_ENABLE_SWAGGER_UI=true
SUPERSET_LOAD_EXAMPLES=yes
# Once the example data and dashboards are present in the databases,
# `docker-init.sh` skips `superset load_examples` on later runs. Set to "yes"
# (or run `SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up`) to reload the
# examples anyway, e.g. after changing the example datasets or after a partial
# load.
#SUPERSET_FORCE_LOAD_EXAMPLES=no
CYPRESS_CONFIG=false
SUPERSET_PORT=8088
MAPBOX_API_KEY=''
+33 -3
View File
@@ -66,14 +66,44 @@ echo_step "3" "Starting" "Setting up roles and perms"
superset init
echo_step "3" "Complete" "Setting up roles and perms"
# Loading examples parses and inserts every example dataset, chart and
# dashboard and is one of the slowest steps of `docker compose up`. Rather
# than trusting a marker file (which goes stale as soon as the database volume
# is recreated), ask the databases themselves: when both the example data and
# the dashboards imported from it are present, the previous load completed and
# there is nothing left to redo. Any failure here (missing tables, unreachable
# database, import error) simply reports "not loaded" so the full load runs.
examples_already_loaded() {
python - <<'PY' 2>/dev/null
import sys
from superset.app import create_app
from superset.sql.parse import Table
app = create_app()
with app.app_context():
from superset import db
from superset.models.dashboard import Dashboard
from superset.utils.database import get_example_database
has_dashboard = (
db.session.query(Dashboard).filter_by(slug="world_health").first() is not None
)
has_data = get_example_database().has_table(Table("wb_health_population"))
sys.exit(0 if has_dashboard and has_data else 1)
PY
}
if [ "$SUPERSET_LOAD_EXAMPLES" = "yes" ]; then
# Load some data to play with
echo_step "4" "Starting" "Loading examples"
# If Cypress run which consumes superset_test_config load required data for tests
# Cypress runs always load, since they need a distinct set of test data
# (`--load-test-data`) in a separate database. Set
# SUPERSET_FORCE_LOAD_EXAMPLES=yes to reload the examples regardless.
if [ "$CYPRESS_CONFIG" == "true" ]; then
superset load_examples --load-test-data
elif [ "$SUPERSET_FORCE_LOAD_EXAMPLES" != "yes" ] && examples_already_loaded; then
echo "Examples already loaded, skipping (set SUPERSET_FORCE_LOAD_EXAMPLES=yes to reload them)"
else
superset load_examples
fi
@@ -476,6 +476,16 @@ to determine which hosts are internal.
There are many reasons that reports might not be working. Try these steps to check for specific issues.
### Blank PDF or PNG captures
Playwright report captures are checked for blank image content as well as chart
readiness. Captures detected as blank where chart content is expected are retried
up to three total attempts, within the execution deadline. If they remain blank,
the report fails instead of delivering the blank or partial attachment. This also
applies to alerts that attach reports. Check worker logs for
`ScreenshotBlankCaptureError` and `report_capture_validation` when investigating
these failures.
### Confirm feature flag is enabled and you have sufficient permissions
If you don't see "Alerts & Reports" under the _Manage_ section of the Settings dropdown in the Superset UI, you need to enable the `ALERT_REPORTS` feature flag (see above). Enable another feature flag and check to see that it took effect, to verify that your config file is getting loaded.
+5 -1
View File
@@ -954,7 +954,11 @@ Specifying a tool name that does not exist logs a warning at startup and is othe
## Disabling chart type plugins
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `pivot_table`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
Gauge data inspection and JSON/CSV/XLSX exports preserve source groups, including NULL and non-finite metric values, using the same row-count and completeness semantics as other chart types. The default XLSX exporter writes non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and distinguishing them from blank NULL cells. Only previews and compile checks skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving finite groups. In preview/compile paths, a nonempty result containing no finite dial returns an error; genuinely empty results retain the no-data behavior. Compile checks inspect the configured dial limit (at most 10), rather than only two groups. Grouped Vega previews center each needle within its own facet and extend it to the midpoint of the dial band. Automatic all-zero ranges span 01. Empty or whitespace-only temporal ranges, like `No filter`, are neutral sentinels; missing or non-string native temporal comparators are invalid.
Gauge previews retain data-derived automatic bounds even when configured interval thresholds extend beyond the visible range; Vega previews clip those thresholds into adjacent, non-overlapping bands. Explicit min/max bounds still constrain intervals. All-zero data with both bounds omitted uses a nondegenerate preview range of 01. On same-dataset Gauge updates, clearing or changing the dashboard temporal binding preserves user-authored temporal ranges; dataset rebinds discard old query roles and filters. The native `"No filter"` sentinel is a neutral dashboard temporal binding, not a conflicting restriction. Duplicate generated neutral bindings self-heal on updates without removing user-authored ranges. Restating the existing dataset ID without a config is allowed; changing the Gauge dataset still requires a complete config. Native restrictive temporal filters that conflict with the top-level range or another restrictive temporal filter are rejected rather than silently dropped. Unsupported native filter operators are rejected with an indexed error. Native `TEMPORAL_RANGE` adhoc filters require an explicit string comparator; `"No filter"`, empty strings, and whitespace represent an unrestricted range.
### Static deny-list
@@ -196,7 +196,9 @@ One important variable is `SUPERSET_LOAD_EXAMPLES` which determines whether the
container will populate example data and visualizations into the metadata database. These examples
are helpful for learning and testing out Superset but unnecessary for experienced users and
production deployments. The loading process can sometimes take a few minutes and a good amount of
CPU, so you may want to disable it on a resource-constrained device.
CPU, so you may want to disable it on a resource-constrained device. Once the example data and
dashboards are present in the databases, later `superset_init` runs skip loading them; run
`SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up` to reload the examples anyway.
For more advanced or dynamic configurations that are typically managed in a `superset_config.py` file
located in your `PYTHONPATH`, note that it can be done by providing a
@@ -99,11 +99,16 @@ Affecting the Docker build process:
- **INCLUDE_CHROMIUM (default=false):** whether to include the Chromium headless browser in the build
- **BUILD_TRANSLATIONS(default=false):** whether to compile the translations from the .po files available
- **SUPERSET_LOAD_EXAMPLES (default=yes):** whether to load the examples into the database upon startup,
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`
save some precious time on startup by `SUPERSET_LOAD_EXAMPLES=no docker compose up`. Once the example
data and dashboards are present in the databases, later `docker compose up` runs skip loading
them; run `SUPERSET_FORCE_LOAD_EXAMPLES=yes docker compose up` to reload the examples anyway.
- **SUPERSET_LOG_LEVEL (default=info)**: Can be set to debug, info, warning, error, critical
for more verbose logging
- **SUPERSET_DEBUG_ENABLED (default=false)**: Enable Werkzeug debugger with interactive console.
Set to `true` for debugging: `SUPERSET_DEBUG_ENABLED=true docker compose up`
- **DISABLE_TS_CHECKER (default=true)**: whether the `superset-node` webpack dev server skips
TypeScript type-checking, which speeds up rebuilds and saves several GB of memory. Set to
`false` to have webpack surface type errors during development.
For more env vars that affect your configuration, see this
[superset_config.py](https://github.com/apache/superset/blob/master/docker/pythonpath_dev/superset_config.py)
@@ -9,22 +9,31 @@ version: 1
Superset can export every chart on a dashboard to a single Excel workbook, with
each chart's underlying data rendered as its own worksheet. The export reflects
the dashboard's currently applied filters and runs asynchronously: when it
finishes, the requesting user receives an email with a time-limited download
link.
the dashboard's currently applied filters.
How the finished workbook reaches you depends on whether the deployment has
export storage configured:
- **With an export bucket**, a background worker builds the workbook and emails
the user a time-limited download link.
- **Without an export bucket**, Superset builds the workbook during the request
and returns it to the browser. This path only supports data exports within
`EXCEL_EXPORT_SYNC_MAX_ROWS` (see [Prerequisites](#prerequisites)).
## Using the export
From a dashboard, open the **... (actions) → Download** submenu and choose
**Export Data to Excel**. The action appears for users who have the dashboard
`can_export` permission. You'll see a confirmation that the export is being
prepared; the workbook arrives by email when it's ready.
`can_export` permission. Where the export is queued you'll see a confirmation
that it is being prepared, then receive the workbook by email. Direct exports
download when ready.
A second option, **Export Images to Excel**, embeds each non-table chart as a
rendered image (tables stay tabular) instead of exporting raw data. Because it
renders charts through the headless webdriver, this option only appears when the
webdriver screenshot feature flags are enabled (see the prerequisites below);
which viz types stay tabular is controlled by `EXCEL_EXPORT_TABLE_VIZ_TYPES`.
Image exports always run in the background and require an export bucket.
Notes on the generated workbook:
@@ -36,31 +45,37 @@ Notes on the generated workbook:
re-saved in Explore) still exports when it is a `table`, `big_number`,
`big_number_total` or `pie`, by rebuilding the query from the chart's saved
form data. Charts of other types — and charts relying on post-processing the
rebuild can't reproduce — are skipped and listed in the email; open the chart
in Explore and re-save it to include it next time, or configure
`EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
rebuild can't reproduce — are skipped; open the chart in Explore and re-save it
to include it next time, or configure `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`.
The workbook's **Export Summary** sheet lists skipped charts. Queued exports
also list them in the email.
- Row counts per sheet are capped the same way as the chart-level CSV/Excel
export (`ROW_LIMIT`, bounded by `SQL_MAX_ROW`), and never exceed Excel's
per-sheet maximum.
## Prerequisites
This feature is **disabled by default**. It requires:
Dashboard data exports need no configuration. By default, Superset builds the
workbook during the request and returns it to the browser. The combined
`row_limit` of its queries must not exceed `EXCEL_EXPORT_SYNC_MAX_ROWS` (100,000
by default). Queries without a `row_limit` use `ROW_LIMIT`. Keep this setting
within your web server's request timeout.
For larger data exports and all image exports, configure the background path:
1. **The `boto3` dependency.** It is not installed by default; install it with
`pip install apache-superset[excel-export]`. Without it, exports fail and the
user receives a failure email.
2. **An S3 bucket.** Set `EXCEL_EXPORT_S3_BUCKET`. Until it is set, the export
endpoint returns `501` and the menu action surfaces a "not configured"
message.
3. **A running Celery worker.** The export runs as a Celery task. If no worker
is running, the request is accepted but nothing is produced.
2. **An S3 bucket.** Set `EXCEL_EXPORT_S3_BUCKET` to queue exports and deliver
them by email.
3. **A running Celery worker.** The queued export runs as a Celery task. If no
worker is running, the request is accepted but nothing is produced.
4. **A configured SMTP transport.** The download link is delivered by email
using the same settings as alerts & reports (`SMTP_*`,
`EMAIL_REPORTS_SUBJECT_PREFIX`).
**Export Images to Excel** additionally requires a working headless webdriver
the same infrastructure scheduled reports and thumbnails use (`WEBDRIVER_*`,
**Export Images to Excel** also requires the headless webdriver used by scheduled
reports and thumbnails (`WEBDRIVER_*`,
plus the `ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS` and
`ENABLE_DASHBOARD_DOWNLOAD_WEBDRIVER_SCREENSHOT` feature flags). The menu option
is hidden when those flags are off; if the webdriver is unreachable, image
@@ -72,14 +87,15 @@ will not register.
## Configuration keys
| Key | Default | Description |
| ------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `EXCEL_EXPORT_S3_BUCKET` | `None` | Destination bucket. Required; `501` if unset. |
| `EXCEL_EXPORT_S3_KEY_PREFIX` | `"dashboard-exports/"` | Key prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. |
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the pre-signed download URL (24h). |
| `EXCEL_EXPORT_S3_CLIENT_KWARGS` | `{}` | Extra kwargs for `boto3.client("s3", ...)` — e.g. `region_name`, or `endpoint_url` for MinIO/LocalStack. |
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in **Export Images to Excel** mode; every other type is embedded as an image. `None` uses the built-in default (`table`, `pivot_table`, `pivot_table_v2`). |
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional `Callable[[form_data_dict], dict \| None]` to build a query context for a chart missing a saved one, tried before the built-in form-data rebuild. Point it at a service that runs the chart's real frontend `buildQuery` to faithfully export viz types the built-in rebuild can't handle. Must return `None` when it can't build faithfully, so the export falls back. |
| Key | Default | Description |
| ------------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `EXCEL_EXPORT_S3_BUCKET` | `None` | Destination bucket. When unset, eligible data exports download directly. |
| `EXCEL_EXPORT_SYNC_MAX_ROWS` | `100000` | Maximum combined query `row_limit` for a direct download. Ignored when a bucket is configured. |
| `EXCEL_EXPORT_S3_KEY_PREFIX` | `"dashboard-exports/"` | Key prefix: `{prefix}{dashboard_id}/{job_id}.xlsx`. |
| `EXCEL_EXPORT_LINK_TTL_SECONDS` | `86400` | Lifetime of the pre-signed download URL (24h). |
| `EXCEL_EXPORT_S3_CLIENT_KWARGS` | `{}` | Extra arguments for `boto3.client("s3", ...)`, such as `region_name` or an `endpoint_url` for MinIO or LocalStack. |
| `EXCEL_EXPORT_TABLE_VIZ_TYPES` | `None` | Viz types kept tabular in image mode. `None` uses `table`, `pivot_table`, and `pivot_table_v2`. |
| `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER` | `None` | Optional callable that builds a query context from `Slice.form_data`. Return `None` to use the built-in rebuild. |
Credentials and region resolve through the standard boto3 chain (environment
variables, shared config, or instance role) unless overridden via
@@ -88,8 +104,9 @@ variables, shared config, or instance role) unless overridden via
## Security considerations
- The emailed link is a **pre-signed S3 URL**: anyone who holds it can download
the workbook until it expires. Keep the bucket **private**, enable
encryption, and consider a lifecycle rule to delete objects after a few days.
the workbook until it expires. Direct downloads are not stored or linked.
Keep the bucket **private**, enable encryption, and consider a lifecycle rule
to delete objects after a few days.
Lower `EXCEL_EXPORT_LINK_TTL_SECONDS` if 24 hours is too long for your data.
- The export runs with the requesting user's permissions; each chart's query is
access-checked, so users only ever receive data they are entitled to.
@@ -97,9 +114,9 @@ variables, shared config, or instance role) unless overridden via
## Limitations
- **Embedded dashboards / guest tokens are not supported** in this version,
because guest users have no email address to deliver the link to. Logged-in
users viewing an embedded dashboard can still use the export.
including direct downloads. Logged-in users viewing an embedded dashboard can
still use the export.
- The default **Export Data to Excel** mode exports data only (no visual
styling). Use **Export Images to Excel** to embed rendered chart images, which
requires the webdriver infrastructure described in the prerequisites.
requires an export bucket and the webdriver setup described above.
- Scheduled/automated exports are not part of this feature.
+3 -3
View File
@@ -87,11 +87,11 @@
"@docusaurus/module-type-aliases": "^3.10.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxfmt": "^0.66.0",
"oxlint": "^1.81.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.2"
"webpack": "^5.110.3"
},
"browserslist": {
"production": [
+14 -26
View File
@@ -1203,28 +1203,6 @@
},
"time_grain_sqla": {
"description": "To what level of granularity should the temporal column be aggregated. Supports [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
"enum": [
"PT1S",
"PT5S",
"PT30S",
"PT1M",
"PT5M",
"PT10M",
"PT15M",
"PT30M",
"PT1H",
"PT6H",
"P1D",
"P1W",
"P1M",
"P3M",
"P1Y",
"1969-12-28T00:00:00Z/P1W",
"1969-12-29T00:00:00Z/P1W",
"P1W/1970-01-03T00:00:00Z",
"P1W/1970-01-04T00:00:00Z",
null
],
"example": "P1D",
"nullable": true,
"type": "string"
@@ -1485,12 +1463,14 @@
"PT10M",
"PT15M",
"PT30M",
"PT0.5H",
"PT1H",
"PT6H",
"P1D",
"P1W",
"P1M",
"P3M",
"P0.25Y",
"P1Y",
"1969-12-28T00:00:00Z/P1W",
"1969-12-29T00:00:00Z/P1W",
@@ -19541,7 +19521,7 @@
},
"/api/v1/dashboard/{pk}/export_xlsx/": {
"post": {
"description": "Enqueues an async task that writes each chart's data to its own worksheet, uploads the .xlsx to S3, and emails the requesting user a pre-signed download link. Returns immediately with a job id.",
"description": "Writes each chart to a worksheet. With export storage, the work is queued and the user receives a download link by email. Without it, eligible workbooks are returned in the response.",
"parameters": [
{
"description": "The dashboard id",
@@ -19563,6 +19543,17 @@
}
},
"responses": {
"200": {
"content": {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"schema": {
"format": "binary",
"type": "string"
}
}
},
"description": "The exported workbook, built during this request"
},
"202": {
"content": {
"application/json": {
@@ -19587,9 +19578,6 @@
},
"500": {
"$ref": "#/components/responses/500"
},
"501": {
"description": "Excel export is not configured on this server"
}
},
"security": [
+211 -211
View File
@@ -3078,100 +3078,100 @@
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz#dbdf12721396ef4c31899d0963b002df32f57050"
integrity sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==
"@oxfmt/binding-android-arm-eabi@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz#1d9be45cec55c86b17e1feffbffaeadd45fd2c9a"
integrity sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==
"@oxfmt/binding-android-arm-eabi@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.66.0.tgz#81935e074d88a7da5afc731d4cc74bb1763aa3f1"
integrity sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==
"@oxfmt/binding-android-arm64@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz#7a6dbd301c1198d2a16439e005ca33fb3ebd2f62"
integrity sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==
"@oxfmt/binding-android-arm64@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.66.0.tgz#ea52efcaff8f7d708a61119124b36e656b0d74ca"
integrity sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==
"@oxfmt/binding-darwin-arm64@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz#7fa69acf25aefb7ff832aaa207ece9f55c48398e"
integrity sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==
"@oxfmt/binding-darwin-arm64@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.66.0.tgz#620dfd8993f71e557b169eeda792203d15698061"
integrity sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==
"@oxfmt/binding-darwin-x64@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz#c315ca1fb9fb606477dc76fc4154d92c32a244a9"
integrity sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==
"@oxfmt/binding-darwin-x64@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.66.0.tgz#9f45ff27d347283fdc2a79b87cd67cc40204a46e"
integrity sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==
"@oxfmt/binding-freebsd-x64@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz#4815e6ac50a9f38cd93dda3d649c6988bded481d"
integrity sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==
"@oxfmt/binding-freebsd-x64@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.66.0.tgz#5c04bc6fea57c0829d06698c480331019acf57f3"
integrity sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==
"@oxfmt/binding-linux-arm-gnueabihf@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz#36c1c82b18186293c62c48fbcf1e3e4bdc9cd72d"
integrity sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==
"@oxfmt/binding-linux-arm-gnueabihf@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.66.0.tgz#c343c923fb1acab9b22bf411ad7feb12ee9c415a"
integrity sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==
"@oxfmt/binding-linux-arm-musleabihf@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz#623880e16cd16fb36abf02aa050e5e33d1acbf67"
integrity sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==
"@oxfmt/binding-linux-arm-musleabihf@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.66.0.tgz#ec038e354a0b4fa903eaaf29b7e70b3ea0ce4956"
integrity sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==
"@oxfmt/binding-linux-arm64-gnu@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz#1570f61b877385b9c1b56800663f68e8f988c533"
integrity sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==
"@oxfmt/binding-linux-arm64-gnu@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.66.0.tgz#11b3a1d966acbb65cb76238594fa62b68d226109"
integrity sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==
"@oxfmt/binding-linux-arm64-musl@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz#e7494ed26c0cd52a1ac4b38fe5f0a2a88231cd2e"
integrity sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==
"@oxfmt/binding-linux-arm64-musl@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.66.0.tgz#b441c3255ff3cecf393a15e4393e2bc506c59dba"
integrity sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==
"@oxfmt/binding-linux-ppc64-gnu@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz#e87bc27e8d9fba5eaba59b11a1f00ad1e116f803"
integrity sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==
"@oxfmt/binding-linux-ppc64-gnu@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.66.0.tgz#8300b1420ffab047d07527ae78776f331f5aeef6"
integrity sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==
"@oxfmt/binding-linux-riscv64-gnu@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz#dd27e2b20b27817938815a2b47f87fcf41ca1aa0"
integrity sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==
"@oxfmt/binding-linux-riscv64-gnu@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.66.0.tgz#58718cadbd2e876414802ffd2445b61063f496fc"
integrity sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==
"@oxfmt/binding-linux-riscv64-musl@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz#dc37b95c1f07579eeedcf869d7cf3b47ca695ac9"
integrity sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==
"@oxfmt/binding-linux-riscv64-musl@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.66.0.tgz#f397a34335356dacaefc5e3e9fb4a58de7d9c0c8"
integrity sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==
"@oxfmt/binding-linux-s390x-gnu@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz#c898674718ce8da7a7c625f3b3bcda0b0ff55bc1"
integrity sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==
"@oxfmt/binding-linux-s390x-gnu@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.66.0.tgz#4d0d9f6c18136836d9e366f366d1cd5afff0f1c0"
integrity sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==
"@oxfmt/binding-linux-x64-gnu@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz#720a641158804551480d79cdc85cfed21b0882c8"
integrity sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==
"@oxfmt/binding-linux-x64-gnu@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.66.0.tgz#cd02a84ea2ac21d5267592d947a0df7234ad5a7a"
integrity sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==
"@oxfmt/binding-linux-x64-musl@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz#6cb3e1f25c85c619ce634b2f7e3238c33caafe86"
integrity sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==
"@oxfmt/binding-linux-x64-musl@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.66.0.tgz#9b3066ef25ee4c011dbda21cd96479b645997c58"
integrity sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==
"@oxfmt/binding-openharmony-arm64@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz#c620f9f383c544428f363bf4418b94b81b5b9346"
integrity sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==
"@oxfmt/binding-openharmony-arm64@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.66.0.tgz#7d0e493670a2e6b8a15d03698be7eabb40234976"
integrity sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==
"@oxfmt/binding-win32-arm64-msvc@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz#79c59706cc5450854d57d582116b13d6611e875c"
integrity sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==
"@oxfmt/binding-win32-arm64-msvc@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.66.0.tgz#3d20c363517caea2ddc6621641216d1f099ce5d8"
integrity sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==
"@oxfmt/binding-win32-ia32-msvc@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz#6a52067c7fc666ee46a1b95366c08a9a1c9b26a1"
integrity sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==
"@oxfmt/binding-win32-ia32-msvc@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.66.0.tgz#19f58a4532b4499ea14bf1a740d5fe1be7640e36"
integrity sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==
"@oxfmt/binding-win32-x64-msvc@0.65.0":
version "0.65.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz#f63624f39bbfddcd679d471c15879176581bb32b"
integrity sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==
"@oxfmt/binding-win32-x64-msvc@0.66.0":
version "0.66.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.66.0.tgz#6a427d9080a54713e59dcef338b13a870df888ea"
integrity sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==
"@oxlint-tsgolint/darwin-arm64@7.0.2001":
version "7.0.2001"
@@ -3203,100 +3203,100 @@
resolved "https://registry.yarnpkg.com/@oxlint-tsgolint/win32-x64/-/win32-x64-7.0.2001.tgz#814bcdd2707fa8ab1ae0f0b51a7243b034d2833a"
integrity sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==
"@oxlint/binding-android-arm-eabi@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz#924b041cbcea4e934fd9ef66a2d2b7d7463c0180"
integrity sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==
"@oxlint/binding-android-arm-eabi@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz#86a0305480e680431429c8259c5701ac5592e4a7"
integrity sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==
"@oxlint/binding-android-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz#d1716d2be903de06b4fdb2fdaa121a2943695c35"
integrity sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==
"@oxlint/binding-android-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz#dba51cb2a1258f37eaca15a4e74d5d6ee19f238c"
integrity sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==
"@oxlint/binding-darwin-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz#475eb4061db4d4fe9349e92f15962c0203e8ebec"
integrity sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==
"@oxlint/binding-darwin-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz#f384ae1eddd399429281f88a0073729e287ec0b1"
integrity sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==
"@oxlint/binding-darwin-x64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz#602fa5681dd746c0fb5ca7a0e4a81d0dd07b7f90"
integrity sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==
"@oxlint/binding-darwin-x64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz#7167e3b4c18852fcd1ddade536d232d73d55d30c"
integrity sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==
"@oxlint/binding-freebsd-x64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz#4e0490c344726fd0b1a027afc129fb9c78f069ec"
integrity sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==
"@oxlint/binding-freebsd-x64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz#64f230765343bfe7e2280b071bb191bb666303ac"
integrity sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==
"@oxlint/binding-linux-arm-gnueabihf@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz#82d215fc05e046a0ec085821c1da8ab99d35fbdc"
integrity sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==
"@oxlint/binding-linux-arm-gnueabihf@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz#5095f021a28009146f7536a7ccd518cdfcbfc3bf"
integrity sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==
"@oxlint/binding-linux-arm-musleabihf@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz#92068e3b51cd50fc5a83a3ebfba7925313d6cd11"
integrity sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==
"@oxlint/binding-linux-arm-musleabihf@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz#d894dcfb6fb1b8b224b79e6ce07b17403f413749"
integrity sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==
"@oxlint/binding-linux-arm64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz#6c81ddc85dd5b79070f87401d61813172068666d"
integrity sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==
"@oxlint/binding-linux-arm64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz#bf8e362c87e7828ba28c2ec36bae73ad82989ef1"
integrity sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==
"@oxlint/binding-linux-arm64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz#11216704b606d67e946a868850243dc2739ec92e"
integrity sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==
"@oxlint/binding-linux-arm64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz#2aa5214eb5b032e8f77d94a27c9bd3f6dbc446f3"
integrity sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==
"@oxlint/binding-linux-ppc64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz#125302a0e6732e32e4a7d53f0212d58f1b13af01"
integrity sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==
"@oxlint/binding-linux-ppc64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz#a0af1af6fd309214555f33b4fa0432f3ec45f242"
integrity sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==
"@oxlint/binding-linux-riscv64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz#03846614f184ed3dcbab4bffad7417fd54cd0967"
integrity sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==
"@oxlint/binding-linux-riscv64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz#60413a335a63e0911a625d047690e41c03f2a833"
integrity sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==
"@oxlint/binding-linux-riscv64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz#49e0fb90e1c8358429b9989a407f4020c56d8ea9"
integrity sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==
"@oxlint/binding-linux-riscv64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz#5f1dd7bc2bdbe92fa5454df081ee91fa2c499948"
integrity sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==
"@oxlint/binding-linux-s390x-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz#7aee2ae2426f7bdcae77969073a015a6b3f8373e"
integrity sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==
"@oxlint/binding-linux-s390x-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz#830c5bdd145ad39c9b5371e3214d0a9f9c1f46b1"
integrity sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==
"@oxlint/binding-linux-x64-gnu@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz#0c850b00faed2f884cf8665fe9c391f9f653d6ca"
integrity sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==
"@oxlint/binding-linux-x64-gnu@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz#7f72ea3c9ae70a76c1a92e56a5755869f335e756"
integrity sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==
"@oxlint/binding-linux-x64-musl@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz#91f54f1b0cc93a7e75ce4118aa85f9fd112f25c0"
integrity sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==
"@oxlint/binding-linux-x64-musl@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz#758fd6ea69123c6f4c1ba160bfc28f6b84548c11"
integrity sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==
"@oxlint/binding-openharmony-arm64@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz#7ab37c21e547812177bffdbd97c8a34493eedd7b"
integrity sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==
"@oxlint/binding-openharmony-arm64@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz#e492b7a1875b70ea55ffe13f685d4302e6b1900e"
integrity sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==
"@oxlint/binding-win32-arm64-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz#5a470fa82339044ad9cceef2bb8c62d88695e394"
integrity sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==
"@oxlint/binding-win32-arm64-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz#9247fdc1b4c86ecc718747c5f8c68902969677d1"
integrity sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==
"@oxlint/binding-win32-ia32-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz#06f6559998f1a53a8f5ace78d91f217a54f6a963"
integrity sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==
"@oxlint/binding-win32-ia32-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz#dfa4eed78612c5f4df788eb056fd5d1b05a75bb1"
integrity sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==
"@oxlint/binding-win32-x64-msvc@1.80.0":
version "1.80.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz#d3abbf1a7a09b9039ca5ca570c9689908850efc4"
integrity sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==
"@oxlint/binding-win32-x64-msvc@1.81.0":
version "1.81.0"
resolved "https://registry.yarnpkg.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz#27a474cc288e47a0e5c2d9c922bee64de0cfd614"
integrity sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==
"@parcel/watcher-android-arm64@2.5.6":
version "2.5.6"
@@ -6924,9 +6924,9 @@ color-name@~1.1.4:
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colord@^2.9.3:
version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
version "2.10.0"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.10.0.tgz#56c9050e6b06b4b6c62ddec366a48d65ef57e860"
integrity sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==
colorette@^1.2.0:
version "1.4.0"
@@ -9646,9 +9646,9 @@ jiti@^1.20.0:
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
joi@^17.9.2:
version "17.13.4"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
version "17.13.7"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.7.tgz#92e212c50dbbbcb1a1592424f84083eb265cc778"
integrity sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"
@@ -11541,32 +11541,32 @@ oxc-resolver@11.21.2:
"@oxc-resolver/binding-win32-arm64-msvc" "11.21.2"
"@oxc-resolver/binding-win32-x64-msvc" "11.21.2"
oxfmt@^0.65.0:
version "0.65.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.65.0.tgz#43f38114a6d9ad0952741c13c49f70ff1a4208d6"
integrity sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==
oxfmt@^0.66.0:
version "0.66.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.66.0.tgz#4b1e831d31676a2a8fc9e85551115ffa778515a7"
integrity sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==
dependencies:
tinypool "2.1.0"
optionalDependencies:
"@oxfmt/binding-android-arm-eabi" "0.65.0"
"@oxfmt/binding-android-arm64" "0.65.0"
"@oxfmt/binding-darwin-arm64" "0.65.0"
"@oxfmt/binding-darwin-x64" "0.65.0"
"@oxfmt/binding-freebsd-x64" "0.65.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.65.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.65.0"
"@oxfmt/binding-linux-arm64-gnu" "0.65.0"
"@oxfmt/binding-linux-arm64-musl" "0.65.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.65.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.65.0"
"@oxfmt/binding-linux-riscv64-musl" "0.65.0"
"@oxfmt/binding-linux-s390x-gnu" "0.65.0"
"@oxfmt/binding-linux-x64-gnu" "0.65.0"
"@oxfmt/binding-linux-x64-musl" "0.65.0"
"@oxfmt/binding-openharmony-arm64" "0.65.0"
"@oxfmt/binding-win32-arm64-msvc" "0.65.0"
"@oxfmt/binding-win32-ia32-msvc" "0.65.0"
"@oxfmt/binding-win32-x64-msvc" "0.65.0"
"@oxfmt/binding-android-arm-eabi" "0.66.0"
"@oxfmt/binding-android-arm64" "0.66.0"
"@oxfmt/binding-darwin-arm64" "0.66.0"
"@oxfmt/binding-darwin-x64" "0.66.0"
"@oxfmt/binding-freebsd-x64" "0.66.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.66.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.66.0"
"@oxfmt/binding-linux-arm64-gnu" "0.66.0"
"@oxfmt/binding-linux-arm64-musl" "0.66.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.66.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.66.0"
"@oxfmt/binding-linux-riscv64-musl" "0.66.0"
"@oxfmt/binding-linux-s390x-gnu" "0.66.0"
"@oxfmt/binding-linux-x64-gnu" "0.66.0"
"@oxfmt/binding-linux-x64-musl" "0.66.0"
"@oxfmt/binding-openharmony-arm64" "0.66.0"
"@oxfmt/binding-win32-arm64-msvc" "0.66.0"
"@oxfmt/binding-win32-ia32-msvc" "0.66.0"
"@oxfmt/binding-win32-x64-msvc" "0.66.0"
oxlint-tsgolint@^7.0.2001:
version "7.0.2001"
@@ -11580,30 +11580,30 @@ oxlint-tsgolint@^7.0.2001:
"@oxlint-tsgolint/win32-arm64" "7.0.2001"
"@oxlint-tsgolint/win32-x64" "7.0.2001"
oxlint@^1.80.0:
version "1.80.0"
resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.80.0.tgz#228271087d3f04e391e383ccdc0e840458d8b653"
integrity sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==
oxlint@^1.81.0:
version "1.81.0"
resolved "https://registry.yarnpkg.com/oxlint/-/oxlint-1.81.0.tgz#7b20ada29a171883de4517d041ea5b057fb48ab5"
integrity sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==
optionalDependencies:
"@oxlint/binding-android-arm-eabi" "1.80.0"
"@oxlint/binding-android-arm64" "1.80.0"
"@oxlint/binding-darwin-arm64" "1.80.0"
"@oxlint/binding-darwin-x64" "1.80.0"
"@oxlint/binding-freebsd-x64" "1.80.0"
"@oxlint/binding-linux-arm-gnueabihf" "1.80.0"
"@oxlint/binding-linux-arm-musleabihf" "1.80.0"
"@oxlint/binding-linux-arm64-gnu" "1.80.0"
"@oxlint/binding-linux-arm64-musl" "1.80.0"
"@oxlint/binding-linux-ppc64-gnu" "1.80.0"
"@oxlint/binding-linux-riscv64-gnu" "1.80.0"
"@oxlint/binding-linux-riscv64-musl" "1.80.0"
"@oxlint/binding-linux-s390x-gnu" "1.80.0"
"@oxlint/binding-linux-x64-gnu" "1.80.0"
"@oxlint/binding-linux-x64-musl" "1.80.0"
"@oxlint/binding-openharmony-arm64" "1.80.0"
"@oxlint/binding-win32-arm64-msvc" "1.80.0"
"@oxlint/binding-win32-ia32-msvc" "1.80.0"
"@oxlint/binding-win32-x64-msvc" "1.80.0"
"@oxlint/binding-android-arm-eabi" "1.81.0"
"@oxlint/binding-android-arm64" "1.81.0"
"@oxlint/binding-darwin-arm64" "1.81.0"
"@oxlint/binding-darwin-x64" "1.81.0"
"@oxlint/binding-freebsd-x64" "1.81.0"
"@oxlint/binding-linux-arm-gnueabihf" "1.81.0"
"@oxlint/binding-linux-arm-musleabihf" "1.81.0"
"@oxlint/binding-linux-arm64-gnu" "1.81.0"
"@oxlint/binding-linux-arm64-musl" "1.81.0"
"@oxlint/binding-linux-ppc64-gnu" "1.81.0"
"@oxlint/binding-linux-riscv64-gnu" "1.81.0"
"@oxlint/binding-linux-riscv64-musl" "1.81.0"
"@oxlint/binding-linux-s390x-gnu" "1.81.0"
"@oxlint/binding-linux-x64-gnu" "1.81.0"
"@oxlint/binding-linux-x64-musl" "1.81.0"
"@oxlint/binding-openharmony-arm64" "1.81.0"
"@oxlint/binding-win32-arm64-msvc" "1.81.0"
"@oxlint/binding-win32-ia32-msvc" "1.81.0"
"@oxlint/binding-win32-x64-msvc" "1.81.0"
p-cancelable@^3.0.0:
version "3.0.0"
@@ -14184,9 +14184,9 @@ svg-parser@^2.0.4:
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
svgo@^3.0.2, svgo@^3.2.0:
version "3.3.4"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.4.tgz#fd2aa10ff585b3bd2b83ce3602f5582bc0718bb5"
integrity sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==
version "3.3.5"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.5.tgz#8a3d9557ab2f386eca7e24760385849554985a1c"
integrity sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==
dependencies:
commander "^7.2.0"
css-select "^5.1.0"
@@ -15128,10 +15128,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.110.2, webpack@^5.88.1, webpack@^5.95.0:
version "5.110.2"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.2.tgz#ef23a0e62fe5e1ba71b033e3505b7f005d8d5c6e"
integrity sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==
webpack@^5.110.3, webpack@^5.88.1, webpack@^5.95.0:
version "5.110.3"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.110.3.tgz#e122b66f6226b7af8f209b6102cd13238c4813a8"
integrity sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
"flask-caching>=2.4.1, <3",
"flask-caching>=2.5.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
+5 -4
View File
@@ -40,7 +40,7 @@ brotli==1.2.0
# via
# -r requirements/base.in
# flask-compress
cachelib==0.13.0
cachelib==0.17.0
# via
# flask-caching
# flask-session
@@ -105,7 +105,7 @@ et-xmlfile==2.0.0
# via openpyxl
filelock==3.20.3
# via -r requirements/base.in
flask==2.3.3
flask==3.1.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -124,9 +124,9 @@ flask-appbuilder==5.2.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==3.1.0
flask-babel==4.0.0
# via flask-appbuilder
flask-caching==2.4.1
flask-caching==2.5.0
# via apache-superset (pyproject.toml)
flask-compress==1.24
# via apache-superset (pyproject.toml)
@@ -218,6 +218,7 @@ markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via
# flask
# jinja2
# mako
# werkzeug
+5 -4
View File
@@ -94,7 +94,7 @@ brotli==1.2.0
# via
# -c requirements/base-constraint.txt
# flask-compress
cachelib==0.13.0
cachelib==0.17.0
# via
# -c requirements/base-constraint.txt
# flask-caching
@@ -247,7 +247,7 @@ filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
flask==2.3.3
flask==3.1.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -269,11 +269,11 @@ flask-appbuilder==5.2.2
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==3.1.0
flask-babel==4.0.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
flask-caching==2.4.1
flask-caching==2.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -524,6 +524,7 @@ markdown-it-py==3.0.0
markupsafe==3.0.2
# via
# -c requirements/base-constraint.txt
# flask
# jinja2
# mako
# werkzeug
+8 -8
View File
@@ -17,13 +17,13 @@
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.1",
"@babel/preset-typescript": "^8.0.1",
"@types/node": "^26.4.0",
"@types/node": "^26.4.1",
"babel-loader": "^10.1.1",
"jsdom": "^30.0.1",
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^4.0.18",
"webpack": "^5.110.2",
"webpack": "^5.110.3",
"webpack-cli": "^7.2.3"
}
},
@@ -2184,9 +2184,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
"integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"version": "26.4.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz",
"integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5260,9 +5260,9 @@
}
},
"node_modules/webpack": {
"version": "5.110.2",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz",
"integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==",
"version": "5.110.3",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.3.tgz",
"integrity": "sha512-GuizBzRvo9YPpyoNMf3ag7AzxbaW85qrRSqTha345KyJbAFPt3/cMzBM0h+RWg7SK/7DdzRLINP3LvQ0hvr4hg==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -41,13 +41,13 @@
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.1",
"@babel/preset-typescript": "^8.0.1",
"@types/node": "^26.4.0",
"@types/node": "^26.4.1",
"babel-loader": "^10.1.1",
"jsdom": "^30.0.1",
"tscw-config": "^1.1.2",
"typescript": "^7.0.2",
"vitest": "^4.0.18",
"webpack": "^5.110.2",
"webpack": "^5.110.3",
"webpack-cli": "^7.2.3"
},
"repository": {
+709 -741
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -193,7 +193,7 @@
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"postcss": "^8.5.26",
"query-string": "9.5.0",
"query-string": "9.5.1",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react-arborist": "^3.16.0",
@@ -280,7 +280,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.4.0",
"@types/node": "^26.4.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -294,7 +294,7 @@
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.5.0",
"babel-jest": "^30.5.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
@@ -320,8 +320,8 @@
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.8",
"imports-loader": "^5.0.0",
"jest": "^30.5.0",
"jest-environment-jsdom": "^30.5.0",
"jest": "^30.5.1",
"jest-environment-jsdom": "^30.5.1",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
@@ -331,7 +331,7 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.8.0",
"open-cli": "^9.0.0",
"oxfmt": "^0.65.0",
"oxfmt": "^0.66.0",
"oxlint": "^1.80.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
@@ -353,7 +353,7 @@
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.110.2",
"webpack": "^5.110.3",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
@@ -39,7 +39,7 @@
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.4.0",
"jest": "^30.5.0",
"jest": "^30.5.1",
"yeoman-test": "^11.6.0"
},
"engines": {
@@ -20,7 +20,3 @@
export * from './TranslatorSingleton';
export * from './types';
export { default as Translator } from './Translator';
export default {};
export { default as __hack_reexport_translation } from './types';
@@ -18,7 +18,7 @@
*/
import { Jed as BaseJed, JedOptions, DomainData, Translations } from './jed';
export { Translations, DomainConfig, DomainData, JedOptions } from './jed';
export type { Translations, DomainConfig, DomainData, JedOptions } from './jed';
/**
* Superset supported languages.
@@ -86,5 +86,3 @@ export interface TranslatorConfig {
* Key-value mapping of translation key and the translations.
*/
export type LocaleData = Partial<Record<Locale, Translations>>;
export default {};
@@ -52,5 +52,3 @@ export interface Jed {
options: JedOptions;
}
export default {};
@@ -537,8 +537,6 @@ export type ResolvedColorFormatterResult = {
color?: string;
};
export default {};
export function isColumnMeta(column: AnyDict): column is ColumnMeta {
return !!column && 'column_name' in column;
}
@@ -78,7 +78,7 @@
"math-expression-evaluator": "^2.0.7",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-ace": "^15.0.0",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.4",
"react-js-cron": "^6.0.2",
@@ -103,7 +103,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.4.0",
"@types/node": "^26.4.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",
@@ -44,7 +44,3 @@ export * from './types/TransformFunction';
export * from './types/QueryResponse';
export * from './types/VizType';
export * from './types/matrixify';
export { default as __hack_reexport_chart_Base } from './types/Base';
export { default as __hack_reexport_chart_TransformFunction } from './types/TransformFunction';
export { default as __hack_reexport_chart_QueryResponse } from './types/QueryResponse';
@@ -113,8 +113,19 @@ export interface BackendOwnState {
* Each chart plugin can implement this to convert its internal state representation
* to the standardized backend format.
*/
export interface ChartStateConverterOptions {
// Set when converting for a download/export query rather than the chart's
// live (re-)query. Some chart-specific state (e.g. AG Grid's client-side
// sort/filter) is normally excluded from the live query's ownState to
// avoid triggering an unnecessary requery, but a downloaded file has no
// client-side pass to apply that state, so it still needs to be converted
// for exports to reproduce the displayed view.
forExport?: boolean;
}
export type ChartStateConverter<TChartState = JsonObject> = (
chartState: TChartState,
options?: ChartStateConverterOptions,
) => Partial<BackendOwnState>;
export interface PlainObject {
@@ -153,5 +164,3 @@ export enum AxisType {
export interface LegendState {
[key: string]: boolean;
}
export default {};
@@ -48,5 +48,3 @@ export type LegacyQueryData = PlainObject;
* Don't use this for a specific chart (since you know which API it uses already).
*/
export type QueryData = LegacyQueryData | ChartDataResponseResult;
export default {};
@@ -50,5 +50,3 @@ export type BuildQueryFunction<T extends QueryFormData> = (
};
},
) => QueryContext;
export default {};
@@ -604,6 +604,61 @@ test('cleans up event listeners on unmount', async () => {
offSpy.mockRestore();
});
test('re-applies annotations only when their content actually changes across renders (react-ace 15 fast-equals regression guard)', async () => {
// react-ace's componentDidUpdate decides whether to call
// session.setAnnotations() by deep-comparing the new/old `annotations`
// prop (lib/ace.js, using an internal deep-equality helper -- lodash's
// isEqual through react-ace 14.x, fast-equals's deepEqual from 15.0.0
// onward). Superset's own AceEditorProvider/EditorWrapper always pass a
// freshly `.map()`-derived annotations array on every render, so this
// guards the actual behavior Superset relies on: a same-content-but-
// different-reference array must NOT re-trigger setAnnotations (or the
// editor would thrash on every keystroke-driven re-render), while a
// genuinely different array must still update the editor.
const ref = createRef<AceEditor>();
const annotationsV1 = [{ row: 0, column: 0, type: 'error', text: 'oops' }];
const { rerender, container } = render(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV1} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const session = ref.current?.editor?.getSession();
expect(session).toBeDefined();
if (!session) return;
// The initial mount already applies annotations via componentDidMount,
// not componentDidUpdate, so start observing only from the first update.
const setAnnotationsSpy = jest.spyOn(session, 'setAnnotations');
// Same content, new array/object references -- must be a no-op.
const annotationsV1SameContent = [
{ row: 0, column: 0, type: 'error', text: 'oops' },
];
rerender(
<SQLEditor
ref={ref as React.Ref<never>}
annotations={annotationsV1SameContent}
/>,
);
expect(setAnnotationsSpy).not.toHaveBeenCalled();
// Genuinely different content -- must update, with the new value.
const annotationsV2 = [
{ row: 1, column: 2, type: 'warning', text: 'different' },
];
rerender(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV2} />,
);
expect(setAnnotationsSpy).toHaveBeenCalledTimes(1);
expect(setAnnotationsSpy).toHaveBeenCalledWith(annotationsV2);
setAnnotationsSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { CSSProperties } from 'react';
import { screen, render } from '@superset-ui/core/spec';
import { Button, DropdownContainer, Icons } from '..';
@@ -178,3 +179,73 @@ test('component renders and functions without throwing errors', () => {
// Basic functionality test
expect(screen.getByText('Element 1')).toBeInTheDocument();
});
const ITEM_WIDTH = 100;
/* Width the flex layout leaves the row once the trigger button is laid out. */
const ROW_WIDTH = 250;
/* Item count of the transient row that holds every item while remeasuring. */
const ALL_ITEMS = 4;
/**
* Lays items out at ITEM_WIDTH each. In the steady state the row is reported at
* the flex-bounded ROW_WIDTH, so a three-item row overflows and the trigger
* shows. In the all-items frame the row is reported at its own content width
* instead, which is the frame Edge can paint before the flex layout bounds the
* row and its children spill out. `onRowMeasure` receives the row during that
* frame so a test can assert how it is styled.
*/
const mockBoundingRects = (onRowMeasure: (row: HTMLElement) => void) => {
const getBoundingClientRect: (this: HTMLElement) => DOMRect = function () {
let right: number;
if (this.dataset.test === 'container') {
const allItemsFrame = this.children.length === ALL_ITEMS;
right = allItemsFrame ? this.children.length * ITEM_WIDTH : ROW_WIDTH;
if (allItemsFrame) {
onRowMeasure(this);
}
} else {
const itemNumber = Number(this.textContent?.match(/Element (\d+)/)?.[1]);
right = itemNumber ? itemNumber * ITEM_WIDTH : ROW_WIDTH;
}
return {
bottom: 0,
height: 0,
left: right - ITEM_WIDTH,
right,
top: 0,
width: ITEM_WIDTH,
x: right - ITEM_WIDTH,
y: 0,
toJSON: () => ({}),
};
};
jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(getBoundingClientRect);
};
/* Grows an overflowing row by one item, which mounts every item for a frame
* while the new overflow index is calculated. */
const remeasureWithExtraItem = (style?: CSSProperties) => {
const { rerender } = render(<DropdownContainer items={generateItems(3)} />);
rerender(<DropdownContainer items={generateItems(3)} />);
expect(screen.getByTestId('dropdown-container-btn')).toBeInTheDocument();
rerender(
<DropdownContainer items={generateItems(ALL_ITEMS)} style={style} />,
);
};
test('clips the item row while remeasuring, then restores it', () => {
const measured: string[] = [];
mockBoundingRects(row => {
measured.push(row.style.overflow);
});
remeasureWithExtraItem({ overflow: 'visible' });
/* While the row holds every item its children can spill past its own box, so
* clipping wins over the consumer's `overflow: visible` for that frame. Once
* the new overflow index is applied the consumer's value comes back. */
expect(measured[0]).toBe('hidden');
expect(measured.at(-1)).toBe('visible');
});
@@ -330,7 +330,18 @@ export const DropdownContainer = forwardRef(
min-width: 0px;
`}
data-test="container"
style={style}
style={
recalculating
? {
...style,
/* The flex layout already bounds the row's own box, but its
* children can spill past that box in the frame Edge paints
* before the new overflow index is applied. Clipping keeps
* them inside the filter bar. */
overflow: 'hidden',
}
: style
}
>
{notOverflowedItems.map(item => item.element)}
</div>
@@ -222,6 +222,7 @@ export type {
GridState,
GridReadyEvent,
CellClickedEvent,
CellContextMenuEvent,
CellKeyDownEvent,
CellClassParams,
IMenuActionParams,
@@ -26,4 +26,3 @@ export type { NormalizeOptions } from './normalizeBackendUrls';
export * from './types';
export * from './constants';
export { default as __hack_reexport_connection } from './types';
@@ -176,5 +176,3 @@ export interface SupersetClientInterface extends Pick<
}
export type SupersetClientResponse = Response | JsonResponse | TextResponse;
export default {};
@@ -24,4 +24,3 @@ export { default as mergeMargin } from './mergeMargin';
export { default as parseLength } from './parseLength';
export * from './types';
export { default as __hack_reexport_dimension } from './types';
@@ -37,5 +37,3 @@ export interface Dimension {
width: number;
height: number;
}
export default {};
@@ -21,4 +21,3 @@ export { default as getFormData } from './getFormData';
export { default as getDatasourceMetadata } from './getDatasourceMetadata';
export * from './types';
export { default as __hack_reexport_query_api_legacy } from './types';
@@ -24,5 +24,3 @@ export interface LegacyChartDataResponse extends Omit<
> {
data: Record<string, unknown>[] | Record<string, unknown>;
}
export default {};
@@ -39,5 +39,3 @@ export interface TimeCompare {
time_compare?: string;
comparison_type?: ComparisonType;
}
export default {};
@@ -82,5 +82,3 @@ export function isAdhocColumnReference(column?: any): column is AdhocColumn {
export function isQueryFormColumn(column: any): column is QueryFormColumn {
return isPhysicalColumn(column) || isAdhocColumn(column);
}
export default {};
@@ -271,5 +271,3 @@ export interface LegacyChartCustomizationItem {
chartId?: number;
customization: LegacyChartCustomizationConfig;
}
export default {};
@@ -71,5 +71,3 @@ export const DEFAULT_METRICS: Metric[] = [
uuid: nanoid(),
},
];
export default {};
@@ -97,5 +97,3 @@ export function isQueryFormMetric(metric: any): metric is QueryFormMetric {
isAdhocMetricSQL(metric)
);
}
export default {};
@@ -494,5 +494,3 @@ export enum ContributionType {
export type DatasourceSamplesQuery = {
filters?: QueryObjectFilterClause[];
};
export default {};
@@ -214,5 +214,3 @@ export type LatestQueryFormData = Partial<QueryFormData>;
//---------------------------------------------------
// Type guards
//---------------------------------------------------
export default {};
@@ -114,5 +114,3 @@ export interface TimeseriesChartDataResponseResult extends ChartDataResponseResu
export interface ChartDataResponse {
queries: ChartDataResponseResult[];
}
export default {};
@@ -36,5 +36,3 @@ export type TimeColumnConfigKey =
export type AppliedTimeExtras = Partial<
Record<TimeColumnConfigKey, keyof QueryObject>
>;
export default {};
@@ -28,15 +28,3 @@ export * from './Time';
export * from './AdvancedAnalytics';
export * from './PostProcessing';
export * from './Dashboard';
export { default as __hack_reexport_Datasource } from './Datasource';
export { default as __hack_reexport_Column } from './Column';
export { default as __hack_reexport_Metric } from './Metric';
export { default as __hack_reexport_Query } from './Query';
export { default as __hack_reexport_QueryResponse } from './QueryResponse';
export { default as __hack_reexport_QueryFormData } from './QueryFormData';
export { default as __hack_reexport_Time } from './Time';
export { default as __hack_reexport_AdvancedAnalytics } from './AdvancedAnalytics';
export { default as __hack_reexport_Dashboard } from './Dashboard';
export default {};
@@ -20,6 +20,15 @@ import type { DataRecordValue } from '../query/types/QueryResponse';
import type { TimeFormatFunction } from './types';
import normalizeTimestamp from './utils/normalizeTimestamp';
/**
* A missing date can arrive as either `null`/`undefined` or an empty string
* (e.g. a blank cell in an otherwise-numeric epoch column, which also has the
* side effect of degrading the whole column's formatter to `String` - see
* `isNumeric` in transformProps.ts). Both should be treated as "no value".
*/
export const isEmptyDateInput = (input: DataRecordValue): boolean =>
input === null || input === undefined || input === '';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
@@ -19,7 +19,10 @@
export { default as TimeFormats, LOCAL_PREFIX } from './TimeFormats';
export { default as TimeFormatter, PREVIEW_TIME } from './TimeFormatter';
export { default as DateWithFormatter } from './DateWithFormatter';
export {
default as DateWithFormatter,
isEmptyDateInput,
} from './DateWithFormatter';
export { DEFAULT_D3_TIME_FORMAT } from './D3FormatConfig';
export {
@@ -79,4 +79,5 @@ export interface AgGridChartState {
columnOrder?: string[];
pageSize?: number;
currentPage?: number;
serverPagination?: boolean;
}
@@ -40,6 +40,7 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
CellContextMenuEvent,
CellKeyDownEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
@@ -59,9 +60,13 @@ import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import getInitialFilterModel from '../utils/getInitialFilterModel';
import reconcileColumnState from '../utils/reconcileColumnState';
import getColumnStateSignature from '../utils/getColumnStateSignature';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { getCompleteFilterState } from '../utils/filterStateManager';
import { PAGE_SIZE_OPTIONS, ROW_NUMBER_COL_ID } from '../consts';
import {
getCompleteFilterState,
type FilterState,
} from '../utils/filterStateManager';
import { copyCellValueOnKeyDown } from '../utils/copyCellValue';
import type { ClientViewSnapshot } from '../utils/externalAPIs';
export interface AgGridState extends Partial<GridState> {
timestamp?: number;
@@ -77,7 +82,6 @@ export type AgGridChartStateWithMetadata = Partial<AgGridChartState> & {
export interface AgGridTableProps {
gridTheme?: string;
isDarkMode?: boolean;
gridHeight?: number;
updateInterval?: number;
data?: any[];
onGridReady?: (params: GridReadyEvent) => void;
@@ -100,17 +104,20 @@ export interface AgGridTableProps {
serverPageLength: number;
hasServerPageLengthChanged: boolean;
handleCellClicked: (event: CellClickedEvent) => void;
handleCellContextMenu?: (event: CellContextMenuEvent) => void;
handleSelectionChanged: (event: SelectionChangedEvent) => void;
filters?: Record<string, DataRecordValue[]> | null;
isActiveFilterValue?: (key: string, val: DataRecordValue) => boolean;
renderTimeComparisonDropdown: () => JSX.Element | null;
cleanedTotals: DataRecord;
showTotals: boolean;
width: number;
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
onFilterChanged?: (filterModel: Record<string, any>) => void;
onFilterChanged?: (completeFilterState: FilterState) => void;
metricColumns?: string[];
gridRef?: RefObject<AgGridReact>;
chartState?: AgGridChartState;
onClientViewChange?: (snapshot: ClientViewSnapshot) => void;
}
ModuleRegistry.registerModules([AllCommunityModule, ClientSideRowModelModule]);
@@ -119,7 +126,6 @@ const isSearchFocused = new Map<string, boolean>();
const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
({
gridHeight,
data = [],
colDefsFromProps,
includeSearch,
@@ -140,8 +146,10 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
serverPageLength,
hasServerPageLengthChanged,
handleCellClicked,
handleCellContextMenu,
handleSelectionChanged,
filters,
isActiveFilterValue,
renderTimeComparisonDropdown,
cleanedTotals,
showTotals,
@@ -150,12 +158,14 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
onFilterChanged,
metricColumns = [],
chartState,
onClientViewChange,
}) => {
const gridRef = useRef<AgGridReact>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowData = useMemo(() => data, [data]);
const containerRef = useRef<HTMLDivElement>(null);
const lastCapturedStateRef = useRef<string | null>(null);
const hasCapturedInitialGridStateRef = useRef(false);
const filterOperationVersionRef = useRef(0);
const searchId = `search-${id}`;
@@ -189,13 +199,26 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
[],
);
// Memoize container style
// Fills the full height allotted by the chart container (StyledChartContainer);
// the search/time-comparison controls and pagination bar take their natural
// height and the grid flexes into whatever space remains (see gridFlexStyles),
// instead of a hardcoded pixel height that drifts from the actual chrome height.
const containerStyles = useMemo(
() => ({
height: gridHeight,
height: '100%',
width,
display: 'flex',
flexDirection: 'column' as const,
}),
[gridHeight, width],
[width],
);
const gridFlexStyles = useMemo(
() => ({
flex: '1 1 auto',
minHeight: 0,
}),
[],
);
const [quickFilterText, setQuickFilterText] = useState<string>();
@@ -293,6 +316,7 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
sortModel,
filterModel,
timestamp: Date.now(),
serverPagination: true,
});
}
@@ -321,49 +345,85 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
[serverPagination, gridInitialState, percentMetrics, onSortChange],
);
const handleGridStateChange = useCallback(
const captureGridState = useCallback(() => {
const { api } = gridRef.current ?? {};
if (!api) return null;
const columnState = api.getColumnState ? api.getColumnState() : [];
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
const sortModel = columnState
.filter(col => col.sort)
.map(col => ({
colId: col.colId,
sort: col.sort as 'asc' | 'desc',
sortIndex: col.sortIndex || 0,
}))
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
return {
stateToSave: {
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
serverPagination: !!serverPagination,
},
stateHash: getColumnStateSignature(columnState, sortModel, filterModel),
};
}, [serverPagination]);
const persistGridStateChange = useCallback(
debounce(() => {
if (onColumnStateChange && gridRef.current?.api) {
try {
const { api } = gridRef.current;
if (!onColumnStateChange) return;
try {
const captured = captureGridState();
if (!captured) return;
const { stateToSave, stateHash } = captured;
const columnState = api.getColumnState ? api.getColumnState() : [];
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
const filterModel = api.getFilterModel ? api.getFilterModel() : {};
const sortModel = columnState
.filter(col => col.sort)
.map(col => ({
colId: col.colId,
sort: col.sort as 'asc' | 'desc',
sortIndex: col.sortIndex || 0,
}))
.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0));
const stateToSave = {
columnState,
sortModel,
filterModel,
timestamp: Date.now(),
};
const stateHash = getColumnStateSignature(
columnState,
sortModel,
filterModel,
);
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
onColumnStateChange(stateToSave);
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
onColumnStateChange(stateToSave);
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
}, Constants.SLOW_DEBOUNCE),
[onColumnStateChange],
[onColumnStateChange, captureGridState],
);
const handleGridStateChange = useCallback(() => {
// AG Grid fires onStateUpdated once as it applies the initial
// column/sort/filter state on mount, before any user interaction.
// That first event just reflects the state the grid was initialized
// with (chartState/gridInitialState) - not a user-driven change - so
// it's captured synchronously as the baseline rather than persisted.
// This check runs on every raw call, before debouncing, so a real
// user action that lands inside the same debounce window as this
// first call is never coalesced into it and dropped.
if (!hasCapturedInitialGridStateRef.current) {
hasCapturedInitialGridStateRef.current = true;
try {
const captured = captureGridState();
if (captured) {
lastCapturedStateRef.current = captured.stateHash;
}
} catch (error) {
console.warn('Error capturing AG Grid state:', error);
}
return;
}
persistGridStateChange();
}, [captureGridState, persistGridStateChange]);
useEffect(
() =>
// Cleanup debounced grid-state capture
() => {
persistGridStateChange.cancel();
},
[persistGridStateChange],
);
const handleFilterChanged = useCallback(async () => {
@@ -416,6 +476,81 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
serverPaginationData?.agGridFilterModel,
]);
// Captures the "current view" (post-filter/sort, all rows across all
// pages) for the "Export Current View" menu, mirroring Table V1's
// clientView snapshot. Client-side mode only: in server pagination mode
// the grid only ever holds a single page's rows, so a client-derived
// snapshot can't represent the full filtered/sorted result and export
// falls back to a fresh backend query instead (see useExploreAdditionalActionsMenu).
const lastClientViewSignatureRef = useRef<string | null>(null);
// Unlike handleGridStateChange's columnState/sortModel/filterModel,
// clientView is excluded from ownState re-query comparisons on both the
// Explore (ExploreViewContainer) and dashboard (activeAllDashboardFilters)
// paths, so publishing it - including the very first snapshot right
// after mount - can't trigger a requery/remount loop. It's therefore
// always persisted below rather than having its initial value skipped;
// skipping it would leave "Export Current View" without a snapshot to
// export until some later grid event changes the signature.
// Debounced (like handleGridStateChange below) because the full
// filtered+sorted traversal is O(n) and onModelUpdated can fire rapidly
// in succession (e.g. while typing into a quick filter); only the
// trailing update needs to recompute the snapshot.
const handleModelUpdated = useCallback(
debounce(() => {
if (serverPagination || !onClientViewChange || !gridRef.current?.api) {
return;
}
const { api } = gridRef.current;
const displayedColumns = api
.getAllDisplayedColumns()
.filter(column => column.getColId() !== ROW_NUMBER_COL_ID);
const columns = displayedColumns.map(column => {
const colDef = column.getColDef();
// For comparison columns, colId has "Main " stripped for display,
// but row data is still keyed by the unstripped original field
// (colDef.context.dataKey, set in useColDefs) -- use that to read
// row values so exported rows aren't blank for the main metric.
const dataKey = colDef.context?.dataKey ?? column.getColId();
return {
key: dataKey,
label: colDef.headerName || column.getColId(),
};
});
const rows: Record<string, unknown>[] = [];
api.forEachNodeAfterFilterAndSort(node => {
if (node.data) {
rows.push(node.data);
}
});
// Without a getRowId callback, AG Grid's node ids are purely
// positional and reset to 0..n-1 on every setRowData call, so they
// don't identify a row's content across a data refresh — hashing
// the actual filtered+sorted row content (which this function
// already has to visit to build `rows`) is what actually detects
// both value changes (e.g. a refresh with the same row count) and
// order changes (e.g. a pure sort), not just count/column changes.
const signature = `${JSON.stringify(rows)}|${columns.map(c => c.key).join(',')}`;
if (signature === lastClientViewSignatureRef.current) {
return;
}
lastClientViewSignatureRef.current = signature;
onClientViewChange({ rows, columns, count: rows.length });
}, Constants.SLOW_DEBOUNCE),
[serverPagination, onClientViewChange],
);
useEffect(
() =>
// Cleanup debounced client-view snapshot capture
() => {
handleModelUpdated.cancel();
},
[handleModelUpdated],
);
useEffect(() => {
if (
hasServerPageLengthChanged &&
@@ -436,14 +571,32 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
}
}, [width]);
// Row highlighting must reflect the active cross filter regardless of how
// it was applied (cell click, context menu, or an external dashboard
// filter), so it survives re-renders and server-side re-queries rather
// than only reflecting whichever handler last called setSelected.
useEffect(() => {
if (
(!filters || Object.keys(filters).length === 0) &&
gridRef.current?.api?.getSelectedRows().length
) {
gridRef.current.api.deselectAll();
const api = gridRef.current?.api;
if (!api) return;
if (!filters || Object.keys(filters).length === 0) {
if (api.getSelectedRows().length) {
api.deselectAll();
}
return;
}
}, [filters]);
if (!isActiveFilterValue) return;
api.forEachNode(node => {
const matches = Object.keys(filters).some(key =>
isActiveFilterValue(key, node.data?.[key] as DataRecordValue),
);
if (node.isSelected() !== matches) {
node.setSelected(matches, false, 'api');
}
});
}, [filters, isActiveFilterValue, rowData]);
const onGridReady = (params: GridReadyEvent) => {
// This will make columns fill the grid width
@@ -511,126 +664,130 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
)}
</div>
<ThemedAgGridReact
ref={gridRef}
onGridReady={onGridReady}
className="ag-container"
rowData={rowData}
headerHeight={36}
rowHeight={30}
columnDefs={colDefsFromProps}
defaultColDef={defaultColDef}
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
paginationPageSize={pageSize}
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),
previous: t('Previous'),
page: t('Page'),
more: t('More'),
to: t('to'),
of: t('of'),
first: t('First'),
last: t('Last'),
loadingOoo: t('Loading...'),
// Set Filter
selectAll: t('Select All'),
searchOoo: t('Search...'),
blanks: t('Blanks'),
// Filter operations
filterOoo: t('Filter'),
applyFilter: t('Apply Filter'),
equals: t('Equals'),
notEqual: t('Not Equal'),
lessThan: t('Less Than'),
greaterThan: t('Greater Than'),
lessThanOrEqual: t('Less Than or Equal'),
greaterThanOrEqual: t('Greater Than or Equal'),
inRange: t('In Range'),
contains: t('Contains'),
notContains: t('Not Contains'),
startsWith: t('Starts With'),
endsWith: t('Ends With'),
// Logical conditions
andCondition: t('AND'),
orCondition: t('OR'),
// Panel and group labels
group: t('Group'),
columns: t('Columns'),
filters: t('Filters'),
valueColumns: t('Value Columns'),
pivotMode: t('Pivot Mode'),
groups: t('Groups'),
values: t('Values'),
pivots: t('Pivots'),
toolPanelButton: t('Tool Panel'),
// Enterprise menu items
pinColumn: t('Pin Column'),
valueAggregation: t('Value Aggregation'),
autosizeThiscolumn: t('Autosize This Column'),
autosizeAllColumns: t('Autosize All Columns'),
groupBy: t('Group By'),
ungroupBy: t('Ungroup By'),
resetColumns: t('Reset Columns'),
expandAll: t('Expand All'),
collapseAll: t('Collapse All'),
toolPanel: t('Tool Panel'),
export: t('Export'),
csvExport: t('CSV Export'),
excelExport: t('Excel Export'),
excelXmlExport: t('Excel XML Export'),
// Aggregation functions
sum: t('Sum'),
min: t('Min'),
max: t('Max'),
none: t('None'),
count: t('Count'),
average: t('Average'),
// Standard menu items
copy: t('Copy'),
copyWithHeaders: t('Copy with Headers'),
paste: t('Paste'),
// Column menu and sorting
sortAscending: t('Sort Ascending'),
sortDescending: t('Sort Descending'),
sortUnSort: t('Clear Sort'),
}}
context={{
onColumnHeaderClicked: handleColumnHeaderClick,
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
lastFilteredInputPosition:
serverPaginationData?.lastFilteredInputPosition,
}}
/>
<div style={gridFlexStyles}>
<ThemedAgGridReact
ref={gridRef}
onGridReady={onGridReady}
className="ag-container"
rowData={rowData}
headerHeight={36}
rowHeight={30}
columnDefs={colDefsFromProps}
defaultColDef={defaultColDef}
onColumnGroupOpened={params => params.api.sizeColumnsToFit()}
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellContextMenu={handleCellContextMenu}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onModelUpdated={handleModelUpdated}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
paginationPageSize={pageSize}
paginationPageSizeSelector={PAGE_SIZE_OPTIONS}
suppressDragLeaveHidesColumns
pinnedBottomRowData={showTotals ? [cleanedTotals] : undefined}
tooltipShowDelay={500}
localeText={{
// Pagination controls
next: t('Next'),
previous: t('Previous'),
page: t('Page'),
more: t('More'),
to: t('to'),
of: t('of'),
first: t('First'),
last: t('Last'),
loadingOoo: t('Loading...'),
// Set Filter
selectAll: t('Select All'),
searchOoo: t('Search...'),
blanks: t('Blanks'),
// Filter operations
filterOoo: t('Filter'),
applyFilter: t('Apply Filter'),
equals: t('Equals'),
notEqual: t('Not Equal'),
lessThan: t('Less Than'),
greaterThan: t('Greater Than'),
lessThanOrEqual: t('Less Than or Equal'),
greaterThanOrEqual: t('Greater Than or Equal'),
inRange: t('In Range'),
contains: t('Contains'),
notContains: t('Not Contains'),
startsWith: t('Starts With'),
endsWith: t('Ends With'),
// Logical conditions
andCondition: t('AND'),
orCondition: t('OR'),
// Panel and group labels
group: t('Group'),
columns: t('Columns'),
filters: t('Filters'),
valueColumns: t('Value Columns'),
pivotMode: t('Pivot Mode'),
groups: t('Groups'),
values: t('Values'),
pivots: t('Pivots'),
toolPanelButton: t('Tool Panel'),
// Enterprise menu items
pinColumn: t('Pin Column'),
valueAggregation: t('Value Aggregation'),
autosizeThiscolumn: t('Autosize This Column'),
autosizeAllColumns: t('Autosize All Columns'),
groupBy: t('Group By'),
ungroupBy: t('Ungroup By'),
resetColumns: t('Reset Columns'),
expandAll: t('Expand All'),
collapseAll: t('Collapse All'),
toolPanel: t('Tool Panel'),
export: t('Export'),
csvExport: t('CSV Export'),
excelExport: t('Excel Export'),
excelXmlExport: t('Excel XML Export'),
// Aggregation functions
sum: t('Sum'),
min: t('Min'),
max: t('Max'),
none: t('None'),
count: t('Count'),
average: t('Average'),
// Standard menu items
copy: t('Copy'),
copyWithHeaders: t('Copy with Headers'),
paste: t('Paste'),
// Column menu and sorting
sortAscending: t('Sort Ascending'),
sortDescending: t('Sort Descending'),
sortUnSort: t('Clear Sort'),
}}
context={{
onColumnHeaderClicked: handleColumnHeaderClick,
initialSortState: getInitialSortState(
serverPaginationData?.sortBy || [],
),
lastFilteredColumn: serverPaginationData?.lastFilteredColumn,
lastFilteredInputPosition:
serverPaginationData?.lastFilteredInputPosition,
}}
/>
</div>
{serverPagination && (
<Pagination
currentPage={serverPaginationData?.currentPage || 0}
@@ -18,16 +18,28 @@
*/
import { t } from '@apache-superset/core/translation';
import {
BinaryQueryObjectFilterClause,
DataRecord,
DataRecordValue,
DateWithFormatter,
extractTextFromHTML,
getTimeFormatterForGranularity,
isEmptyDateInput,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { isEqual } from 'lodash-es';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useMemo,
} from 'react';
import { debounce, isEqual } from 'lodash-es';
import {
CellClickedEvent,
CellContextMenuEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import {
@@ -37,20 +49,18 @@ import {
SortByItem,
} from './types';
import AgGridDataTable from './AgGridTable';
import { updateTableOwnState } from './utils/externalAPIs';
import { updateTableOwnState, ClientViewSnapshot } from './utils/externalAPIs';
import TimeComparisonVisibility from './AgGridTable/components/TimeComparisonVisibility';
import { useColDefs } from './utils/useColDefs';
import { buildSelectionCrossFilterDataMask } from './utils/getCrossFilterDataMask';
import {
buildSelectionCrossFilterDataMask,
getCrossFilterDataMask,
} from './utils/getCrossFilterDataMask';
import { StyledChartContainer } from './styles';
import type { FilterState } from './utils/filterStateManager';
const getGridHeight = (height: number, includeSearch: boolean | undefined) => {
let calculatedGridHeight = height;
if (includeSearch) {
calculatedGridHeight -= 16;
}
return calculatedGridHeight - 80;
};
import { formatColumnValue } from './utils/formatValue';
import getTimeRangeFromGranularity from './utils/getTimeRangeFromGranularity';
import getScrollBarSize from './utils/getScrollBarSize';
export default function TableChart<D extends DataRecord = DataRecord>(
props: AgGridTableChartTransformedProps<D> & {},
@@ -61,6 +71,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
data,
includeSearch,
allowRearrangeColumns,
allowRenderHtml,
pageSize,
serverPagination,
rowCount,
@@ -88,8 +99,60 @@ export default function TableChart<D extends DataRecord = DataRecord>(
metricSqlExpressions,
rawSummaryColumns,
showNumberedColumn,
onContextMenu,
formData,
} = props;
// The dashboard's layout engine reports a burst of close-but-not-identical
// width/height values while it settles on initial load. Committing each
// intermediate value resizes the chart container and re-fits AG Grid's
// columns once per value; for any column with wrapText/autoHeight (the
// default - see useColDefs), each re-fit can flip a borderline cell across
// its wrap boundary and change that row's height, which is what actually
// reads as "flicker" rather than the container resize itself.
//
// A scrollbar-sized threshold (matching plugin-chart-table/v1's guard)
// filters out sub-pixel noise, but genuine multi-step settling still gets
// through as several real width values in quick succession. Debouncing
// every commit after the first collapses that burst into the single final
// value once it stops changing, while still painting the first available
// size immediately so the chart isn't blank while it waits.
const [tableSize, setTableSize] = useState({ width: 0, height: 0 });
const hasCommittedInitialSize = useRef(false);
const debouncedSetTableSize = useMemo(
() =>
debounce((size: { width: number; height: number }) => {
setTableSize(size);
}, 250),
[],
);
useEffect(
() =>
// Cleanup debounced size commit
() => {
debouncedSetTableSize.cancel();
},
[debouncedSetTableSize],
);
useLayoutEffect(() => {
const scrollBarSize = getScrollBarSize();
const sizeChanged =
Math.abs(width - tableSize.width) > scrollBarSize ||
Math.abs(height - tableSize.height) > scrollBarSize;
if (!sizeChanged) {
return;
}
if (!hasCommittedInitialSize.current) {
hasCommittedInitialSize.current = true;
setTableSize({ width, height });
} else {
debouncedSetTableSize({ width, height });
}
}, [width, height, tableSize, debouncedSetTableSize]);
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
// Extract metric column names for SQL conversion
@@ -114,6 +177,27 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}, [columns]);
// Tracks the most recently written ownState so that writes triggered
// asynchronously (e.g. clientView from AG Grid's onModelUpdated, which can
// fire with a stale closure) merge onto the latest known state instead of
// a stale render-time serverPaginationData snapshot. updateTableOwnState
// replaces ownState wholesale, so merging at write time - rather than at
// render time - is what keeps concurrent writers from clobbering one
// another's keys.
const ownStateRef = useRef(serverPaginationData);
useEffect(() => {
ownStateRef.current = serverPaginationData;
}, [serverPaginationData]);
const writeOwnState = useCallback(
(patch: Record<string, unknown>) => {
const nextOwnState = { ...ownStateRef.current, ...patch };
ownStateRef.current = nextOwnState;
updateTableOwnState(setDataMask, nextOwnState);
},
[setDataMask],
);
// A single effect owns every ownState write derived from render state.
// updateTableOwnState replaces ownState wholesale, so separate effects that
// each spread serverPaginationData in the same render would clobber one
@@ -121,7 +205,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// columns and nudging a re-query for missing totals must be one combined
// delta.
useEffect(() => {
const nextOwnState = { ...serverPaginationData };
const patch: Record<string, unknown> = {};
let changed = false;
if (serverPagination && serverPaginationData && rowCount !== undefined) {
@@ -132,7 +216,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// last remaining page.
const clampedPage = Math.max(0, Math.min(currentPage, totalPages - 1));
if (clampedPage !== currentPage) {
nextOwnState.currentPage = clampedPage;
patch.currentPage = clampedPage;
changed = true;
}
}
@@ -140,22 +224,22 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const primed = (serverPaginationData?.rawSummaryColumns ?? []) as string[];
const requested = Boolean(serverPaginationData?.totalsRequested);
if (isRawRecords && showTotals && !isEqual(primed, rawSummaryColumns)) {
nextOwnState.rawSummaryColumns = rawSummaryColumns;
patch.rawSummaryColumns = rawSummaryColumns;
changed = true;
}
// A renderTrigger toggle re-renders without re-querying; requesting totals
// through ownState dispatches the standard re-query whose buildQuery
// carries the totals query for the active mode.
if (showTotals && totals === undefined && !requested) {
nextOwnState.totalsRequested = true;
patch.totalsRequested = true;
changed = true;
} else if (!showTotals && requested) {
nextOwnState.totalsRequested = false;
patch.totalsRequested = false;
changed = true;
}
if (changed) {
updateTableOwnState(setDataMask, nextOwnState);
writeOwnState(patch);
}
}, [
serverPagination,
@@ -166,7 +250,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
totals,
rawSummaryColumns,
serverPaginationData,
setDataMask,
writeOwnState,
]);
const comparisonColumns = [
@@ -209,8 +293,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
// Prepare modified own state for server pagination
const modifiedOwnState = {
...serverPaginationData,
writeOwnState({
agGridFilterModel:
completeFilterState.originalFilterModel &&
Object.keys(completeFilterState.originalFilterModel).length > 0
@@ -223,14 +306,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
lastFilteredInputPosition: completeFilterState.inputPosition,
currentPage: 0, // Reset to first page when filtering
metricSqlExpressions,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
},
[
setDataMask,
writeOwnState,
serverPagination,
serverPaginationData,
onChartStateChange,
chartState,
metricSqlExpressions,
@@ -273,15 +353,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
colorPositiveNegative,
columnColorFormatters,
allowRearrangeColumns,
allowRenderHtml,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
conditionalFormatting: formData?.conditional_formatting,
comparisonColorEnabled: formData?.comparison_color_enabled,
comparisonColorScheme: formData?.comparison_color_scheme,
});
const gridHeight = getGridHeight(height, includeSearch);
const isActiveFilterValue = useCallback(
function isActiveFilterValue(key: string, val: DataRecordValue) {
if (!filters || !filters[key]) return false;
@@ -348,7 +430,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const handleSelectionChanged = useCallback(
(event: SelectionChangedEvent) => {
if (!emitCrossFilters || !activeColumnRef.current) return;
// Selection changes triggered by the highlight-sync effect (source
// 'api') reflect a filter that was already applied elsewhere (context
// menu, dashboard filter, etc.), so re-deriving and re-dispatching a
// mask from them here would use a stale activeColumnRef and could
// clobber that filter with the wrong column.
if (
!emitCrossFilters ||
!activeColumnRef.current ||
event.source === 'api'
)
return;
const key = activeColumnRef.current;
const selectedRows = event.api.getSelectedRows();
@@ -368,75 +460,204 @@ export default function TableChart<D extends DataRecord = DataRecord>(
[emitCrossFilters, setDataMask, timeGrain, timestampFormatter],
);
const drillColumns = isUsingTimeComparison
? (filteredColumns as InputColumn[])
: (columns as InputColumn[]);
const handleContextMenu = useCallback(
(event: CellContextMenuEvent) => {
if (!onContextMenu || isRawRecords || !event.column || !event.data) {
return;
}
const nativeEvent = event.event as MouseEvent | null | undefined;
if (!nativeEvent) return;
nativeEvent.preventDefault();
nativeEvent.stopPropagation();
const rowData = event.data as Record<string, DataRecordValue>;
const key = event.column.getColId();
const cellValue = event.value as DataRecordValue;
const colDef = event.column.getColDef();
const isMetric = Boolean(
colDef.context?.isMetric || colDef.context?.isPercentMetric,
);
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
drillColumns.forEach(col => {
if (col.isMetric || col.isPercentMetric) return;
const dataRecordValue = rowData[col.key];
if (
dataRecordValue == null ||
(dataRecordValue instanceof DateWithFormatter &&
isEmptyDateInput(dataRecordValue.input))
) {
drillToDetailFilters.push({
col: col.key,
op: 'IS NULL' as any,
val: null,
});
} else if (col.dataType === GenericDataType.Temporal && timeGrain) {
const startTime =
dataRecordValue instanceof Date
? dataRecordValue
: new Date(dataRecordValue as string | number);
if (Number.isNaN(startTime.getTime())) {
// Malformed temporal value: fall back to an equality filter
// instead of building a TEMPORAL_RANGE, since toISOString()
// throws on an Invalid Date and would crash the context menu.
const sanitizedValue = extractTextFromHTML(dataRecordValue);
drillToDetailFilters.push({
col: col.key,
op: '==',
val: sanitizedValue as string | number | boolean,
formattedVal: formatColumnValue(col, sanitizedValue)[1],
});
} else {
const [rangeStartTime, rangeEndTime] = getTimeRangeFromGranularity(
startTime,
timeGrain,
);
const timeRangeValue = `${rangeStartTime.toISOString()} : ${rangeEndTime.toISOString()}`;
drillToDetailFilters.push({
col: col.key,
op: 'TEMPORAL_RANGE',
val: timeRangeValue,
grain: timeGrain,
formattedVal: formatColumnValue(col, dataRecordValue)[1],
});
}
} else {
const sanitizedValue = extractTextFromHTML(dataRecordValue);
drillToDetailFilters.push({
col: col.key,
op: '==',
val: sanitizedValue as string | number | boolean,
formattedVal: formatColumnValue(col, sanitizedValue)[1],
});
}
});
const isCellValueNull =
cellValue == null ||
(cellValue instanceof DateWithFormatter &&
isEmptyDateInput(cellValue.input));
onContextMenu(nativeEvent.clientX, nativeEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: isMetric
? undefined
: getCrossFilterDataMask({
key,
value: cellValue,
filters,
timeGrain,
isActiveFilterValue,
timestampFormatter,
}),
drillBy: isMetric
? undefined
: {
filters: [
isCellValueNull
? { col: key, op: 'IS NULL' as any, val: null }
: {
col: key,
op: '==' as any,
val: extractTextFromHTML(cellValue),
},
],
groupbyFieldName: 'groupby',
},
});
},
[
onContextMenu,
isRawRecords,
drillColumns,
timeGrain,
filters,
isActiveFilterValue,
timestampFormatter,
],
);
const handleServerPaginationChange = useCallback(
(pageNumber: number, pageSize: number) => {
const modifiedOwnState = {
...serverPaginationData,
writeOwnState({
currentPage: pageNumber,
pageSize,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
},
[setDataMask],
[writeOwnState],
);
const handlePageSizeChange = useCallback(
(pageSize: number) => {
const modifiedOwnState = {
...serverPaginationData,
writeOwnState({
currentPage: 0,
pageSize,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
},
[setDataMask],
[writeOwnState],
);
const handleChangeSearchCol = (searchCol: string) => {
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
const modifiedOwnState = {
...serverPaginationData,
if (!isEqual(searchCol, ownStateRef.current?.searchColumn)) {
writeOwnState({
searchColumn: searchCol,
searchText: '',
currentPage: 0, // Reset to first page when the search column changes
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
}
};
const handleSearch = useCallback(
(searchText: string) => {
const modifiedOwnState = {
...serverPaginationData,
writeOwnState({
searchColumn:
serverPaginationData?.searchColumn || searchOptions[0]?.value,
(ownStateRef.current?.searchColumn as string | undefined) ||
searchOptions[0]?.value,
searchText,
currentPage: 0, // Reset to first page when searching
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
},
[setDataMask, searchOptions],
[writeOwnState, searchOptions],
);
const handleSortByChange = useCallback(
(sortBy: SortByItem[]) => {
if (!serverPagination) return;
const modifiedOwnState = {
...serverPaginationData,
writeOwnState({
sortBy,
lastFilteredColumn: undefined,
lastFilteredInputPosition: undefined,
};
updateTableOwnState(setDataMask, modifiedOwnState);
});
},
[setDataMask, serverPagination],
[writeOwnState, serverPagination],
);
// Feeds the "Export Current View" menu item (EXPORT_CURRENT_VIEW behavior),
// mirroring Table V1's clientView snapshot on ownState. Written through
// writeOwnState (rather than spreading serverPaginationData directly)
// because onModelUpdated can fire with a stale closure relative to other
// ownState writers (e.g. a just-applied filter), and updateTableOwnState
// replaces ownState wholesale.
const handleClientViewChange = useCallback(
(clientView: ClientViewSnapshot) => {
writeOwnState({ clientView });
},
[writeOwnState],
);
const renderTimeComparisonVisibility = (): JSX.Element => (
@@ -455,9 +676,22 @@ export default function TableChart<D extends DataRecord = DataRecord>(
.join('|');
return (
<StyledChartContainer height={height}>
<StyledChartContainer
height={tableSize.height}
onContextMenu={event => {
// Safety net: AG Grid only calls handleContextMenu (which calls
// preventDefault) when it resolves the native contextmenu event to
// a cell. If that per-cell resolution ever misses - e.g. a second,
// near-duplicate contextmenu event dispatched in quick succession by
// some mice's right-button switches - the event still bubbles
// through this container, so the browser's native menu is
// suppressed here regardless of whether AG Grid's own handler ran.
if (!isRawRecords) {
event.preventDefault();
}
}}
>
<AgGridDataTable
gridHeight={gridHeight}
key={descriptionsKey}
data={data || []}
colDefsFromProps={colDefs}
@@ -478,8 +712,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
metricColumns={metricColumns}
id={slice_id}
handleCellClicked={handleCellClicked}
handleCellContextMenu={handleContextMenu}
handleSelectionChanged={handleSelectionChanged}
filters={filters}
isActiveFilterValue={isActiveFilterValue}
percentMetrics={percentMetrics}
serverPageLength={serverPageLength}
hasServerPageLengthChanged={hasServerPageLengthChanged}
@@ -490,9 +726,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
showTotals={
showTotals && totals !== undefined && Object.keys(totals).length > 0
}
width={width}
width={tableSize.width}
onColumnStateChange={handleColumnStateChange}
chartState={chartState}
onClientViewChange={handleClientViewChange}
/>
</StyledChartContainer>
);
@@ -674,6 +674,29 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
}
}
// Build the "all records" percent-metric denominator query AFTER all
// filter mutations (interactive group-by, search, AG Grid WHERE/HAVING)
// above, so its denominator reflects the same filtered result set as the
// main query instead of a stale pre-filter snapshot.
const calculationMode = formData.percent_metric_calculation || 'row_limit';
if (
calculationMode === 'all_records' &&
percentMetrics &&
percentMetrics.length > 0
) {
extraQueries.push({
...queryObject,
columns: [],
metrics: percentMetrics,
post_processing: [],
row_limit: 0,
row_offset: 0,
orderby: [],
is_timeseries: false,
});
}
// Create totals query AFTER all filters (including AG Grid filters) are applied
// This ensures we can properly exclude AG Grid WHERE filters from the totals
// In raw records mode the summary is a SUM over the numeric columns primed
@@ -714,33 +737,21 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
: undefined;
if (showAggregateTotals || rawSummaryColumns.length > 0) {
// Create a copy of extras without the AG Grid WHERE clause
// AG Grid filters in extras.where can reference calculated columns
// which aren't available in the totals subquery
const totalsExtras = { ...queryObject.extras };
if (ownState.agGridComplexWhere) {
// Remove AG Grid WHERE clause from totals query
const whereClause = totalsExtras.where;
if (whereClause) {
// Remove the AG Grid filter part from the WHERE clause using string methods
const agGridWhere = ownState.agGridComplexWhere;
let newWhereClause = whereClause;
// Try to remove with " AND " before
newWhereClause = newWhereClause.replace(` AND ${agGridWhere}`, '');
// Try to remove with " AND " after
newWhereClause = newWhereClause.replace(`${agGridWhere} AND `, '');
// If it's the only clause, remove it entirely
if (newWhereClause === agGridWhere) {
newWhereClause = '';
}
if (newWhereClause.trim()) {
totalsExtras.where = newWhereClause;
} else {
delete totalsExtras.where;
}
}
// Start from the original, pre-filter extras (captured before any
// AG Grid WHERE/HAVING or download sqlClauses were merged in above)
// rather than trying to subtract those fragments back out of the
// now-combined `queryObject.extras` string. AG Grid filters can
// reference calculated columns that aren't available once the
// totals subquery drops all grouping columns (columns: []), and that
// applies to HAVING just as much as WHERE, and to the download
// sqlClauses path just as much as the live agGridComplexWhere path —
// starting clean avoids having to special-case each source.
const totalsExtras = { ...extras };
if (!totalsExtras.where) {
delete totalsExtras.where;
}
if (!totalsExtras.having) {
delete totalsExtras.having;
}
extraQueries.push({
@@ -39,6 +39,8 @@ import {
shouldSkipMetricColumn,
isRegularMetric,
isPercentMetric,
ConditionalFormattingConfig,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
@@ -194,6 +196,23 @@ const percentMetricsControl: typeof sharedControls.metrics = {
validators: [],
};
const percentMetricCalculationControl: ControlConfig<'SelectControl'> = {
type: 'SelectControl',
label: t('Percentage metric calculation'),
description: t(
'Row Limit: percentages are calculated based on the subset of data retrieved, respecting the row limit. ' +
'All Records: Percentages are calculated based on the total dataset, ignoring the row limit.',
),
default: 'row_limit',
clearable: false,
choices: [
['row_limit', t('Row limit')],
['all_records', t('All records')],
],
visibility: isAggMode,
renderTrigger: false,
};
/*
Options for row limit control
*/
@@ -431,6 +450,12 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'percent_metric_calculation',
config: percentMetricCalculationControl,
},
],
],
},
{
@@ -481,6 +506,36 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'allow_rearrange_columns',
config: {
type: 'CheckboxControl',
label: t('Allow columns to be rearranged'),
renderTrigger: true,
default: false,
description: t(
"Allow end user to drag-and-drop column headers to rearrange them. Note their changes won't persist for the next time they open the chart.",
),
visibility: ({ controls }: ControlPanelsContainerProps) =>
isEmpty(controls?.time_compare?.value),
},
},
],
[
{
name: 'allow_render_html',
config: {
type: 'CheckboxControl',
label: t('Render columns in HTML format'),
renderTrigger: true,
default: true,
description: t(
'Renders table cells as HTML when applicable. For example, HTML <a> tags will be rendered as hyperlinks.',
),
},
},
],
],
},
{
@@ -573,11 +628,14 @@ const config: ControlPanelConfig = {
const updatedColtypes: GenericDataType[] = [];
colnames
.map(
(colname, index) => [colname, index] as [string, number],
)
.filter(
colname =>
([colname]) =>
last(colname.split('__')) !== timeComparisonValue,
)
.forEach((colname, index) => {
.forEach(([colname, originalIndex]) => {
if (
shouldSkipMetricColumn({
colname,
@@ -614,7 +672,12 @@ const config: ControlPanelConfig = {
});
} else {
updatedColnames.push(colname);
updatedColtypes.push(coltypes[index]);
// Look up by the column's original position in
// colnames/coltypes, not its position after the
// filter above — those diverge whenever any
// earlier column is a comparison-suffixed one that
// got filtered out.
updatedColtypes.push(coltypes[originalIndex]);
childColumnMap[colname] = false;
timeComparisonColumnMap[colname] = false;
}
@@ -749,24 +812,71 @@ const config: ControlPanelConfig = {
: [];
const chartStatus = chart?.chartStatus;
// Normalize legacy `toAllRow`/`toTextColor` flags saved before
// `columnFormatting`/`objectFormatting` existed, so "entire row"
// formatters set under the old schema keep working.
const value = _?.value ?? [];
if (value && Array.isArray(value)) {
value.forEach(
(item: ConditionalFormattingConfig, index, array) => {
if (
item.colorScheme &&
(typeof item.colorScheme !== 'string' ||
!['Green', 'Red'].includes(item.colorScheme))
) {
if (item.columnFormatting === undefined) {
// eslint-disable-next-line no-param-reassign
array[index] = {
...item,
...(item.toTextColor === true && {
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
}),
...(item.toAllRow === true && {
columnFormatting: ObjectFormattingEnum.ENTIRE_ROW,
}),
};
}
}
},
);
}
const { colnames, coltypes } =
chart?.queriesResponse?.[0] ?? {};
const numericColumns =
Array.isArray(colnames) && Array.isArray(coltypes)
? colnames
.filter(
(colname: string, index: number) =>
coltypes[index] === GenericDataType.Numeric,
)
.map((colname: string) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType:
colnames && coltypes[colnames?.indexOf(colname)],
}))
: [];
const hasColumns =
Array.isArray(colnames) && Array.isArray(coltypes);
const allColumns = hasColumns
? [
{
value: ObjectFormattingEnum.ENTIRE_ROW,
label: t('entire row'),
dataType: GenericDataType.String,
},
...colnames.map((colname: string, index: number) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap?.[colname] ?? colname),
dataType: coltypes[index],
})),
]
: [];
const numericColumns = hasColumns
? colnames
.filter(
(colname: string, index: number) =>
coltypes[index] === GenericDataType.Numeric,
)
.map((colname: string) => ({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap?.[colname] ?? colname),
// Every entry here already passed the Numeric filter
// above, so the type is always Numeric — no need to
// re-look it up (which breaks on duplicate colnames).
dataType: GenericDataType.Numeric,
}))
: [];
const columnOptions = hasTimeComparison
? processComparisonColumns(
numericColumns || [],
@@ -778,6 +888,7 @@ const config: ControlPanelConfig = {
removeIrrelevantConditions: chartStatus === 'success',
columnOptions,
verboseMap,
allColumns,
extraColorChoices,
serverPagination: Boolean(
explore?.controls?.server_pagination?.value,
@@ -31,8 +31,6 @@ import controlPanel from './controlPanel';
import buildQuery from './buildQuery';
import { TableChartFormData, TableChartProps } from './types';
// must export something for the module to be exist in dev mode
export { default as __hack__ } from './types';
export * from './types';
export {
convertAgGridStateToOwnState,
@@ -46,6 +44,7 @@ const metadata = new ChartMetadata({
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
'EXPORT_CURRENT_VIEW' as Behavior,
],
category: t('Table'),
canBeAnnotationTypes: ['EVENT', 'INTERVAL'],
@@ -19,6 +19,7 @@
import {
BackendOwnState,
ChartStateConverterOptions,
QuerySortBy,
type AgGridChartState,
type AgGridSortModel,
@@ -353,7 +354,24 @@ export function convertFilterModel(
*/
export function convertAgGridStateToOwnState(
agGridState: AgGridChartState,
options: ChartStateConverterOptions = {},
): Partial<BackendOwnState> {
// In client mode, AG Grid handles sort/filter/pagination locally, so for
// the *live* query none of it needs to reach the backend -- folding it
// into ownState there would only trigger an unnecessary requery/remount.
// A *download* query has no client-side pass to apply that state though:
// dashboard doesn't consume the Explore-only clientView snapshot, so
// exports still need it converted to reproduce the displayed
// sort/filter/columns (options.forExport).
//
// Only an explicit `false` is treated as "definitely client mode":
// legacy persisted table_state/permalinks predate serverPagination and
// have it `undefined`, and treating that the same as `false` would
// silently drop their persisted server-side sort/filter on restore.
if (agGridState.serverPagination === false && !options.forExport) {
return {};
}
const ownState: Partial<BackendOwnState> = {};
const sortBy = convertSortModel(agGridState.sortModel);
@@ -184,6 +184,7 @@ export const PaginationContainer = styled.div`
color: ${theme.colorTextBase};
transform: translateY(-${theme.sizeUnit}px);
background: ${theme.colorBgBase};
flex-shrink: 0;
`}
`;
@@ -353,6 +354,7 @@ export const StyledChartContainer = styled.div<{
.dropdown-controls-container {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
.time-comparison-dropdown {
@@ -29,6 +29,7 @@ import {
getNumberFormatter,
getTimeFormatter,
getTimeFormatterForGranularity,
normalizeCurrency,
NumberFormats,
QueryMode,
SMART_DATE_ID,
@@ -60,7 +61,11 @@ const { DATABASE_DATETIME } = TimeFormats;
function isNumeric(key: string, data: DataRecord[] = []) {
return data.every(
x => x[key] === null || x[key] === undefined || typeof x[key] === 'number',
x =>
x[key] === null ||
x[key] === undefined ||
x[key] === '' ||
typeof x[key] === 'number',
);
}
@@ -168,7 +173,33 @@ const getComparisonColFormatter = (
return formatter;
};
const processComparisonDataRecords = memoizeOne(
// transformProps is a single module-level function shared by every mounted
// instance of this chart plugin on a dashboard (one plugin registration,
// not one per chart). memoizeOne only remembers the single most-recent
// call, so wrapping a function in it directly here means unrelated chart
// instances evict each other's cached result whenever they render in the
// same tick, forcing a full rebuild - with brand-new array/object
// references - even when a given chart's own inputs are unchanged. AG
// Grid treats a new colDefs identity as "columns changed" and re-measures
// autoHeight/wrapText rows, which is what actually reads as a layout
// flicker on a chart that never changed. Keying a separate memoized
// function per chart id isolates each chart's cache from its siblings.
function memoizePerChart<Args extends unknown[], R>(
fn: (...args: Args) => R,
isEqual?: (newArgs: Args, lastArgs: Args) => boolean,
) {
const memoizedByChart = new Map<number, (...args: Args) => R>();
return (sliceId: number, ...args: Args): R => {
let fnForChart = memoizedByChart.get(sliceId);
if (!fnForChart) {
fnForChart = isEqual ? memoizeOne(fn, isEqual) : memoizeOne(fn);
memoizedByChart.set(sliceId, fnForChart);
}
return fnForChart(...args);
};
}
const processComparisonDataRecords = memoizePerChart(
function processComparisonDataRecords(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
@@ -309,7 +340,7 @@ const processComparisonColumns = (
const serverPageLengthMap = new Map();
const processDataRecords = memoizeOne(function processDataRecords(
const processDataRecords = memoizePerChart(function processDataRecords(
data: DataRecord[] | undefined,
columns: DataColumnMeta[],
) {
@@ -336,11 +367,16 @@ const processDataRecords = memoizeOne(function processDataRecords(
return data;
});
const processColumns = memoizeOne(function processColumns(
const processColumns = memoizePerChart(function processColumns(
props: TableChartProps,
) {
const {
datasource: { columnFormats, currencyFormats, verboseMap },
datasource: {
columnFormats,
currencyFormats,
verboseMap,
currencyCodeColumn,
},
rawFormData: {
table_timestamp_format: tableTimestampFormat,
metrics: metrics_,
@@ -352,7 +388,12 @@ const processColumns = memoizeOne(function processColumns(
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
const { data: records, colnames, coltypes } = queriesData[0] || {};
const {
data: records,
colnames,
coltypes,
detected_currency: detectedCurrency,
} = queriesData[0] || {};
// convert `metrics` and `percentMetrics` to the key names in `data.records`
const metrics = (metrics_ ?? []).map(getMetricLabel);
const rawPercentMetrics = (percentMetrics_ ?? []).map(getMetricLabel);
@@ -363,13 +404,18 @@ const processColumns = memoizeOne(function processColumns(
const rawPercentMetricsSet = new Set(rawPercentMetrics);
const columns: DataColumnMeta[] = (colnames || [])
.map((key: string, originalIndex: number) => ({ key, originalIndex }))
.filter(
key =>
({ key }) =>
// if a metric was only added to percent_metrics, they should not show up in the table.
!(rawPercentMetricsSet.has(key) && !metricsSet.has(key)),
)
.map((key: string, i) => {
const dataType = coltypes[i];
.map(({ key, originalIndex }) => {
// Look up by the column's original position in colnames/coltypes,
// not its position after the filter above — those diverge whenever
// an earlier column (e.g. a percent-metric-only one) got filtered
// out, which would otherwise shift every later column's dataType.
const dataType = coltypes[originalIndex];
const config = columnConfig[key] || {};
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
@@ -431,10 +477,25 @@ const processColumns = memoizeOne(function processColumns(
// percent metrics have a default format
formatter = getNumberFormatter(numberFormat || PERCENT_3_POINT);
} else if (isMetric || (isNumber && (numberFormat || currency))) {
formatter = currency?.symbol
// Resolve AUTO currency when currency column isn't in query results
let resolvedCurrency = currency;
if (
currency?.symbol === 'AUTO' &&
detectedCurrency &&
(!currencyCodeColumn || !colnames?.includes(currencyCodeColumn))
) {
const normalizedCurrency = normalizeCurrency(detectedCurrency);
if (normalizedCurrency) {
resolvedCurrency = {
...currency,
symbol: normalizedCurrency,
};
}
}
formatter = resolvedCurrency?.symbol
? new CurrencyFormatter({
d3Format: numberFormat,
currency,
currency: resolvedCurrency,
})
: getNumberFormatter(numberFormat);
}
@@ -448,6 +509,7 @@ const processColumns = memoizeOne(function processColumns(
formatter,
config,
description,
currencyCodeColumn,
};
})
.sort((a, b) => {
@@ -494,7 +556,7 @@ const transformProps = (
queriesData = [],
ownState: serverPaginationData,
filterState,
hooks: { setDataMask = () => {}, onChartStateChange },
hooks: { setDataMask = () => {}, onChartStateChange, onContextMenu },
emitCrossFilters,
theme,
} = chartProps;
@@ -526,10 +588,10 @@ const transformProps = (
comparison_color_enabled: comparisonColorEnabled = false,
comparison_color_scheme: comparisonColorScheme = ColorSchemeEnum.Green,
show_numbered_column: showNumberedColumn = false,
allow_rearrange_columns: allowRearrangeColumns = true,
allow_render_html: allowRenderHtml = true,
} = formData;
const allowRearrangeColumns = true;
// Calculate time comparison settings early since they're used in multiple places
const isUsingTimeComparison =
!isEmpty(time_compare) &&
@@ -682,7 +744,7 @@ const transformProps = (
hasServerPageLengthChanged = true;
}
const [, percentMetrics, columns] = processColumns(chartProps);
const [, percentMetrics, columns] = processColumns(slice_id, chartProps);
const timeGrain = extractTimegrain(formData);
@@ -700,20 +762,34 @@ const transformProps = (
);
}
// buildQuery.ts can append an "all records" percent-metric denominator
// query *and* a totals query, independently of each other, both landing
// in extraQueries before the totals one. A fixed totalQuery index would
// silently bind to the wrong query's data (or drop the totals query
// entirely) whenever both are present, so replicate buildQuery.ts's own
// gating condition here to know whether to skip that extra slot.
const hasAllRecordsExtraQuery = Boolean(
formData.percent_metrics?.length &&
(formData.percent_metric_calculation || 'row_limit') === 'all_records',
);
let baseQuery;
let countQuery;
let rowCount;
let totalQuery;
if (serverPagination) {
[baseQuery, countQuery, totalQuery] = queriesData;
[baseQuery, countQuery] = queriesData;
totalQuery = hasAllRecordsExtraQuery ? queriesData[3] : queriesData[2];
rowCount = (countQuery?.data?.[0]?.rowcount as number) ?? 0;
} else {
[baseQuery, totalQuery] = queriesData;
[baseQuery] = queriesData;
totalQuery = hasAllRecordsExtraQuery ? queriesData[2] : queriesData[1];
rowCount = baseQuery?.rowcount ?? 0;
}
const data = processDataRecords(baseQuery?.data, columns);
const data = processDataRecords(slice_id, baseQuery?.data, columns);
const comparisonData = processComparisonDataRecords(
slice_id,
baseQuery?.data,
columns,
comparisonSuffix,
@@ -793,12 +869,12 @@ const transformProps = (
// Map saved metric/calculated column labels to their SQL expressions for filter resolution
const metricSqlExpressions: Record<string, string> = {};
chartProps.datasource.metrics.forEach(metric => {
(chartProps.datasource?.metrics ?? []).forEach(metric => {
if (metric.metric_name && metric.expression) {
metricSqlExpressions[metric.metric_name] = metric.expression;
}
});
chartProps.datasource.columns.forEach(col => {
(chartProps.datasource?.columns ?? []).forEach(col => {
if (col.column_name && col.expression) {
metricSqlExpressions[col.column_name] = col.expression;
if (col.verbose_name && col.verbose_name !== col.column_name) {
@@ -811,7 +887,7 @@ const transformProps = (
// backed by a dataset (physical or calculated) column can be summed
// server-side; free-form SQL expression columns are excluded.
const datasetColumnNames = new Set(
chartProps.datasource.columns
(chartProps.datasource?.columns ?? [])
.map(col => col.column_name)
.filter((name): name is string => Boolean(name)),
);
@@ -849,6 +925,7 @@ const transformProps = (
filters: filterState.filters,
emitCrossFilters,
allowRearrangeColumns,
allowRenderHtml,
slice_id,
serverPagination,
rowCount,
@@ -873,6 +950,7 @@ const transformProps = (
chartState,
onChartStateChange,
showNumberedColumn,
onContextMenu,
};
};
@@ -39,6 +39,7 @@ import {
JsonObject,
Metric,
AgGridChartState,
ContextMenuFilters,
} from '@superset-ui/core';
import {
ColDef,
@@ -81,6 +82,7 @@ export type TableChartFormData = QueryFormData & {
time_grain_sqla?: TimeGranularity;
column_config?: Record<string, TableColumnConfig>;
allow_rearrange_columns?: boolean;
allow_render_html?: boolean;
show_numbered_column?: boolean;
};
@@ -134,6 +136,11 @@ export interface AgGridTableChartTransformedProps<
onChartStateChange?: (chartState: JsonObject) => void;
chartState?: AgGridChartState;
showNumberedColumn: boolean;
onContextMenu?: (
clientX: number,
clientY: number,
filters?: ContextMenuFilters,
) => void;
}
export interface SortState {
@@ -196,6 +203,7 @@ export interface InputColumn {
originalLabel?: string;
metricName?: string;
description?: string;
currencyCodeColumn?: string;
}
export type ValueRange = [number, number] | null;
@@ -234,5 +242,3 @@ export type Dataset = {
metrics?: Metric[];
verbose_map?: Record<string, string>;
};
export default {};
@@ -20,6 +20,17 @@
import { SetDataMaskHook } from '@superset-ui/core';
import { SortByItem } from '../types';
export interface ClientViewColumn {
key: string;
label: string;
}
export interface ClientViewSnapshot {
rows: Record<string, unknown>[];
columns: ClientViewColumn[];
count: number;
}
interface TableOwnState {
currentPage?: number;
pageSize?: number;
@@ -29,6 +40,7 @@ interface TableOwnState {
sortBy?: SortByItem[];
rawSummaryColumns?: string[];
totalsRequested?: boolean;
clientView?: ClientViewSnapshot;
}
export const updateTableOwnState = (
@@ -17,9 +17,11 @@
* under the License.
*/
import {
CurrencyFormatter,
DataRecordValue,
getSmallNumberFormatter,
isDefined,
isEmptyDateInput,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
@@ -37,6 +39,8 @@ import { DataColumnMeta, InputColumn } from '../types';
function formatValue(
formatter: DataColumnMeta['formatter'],
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
currencyColumn?: string,
): [boolean, string] {
// render undefined as empty string
if (value === undefined) {
@@ -45,13 +49,17 @@ function formatValue(
// render null as `N/A`
if (
value === null ||
// null values in temporal columns are wrapped in a Date object, so make sure we
// handle them here too
(value instanceof DateWithFormatter && value.input === null)
// null/empty values in temporal columns are wrapped in a Date object, so make
// sure we handle them here too
(value instanceof DateWithFormatter && isEmptyDateInput(value.input))
) {
return [false, 'N/A'];
}
if (formatter) {
// If formatter is a CurrencyFormatter, pass row context for AUTO mode
if (formatter instanceof CurrencyFormatter) {
return [false, formatter(value as number, rowData, currencyColumn)];
}
return [false, formatter(value as number)];
}
if (typeof value === 'string') {
@@ -63,8 +71,9 @@ function formatValue(
export function formatColumnValue(
column: DataColumnMeta,
value: DataRecordValue,
rowData?: Record<string, DataRecordValue>,
) {
const { dataType, formatter, config = {} } = column;
const { dataType, formatter, config = {}, currencyCodeColumn } = column;
const isNumber = dataType === GenericDataType.Numeric;
const smallNumberFormatter = getSmallNumberFormatter(
formatter,
@@ -76,6 +85,8 @@ export function formatColumnValue(
? smallNumberFormatter
: formatter,
value,
rowData,
currencyCodeColumn,
);
}
@@ -83,13 +94,24 @@ export const valueFormatter = (
params: ValueFormatterParams,
col: InputColumn,
): string => {
const { value, node } = params;
const { value, node, data } = params;
if (
isDefined(value) &&
value !== '' &&
!(value instanceof DateWithFormatter && value.input === null)
!(value instanceof DateWithFormatter && isEmptyDateInput(value.input))
) {
return col.formatter?.(value) || value;
// Fall back to String(value) rather than the raw value: value can be a
// DateWithFormatter/Date (or other object) when col.formatter is unset or
// returns a falsy result, and returning that raw object here - though it
// satisfies this function's `: string` signature at compile time since
// `value`'s param type is loosely typed - crashes React with "Objects are
// not valid as a React child" once a cell renderer renders it directly.
if (col.formatter instanceof CurrencyFormatter) {
return (
col.formatter(value, data, col.currencyCodeColumn) || String(value)
);
}
return col.formatter?.(value) || String(value);
}
if (node?.level === -1) {
return '';
@@ -55,29 +55,81 @@ const getCellStyle = (params: CellStyleParams) => {
let backgroundColor;
let color;
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => {
const colTitle = formatter?.column?.includes('Main')
? formatter?.column?.replace('Main', '').trim()
: formatter?.column;
return colTitle === colDef.field;
})
.forEach(formatter => {
const formatterResult =
value || value === 0 ? formatter.getColorFromValue(value) : false;
if (formatterResult) {
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = formatterResult;
}
const applyFormatter = (
formatter: ColorFormatters[number],
valueToFormat: typeof value,
) => {
const formatterResult =
valueToFormat || valueToFormat === 0
? formatter.getColorFromValue(valueToFormat)
: false;
if (formatterResult) {
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = formatterResult;
}
});
}
};
// formatter.column can be a legacy display label ("Main colname") for
// time-comparison columns rather than the row's actual data key, so
// resolve it to the real field id before using it to read row values.
const resolveColumnKey = (columnKey: string) =>
columnKey.startsWith('Main ')
? columnKey.slice('Main '.length)
: columnKey;
// Formatters with no formatting target color their own source column,
// keyed off this cell's own value. Excludes legacy v1 `toAllRow` rules,
// which are entire-row formatters handled below.
columnColorFormatters!
.filter(
formatter =>
!formatter.columnFormatting &&
!formatter.toAllRow &&
resolveColumnKey(formatter.column) === colDef.field,
)
.forEach(formatter => applyFormatter(formatter, value));
// Formatters with a real target column color that target column,
// keyed off the value in the formatter's own (source) column.
columnColorFormatters!
.filter(
formatter =>
formatter.columnFormatting &&
formatter.columnFormatting !== ObjectFormattingEnum.ENTIRE_ROW &&
resolveColumnKey(formatter.columnFormatting) === colDef.field,
)
.forEach(formatter =>
applyFormatter(
formatter,
node?.data?.[resolveColumnKey(formatter.column)],
),
);
// Entire-row formatters apply to every cell in the row, keyed off the
// value in the formatter's own column rather than this cell's column.
// `toAllRow` is the legacy v1 flag for the same behavior; migrated
// charts carry it over unchanged rather than being rewritten to
// `columnFormatting: ENTIRE_ROW`, so both are honored here.
columnColorFormatters!
.filter(
formatter =>
formatter.columnFormatting === ObjectFormattingEnum.ENTIRE_ROW ||
formatter.toAllRow,
)
.forEach(formatter =>
applyFormatter(
formatter,
node?.data?.[resolveColumnKey(formatter.column)],
),
);
}
if (
@@ -0,0 +1,48 @@
/**
* 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.
*/
let cached: number | undefined;
const css = (x: TemplateStringsArray) => x.join('\n');
export default function getScrollBarSize(forceRefresh = false) {
if (typeof document === 'undefined') {
return 0;
}
if (cached === undefined || forceRefresh) {
const inner = document.createElement('div');
const outer = document.createElement('div');
inner.style.cssText = css`
width: auto;
height: 100%;
overflow: scroll;
`;
outer.style.cssText = css`
position: absolute;
visibility: hidden;
overflow: hidden;
width: 100px;
height: 50px;
`;
outer.append(inner);
document.body.append(outer);
cached = outer.clientWidth - inner.clientWidth;
outer.remove();
}
return cached;
}
@@ -0,0 +1,80 @@
/**
* 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 { TimeGranularity } from '@superset-ui/core';
/**
* Calculates the inclusive/exclusive temporal range for a bucket.
* standard SQL range pattern: [start, end)
*/
export default function getTimeRangeFromGranularity(
startTime: Date,
granularity: TimeGranularity,
): [Date, Date] {
const time = startTime.getTime();
const date = startTime.getUTCDate();
const month = startTime.getUTCMonth();
const year = startTime.getUTCFullYear();
// Constants
const MS_IN_SECOND = 1000;
const MS_IN_MINUTE = 60 * MS_IN_SECOND;
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
switch (granularity) {
case TimeGranularity.SECOND:
return [startTime, new Date(time + MS_IN_SECOND)];
case TimeGranularity.MINUTE:
return [startTime, new Date(time + MS_IN_MINUTE)];
case TimeGranularity.FIVE_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 5)];
case TimeGranularity.TEN_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 10)];
case TimeGranularity.FIFTEEN_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 15)];
case TimeGranularity.THIRTY_MINUTES:
return [startTime, new Date(time + MS_IN_MINUTE * 30)];
case TimeGranularity.HOUR:
return [startTime, new Date(time + MS_IN_HOUR)];
case TimeGranularity.DAY:
case TimeGranularity.DATE:
return [startTime, new Date(Date.UTC(year, month, date + 1))];
case TimeGranularity.WEEK:
case TimeGranularity.WEEK_STARTING_SUNDAY:
case TimeGranularity.WEEK_STARTING_MONDAY:
return [startTime, new Date(Date.UTC(year, month, date + 7))];
case TimeGranularity.WEEK_ENDING_SATURDAY:
case TimeGranularity.WEEK_ENDING_SUNDAY:
// Week-ending buckets are labeled by the bucket's final day.
return [
new Date(Date.UTC(year, month, date - 6)),
new Date(Date.UTC(year, month, date + 1)),
];
case TimeGranularity.MONTH:
return [startTime, new Date(Date.UTC(year, month + 1, 1))];
case TimeGranularity.QUARTER:
return [
startTime,
new Date(Date.UTC(year, Math.floor(month / 3) * 3 + 3, 1)),
];
case TimeGranularity.YEAR:
return [startTime, new Date(Date.UTC(year + 1, 0, 1))];
default:
return [startTime, new Date(Date.UTC(year, month, date + 1))];
}
}
@@ -17,6 +17,7 @@
* under the License.
*/
import { isEqualArray } from '@superset-ui/core';
import { isEqual } from 'lodash-es';
import { TableChartProps } from '../types';
const getDescriptions = (props: TableChartProps) => {
@@ -47,23 +48,55 @@ export default function isEqualColumns(
const descA = getDescriptions(a);
const descB = getDescriptions(b);
return (
a.datasource.columnFormats === b.datasource.columnFormats &&
a.datasource.currencyFormats === b.datasource.currencyFormats &&
a.datasource.verboseMap === b.datasource.verboseMap &&
a.formData.tableTimestampFormat === b.formData.tableTimestampFormat &&
a.formData.timeGrainSqla === b.formData.timeGrainSqla &&
JSON.stringify(a.formData.columnConfig || null) ===
JSON.stringify(b.formData.columnConfig || null) &&
isEqualArray(a.formData.metrics, b.formData.metrics) &&
isEqualArray(a.queriesData?.[0]?.colnames, b.queriesData?.[0]?.colnames) &&
isEqualArray(a.queriesData?.[0]?.coltypes, b.queriesData?.[0]?.coltypes) &&
JSON.stringify(a.formData.extraFilters || null) ===
JSON.stringify(b.formData.extraFilters || null) &&
JSON.stringify(a.formData.extraFormData || null) ===
JSON.stringify(b.formData.extraFormData || null) &&
JSON.stringify(a.rawFormData.column_config || null) ===
JSON.stringify(b.rawFormData.column_config || null) &&
JSON.stringify(descA) === JSON.stringify(descB)
);
// Every field below is read with optional chaining because this comparator
// also runs against partial/mock props in tests; production TableChartProps
// always has these populated.
const checks = {
// These three are plain, serializable per-column config maps. Superset's
// core datasource pipeline can rebuild them with a new object reference
// on renders that don't actually change any formatting, so compare by
// value here - otherwise an incidental new reference looks like a real
// change and forces a full AG Grid column/row rebuild downstream.
columnFormats: isEqual(
a.datasource?.columnFormats,
b.datasource?.columnFormats,
),
currencyFormats: isEqual(
a.datasource?.currencyFormats,
b.datasource?.currencyFormats,
),
verboseMap: isEqual(a.datasource?.verboseMap, b.datasource?.verboseMap),
currencyCodeColumn:
a.datasource?.currencyCodeColumn === b.datasource?.currencyCodeColumn,
detectedCurrency:
a.queriesData?.[0]?.detected_currency ===
b.queriesData?.[0]?.detected_currency,
tableTimestampFormat:
a.formData?.tableTimestampFormat === b.formData?.tableTimestampFormat,
timeGrainSqla: a.formData?.timeGrainSqla === b.formData?.timeGrainSqla,
columnConfig:
JSON.stringify(a.formData?.columnConfig || null) ===
JSON.stringify(b.formData?.columnConfig || null),
metrics: isEqualArray(a.formData?.metrics, b.formData?.metrics),
colnames: isEqualArray(
a.queriesData?.[0]?.colnames,
b.queriesData?.[0]?.colnames,
),
coltypes: isEqualArray(
a.queriesData?.[0]?.coltypes,
b.queriesData?.[0]?.coltypes,
),
extraFilters:
JSON.stringify(a.formData?.extraFilters || null) ===
JSON.stringify(b.formData?.extraFilters || null),
extraFormData:
JSON.stringify(a.formData?.extraFormData || null) ===
JSON.stringify(b.formData?.extraFormData || null),
rawColumnConfig:
JSON.stringify(a.rawFormData?.column_config || null) ===
JSON.stringify(b.rawFormData?.column_config || null),
descriptions: JSON.stringify(descA) === JSON.stringify(descB),
};
return Object.values(checks).every(Boolean);
}
@@ -28,11 +28,15 @@ import { useCallback, useMemo } from 'react';
import {
DataRecordValue,
DateWithFormatter,
isEmptyDateInput,
JsonObject,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import { ColorFormatters } from '@superset-ui/chart-controls';
import {
ColorFormatters,
ConditionalFormattingConfig,
} from '@superset-ui/chart-controls';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
BasicColorFormatterType,
@@ -71,11 +75,15 @@ type UseColDefsProps = {
colorPositiveNegative: boolean;
columnColorFormatters: ColorFormatters;
allowRearrangeColumns?: boolean;
allowRenderHtml?: boolean;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
isUsingTimeComparison?: boolean;
emitCrossFilters?: boolean;
alignPositiveNegative: boolean;
slice_id: number;
conditionalFormatting?: ConditionalFormattingConfig[];
comparisonColorEnabled?: boolean;
comparisonColorScheme?: string;
};
function getValueRange(
@@ -131,7 +139,7 @@ const getFilterType = (col: InputColumn) => {
/**
* Filter value getter for temporal columns.
* Returns null for DateWithFormatter objects with null input,
* Returns null for DateWithFormatter objects with null/empty input,
* enabling AG Grid's blank filter to correctly identify null dates.
*/
const dateFilterValueGetter = (params: {
@@ -139,8 +147,8 @@ const dateFilterValueGetter = (params: {
colDef: { field?: string };
}) => {
const value = params.data?.[params.colDef.field as string];
// Return null for DateWithFormatter with null input so AG Grid blank filter works
if (value instanceof DateWithFormatter && value.input === null) {
// Return null for DateWithFormatter with null/empty input so AG Grid blank filter works
if (value instanceof DateWithFormatter && isEmptyDateInput(value.input)) {
return null;
}
return value;
@@ -236,13 +244,37 @@ export const useColDefs = ({
colorPositiveNegative,
columnColorFormatters,
allowRearrangeColumns,
allowRenderHtml,
basicColorFormatters,
isUsingTimeComparison,
emitCrossFilters,
alignPositiveNegative,
slice_id,
conditionalFormatting,
comparisonColorEnabled,
comparisonColorScheme,
}: UseColDefsProps) => {
const theme = useTheme();
// transformProps.ts computes these fresh on every call (no memoization),
// so a reference-based dependency here would recreate getCommonColProps -
// and therefore colDefs - on every render regardless of whether the
// formatting actually changed. Compare by content instead.
//
// columnColorFormatters/basicColorFormatters can't be stringified directly:
// each entry's getColorFromValue closes over the rule's operator/
// thresholds/gradient/color, none of which are mirrored as serializable
// fields on the entry itself, so JSON.stringify drops them and two
// differently-configured rules for the same column serialize identically.
// Depend on the raw, fully-serializable formData that produced those
// formatters instead.
const stringifiedColumnColorFormatters = JSON.stringify(
conditionalFormatting,
);
const stringifiedBasicColorFormatters = JSON.stringify([
conditionalFormatting,
comparisonColorEnabled,
comparisonColorScheme,
]);
const getCommonColProps = useCallback(
(
col: InputColumn,
@@ -387,7 +419,7 @@ export const useColDefs = ({
cellRenderer: (p: CellRendererProps) =>
isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p),
cellRendererParams: {
allowRenderHtml: true,
allowRenderHtml,
columns,
hasBasicColorFormatters,
col,
@@ -401,6 +433,12 @@ export const useColDefs = ({
isMetric,
isPercentMetric,
isNumeric,
// colId (`field` above) has "Main " stripped for comparison
// columns, but row data is still keyed by the unstripped
// originalKey -- consumers reading row values by column (e.g. the
// "Export Current View" snapshot) need this to look values up
// correctly.
dataKey: originalKey,
},
lockPinned: !allowRearrangeColumns,
sortable: !serverPagination || !isPercentMetric,
@@ -427,14 +465,15 @@ export const useColDefs = ({
columns,
data,
defaultAlignPN,
columnColorFormatters,
basicColorFormatters,
stringifiedColumnColorFormatters,
stringifiedBasicColorFormatters,
showCellBars,
colorPositiveNegative,
isUsingTimeComparison,
isRawRecords,
emitCrossFilters,
allowRearrangeColumns,
allowRenderHtml,
serverPagination,
alignPositiveNegative,
theme.colorBgBase,
@@ -17,7 +17,14 @@
* under the License.
*/
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import {
render,
screen,
waitFor,
fireEvent,
within,
userEvent,
} from '@superset-ui/core/spec';
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -259,6 +266,59 @@ test('AgGridTableChart renders Search by dropdown if includeSearch is true and t
expect(screen.getByText(/Search by/i)).toBeInTheDocument();
});
test('AgGridTableChart resets currentPage when the search column changes', async () => {
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
server_pagination: true,
include_search: true,
},
});
props.serverPagination = true;
props.includeSearch = true;
props.rowCount = 50;
props.serverPaginationData = {
currentPage: 1,
pageSize: 20,
};
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
const searchByContainer = await waitFor(() => {
const container = document.querySelector('.search-select');
expect(container).toBeInTheDocument();
return container as HTMLElement;
});
const searchByDropdown = within(searchByContainer).getByRole('combobox');
await userEvent.click(searchByDropdown);
const otherOption = await waitFor(() =>
within(screen.getByRole('listbox')).getByText('abc.com'),
);
await userEvent.click(otherOption);
await waitFor(() => {
expect(mockSetDataMask).toHaveBeenCalledWith(
expect.objectContaining({
ownState: expect.objectContaining({
searchColumn: 'abc.com',
currentPage: 0,
}),
}),
);
});
});
test('AgGridTableChart does not render Search by dropdown if includeSearch is true but searchOptions is empty', async () => {
const noStringColumnsData = {
...testData.basic,
@@ -873,9 +933,24 @@ test('AgGridTableChart emits column state with aggFunc through the debounced sav
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
// The very first onStateUpdated after mount just reflects the chartState
// the grid was initialized with, so it must not trigger a save on its own
// (persisting it unconditionally caused a mount -> save -> remount ->
// mount loop). Let that initial debounced capture settle before
// simulating a real user action - clicking a sortable header - so it
// isn't coalesced into the same debounce window and mistaken for the
// initial, ignorable capture.
await new Promise(resolve => setTimeout(resolve, 1500));
const sortableHeaderLabel = document.querySelector(
'.ag-header-cell-sortable .ag-header-cell-label',
);
expect(sortableHeaderLabel).toBeTruthy();
fireEvent.click(sortableHeaderLabel!);
// The save path is debounced (SLOW_DEBOUNCE = 500ms); wait for a capture.
await waitFor(() => expect(onChartStateChange).toHaveBeenCalled(), {
timeout: 3000,
timeout: 5000,
});
const savedState =
@@ -889,3 +964,66 @@ test('AgGridTableChart emits column state with aggFunc through the debounced sav
// (SharedAggregation) module; the community modules always report null.
expect(savedColumn).toMatchObject({ aggFunc: null });
});
test('AgGridTableChart renders a temporal column with a blank row without crashing', async () => {
// Regression test: a raw-mode temporal column backed by numeric epoch
// values, where one row's raw value is '' rather than null/undefined/a
// number, used to flip isNumeric() false for the whole column (see
// transformProps.ts), degrading its formatter to plain `String`. That made
// DateWithFormatter.toString() return String('') for the blank row, which
// is falsy - and valueFormatter's old `|| value` fallback then rendered the
// raw Date object directly, crashing React with "Objects are not valid as
// a React child (found: [object Date])".
const props = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
query_mode: QueryMode.Raw,
table_timestamp_format: SMART_DATE_ID,
server_pagination: false,
},
queriesData: [
{
...testData.basic.queriesData[0],
colnames: ['__timestamp', 'name'],
coltypes: [GenericDataType.Temporal, GenericDataType.String],
data: [
{ __timestamp: 1069113600000, name: 'foo' },
{ __timestamp: 1057016400000, name: 'bar' },
{ __timestamp: '', name: 'baz' },
],
},
],
});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
const reactChildError = errorSpy.mock.calls
.map(call => call.join(' '))
.find(message =>
message.includes('Objects are not valid as a React child'),
);
errorSpy.mockRestore();
expect(reactChildError).toBeUndefined();
const cells = document.querySelectorAll('[col-id="__timestamp"]');
const cellText = Array.from(cells).map(cell => cell.textContent);
expect(cellText).toContain('N/A');
expect(cellText).not.toContain('');
});
@@ -834,6 +834,51 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.extras).toBeDefined();
});
test('should exclude AG Grid HAVING filters from totals query', () => {
const { queries } = buildQuery(
{
...basicFormData,
server_pagination: true,
show_totals: true,
query_mode: QueryMode.Aggregate,
},
{
ownState: {
agGridHavingClause: 'count > 10',
},
},
);
const mainQuery = queries[0];
const totalsQuery = queries[2]; // queries[1] is rowcount, queries[2] is totals
expect(mainQuery.extras?.having).toBe('count > 10');
expect(totalsQuery.extras?.having).toBeUndefined();
});
test('should exclude download HAVING filters (sqlClauses) from totals query', () => {
const { queries } = buildQuery(
{
...basicFormData,
show_totals: true,
query_mode: QueryMode.Aggregate,
result_format: 'csv',
},
{
ownState: {
sqlClauses: { count: 'count > 10' },
},
},
);
const mainQuery = queries[0];
// Downloads never get a rowcount query, so totals is queries[1].
const totalsQuery = queries[1];
expect(mainQuery.extras?.having).toBe('count > 10');
expect(totalsQuery.extras?.having).toBeUndefined();
});
test('should not modify totals query when no AG Grid filters applied', () => {
const { queries } = buildQuery(
{
@@ -853,6 +898,43 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.row_limit).toBe(0);
});
test('all_records percent-metric denominator reflects AG Grid filters but totals do not', () => {
// Regression test: the all_records denominator query is built from
// the post-filter queryObject (so it matches the main query's result
// set), while the totals query intentionally strips AG Grid
// WHERE/HAVING so it summarizes the unfiltered chart-level data.
const { queries } = buildQuery(
{
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
percent_metric_calculation: 'all_records',
show_totals: true,
server_pagination: true,
query_mode: QueryMode.Aggregate,
},
{
ownState: {
agGridComplexWhere: 'age > 18',
},
},
);
// [main, rowcount, all_records denominator, totals]
const allRecordsQuery = queries[2];
const totalsQuery = queries[3];
expect(allRecordsQuery.extras?.where).toBe('age > 18');
expect(allRecordsQuery.columns).toEqual([]);
expect(allRecordsQuery.metrics).toEqual(['count']);
expect(allRecordsQuery.row_limit).toBe(0);
expect(allRecordsQuery.row_offset).toBe(0);
expect(allRecordsQuery.orderby).toEqual([]);
expect(allRecordsQuery.is_timeseries).toBe(false);
expect(totalsQuery.extras?.where).toBeUndefined();
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
@@ -184,3 +184,45 @@ test('every Visual formatting control is a renderTrigger', () => {
expect(control.config.renderTrigger).toBe(true);
});
});
function findControl(
panel: ControlPanelConfig,
controlName: string,
): CustomControlItem {
const item = (panel.controlPanelSections || [])
.flatMap(section => section?.controlSetRows || [])
.flat()
.find(c => isCustomControlItem(c) && c.name === controlName);
if (!item || !isCustomControlItem(item)) {
throw new Error(`Control "${controlName}" not found`);
}
return item;
}
test('allow_rearrange_columns defaults to false, matching v1, and hides while time_compare is set', () => {
const control = findControl(config, 'allow_rearrange_columns');
expect(control.config.type).toBe('CheckboxControl');
expect(control.config.default).toBe(false);
expect(control.config.renderTrigger).toBe(true);
const vis = control.config.visibility as VisibilityFn;
expect(
vis({
controls: { time_compare: { value: [] } },
} as unknown as ControlPanelsContainerProps),
).toBe(true);
expect(
vis({
controls: { time_compare: { value: ['1 year ago'] } },
} as unknown as ControlPanelsContainerProps),
).toBe(false);
});
test('allow_render_html defaults to true, matching v1, and has no visibility gate', () => {
const control = findControl(config, 'allow_render_html');
expect(control.config.type).toBe('CheckboxControl');
expect(control.config.default).toBe(true);
expect(control.config.renderTrigger).toBe(true);
expect(control.config.visibility).toBeUndefined();
});
@@ -178,6 +178,38 @@ test('extraColorChoices not included when time_compare is empty array', () => {
expect(result.extraColorChoices).toEqual([]);
});
test('numericColumns resolves dataType by position, not a stale name lookup', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
const explore = createMockExplore(undefined);
// Two columns share the name "metric" (e.g. a dimension and a metric
// both aliased the same way); only the second occurrence is Numeric.
const chart = {
chartStatus: 'success' as const,
queriesResponse: [
{
colnames: ['metric', 'metric'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
},
],
};
const result = controlConfig!.mapStateToProps!(
explore,
createMockControlStateForConditionalFormatting(),
chart,
);
// Resolving dataType via `colnames.indexOf(colname)` would always find
// the first "metric" (String) and misclassify this numeric column.
expect(result.columnOptions).toEqual([
expect.objectContaining({
value: 'metric',
dataType: GenericDataType.Numeric,
}),
]);
});
test('consistency between extraColorChoices and columnOptions', () => {
const controlConfig = findConditionalFormattingControl();
expect(controlConfig).toBeTruthy();
@@ -0,0 +1,290 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@superset-ui/core/spec';
import { DateWithFormatter, TimeGranularity } from '@superset-ui/core';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
import testData from '../../plugin-chart-table/test/testData';
// Only the context-menu handler is exercised below; the mock below fakes
// its event argument rather than a real ag-grid CellContextMenuEvent, so
// it's typed loosely (unknown) rather than pinned to that library type.
interface CapturedGridProps {
onCellContextMenu?: (event: Record<string, unknown>) => void;
}
// Capture the props the grid is rendered with, so we can invoke the
// onCellContextMenu handler directly without depending on AG Grid's DOM
// rendering or the (unregistered) Enterprise context-menu module.
const captured: { props?: CapturedGridProps } = {};
jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
__esModule: true,
ThemedAgGridReact: (props: CapturedGridProps) => {
captured.props = props;
return null;
},
AgGridReact: function AgGridReact() {
return null;
},
AllCommunityModule: {},
ClientSideRowModelModule: {},
ModuleRegistry: { registerModules: () => undefined },
setupAGGridModules: () => undefined,
defaultModules: [],
themeQuartz: {},
colorSchemeDark: {},
colorSchemeLight: {},
}));
// Imported after the mock is declared (jest.mock is hoisted above imports).
// eslint-disable-next-line import/first
import AgGridTableChart from '../src/AgGridTableChart';
// eslint-disable-next-line import/first
import transformProps from '../src/transformProps';
function renderChart(
onContextMenu: jest.Mock,
propsOverrides: Record<string, unknown> = {},
) {
captured.props = undefined;
const props = {
...transformProps({
...testData.basic,
hooks: { ...testData.basic.hooks, onContextMenu },
emitCrossFilters: true,
}),
...propsOverrides,
};
render(
ProviderWrapper({
children: (
<AgGridTableChart {...props} setDataMask={jest.fn()} slice_id={1} />
),
}),
);
}
function makeColumn(colId: string, context: Record<string, unknown> = {}) {
return {
getColId: () => colId,
getColDef: () => ({ context }),
};
}
test('wires an onCellContextMenu handler when onContextMenu is provided', async () => {
renderChart(jest.fn());
await waitFor(() => expect(captured.props).toBeDefined());
expect(typeof captured.props?.onCellContextMenu).toBe('function');
});
test('right-clicking a dimension cell emits drillToDetail, crossFilter and drillBy', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const rowData = {
__timestamp: null,
name: 'Michael',
sum__num: 2467063,
'abc.com': 'foo',
};
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: rowData,
value: 'Michael',
event: {
preventDefault,
stopPropagation,
clientX: 10,
clientY: 20,
},
});
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [clientX, clientY, filters] = onContextMenu.mock.calls[0];
expect(clientX).toBe(10);
expect(clientY).toBe(20);
// Non-temporal, non-null column → exact-match filter.
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: 'name', op: '==', val: 'Michael' }),
expect.objectContaining({ col: 'abc.com', op: '==', val: 'foo' }),
]),
);
// Null column → IS NULL filter, not an exact match on null.
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: '__timestamp', op: 'IS NULL' }),
]),
);
expect(filters.crossFilter).toBeDefined();
expect(filters.drillBy).toEqual({
filters: [{ col: 'name', op: '==', val: 'Michael' }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a null cell emits an IS NULL drillBy filter with a null val', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('__timestamp'),
data: { __timestamp: null, name: 'Michael', sum__num: 2467063 },
value: null,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
// op and val must agree: IS NULL must carry a null val, not the clicked
// cell's (possibly wrapped) value.
expect(filters.drillBy).toEqual({
filters: [{ col: '__timestamp', op: 'IS NULL', val: null }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a blank (empty-string) date cell emits IS NULL, not an equality filter on an invalid date', async () => {
// A blank temporal value arrives wrapped as DateWithFormatter(input: ''),
// not null/undefined -- the null checks below must treat that the same
// as null rather than falling through to the temporal/equality branches,
// which would build an invalid Date or serialize the filter value as null
// under an '==' op instead of an 'IS NULL' op.
const onContextMenu = jest.fn();
const blankDate = new DateWithFormatter('');
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('__timestamp'),
data: { __timestamp: blankDate, name: 'Michael', sum__num: 2467063 },
value: blankDate,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
expect(filters.drillToDetail).toEqual(
expect.arrayContaining([
expect.objectContaining({ col: '__timestamp', op: 'IS NULL' }),
]),
);
expect(filters.drillBy).toEqual({
filters: [{ col: '__timestamp', op: 'IS NULL', val: null }],
groupbyFieldName: 'groupby',
});
});
test('right-clicking a metric cell omits crossFilter and drillBy', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu);
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('sum__num', { isMetric: true }),
data: { name: 'Michael', sum__num: 2467063 },
value: 2467063,
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
expect(filters.crossFilter).toBeUndefined();
expect(filters.drillBy).toBeUndefined();
// drillToDetail is still populated from the row's dimension columns.
expect(filters.drillToDetail.length).toBeGreaterThan(0);
});
test('right-clicking a temporal cell with a time grain emits a TEMPORAL_RANGE filter', async () => {
const onContextMenu = jest.fn();
renderChart(onContextMenu, { timeGrain: TimeGranularity.DAY });
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: {
__timestamp: '2020-01-01T12:34:56.000Z',
name: 'Michael',
sum__num: 2467063,
},
value: 'Michael',
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
const [, , filters] = onContextMenu.mock.calls[0];
const timestampFilter = filters.drillToDetail.find(
(f: { col: string }) => f.col === '__timestamp',
);
expect(timestampFilter.op).toBe('TEMPORAL_RANGE');
// DAY granularity's range starts at the row's own timestamp (not
// truncated to midnight) and ends at the start of the next UTC day.
expect(timestampFilter.val).toBe(
'2020-01-01T12:34:56.000Z : 2020-01-02T00:00:00.000Z',
);
});
test('does not call onContextMenu in raw records mode', async () => {
const onContextMenu = jest.fn();
// isRawRecords is derived from query_mode inside transformProps; force it
// here to isolate the handler's own guard from that derivation.
renderChart(onContextMenu, { isRawRecords: true });
await waitFor(() => expect(captured.props?.onCellContextMenu).toBeDefined());
captured.props?.onCellContextMenu?.({
column: makeColumn('name'),
data: { name: 'Michael' },
value: 'Michael',
event: {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
clientX: 0,
clientY: 0,
},
});
expect(onContextMenu).not.toHaveBeenCalled();
});
@@ -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.
*/
import { CurrencyFormatter, DateWithFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { ValueFormatterParams } from '@superset-ui/core/components/ThemedAgGridReact';
import {
formatColumnValue,
valueFormatter,
valueGetter,
} from '../src/utils/formatValue';
import { DataColumnMeta, InputColumn } from '../src/types';
const baseCol: InputColumn = {
key: 'order_date',
label: 'order_date',
dataType: GenericDataType.Temporal,
isNumeric: false,
isMetric: false,
isPercentMetric: false,
config: {},
};
function makeParams(value: unknown, node?: { level?: number }) {
return {
value,
node,
data: {},
} as unknown as ValueFormatterParams;
}
test('valueFormatter never returns a raw Date/object when col.formatter is unset', () => {
// Regression test: order_date (or any temporal column) is wrapped into a
// DateWithFormatter instance before reaching this function. If col.formatter
// is undefined - or returns a falsy result - the old `|| value` fallback
// returned that raw object, which crashes React with "Objects are not valid
// as a React child" once a cell renderer renders it directly.
const date = new DateWithFormatter(1069113600000);
const result = valueFormatter(makeParams(date), {
...baseCol,
formatter: undefined,
});
expect(typeof result).toBe('string');
expect(result).not.toBe(date);
});
test('valueFormatter falls back to a string when the formatter returns a falsy result', () => {
const date = new DateWithFormatter(1069113600000);
const formatter = jest.fn().mockReturnValue('');
const result = valueFormatter(makeParams(date), {
...baseCol,
formatter: formatter as unknown as InputColumn['formatter'],
});
expect(typeof result).toBe('string');
expect(result).not.toBe(date);
});
test('valueFormatter falls back to a string when the CurrencyFormatter returns a falsy result', () => {
const currencyFormatter = new CurrencyFormatter({
currency: { symbol: 'USD', symbolPosition: 'prefix' },
});
jest.spyOn(currencyFormatter, 'format').mockReturnValue('');
const result = valueFormatter(makeParams(42), {
...baseCol,
dataType: GenericDataType.Numeric,
formatter: currencyFormatter,
});
expect(typeof result).toBe('string');
expect(result).toBe('42');
});
test('valueFormatter uses the formatter result when it is truthy', () => {
const formatter = jest.fn().mockReturnValue('2003-11-18');
const result = valueFormatter(
makeParams(new DateWithFormatter(1069113600000)),
{
...baseCol,
formatter: formatter as unknown as InputColumn['formatter'],
},
);
expect(result).toBe('2003-11-18');
});
test('valueFormatter returns N/A for a DateWithFormatter wrapping a null input', () => {
const nullDate = new DateWithFormatter(null);
const result = valueFormatter(makeParams(nullDate), baseCol);
expect(result).toBe('N/A');
});
test('valueFormatter returns empty string for the root aggregation row', () => {
const result = valueFormatter(makeParams(undefined, { level: -1 }), baseCol);
expect(result).toBe('');
});
test('valueGetter returns the main column value when colDef.isMain is set', () => {
const params = {
colDef: { isMain: true },
column: { getColId: () => 'sum__num' },
data: { 'Main sum__num': 42 },
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, baseCol)).toBe(42);
});
test('valueGetter returns undefined for missing numeric column values', () => {
const params = {
column: { getColId: () => 'sum__num' },
data: {},
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, { ...baseCol, isNumeric: true })).toBeUndefined();
});
test('valueGetter returns empty string for missing non-numeric column values', () => {
const params = {
column: { getColId: () => 'name' },
data: {},
} as unknown as Parameters<typeof valueGetter>[0];
expect(valueGetter(params, baseCol)).toBe('');
});
test('formatColumnValue applies the small-number formatter for values under 1 in AUTO currency mode', () => {
const column: DataColumnMeta = {
key: 'pct',
label: 'pct',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
isPercentMetric: false,
formatter: new CurrencyFormatter({
currency: { symbol: 'AUTO', symbolPosition: 'prefix' },
}),
config: {},
};
const [isHtml, formatted] = formatColumnValue(column, 0.005);
expect(isHtml).toBe(false);
expect(formatted).not.toBe('');
});
test('formatColumnValue renders null as N/A', () => {
const column: DataColumnMeta = {
...baseCol,
formatter: undefined,
};
expect(formatColumnValue(column, null)).toEqual([false, 'N/A']);
});
@@ -54,11 +54,12 @@ test('transformProps busts its memoization caches when sub-field inputs change (
const first = transformProps(testData.basic);
// `processColumns` is wrapped with a custom equality (`isEqualColumns`) that
// compares specific chartProps sub-fields by identity — mutating only the
// top-level props reference is NOT enough to bust it. Here we supply a fresh
// `datasource.columnFormats` reference, which `isEqualColumns` compares with
// `===`, forcing `processColumns` to recompute and return a new `columns`
// array.
// compares specific chartProps sub-fields by value — mutating only the
// top-level props reference is NOT enough to bust it, and neither is
// handing it a new-but-value-equal `columnFormats` reference (e.g. another
// `{}`). Here we supply a `datasource.columnFormats` with genuinely
// different content, forcing `processColumns` to recompute and return a
// new `columns` array.
//
// `processDataRecords` uses memoize-one's default referential equality on
// `(data, columns)`. We also hand it a fresh `queriesData[0].data` array, so
@@ -67,7 +68,7 @@ test('transformProps busts its memoization caches when sub-field inputs change (
...testData.basic,
datasource: {
...testData.basic.datasource,
columnFormats: {},
columnFormats: { name: '.2f' },
},
queriesData: [
{
@@ -16,7 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { convertFilterModel } from '../src/stateConversion';
import {
convertFilterModel,
convertAgGridStateToOwnState,
} from '../src/stateConversion';
const baseAgGridState = {
columnState: [],
sortModel: [{ colId: 'name', sort: 'asc' as const, sortIndex: 0 }],
filterModel: {},
};
describe('convertFilterModel', () => {
test('emits a clause for a valid numeric comparison filter', () => {
@@ -71,3 +80,38 @@ describe('convertFilterModel', () => {
expect(result?.sqlClauses?.constructor).toBe('constructor = 5');
});
});
describe('convertAgGridStateToOwnState', () => {
test('suppresses client-mode state for the live query (serverPagination: false)', () => {
const result = convertAgGridStateToOwnState({
...baseAgGridState,
serverPagination: false,
});
expect(result).toEqual({});
});
test('converts client-mode state anyway when forExport is set, so a download reproduces the displayed sort/filter', () => {
const result = convertAgGridStateToOwnState(
{ ...baseAgGridState, serverPagination: false },
{ forExport: true },
);
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
test('converts state when serverPagination is undefined, preserving legacy persisted table_state/permalinks saved before this field existed', () => {
const result = convertAgGridStateToOwnState(baseAgGridState);
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
test('converts state for the live query when serverPagination is true', () => {
const result = convertAgGridStateToOwnState({
...baseAgGridState,
serverPagination: true,
});
expect(result.sortBy).toEqual([{ id: 'name', key: 'name', desc: false }]);
});
});
@@ -266,6 +266,69 @@ test('uses description from column even when verboseMap renames the column', ()
expect(columnMeta!.description).toBe('Original column description');
});
test('does not crash when datasource omits metrics/columns (drill-to-detail datasource)', () => {
const props = createMockChartProps({
queriesData: [
{
data: [{ col_x: 10 }],
colnames: ['col_x'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
datasource: {} as unknown as TableChartProps['datasource'],
});
expect(() => transformProps(props)).not.toThrow();
});
test('does not mistake the all_records percent-metric query for the totals query', () => {
// buildQuery.ts appends both an "all records" percent-metric denominator
// query and a totals query as independent extraQueries when percent
// metrics with percent_metric_calculation "all_records" and show_totals
// are both enabled — queriesData has 3 entries, not 2.
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['sum__num'],
percent_metrics: ['sum__num'],
percent_metric_calculation: 'all_records',
show_totals: true,
column_config: {},
table_timestamp_format: '',
},
queriesData: [
{
data: [{ name: 'a', sum__num: 1 }],
colnames: ['name', 'sum__num'],
coltypes: [GenericDataType.String, GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
// all_records extra query: raw percent-metric denominator, not totals.
{
data: [{ sum__num: 100 }],
colnames: ['sum__num'],
coltypes: [GenericDataType.Numeric],
},
// totals extra query: the real one.
{
data: [{ sum__num: 42 }],
colnames: ['sum__num'],
coltypes: [GenericDataType.Numeric],
},
] as unknown as TableChartProps['queriesData'],
});
const result = transformProps(props);
expect(result.totals).toEqual({ sum__num: 42 });
});
test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
// Green/Red rules are rendered via the increase/decrease path, so they must
// not reach getColorFormatters, which would treat the scheme name as a hex
@@ -316,6 +379,59 @@ test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
expect(formattedColumns).not.toContain('metric_a');
});
test('allowRearrangeColumns defaults to true when allow_rearrange_columns is unset', () => {
// Pre-existing v2 charts saved before this control existed have no
// allow_rearrange_columns key at all -- they must keep the always-on
// behavior v2 originally shipped with, not v1's false default.
const props = createMockChartProps();
const result = transformProps(props);
expect(result.allowRearrangeColumns).toBe(true);
});
test('allowRearrangeColumns is false when allow_rearrange_columns is explicitly false', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
allow_rearrange_columns: false,
} as unknown as TableChartProps['rawFormData'],
});
const result = transformProps(props);
expect(result.allowRearrangeColumns).toBe(false);
});
test('allowRenderHtml defaults to true when allow_render_html is unset', () => {
const props = createMockChartProps();
const result = transformProps(props);
expect(result.allowRenderHtml).toBe(true);
});
test('allowRenderHtml is false when allow_render_html is explicitly false', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: [],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
allow_render_html: false,
} as unknown as TableChartProps['rawFormData'],
});
const result = transformProps(props);
expect(result.allowRenderHtml).toBe(false);
});
test('retains saved percentage rules with automatic bounds when server pagination is enabled', () => {
const props = createMockChartProps({
rawFormData: {
@@ -94,6 +94,39 @@ test('applies the increase/decrease background when the column has one', () => {
expect(style.backgroundColor).toBe('#00ff00');
});
test('applies a cross-column formatter to its target column, keyed off the source column value', () => {
// Rule reads metric_a (source) and paints metric_b (target, via columnFormatting).
const crossColumnFormatter = {
column: 'metric_a',
columnFormatting: 'metric_b',
getColorFromValue: (v: number) => (v === 100 ? '#ff0000' : undefined),
objectFormatting: undefined,
toTextColor: false,
};
const targetStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_b' },
value: 999,
hasColumnColorFormatters: true,
columnColorFormatters: [crossColumnFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(targetStyle.backgroundColor).toBe('#ff0000');
const sourceStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_a' },
value: 100,
hasColumnColorFormatters: true,
columnColorFormatters: [crossColumnFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(sourceStyle.backgroundColor).toBe('');
});
test('does not apply basic formatting to the pinned summary row', () => {
const style = getCellStyle(
buildParams({
@@ -106,3 +139,37 @@ test('does not apply basic formatting to the pinned summary row', () => {
);
expect(style.backgroundColor).toBe('');
});
test('applies a legacy v1 toAllRow formatter to every cell in the row', () => {
// Migrated v1 charts carry `toAllRow: true` unchanged rather than being
// rewritten to `columnFormatting: ENTIRE_ROW`; both must color every cell.
const legacyEntireRowFormatter = {
column: 'metric_a',
toAllRow: true,
getColorFromValue: (v: number) => (v === 100 ? '#ff0000' : undefined),
objectFormatting: undefined,
toTextColor: false,
};
const otherColumnStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_b' },
value: 999,
hasColumnColorFormatters: true,
columnColorFormatters: [legacyEntireRowFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(otherColumnStyle.backgroundColor).toBe('#ff0000');
const sourceColumnStyle = getCellStyle(
buildParams({
colDef: { field: 'metric_a' },
value: 100,
hasColumnColorFormatters: true,
columnColorFormatters: [legacyEntireRowFormatter],
node: { rowPinned: undefined, data: { metric_a: 100, metric_b: 999 } },
}),
);
expect(sourceColumnStyle.backgroundColor).toBe('#ff0000');
});
@@ -811,6 +811,79 @@ test('cellStyle defaults non-numeric columns to left alignment', () => {
});
});
test('cellStyle reflects an edited conditional-formatting rule (color/threshold change, same column)', () => {
// columnColorFormatters entries only carry a computed getColorFromValue
// closure -- the rule's operator/threshold/color aren't mirrored onto the
// entry itself. Memoizing on JSON.stringify(columnColorFormatters) alone
// would see the same "shape" on both renders and keep closing over the
// first render's (red) formatter. The memo must instead depend on the raw
// conditionalFormatting config, which does capture the color/threshold.
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
// getCommonColProps also depends on `columns`/`data` by reference (as it
// must, since transformProps.ts doesn't memoize them either), so those
// need to stay referentially stable across rerenders here -- otherwise a
// new array on every render would mask a broken formatter dependency by
// invalidating the memo for an unrelated reason.
const stableColumns = [numericCol];
const stableData = [{ count: 42 }];
const cellStyleParams = {
value: 42,
colDef: { field: 'count' },
rowIndex: 0,
node: {},
} as never;
const { result, rerender } = renderHook(
(props: { color: string; targetValue: number }) =>
useColDefs({
...defaultProps,
columns: stableColumns,
data: stableData,
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? props.color : undefined,
},
],
conditionalFormatting: [
{
column: 'count',
operator: '>',
targetValue: props.targetValue,
colorScheme: props.color,
} as never,
],
}),
{
wrapper: defaultThemeWrapper,
initialProps: { color: '#ff0000', targetValue: 0 },
},
);
const firstCellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(firstCellStyle(cellStyleParams)).toMatchObject({
backgroundColor: '#ff0000',
});
// Same column, edited threshold/color -- must produce a fresh colDef
// whose cellStyle uses the new formatter, not the stale red one.
rerender({ color: '#0000ff', targetValue: 10 });
const secondCellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(secondCellStyle(cellStyleParams)).toMatchObject({
backgroundColor: '#0000ff',
});
});
test('cellStyle respects explicit horizontal alignment overrides', () => {
const numericCol = makeColumn({
key: 'count',
@@ -35,7 +35,7 @@
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.25",
"jest": "^30.5.0"
"jest": "^30.5.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -33,7 +33,7 @@
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/jest": "^30.0.0",
"jest": "^30.5.0"
"jest": "^30.5.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.6.1",
@@ -30,8 +30,10 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
},
@@ -31,8 +31,6 @@ import controlPanel from './controlPanel';
import cachedBuildQueryInstance from './buildQuery';
import { TableChartFormData, TableChartProps } from './types';
// must export something for the module to be exist in dev mode
export { default as __hack__ } from './types';
export * from './types';
const metadata = new ChartMetadata({
@@ -129,5 +129,3 @@ export interface TableChartTransformedProps<D extends DataRecord = DataRecord> {
// emit the correct column name in cross-filter data masks
columnLabelToNameMap?: Record<string, string>;
}
export default {};
@@ -49,7 +49,7 @@
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"maplibre-gl": "^5.24.0",
"maplibre-gl": "^6.8.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
@@ -35,12 +35,14 @@ jest.mock('src/utils/cachedSupersetGet');
// only need a stand-in that lets us trigger onDrillBy with a distinguishable
// config, so we can assert ChartContextMenu wires it into the modal.
jest.mock('../DrillBy/DrillBySubmenu', () => ({
DrillBySubmenu: ({ onDrillBy, dataset }: any) => (
DrillBySubmenu: ({ onDrillBy, onCloseMenu, dataset }: any) => (
<>
<button
type="button"
data-test="fake-drill-by-submenu"
onClick={() =>
onClick={() => {
// Mirrors DrillBySubmenu's real handleSelection, which calls
// onDrillBy and onCloseMenu together once a column is picked.
onDrillBy(
{ column_name: 'city', groupby: true },
{ id: 1, columns: [], metrics: [] },
@@ -48,8 +50,9 @@ jest.mock('../DrillBy/DrillBySubmenu', () => ({
filters: [{ col: 'selected_scope' }],
groupbyFieldName: 'groupby',
},
)
}
);
onCloseMenu?.();
}}
>
Fake Drill By
</button>
@@ -221,6 +224,39 @@ test('drill by modal uses the scope selected in the submenu over the raw context
expect(modalConfig.filters).toEqual([{ col: 'selected_scope' }]);
});
test('context menu can be reopened after Drill By closes it via onCloseMenu', async () => {
// Ant Design's Dropdown keeps its overlay mounted and toggles an
// `ant-dropdown-hidden` class rather than unmounting, so open/closed is
// asserted on that class instead of the overlay's presence in the DOM.
const isMenuOpen = () =>
!screen
.getByTestId('chart-context-menu')
.closest('.ant-dropdown')
?.classList.contains('ant-dropdown-hidden');
setup();
const openButton = screen.getByTestId('open-context-menu');
userEvent.click(openButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(true);
});
const submenuButton = await screen.findByTestId('fake-drill-by-submenu');
userEvent.click(submenuButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(false);
});
userEvent.click(openButton);
await waitFor(() => {
expect(isMenuOpen()).toBe(true);
});
});
test('drill by only offers dimension columns', async () => {
// drill_info returns every column so the results grid can label non-dimension
// ones; narrowing to dimensions is this component's job, not the API's.
@@ -24,6 +24,7 @@ import {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
@@ -110,6 +111,11 @@ const ChartContextMenu = (
);
const [visible, setVisible] = useState(false);
// `visible` state updates aren't synchronous, so a second open() call that
// runs before React re-renders would still see the stale `false` closure.
// This ref is updated synchronously (both here and in onOpenChange) so the
// guard below always reflects the latest known open state.
const visibleRef = useRef(false);
const isDisplayed = (item: ContextMenuItem) =>
displayedItems === ContextMenuItem.All ||
@@ -162,6 +168,7 @@ const ChartContextMenu = (
const [showDrillByModal, setShowDrillByModal] = useState(false);
const closeContextMenu = useCallback(() => {
visibleRef.current = false;
setVisible(false);
onClose();
}, [onClose]);
@@ -400,11 +407,26 @@ const ChartContextMenu = (
filters,
});
// Since Ant Design's Dropdown does not offer an imperative API
// and we can't attach event triggers to charts SVG elements, we
// use a hidden span that gets clicked on when receiving click events
// from the charts.
document.getElementById(`hidden-span-${id}`)?.click();
// Some chart libraries (e.g. AG Grid) can dispatch a single logical
// right-click as two contextmenu events in quick succession, calling
// `open()` twice. Since Ant Design's Dropdown treats a click on an
// already-open trigger as a toggle-to-close, re-clicking the hidden
// span here on the second call would immediately close the menu we
// just opened. Only click it when the menu isn't already visible; the
// position/filters update above still applies on every call.
//
// visibleRef (not the `visible` state) drives this guard: the state
// update from the first call's click hasn't been committed by the time
// the second call runs, so a state-based check would still read the
// stale `false` from this render's closure and click twice anyway.
if (!visibleRef.current) {
visibleRef.current = true;
// Ant Design's Dropdown does not offer an imperative API and we
// can't attach event triggers to charts' SVG elements, so we use a
// hidden span that gets clicked on when receiving click events from
// the charts.
document.getElementById(`hidden-span-${id}`)?.click();
}
},
[id, itemsCount],
);
@@ -426,6 +448,7 @@ const ChartContextMenu = (
? menuItems
: [{ key: 'no-actions', label: t('No actions'), disabled: true }],
onClick: () => {
visibleRef.current = false;
setVisible(false);
onClose();
},
@@ -435,6 +458,7 @@ const ChartContextMenu = (
)}
trigger={['click']}
onOpenChange={value => {
visibleRef.current = value;
setVisible(value);
if (!value) {
onClose();
@@ -44,16 +44,18 @@ const setup = ({
displayedItems = ContextMenuItem.All,
additionalConfig = {},
roles = undefined,
formData = { datasource: '1__table', viz_type: VizType.Pie },
}: {
onSelection?: () => void;
displayedItems?: ContextMenuItem | ContextMenuItem[];
additionalConfig?: Record<string, any>;
roles?: Record<string, string[][]>;
formData?: Record<string, any>;
} = {}) => {
const { result } = renderHook(() =>
useContextMenu(
sliceId,
{ datasource: '1__table', viz_type: VizType.Pie },
formData as { datasource: string; viz_type: string },
onSelection,
displayedItems,
additionalConfig,
@@ -365,3 +367,24 @@ test('Dataset drill info API call is not made when user lacks drill permissions'
expect(screen.queryByText('Drill by')).not.toBeInTheDocument();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
test('Dataset drill info API call is not made when formData.datasource is not yet hydrated', async () => {
// Regression test: right after a client-side navigation back to a
// dashboard from Explore, the chart's formData can transiently be missing
// `datasource` before the dashboard rehydrates. Firing a request for
// dataset "NaN" (Number(undefined)) must not happen - see
// useDatasetDrillInfo's Number.isNaN guard.
const result = setup({ formData: { viz_type: VizType.Pie } });
act(() => {
result.current.onContextMenu(0, 0, {});
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockCachedSupersetGet).not.toHaveBeenCalledWith(
expect.objectContaining({
endpoint: expect.stringContaining('/api/v1/dataset/NaN/drill_info/'),
}),
);
});
@@ -603,7 +603,9 @@ const Chart = (props: ChartProps) => {
const exportOwnState = state
? {
...baseOwnState,
...convertChartStateToOwnState(sliceVizType, state),
...convertChartStateToOwnState(sliceVizType, state, {
forExport: true,
}),
}
: baseOwnState;
@@ -141,11 +141,41 @@ test('Excel export items are hidden when userCanExport is false', () => {
expect(screen.getByText('Export YAML')).toBeInTheDocument();
});
test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
/** A queued export: 202 with a job id, delivered later by email. */
const mockQueuedResponse = (
body: Record<string, unknown> = { job_id: 'abc' },
) =>
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
status: 202,
json: jest.fn().mockResolvedValue(body),
} as never);
/** An inline export: the workbook itself, as the response to the request. */
const mockWorkbookResponse = (
filename = 'World_Health_1.xlsx',
): { blob: jest.Mock } => {
const blob = jest.fn().mockResolvedValue(new Blob(['xlsx'])) as jest.Mock;
mockSupersetClient.post.mockResolvedValue({
status: 200,
blob,
headers: new Headers({
'Content-Disposition': `attachment; filename=${filename}`,
}),
} as never);
return { blob };
};
/** jsdom implements neither, and the download path needs both. */
const stubObjectUrls = (): { createObjectURL: jest.Mock } => {
const createObjectURL = jest.fn(() => 'blob:http://localhost/fake');
window.URL.createObjectURL = createObjectURL;
window.URL.revokeObjectURL = jest.fn();
return { createObjectURL };
};
test('Export Data to Excel posts mode "data" and shows a pending toast', async () => {
mockQueuedResponse();
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
@@ -153,7 +183,9 @@ test('Export Data to Excel posts mode "data" and shows a pending toast', async (
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
fetchRetryOptions: { retries: 0 },
jsonPayload: { active_data_mask: {}, mode: 'data' },
parseMethod: 'raw',
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
@@ -163,9 +195,7 @@ test('Export Data to Excel posts mode "data" and shows a pending toast', async (
test('Export Images to Excel posts mode "images" and shows a pending toast', async () => {
enableWebDriverScreenshot();
mockSupersetClient.post.mockResolvedValue({
json: { job_id: 'abc' },
} as never);
mockQueuedResponse();
render(<MenuWrapper />, { useRedux: true });
@@ -174,7 +204,9 @@ test('Export Images to Excel posts mode "images" and shows a pending toast', asy
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
fetchRetryOptions: { retries: 0 },
jsonPayload: { active_data_mask: {}, mode: 'images' },
parseMethod: 'raw',
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
@@ -182,13 +214,158 @@ test('Export Images to Excel posts mode "images" and shows a pending toast', asy
});
});
test('Export Data to Excel downloads the workbook when it arrives inline', async () => {
// Without storage, the response contains the workbook.
const { blob } = mockWorkbookResponse();
const { createObjectURL } = stubObjectUrls();
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(blob).toHaveBeenCalled();
expect(createObjectURL).toHaveBeenCalled();
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Dashboard data exported to Excel',
);
});
// Direct downloads do not show the queued-export message.
expect(mockAddSuccessToast).not.toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
);
});
test('Export Data to Excel names the downloaded file from the response', async () => {
mockWorkbookResponse('Sales_Overview_7.xlsx');
stubObjectUrls();
// Capture download names without navigating in jsdom.
const downloaded: string[] = [];
const click = jest
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(function recordDownload(this: HTMLAnchorElement) {
downloaded.push(this.download);
});
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => expect(downloaded).toEqual(['Sales_Overview_7.xlsx']));
click.mockRestore();
});
/** Mock a request that the test can settle later. */
const mockPendingResponse = (): { settle: (response: unknown) => void } => {
let settle: (response: unknown) => void = () => {};
mockSupersetClient.post.mockReturnValue(
new Promise(resolve => {
settle = resolve;
}) as never,
);
return { settle: response => settle(response) };
};
const menuItemFor = (label: string) =>
screen.getByText(label).closest('[role="menuitem"]');
test('Export Data to Excel reports progress while the export is running', async () => {
// Keep the menu responsive while the request runs.
const { settle } = mockPendingResponse();
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(screen.getByText('Preparing export…')).toBeInTheDocument();
});
expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
expect(menuItemFor('Preparing export…')).toHaveAttribute(
'aria-disabled',
'true',
);
settle({ status: 202, json: jest.fn().mockResolvedValue({ job_id: 'abc' }) });
// Re-enable the action when the request finishes.
await waitFor(() => {
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
);
});
test('Export Data to Excel is offered again once the download starts', async () => {
const { settle } = mockPendingResponse();
stubObjectUrls();
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(screen.getByText('Preparing export…')).toBeInTheDocument();
});
settle({
status: 200,
blob: jest.fn().mockResolvedValue(new Blob(['xlsx'])),
headers: new Headers({
'Content-Disposition': 'attachment; filename=dash.xlsx',
}),
});
await waitFor(() => {
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Dashboard data exported to Excel',
);
});
test('Export Data to Excel is offered again after a failure', async () => {
const { settle } = mockPendingResponse();
mockGetClientErrorObject.mockResolvedValue({ status: 500 });
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(screen.getByText('Preparing export…')).toBeInTheDocument();
});
// Re-enable the action after an error.
settle(Promise.reject(new Error('boom')));
await waitFor(() => {
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
});
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
);
});
test('Export Images to Excel is blocked while a data export is running', async () => {
// The server allows one export per dashboard and user.
enableWebDriverScreenshot();
mockPendingResponse();
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(menuItemFor('Export Images to Excel')).toHaveAttribute(
'aria-disabled',
'true',
);
});
await userEvent.click(screen.getByText('Export Images to Excel'));
expect(mockSupersetClient.post).toHaveBeenCalledTimes(1);
});
test('Export Data to Excel shows an "already in progress" toast when throttled', async () => {
// The throttle response is 202 with a message but no job_id.
mockSupersetClient.post.mockResolvedValue({
json: {
message: 'An Excel export for this dashboard is already in progress.',
},
} as never);
mockQueuedResponse({
message: 'An Excel export for this dashboard is already in progress.',
});
render(<MenuWrapper />, { useRedux: true });
@@ -201,18 +378,19 @@ test('Export Data to Excel shows an "already in progress" toast when throttled',
});
});
test('Export Data to Excel shows a config error toast on 501', async () => {
mockSupersetClient.post.mockRejectedValue(new Error('not configured'));
mockGetClientErrorObject.mockResolvedValue({ status: 501 });
test('Export Data to Excel surfaces the reason an export was refused', async () => {
// Show the server's actionable refusal.
const message =
'This dashboard requests too many rows to export in a single request.';
mockSupersetClient.post.mockRejectedValue(new Error('too big'));
mockGetClientErrorObject.mockResolvedValue({ status: 400, message });
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Excel export is not configured on this server.',
);
expect(mockAddDangerToast).toHaveBeenCalledWith(message);
});
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SyntheticEvent } from 'react';
import { SyntheticEvent, useState } from 'react';
import { useSelector } from 'react-redux';
import { logging } from '@apache-superset/core/utils';
import { t } from '@apache-superset/core/translation';
@@ -27,13 +27,15 @@ import {
SupersetClient,
} from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { parse as parseContentDisposition } from 'content-disposition';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import downloadAsImage from 'src/utils/downloadAsImage';
import handleResourceExport from 'src/utils/export';
import handleResourceExport, {
downloadBlob,
getFilenameFromResponse,
} from 'src/utils/export';
import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
@@ -72,6 +74,10 @@ export const useDownloadMenuItems = (
const { addDangerToast, addSuccessToast } = useToasts();
const dataMask = useSelector((state: RootState) => state.dataMask);
// Disable both Excel actions while either export is running.
const [exportingXlsx, setExportingXlsx] = useState<'data' | 'images' | null>(
null,
);
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const buildActiveDataMask = (): Record<string, { extraFormData: object }> =>
@@ -130,35 +136,14 @@ export const useDownloadMenuItems = (
parseMethod: 'raw',
});
// Parse filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
let fileName = `dashboard_${dashboardId}_example.zip`;
if (disposition) {
try {
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
fileName = parsed.parameters.filename;
}
} catch (error) {
logging.warn('Failed to parse Content-Disposition header:', error);
}
}
// Convert response to blob and trigger download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
try {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
window.URL.revokeObjectURL(url);
}
downloadBlob(
blob,
getFilenameFromResponse(
response,
`dashboard_${dashboardId}_example.zip`,
),
);
addSuccessToast(t('Dashboard exported as example successfully'));
} catch (error) {
@@ -168,14 +153,31 @@ export const useDownloadMenuItems = (
};
const onExportXlsx = async (mode: 'data' | 'images') => {
setExportingXlsx(mode);
try {
const { json } = await SupersetClient.post({
const response = await SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
jsonPayload: { active_data_mask: buildActiveDataMask(), mode },
// Parse the queued response or workbook after checking its status.
parseMethod: 'raw',
// A retry may hit the first request's lock and lose its file response.
fetchRetryOptions: { retries: 0 },
});
// The throttle response (an export is already running) returns 202 with a
// message but no job_id; only a freshly enqueued job carries a job_id.
if ((json as { job_id?: string })?.job_id) {
// A 202 is queued; any successful non-202 response is the workbook.
if (response.status !== 202) {
const blob = await response.blob();
downloadBlob(
blob,
getFilenameFromResponse(response, `dashboard_${dashboardId}.xlsx`),
);
addSuccessToast(t('Dashboard data exported to Excel'));
return;
}
// Only a newly queued export has a job id.
const json = (await response.json()) as { job_id?: string };
if (json?.job_id) {
addSuccessToast(
t(
"Your export is being prepared. You'll receive an email when it's ready.",
@@ -187,16 +189,20 @@ export const useDownloadMenuItems = (
);
}
} catch (error) {
// status comes from the response (Partial<SupersetClientResponse>), which
// the union type does not expose uniformly; read it via a narrow cast.
const { status } = (await getClientErrorObject(error)) as {
// The client error union does not expose response fields uniformly.
const { status, message } = (await getClientErrorObject(error)) as {
status?: number;
message?: string;
};
if (status === 501) {
addDangerToast(t('Excel export is not configured on this server.'));
// Show actionable client errors; keep server errors generic.
if (message && status && status >= 400 && status < 500) {
addDangerToast(message);
} else {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
} finally {
// Re-enable the actions after success or failure.
setExportingXlsx(null);
}
};
@@ -244,23 +250,25 @@ export const useDownloadMenuItems = (
},
];
const xlsxExportLabel = (mode: 'data' | 'images', text: string) =>
exportingXlsx === mode ? t('Preparing export…') : text;
const exportMenuItems: MenuItem[] = [
...(userCanExport
? [
{
key: 'export-xlsx',
label: t('Export Data to Excel'),
label: xlsxExportLabel('data', t('Export Data to Excel')),
disabled: exportingXlsx !== null,
onClick: () => onExportXlsx('data'),
},
// Image export renders charts through the headless webdriver, so only
// offer it where that infrastructure is available (same signal as the
// PDF/PNG image downloads above); otherwise non-table charts would
// silently come back empty.
// Image exports require the same webdriver flags as PDF and PNG.
...(isWebDriverScreenshotEnabled
? [
{
key: 'export-xlsx-images',
label: t('Export Images to Excel'),
label: xlsxExportLabel('images', t('Export Images to Excel')),
disabled: exportingXlsx !== null,
onClick: () => onExportXlsx('images'),
},
]
@@ -0,0 +1,118 @@
/**
* 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 } from 'spec/helpers/testing-library';
import DefaultValue from './DefaultValue';
const capturedFilterStates: unknown[] = [];
jest.mock('@superset-ui/core', () => {
const original = jest.requireActual('@superset-ui/core');
return {
...original,
SuperChart: (props: Record<string, unknown>) => {
capturedFilterStates.push(props.filterState);
return <div data-test="mock-super-chart" />;
},
};
});
const FILTER_ID = 'filter-1';
// A minimal stand-in for antd's FormInstance: DefaultValue only ever calls
// `form.getFieldValue('filters')`.
const makeForm = (filtersValue: Record<string, unknown>) => ({
getFieldValue: (name: string) =>
name === 'filters' ? filtersValue : undefined,
});
const baseProps = {
hasDefaultValue: true,
filterId: FILTER_ID,
setDataMask: jest.fn(),
hasDataset: true,
formData: { filterType: 'filter_select' } as any,
enableNoResults: true,
};
beforeEach(() => {
capturedFilterStates.length = 0;
});
test('keeps the same filterState object reference across renders that do not change its contents', () => {
// Same underlying filterState object on every call to getFieldValue,
// exactly like re-opening the form without touching the field.
const filterState = { value: [1, 2], label: 'One, Two' };
const formFilter = {
filterType: 'filter_select',
defaultValueQueriesData: [{ data: [{ col: 1 }, { col: 2 }] }],
defaultDataMask: { filterState },
};
const { rerender } = render(
<DefaultValue
{...baseProps}
form={makeForm({ [FILTER_ID]: formFilter }) as any}
/>,
);
// A parent re-render triggered by something unrelated (e.g. the config
// modal's forceUpdate() after an ownState-only dataMask change while the
// user is typing/searching) with the exact same underlying form data.
rerender(
<DefaultValue
{...baseProps}
form={makeForm({ [FILTER_ID]: formFilter }) as any}
/>,
);
expect(capturedFilterStates).toHaveLength(2);
// Before the fix, DefaultValue spread `filterState` into a brand new
// object literal on every render, so this would be two distinct objects
// (even though their contents matched) — and the underlying Select
// resets its selection whenever the object it receives changes identity.
expect(capturedFilterStates[0]).toBe(capturedFilterStates[1]);
});
test('produces a new filterState object once the underlying value actually changes', () => {
const formFilterWithValue = (value: number[]) => ({
filterType: 'filter_select',
defaultValueQueriesData: [{ data: [{ col: 1 }, { col: 2 }] }],
defaultDataMask: { filterState: { value, label: value.join(', ') } },
});
const { rerender } = render(
<DefaultValue
{...baseProps}
form={makeForm({ [FILTER_ID]: formFilterWithValue([1, 2]) }) as any}
/>,
);
rerender(
<DefaultValue
{...baseProps}
form={makeForm({ [FILTER_ID]: formFilterWithValue([1, 2, 3]) }) as any}
/>,
);
expect(capturedFilterStates).toHaveLength(2);
expect(capturedFilterStates[0]).not.toBe(capturedFilterStates[1]);
expect((capturedFilterStates[1] as { value: number[] }).value).toEqual([
1, 2, 3,
]);
});
@@ -62,6 +62,25 @@ const DefaultValue: FC<DefaultValueProps> = ({
const value = formFilter?.defaultDataMask?.filterState?.value;
const isMissingRequiredValue =
hasDefaultValue && (value === null || value === undefined);
const baseFilterState = formFilter?.defaultDataMask?.filterState;
// Every DefaultValue render used to spread `baseFilterState` into a brand
// new object literal here, so the `filterState` prop the underlying Select
// receives got a new identity on every render (e.g. while the user is
// typing/searching), even when its actual contents were unchanged. Select
// resets its internal selection whenever that identity changes, which is
// what wiped out already-chosen default values. Memoizing on the
// underlying reference plus the two derived validation fields keeps the
// object stable across renders that don't actually change anything.
const filterState = useMemo(
() => ({
...baseFilterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
validateStatus: isMissingRequiredValue && 'error',
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[baseFilterState, isMissingRequiredValue],
);
return loading ? (
<Loading position="inline-centered" />
@@ -76,11 +95,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
chartType={chartType}
hooks={{ setDataMask }}
enableNoResults={enableNoResults}
filterState={{
...formFilter?.defaultDataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
validateStatus: isMissingRequiredValue && 'error',
}}
filterState={filterState}
/>
);
};

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