Compare commits

...

81 Commits

Author SHA1 Message Date
Enzo Martellucci
503f46836e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-25 18:14:10 +02:00
Mehmet Salih Yavuz
9a11c15a33 feat(explore): add full-range option for time-shift comparison (#41334) 2026-06-25 18:30:33 +03:00
Enzo Martellucci
615eaa110b Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-25 14:28:03 +02:00
Michael S. Molina
a90c8e0347 feat(extensions): add Chat contribution type (SIP-214) (#41205)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:57:30 -03:00
dependabot[bot]
fe2424ec14 chore(deps): bump mapbox-gl from 3.24.1 to 3.25.0 in /superset-frontend (#41409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:48 -07:00
dependabot[bot]
b4f43bd7e0 chore(deps): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /docs (#41405)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:45 -07:00
dependabot[bot]
2b25345ed9 chore(deps-dev): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /superset-frontend (#41413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:41 -07:00
Evan Rusackas
e0f3f93cd4 fix(mcp): require MCP_JWT_AUDIENCE when MCP JWT auth is enabled (#41292)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:53:36 -07:00
Evan Rusackas
0667ba6097 chore(deps): bump dompurify and http-proxy-middleware (security) (#41289)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:56 -07:00
Evan Rusackas
81f7e42f4e fix(rls): preserve tables/roles on partial RLS rule updates (#41294)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:47 -07:00
Evan Rusackas
0fd244b5c6 fix(security): reject unknown fields on guest-token RLS rules (#41217)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 16:16:43 -07:00
Evan Rusackas
1f16d10cbf chore(deps): bump pyjwt to 2.13.0 (CVE-2026-48526) (#41288)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:40 -07:00
Evan Rusackas
4f4663418f fix(tests): stabilize update_chart MCP test failing on previous-Python CI leg (#41310)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:16:14 -07:00
Evan Rusackas
4519a5c52d fix(safe-markdown): do not mutate the shared sanitization schema (#41298)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:06 -07:00
Evan Rusackas
da9fbadaf6 fix(logout): purge the namespaced Cache API store on logout (#41303)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:50 -07:00
Evan Rusackas
f40abbbefd fix(mcp): fail closed when the JWT verifier has no pinned algorithm (#41296)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:42 -07:00
Evan Rusackas
6166af3c3c fix(mcp): reject non-finite JWT exp instead of 500ing on int() overflow (#41394)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:29 -07:00
Evan Rusackas
076d8c1508 docs(security): add a secrets register and rotation schedule (#41308)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:17 -07:00
Elizabeth Thompson
518cadd907 fix(mcp_service): reduce deprecated authlib.jose.errors imports (#41248) 2026-06-24 15:01:58 -07:00
JUST.in DO IT
b955c90de4 fix(sqllab): Invalid multi sorting state in table header (#40680) 2026-06-25 06:43:02 +09:00
Evan Rusackas
7363774869 fix(theming): deep-merge partial THEME_DEFAULT overrides with built-in defaults (#41347)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 13:27:32 -07:00
Vansh Gilhotra
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
abhyudaytomar
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth Thompson
3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousoph
f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
Evan Rusackas
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski
2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
Shlummie
a57b5f6078 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:14:25 -07:00
MelikHajlawi
d1b523b97f docs: fix placeholder text in @superset-ui/core README (#40002)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:07:24 -07:00
Shashwati Bhattacharyaa
91188a0302 fix(config): Wire LOGO_TARGET_PATH and document custom spinner usage (#36951)
Co-authored-by: Shashwati <shashwatibhattacaharya21.2@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-24 01:56:15 -07:00
MUHAMMED SINAN D
ac234d0fb2 fix(dashboard): prevent x-axis clipping when toggling chart description (#38307) 2026-06-24 01:54:43 -07:00
felipegr0ssi
8eb753eab2 fix(dashboard): keep native filter dropdown from covering input (#40032)
Co-authored-by: feehgrossi <felipe.leite@sptech.school>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:44 -07:00
abhyudaytomar
779fa13679 fix(security): prevent duplicate items in permissions dropdown on scroll (#39292)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:27 -07:00
Greg Neighbors
caf81e71d2 feat(mcp): add typed Pydantic response schemas to generate_explore_link tool (#39900)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:53:08 -07:00
Eddy
1b8c6d109d feat: added deterministic field generation to dashboard export (#36339)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 01:41:44 -07:00
Viktor Högberg
eb60e5477b fix(radar): correct legend margin control in the radar chart (#39414) 2026-06-24 01:41:24 -07:00
Puneet Dixit
7b9bcdd951 fix(bigquery): preserve catalog in partition metadata lookup (#40200)
Co-authored-by: Puneet Dixit <rvit23bcs086.rvitm@rvei.edu.in>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 01:41:06 -07:00
ruhz3
d9d395bde1 fix(helm): remove unused SQLALCHEMY_TRACK_MODIFICATIONS setting (#37259) 2026-06-24 01:28:30 -07:00
Jay Masiwal
584d41759b refactor: migrate test files from nested describe blocks and remove stale lint ignores (#39202)
Co-authored-by: Joe Li <joe@preset.io>
2026-06-24 01:19:15 -07:00
abdullah reveha
8f22b71898 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-06-24 01:17:29 -07:00
omkarhall
1ea3584dcb fix(chart): added Big Number chart support for MAX metric with VARCHAR column (#41182) 2026-06-24 01:11:13 -07:00
Enzo Martellucci
556b336431 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-17 21:56:27 +02:00
Enzo Martellucci
7136fb22ba Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-08 09:13:32 +02:00
Enzo Martellucci
36126d9e2f Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-06 01:46:38 +02:00
Enzo Martellucci
82189e06e3 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-06-04 22:04:32 +02:00
Enzo Martellucci
f35638967c fix: lint 2026-06-04 15:13:06 +02:00
Enzo Martellucci
cba0b99d20 Merge remote-tracking branch 'origin/master' into enxdev/feat/enhance-database-modal-validation
# Conflicts:
#	superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/TableCatalog.tsx
2026-06-04 14:34:54 +02:00
Enzo Martellucci
5abdd4bceb fix(database): tighten validation result handling at save and SSH gating
- Treat `errors === null` from getValidation as blocking during save;
  the previous isEmpty check let unexpected/stale responses slip through
  into create/update
- Preserve SSH section-level server messages under a reserved `_error`
  key and render them in SSHTunnelForm; without this, SSH feature-gate
  failures reduced to an empty `ssh_tunnel: {}` and became invisible
  blockers
- Allow extra.ssh_tunnel errors through the blur-time error filter so
  the SSH form can surface them as the user is typing
2026-05-27 14:18:01 +02:00
Enzo Martellucci
af961ce252 fix(database): address second round of modal validation review feedback
- Treat any non-empty validation object as blocking in the save guard;
  the previous  check missed object-shaped returns like
  the duplicate database_name error and let the save proceed
- Forward ssh_tunnel into build_db_for_connection_test so tunnel-only
  databases are reached through the tunnel during validation instead of
  being pinged directly, mirroring the existing test_connection flow
- Honor explicit parameters.ssh === False as authoritative; a stale
  ssh_tunnel payload no longer keeps SSH validation on after the user
  toggles the tunnel off
- Emit both  and  as missing credentials so the
  active SSH login pane always surfaces the field error (the backend
  doesn't know which method the user picked)
2026-05-27 13:33:33 +02:00
Enzo Martellucci
baf701c022 fix(database): address review feedback on modal validation regressions
- Drop private_key implies private_key_password check; the schema permits
  unencrypted private keys and the prior check rejected valid SSH configs
- Surface SSH feature-flag and missing-port errors as field-level
  SupersetError entries instead of raising 400s, so blur-driven validation
  shows field hints rather than hard toasts
- Return null from both stale paths in useDatabaseValidation so callers
  can distinguish discarded responses from real outcomes and skip caching
- Add isValidating to LabeledErrorBoundInputProps so the prop typechecks
  explicitly instead of via the [x: string]: any index signature
2026-05-27 12:47:56 +02:00
Enzo Martellucci
ee3f8107f9 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-27 12:14:55 +02:00
Enzo Martellucci
9f7624cdc6 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-08 22:43:52 +02:00
Enzo Martellucci
f846e106b8 fix(database): tighten validate_parameters SSH and bypass-engine paths
Three correctness fixes to ValidateDatabaseParametersCommand:

1. Bypass engines (bigquery, datastore, snowflake) now also surface
   database_name uniqueness errors and SSH tunnel field errors during
   progressive validation, instead of silently passing.

2. The SSH feature-flag and database-port guards now fire when the UI
   marks parameters.ssh, not just when the ssh_tunnel payload is
   non-empty — the form sends an empty tunnel object in early stages.

3. The "parameters are missing" message for SSH tunnel fields now
   interpolates the %(missing)s placeholder via gettext, so the
   response surfaces the actual missing fields instead of the literal
   token.

Adds unit tests for each branch and removes the now-unused
_validate_database_name helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:32:35 +02:00
Enzo Martellucci
3e26f0218f fix(database): retry blur validation after a transient request failure
When the validate_parameters request failed without a structured error
body (e.g. network drop), getValidation returned an empty object that
the caller could not distinguish from "validation passed" — and the
blur dedup cache was already updated, so the same form state would never
revalidate until the user changed a field.

Have getValidation return null on unexpected failure and only update
the snapshot cache after a usable response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:32:10 +02:00
Enzo Martellucci
864b27d0e3 test(database): explicitly wait for each blur validation in cypress
The two ``show error alerts on dynamic form`` cases relied on a single
60s ``should('not.be.disabled')`` after typing five fields, but the
``{ timeout: 60000 }`` option on ``.should()`` is not honoured the way
it is on the query — the assertion still uses the project's 8s default
and times out before the chained validation calls complete.

Match the master cadence: explicitly blur each field and wait for the
``@validateParams`` interception before moving on, so the button-state
assertion only fires once validation is settled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:52:14 +02:00
Enzo Martellucci
86b3d8922c test(database): cover new validate command branches
Adds unit tests for the duplicate-database-name check (create + update
paths, plus the bypass-engine path), the SSH tunnel feature-flag and
db-port guards, and the SSH tunnel field-level error collection
(missing required fields, missing credentials, private key without
password). Brings patch coverage on commands/database/validate.py up
from ~44%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:53 +02:00
Enzo Martellucci
9651d0e673 test(database): stabilize DatabaseModal jest tests on slow CI
userEvent.type fires keystrokes serially through React's event system,
which races with the new debounced validation: ports rendered as number
inputs lose values mid-typing, and dynamic-form tests time out at the
20s default while tabbing between five fields. userEvent.paste also
no longer supplies a clipboardData object the Select onPaste handler can
read.

Switch the affected interactions to fireEvent.change/blur (single-shot,
no per-key validation churn) and revert the paste case to fireEvent.paste,
matching the master form. Behavior under test is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:33 +02:00
Enzo Martellucci
15ec3bbb87 fix(database): prevent stale validation errors from out-of-order responses
The blur-debounced validation can fire multiple in-flight requests as the
user types across fields. Without sequencing, an earlier request that
returns later overwrites the result of a newer one, leaving the modal
showing stale errors and keeping the Connect button disabled even after
the form is fully valid.

Track a per-call request id and only update validation state when the
response corresponds to the latest request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:21:16 +02:00
Enzo Martellucci
e4f8472e2e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-06 10:45:18 +02:00
Enzo Martellucci
3a52d609c9 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-05-05 23:44:17 +02:00
Enzo Martellucci
edc8e4b3ab Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-04-27 10:33:23 +02:00
Enzo Martellucci
f6ac345ef3 fix(cypress): wait for final validation to settle before asserting button state 2026-03-18 15:24:12 +01:00
Enzo Martellucci
d036ef4455 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 14:57:15 +01:00
Enzo Martellucci
6c69cc23ea lint 2026-03-18 14:56:58 +01:00
Enzo Martellucci
15b28631bf chore: remove duplicated handleClearValidationErrors function 2026-03-18 14:44:13 +01:00
Enzo Martellucci
e7c9cf0d04 refactor(database): simplify SSH tunnel error accumulation in useDatabaseValidation 2026-03-18 11:57:35 +01:00
Enzo Martellucci
2f980320b6 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-03-18 11:55:33 +01:00
Enzo Martellucci
d1ec3ebb40 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-03-12 23:17:01 +01:00
Enzo Martellucci
13ed9b5bad fix CI test 2026-02-16 16:32:56 +01:00
Enzo Martellucci
0bfaf3c50e perf(database): skip redundant validation API calls on blur 2026-02-16 11:10:34 +01:00
Enzo Martellucci
055fa360bb Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-14 21:46:20 +01:00
Enzo Martellucci
7d53e4d708 Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-02-10 16:03:25 +01:00
Enzo Martellucci
c0be0485b3 Merge branch master into enxdev/feat/enhance-database-modal-validation 2026-02-04 09:41:19 +01:00
Enzo Martellucci
84c228e28b Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-28 11:27:56 +01:00
Enzo Martellucci
899ecf8214 fix: update RTL tests to match new behavior 2026-01-20 11:16:06 +01:00
Enzo Martellucci
4e156dc41e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-20 09:44:00 +01:00
Enzo Martellucci
2df224370e Merge branch 'master' into enxdev/feat/enhance-database-modal-validation 2026-01-13 16:41:44 +01:00
Enzo Martellucci
a44b8a6cf0 feat(database): add SSH tunnel validation to database parameters endpoint
- Remove early return in frontend validation hook when SSH is enabled,
  allowing backend validation to run for all database parameters
- Add SSH tunnel field validation in ValidateDatabaseParametersCommand
  to validate server_address, server_port, username, and credentials
- Add DatabaseSSHTunnelValidation schema for partial SSH tunnel data
  validation without strict authentication requirements
- Add ssh_tunnel field to DatabaseValidateParametersSchema
- Parse SSH tunnel errors in frontend and display under ssh_tunnel key
- Collect database_name duplicate errors alongside parameter errors
2026-01-02 17:53:01 +01:00
Enzo Martellucci
ad92ec683b wip: add validation loading state to SSH tunnel form fields 2025-12-31 17:35:50 +01:00
Enzo Martellucci
6aef573304 feat(database): add validation loading state and duplicate name check
- Add isValidating prop to TableCatalog, ValidatedInputField, and
  CommonParameters to show loading spinner during validation
- Fix LabeledErrorBoundInput hasFeedback to display spinner while
  validating, not just on errors
- Add duplicate database name validation to validate_parameters
  endpoint for real-time feedback before form submission
2025-12-31 17:23:13 +01:00
162 changed files with 7330 additions and 1615 deletions

View File

@@ -24,6 +24,14 @@ assists people when migrating to a new version.
## Next ## Next
### Guest-token RLS rules reject unknown fields
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
### Pivot table First/Last aggregations follow data order ### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order. The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.

View File

@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches. - [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident. - [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
- [ ] Conduct quarterly access reviews for all users. - [ ] Conduct quarterly access reviews for all users.
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly. - [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here: The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
### **Appendix C: Secrets Register and Rotation Schedule**
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
| Secret | Purpose | Risk if leaked | Suggested rotation |
|---|---|---|---|
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
Notes:
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
:::resources :::resources
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/) - [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/) - [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)

View File

@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application. Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
```tsx ```typescript
import React from 'react';
import { views } from '@apache-superset/core'; import { views } from '@apache-superset/core';
import MyPanel from './MyPanel'; import MyPanel from './MyPanel';
views.registerView( views.registerView(
{ id: 'my-extension.main', name: 'My Panel Name' }, { id: 'my-extension.main', name: 'My Panel Name' },
'sqllab.panels', 'sqllab.panels',
() => <MyPanel />, MyPanel,
); );
``` ```
@@ -112,6 +111,24 @@ editors.registerEditor(
See [Editors Extension Point](./extension-points/editors.md) for implementation details. See [Editors Extension Point](./extension-points/editors.md) for implementation details.
### Chat
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
See [Chat](./extension-points/chat.md) for implementation details.
## Backend ## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file. Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.

View File

@@ -0,0 +1,141 @@
---
title: Chat
sidebar_position: 3
---
<!--
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.
-->
# Chat Contributions
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
## Overview
A chat registration consists of two React components:
| Component | Role |
|-----------|------|
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
## Display Modes
The host supports two display modes, switchable by the user or the extension at runtime:
| Mode | Behavior |
|------|----------|
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
The user's last selected mode and open/closed state are persisted across page reloads.
## Registering a Chat
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
## Opening and Closing the Chat
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
```tsx
import { chat } from '@apache-superset/core';
export default function ChatTrigger() {
return (
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
💬
</button>
);
}
```
You can also subscribe to open/close events from any component:
```tsx
useEffect(() => {
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
return dispose;
}, []);
```
## Changing the Display Mode
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
```tsx
import { useState, useEffect } from 'react';
import { chat } from '@apache-superset/core';
export default function ChatPanel() {
const [mode, setMode] = useState(chat.getDisplayMode());
useEffect(() => {
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
return dispose;
}, []);
return (
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
{mode === 'panel' ? 'Float' : 'Dock'}
</button>
{/* message list and input */}
</div>
);
}
```
## Chat API Reference
All methods are available on the `chat` namespace from `@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
| `open()` | Open the chat panel. No-op if already open or no registration. |
| `close()` | Close the chat panel. |
| `isOpen()` | Returns `true` if the panel is currently open. |
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment

View File

@@ -47,6 +47,8 @@ module.exports = {
collapsed: true, collapsed: true,
items: [ items: [
'extensions/extension-points/sqllab', 'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
], ],
}, },
'extensions/development', 'extensions/development',

View File

@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4", "@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41", "@swc/core": "^1.15.41",
"antd": "^6.4.4", "antd": "^6.4.4",
"baseline-browser-mapping": "^2.10.37", "baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799", "caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2", "docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2", "docusaurus-theme-openapi-docs": "^5.0.2",

View File

@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.37" version "2.10.38"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d" resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig== integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
batch@0.6.1: batch@0.6.1:
version "0.6.1" version "0.6.1"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda - name: craig-rueda
email: craig@craigrueda.com email: craig@craigrueda.com
url: https://github.com/craig-rueda url: https://github.com/craig-rueda
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details. version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies: dependencies:
- name: postgresql - name: postgresql
version: 16.7.27 version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset # superset
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square) ![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -108,8 +108,6 @@ else:
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }} {{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }} {{- end }}
SQLALCHEMY_TRACK_MODIFICATIONS = True
class CeleryConfig: class CeleryConfig:
imports = ("superset.sql_lab", ) imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL broker_url = CELERY_REDIS_URL

View File

@@ -315,7 +315,7 @@ pygeohash==3.2.2
# via apache-superset (pyproject.toml) # via apache-superset (pyproject.toml)
pygments==2.20.0 pygments==2.20.0
# via rich # via rich
pyjwt==2.12.0 pyjwt==2.13.0
# via # via
# apache-superset (pyproject.toml) # apache-superset (pyproject.toml)
# flask-appbuilder # flask-appbuilder

View File

@@ -769,7 +769,7 @@ pyhive==0.7.0
# via apache-superset # via apache-superset
pyinstrument==5.1.2 pyinstrument==5.1.2
# via apache-superset # via apache-superset
pyjwt==2.12.0 pyjwt==2.13.0
# via # via
# -c requirements/base-constraint.txt # -c requirements/base-constraint.txt
# apache-superset # apache-superset

View File

@@ -63,56 +63,57 @@ describe('Add database', () => {
it('show error alerts on dynamic form for bad host', () => { it('show error alerts on dynamic form for bad host', () => {
cy.get('.preferred > :nth-child(1)').click(); cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('badhost', { force: true }); cy.get('input[name="host"]').type('badhost', { force: true }).blur();
cy.get('input[name="port"]').type('5432', { force: true }); cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]').type('testusername', { force: true }); cy.get('input[name="port"]').type('5432', { force: true }).blur();
cy.get('input[name="database"]').type('testdb', { force: true }); cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }); cy.get('input[name="username"]')
.type('testusername', { force: true })
cy.get('body').click(0, 0); .blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 }); cy.wait('@validateParams', { timeout: 30000 });
cy.getBySel('btn-submit-connection').should('not.be.disabled'); cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
'not.be.disabled',
);
cy.getBySel('btn-submit-connection').click({ force: true }); cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => { cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.wait('@createDb', { timeout: 60000 }).then(() => { cy.contains(
cy.contains( '.ant-form-item-explain-error',
'.ant-form-item-explain-error', "The hostname provided can't be resolved",
"The hostname provided can't be resolved", ).should('exist');
).should('exist');
});
}); });
}); });
it('show error alerts on dynamic form for bad port', () => { it('show error alerts on dynamic form for bad port', () => {
cy.get('.preferred > :nth-child(1)').click(); cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').type('localhost', { force: true }); cy.get('input[name="host"]').type('localhost', { force: true }).blur();
cy.get('body').click(0, 0); cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="username"]')
.type('testusername', { force: true })
.blur();
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
cy.wait('@validateParams', { timeout: 30000 }); cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="port"]').type('5430', { force: true }); cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
cy.get('input[name="database"]').type('testdb', { force: true }); 'not.be.disabled',
cy.get('input[name="username"]').type('testusername', { force: true }); );
cy.wait('@validateParams', { timeout: 30000 });
cy.get('input[name="password"]').type('testpass', { force: true });
cy.wait('@validateParams');
cy.getBySel('btn-submit-connection').should('not.be.disabled');
cy.getBySel('btn-submit-connection').click({ force: true }); cy.getBySel('btn-submit-connection').click({ force: true });
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
cy.get('body').click(0, 0); cy.wait('@createDb', { timeout: 60000 }).then(() => {
cy.getBySel('btn-submit-connection').click({ force: true }); cy.contains('.ant-form-item-explain-error', 'The port is closed').should(
cy.wait('@createDb', { timeout: 60000 }).then(() => { 'exist',
cy.contains( );
'.ant-form-item-explain-error',
'The port is closed',
).should('exist');
});
}); });
}); });
}); });

View File

@@ -109,7 +109,7 @@
"json-bigint": "^1.0.0", "json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"mapbox-gl": "^3.24.1", "mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2", "markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0", "match-sorter": "^8.3.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
@@ -220,7 +220,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.37", "baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",
@@ -6326,12 +6326,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@mapbox/mapbox-gl-supported": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
"license": "BSD-3-Clause"
},
"node_modules/@mapbox/martini": { "node_modules/@mapbox/martini": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
@@ -11470,15 +11464,6 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/glob-to-regexp": { "node_modules/@types/glob-to-regexp": {
"version": "0.4.4", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz", "resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
@@ -11776,12 +11761,6 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/picomatch": { "node_modules/@types/picomatch": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
@@ -14961,9 +14940,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.37", "version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -15954,12 +15933,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/cheap-ruler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
"license": "ISC"
},
"node_modules/check-error": { "node_modules/check-error": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -17283,12 +17256,6 @@
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/csscolorparser": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
"license": "MIT"
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -18617,11 +18584,10 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.7", "version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@@ -21450,12 +21416,6 @@
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==", "integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
"license": "ISC"
},
"node_modules/geolib": { "node_modules/geolib": {
"version": "3.3.14", "version": "3.3.14",
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz", "resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
@@ -23260,9 +23220,9 @@
} }
}, },
"node_modules/http-proxy-middleware": { "node_modules/http-proxy-middleware": {
"version": "2.0.9", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -28361,9 +28321,9 @@
} }
}, },
"node_modules/mapbox-gl": { "node_modules/mapbox-gl": {
"version": "3.24.1", "version": "3.25.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==", "integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
"license": "SEE LICENSE IN LICENSE.txt", "license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [ "workspaces": [
"src/style-spec", "src/style-spec",
@@ -28371,66 +28331,7 @@
"test/build/vite", "test/build/vite",
"test/build/webpack", "test/build/webpack",
"test/build/typings" "test/build/typings"
], ]
"dependencies": {
"@mapbox/mapbox-gl-supported": "^3.0.0",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "^3.2.5",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"cheap-ruler": "^4.0.0",
"csscolorparser": "~1.0.3",
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"martinez-polygon-clipping": "^0.8.1",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0"
}
},
"node_modules/mapbox-gl/node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/mapbox-gl/node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/mapbox-gl/node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
}, },
"node_modules/maplibre-gl": { "node_modules/maplibre-gl": {
"version": "5.24.0", "version": "5.24.0",
@@ -28548,23 +28449,6 @@
} }
} }
}, },
"node_modules/martinez-polygon-clipping": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^2.0.4",
"splaytree": "^0.1.4",
"tinyqueue": "3.0.0"
}
},
"node_modules/martinez-polygon-clipping/node_modules/robust-predicates": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
"license": "Unlicense"
},
"node_modules/match-sorter": { "node_modules/match-sorter": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz", "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
@@ -39137,12 +39021,6 @@
"webpack": "^1 || ^2 || ^3 || ^4 || ^5" "webpack": "^1 || ^2 || ^3 || ^4 || ^5"
} }
}, },
"node_modules/splaytree": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
"license": "MIT"
},
"node_modules/split": { "node_modules/split": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
@@ -45044,15 +44922,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": { "packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1", "version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz", "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
@@ -45120,6 +44989,26 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
} }
}, },
"packages/superset-ui-core/node_modules/react-syntax-highlighter": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"packages/superset-ui-core/node_modules/timezone-mock": { "packages/superset-ui-core/node_modules/timezone-mock": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/timezone-mock/-/timezone-mock-1.4.2.tgz", "resolved": "https://registry.npmjs.org/timezone-mock/-/timezone-mock-1.4.2.tgz",
@@ -45423,15 +45312,6 @@
"react": "^18.3.0" "react": "^18.3.0"
} }
}, },
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"plugins/plugin-chart-ag-grid-table": { "plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table", "name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3", "version": "0.20.3",
@@ -45611,7 +45491,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@math.gl/web-mercator": "^4.1.0", "@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.1", "mapbox-gl": "^3.25.0",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1", "react-map-gl": "^8.1.1",
"supercluster": "^8.0.1" "supercluster": "^8.0.1"

View File

@@ -192,7 +192,7 @@
"json-bigint": "^1.0.0", "json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"mapbox-gl": "^3.24.1", "mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2", "markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0", "match-sorter": "^8.3.0",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
@@ -303,7 +303,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.37", "baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0", "copy-webpack-plugin": "^14.0.0",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts", "types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js" "default": "./lib/authentication/index.js"
}, },
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": { "./commands": {
"types": "./lib/commands/index.d.ts", "types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js" "default": "./lib/commands/index.js"

View File

@@ -0,0 +1,156 @@
/**
* 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.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type: an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. Only one chat is active at a time; the most
* recently registered chat wins. Disposing the returned Disposable unregisters
* the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger The trigger component — the collapsed bubble entry point.
* Owns dynamic state such as unread counts.
* @param panel The panel component, rendered in either display mode. In
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
* alongside the main content.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable;
/**
* Returns the active chat descriptor, or undefined if none is registered.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes, whether triggered by an extension
* or by the host.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode. The mode is host-global and applies to whichever
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
* including those triggered by the host.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode. Not all hosts provide
* a resizer — do not rely on this event firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[]; dependencies: string[];
/** Human-readable description of the extension */ /** Human-readable description of the extension */
description: string; description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */ /** Unique identifier for the extension */
id: string; id: string;
/** Human-readable name of the extension */ /** Human-readable name of the extension */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json * This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata * manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views, * types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema. * menus, editors, chat) and re-exported here for the manifest schema.
*/ */
import { Chat } from '../chat';
import { Command } from '../commands'; import { Command } from '../commands';
import { View } from '../views'; import { View } from '../views';
import { Menu } from '../menus'; import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
} }
/** /**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module. * Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/ */
export interface Contributions { export interface Contributions {
/** List of commands. */ /** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions; views: ViewContributions;
/** List of editors. */ /** List of editors. */
editors?: Editor[]; editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
} }

View File

@@ -18,10 +18,12 @@
*/ */
export * as common from './common'; export * as common from './common';
export * as authentication from './authentication'; export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands'; export * as commands from './commands';
export * as editors from './editors'; export * as editors from './editors';
export * as extensions from './extensions'; export * as extensions from './extensions';
export * as menus from './menus'; export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab'; export * as sqlLab from './sqlLab';
export * as views from './views'; export * as views from './views';
export * as contributions from './contributions'; export * as contributions from './contributions';

View File

@@ -0,0 +1,81 @@
/**
* 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.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -30,12 +30,12 @@
* *
* views.registerView( * views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' }, * { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
* () => <ResultStatsPanel />, * ResultStatsPanel,
* ); * );
* ``` * ```
*/ */
import { ReactElement } from 'react'; import { ComponentType } from 'react';
import { Disposable, Event } from '../common'; import { Disposable, Event } from '../common';
/** /**
@@ -58,7 +58,7 @@ export interface View {
* *
* @param view The view descriptor (id and name). * @param view The view descriptor (id and name).
* @param location The location where this view should appear (e.g. "sqllab.panels"). * @param location The location where this view should appear (e.g. "sqllab.panels").
* @param provider A function that returns the React element to render. * @param component The React component to render at that location.
* @returns A Disposable that unregisters the view when disposed. * @returns A Disposable that unregisters the view when disposed.
* *
* @example * @example
@@ -66,14 +66,14 @@ export interface View {
* views.registerView( * views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' }, * { id: 'my_ext.result_stats', name: 'Result Stats' },
* 'sqllab.panels', * 'sqllab.panels',
* () => <ResultStatsPanel />, * ResultStatsPanel,
* ); * );
* ``` * ```
*/ */
export declare function registerView( export declare function registerView(
view: View, view: View,
location: string, location: string,
provider: () => ReactElement, component: ComponentType,
): Disposable; ): Disposable;
/** /**

View File

@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
}, },
}, },
], ],
[
{
name: 'time_compare_full_range',
config: {
type: 'CheckboxControl',
label: t('Show full range for time shift'),
default: false,
description: t(
'Plot each time-shifted series across its full time range instead ' +
'of truncating it to the main series. Useful for comparing a ' +
'partial current period (e.g. today so far) against complete ' +
'prior periods (e.g. all of yesterday).',
),
visibility: ({ controls }) =>
Boolean(controls?.time_compare?.value) &&
(!Array.isArray(controls?.time_compare?.value) ||
controls.time_compare.value.length > 0),
},
},
],
[ [
{ {
name: 'comparison_type', name: 'comparison_type',

View File

@@ -22,21 +22,50 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core) [![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore) [![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore)
Description The core package for Apache Superset's frontend. It provides shared utilities,
types, and abstractions used across all Superset chart plugins and UI components.
Key modules include:
- **query** — Utilities for building queries and calling the Superset API
(including `makeApi`)
- **number-format** — Number formatting helpers powered by d3-format
- **time-format** — Time/date formatting helpers powered by d3-time-format
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
- **chart** — Base classes and types for building chart plugins
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
> They now live in `@apache-superset/core`, imported from
> `@apache-superset/core/translation`.
#### Example usage #### Example usage
```js ```js
import { xxx } from '@superset-ui/core'; import { getNumberFormatter, makeApi } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
// Format a number
const formatter = getNumberFormatter('.2f');
console.log(formatter(1234.5)); // "1234.50"
// Translate a string
console.log(t('Hello %s', 'world'));
// Call a Superset API endpoint
const fetchDashboards = makeApi({
method: 'GET',
endpoint: '/api/v1/dashboard',
});
``` ```
#### API
`fn(args)`
- TBD
### Development ### Development
`@data-ui/build-config` is used to manage the build configuration for this package including babel `@data-ui/build-config` is used to manage the build configuration for this package
builds, jest testing, eslint, and prettier. including babel builds, jest testing, eslint, and prettier.
Run tests:
```bash
cd superset-frontend
npx jest packages/superset-ui-core
```

View File

@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
isValidating ? 'validating' : hasError ? 'error' : 'success' isValidating ? 'validating' : hasError ? 'error' : 'success'
} }
help={errorMessage || helpText} help={errorMessage || helpText}
hasFeedback={!!hasError} hasFeedback={isValidating || !!hasError}
> >
{visibilityToggle || props.name === 'password' ? ( {visibilityToggle || props.name === 'password' ? (
<StyledInputPassword <StyledInputPassword

View File

@@ -31,5 +31,6 @@ export interface LabeledErrorBoundInputProps {
id?: string; id?: string;
classname?: string; classname?: string;
visibilityToggle?: boolean; visibilityToggle?: boolean;
isValidating?: boolean;
[x: string]: any; [x: string]: any;
} }

View File

@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// remark-gfm v4+ requires react-markdown v9+, which requires React 18. // remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17. // Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { mergeWith } from 'lodash'; import { cloneDeep, mergeWith } from 'lodash';
import { FeatureFlag, isFeatureEnabled } from '../../utils'; import { FeatureFlag, isFeatureEnabled } from '../../utils';
interface SafeMarkdownProps { interface SafeMarkdownProps {
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
originalSchema: typeof defaultSchema, originalSchema: typeof defaultSchema,
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'], htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
) { ) {
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) => // Merge into a fresh clone: mergeWith mutates its first argument, and the
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined, // array customizer concatenates, so merging into the shared defaultSchema
// import would progressively widen the sanitization allowlist for every
// SafeMarkdown instance app-wide.
return mergeWith(
cloneDeep(originalSchema),
htmlSchemaOverrides,
(objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
); );
} }

View File

@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request), request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(), getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args), getUrl: (...args) => getInstance().getUrl(...args),
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
get guestTokenHeaderName() { get guestTokenHeaderName() {
try { try {
return getInstance().guestTokenHeaderName; return getInstance().guestTokenHeaderName;

View File

@@ -150,6 +150,26 @@ export default class SupersetClientClass {
} }
} }
/**
* POST request that returns a blob for file downloads.
* Unlike postForm, this uses AJAX so errors can be caught and handled.
* @param endpoint - API endpoint
* @param payload - Request payload
* @returns Promise resolving to Response with blob
*/
async postBlob(
endpoint: string,
payload: Record<string, any>,
): Promise<Response> {
await this.ensureAuth();
return this.post({
endpoint,
postPayload: payload,
parseMethod: 'raw',
stringify: false,
});
}
async reAuthenticate() { async reAuthenticate() {
return this.init(true); return this.init(true);
} }

View File

@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
| 'get' | 'get'
| 'post' | 'post'
| 'postForm' | 'postForm'
| 'postBlob'
| 'put' | 'put'
| 'request' | 'request'
| 'init' | 'init'

View File

@@ -17,6 +17,8 @@
* under the License. * under the License.
*/ */
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { cloneDeep } from 'lodash';
import { defaultSchema } from 'rehype-sanitize';
import { import {
getOverrideHtmlSchema, getOverrideHtmlSchema,
SafeMarkdown, SafeMarkdown,
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] }); expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']); expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
}); });
test('should not mutate the original schema', () => {
const original = {
attributes: { '*': ['size'] },
tagNames: ['h1'],
};
getOverrideHtmlSchema(original, {
attributes: { '*': ['src'] },
tagNames: ['iframe'],
});
// The original passed in is left untouched.
expect(original.attributes).toEqual({ '*': ['size'] });
expect(original.tagNames).toEqual(['h1']);
});
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
const snapshot = cloneDeep(defaultSchema);
const overrides = { tagNames: ['iframe'] };
const first = getOverrideHtmlSchema(defaultSchema, overrides);
const second = getOverrideHtmlSchema(defaultSchema, overrides);
// The shared singleton is never modified...
expect(defaultSchema).toEqual(snapshot);
// ...and repeated calls do not accumulate the override (no growing arrays).
expect(first.tagNames).toEqual(second.tagNames);
expect(
(second.tagNames ?? []).filter(name => name === 'iframe'),
).toHaveLength(1);
});
}); });
describe('transformLinkUri', () => { describe('transformLinkUri', () => {

View File

@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
getUrl: (...args: unknown[]) => string; getUrl: (...args: unknown[]) => string;
}; };
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => { test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function'); expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function'); expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function'); expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function'); expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function'); expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.postBlob).toBe('function');
expect(typeof SupersetClient.delete).toBe('function'); expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function'); expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function'); expect(typeof SupersetClient.request).toBe('function');
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
expect(typeof SupersetClient.reAuthenticate).toBe('function'); expect(typeof SupersetClient.reAuthenticate).toBe('function');
}); });
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => { test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow(); expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow(); expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow(); expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow(); expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.postBlob).toThrow();
expect(SupersetClient.delete).toThrow(); expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow(); expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow(); expect(SupersetClient.request).toThrow();

View File

@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
expect(authSpy).toHaveBeenCalledTimes(0); expect(authSpy).toHaveBeenCalledTimes(0);
}); });
}); });
describe('.postBlob()', () => {
const protocol = 'https:';
const host = 'host';
const mockPostBlobEndpoint = '/api/v1/chart/data';
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
let authSpy: jest.SpyInstance;
let client: SupersetClientClass;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
await client.init();
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
const mockResponse = new Response('csv data', { status: 200 });
const postSpy = jest
.spyOn(client, 'post')
.mockResolvedValue(mockResponse);
const response = await client.postBlob(
mockPostBlobEndpoint,
postBlobPayload,
);
expect(authSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledWith({
endpoint: mockPostBlobEndpoint,
postPayload: postBlobPayload,
parseMethod: 'raw',
stringify: false,
});
expect(response).toBe(mockResponse);
});
test('passes payload in request body', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 200,
body: 'csv data',
});
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
});
test('rejects when response is not ok', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 413,
body: 'Payload Too Large',
});
await expect(
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
).rejects.toMatchObject({ status: 413 });
});
});
}); });

View File

@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject; let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject; const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = []; let postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. The totals query must rename percent-metric
// columns the same way (`metric` -> `%metric`) so the footer can look them
// up; without it the totals row renders 0.000%. We deliberately reuse only
// this rule and not the full `postProcessing` array, which may also contain
// a time-comparison operator that must not run on the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray( const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare, formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit'); ).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
metrics.concat(percentMetrics), metrics.concat(percentMetrics),
getMetricLabel, getMetricLabel,
); );
postProcessing = [ contributionPostProcessing = {
{ operation: 'contribution',
operation: 'contribution', options: {
options: { columns: percentMetricLabels,
columns: percentMetricLabels, rename_columns: percentMetricLabels.map(x => `%${x}`),
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
}, },
]; };
postProcessing = [contributionPostProcessing];
} }
// Add the operator for the time comparison if some is selected // Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) { if (!isEmpty(timeOffsets)) {
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extras: totalsExtras, // Use extras with AG Grid WHERE removed extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0, row_limit: 0,
row_offset: 0, row_offset: 0,
post_processing: [], // Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined, // we don't need orderby stuff here, order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation. orderby: undefined, // because this query will be used for get total aggregation.
}); });

View File

@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.columns).toEqual([]); expect(totalsQuery.columns).toEqual([]);
expect(totalsQuery.row_limit).toBe(0); expect(totalsQuery.row_limit).toBe(0);
}); });
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
// percent-metric columns (`metric` -> `%metric`) so the footer can
// look them up. Otherwise the totals row renders 0.000%.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
// No server pagination -> queries[1] is the totals query.
const totalsQuery = queries[1];
const contributionRule = {
operation: 'contribution',
options: {
columns: ['count'],
rename_columns: ['%count'],
},
};
expect(queries[0].post_processing).toContainEqual(contributionRule);
expect(totalsQuery.post_processing).toEqual([contributionRule]);
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
time_compare: ['1 year ago'],
comparison_type: 'values',
});
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
const totalsQuery = queries[1];
expect(totalsQuery.post_processing).toEqual([]);
});
}); });
describe('Integration - all filter types together', () => { describe('Integration - all filter types together', () => {

View File

@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
expect(result.headerFormatter(500)).toBe('$500'); expect(result.headerFormatter(500)).toBe('$500');
}); });
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: 'some-varchar-result' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('some-varchar-result');
});
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: '123' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('123');
});
test('should propagate colorThresholdFormatters from getColorFormatters', () => { test('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value // Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }]; const mockFormatters = [{ formatter: 'red' }];

View File

@@ -79,8 +79,15 @@ export default function transformProps(
const formattedSubtitleFontSize = subtitle?.trim() const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? PROPORTION.SUBHEADER) ? (subtitleFontSize ?? PROPORTION.SUBHEADER)
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER); : (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
const rawValue = data.length === 0 ? null : data[0][metricName];
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
const bigNumber = const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]); parsedValue === null &&
typeof rawValue === 'string' &&
rawValue.trim() !== ''
? rawValue
: parsedValue;
let metricEntry: Metric | undefined; let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) { if (chartProps.datasource?.metrics) {

View File

@@ -189,8 +189,10 @@ function BigNumberVis({
text = t('No data'); text = t('No data');
} else if (typeof bigNumber === 'number') { } else if (typeof bigNumber === 'number') {
text = headerFormatter(bigNumber); text = headerFormatter(bigNumber);
} else if (typeof bigNumber === 'string') {
text = bigNumber;
} else { } else {
// For string/boolean/Date values, convert to number if possible, else show as string // For boolean/Date values, convert to number if possible, else show as string
const numValue = Number(bigNumber); const numValue = Number(bigNumber);
text = Number.isNaN(numValue) text = Number.isNaN(numValue)
? String(bigNumber) ? String(bigNumber)

View File

@@ -318,14 +318,25 @@ function createAdvancedAnalyticsSection(
): ControlPanelSectionConfig { ): ControlPanelSectionConfig {
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls); const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
aaWithSuffix.label = label; aaWithSuffix.label = label;
// `time_compare_full_range` is only wired into the regular timeseries query
// builder, not the mixed-timeseries one, so drop it here to avoid showing a
// control that has no effect.
aaWithSuffix.controlSetRows = aaWithSuffix.controlSetRows
.map(row =>
row.filter(
control =>
(control as CustomControlItem)?.name !== 'time_compare_full_range',
),
)
.filter(row => row.length > 0);
if (!controlSuffix) { if (!controlSuffix) {
return aaWithSuffix; return aaWithSuffix;
} }
aaWithSuffix.controlSetRows.forEach(row => aaWithSuffix.controlSetRows.forEach(row =>
row.forEach((control: CustomControlItem) => { row.forEach(control => {
if (control?.name) { const item = control as CustomControlItem;
// eslint-disable-next-line no-param-reassign if (item?.name) {
control.name = `${control.name}${controlSuffix}`; item.name = `${item.name}${controlSuffix}`;
} }
}), }),
); );

View File

@@ -331,10 +331,16 @@ export default function transformProps(
type: legendType, type: legendType,
}); });
const chartPadding = getChartPadding(
showLegend,
legendOrientation,
effectiveLegendMargin,
);
const series: RadarSeriesOption[] = [ const series: RadarSeriesOption[] = [
{ {
type: 'radar', type: 'radar',
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin), ...chartPadding,
animation: false, animation: false,
emphasis: { emphasis: {
label: { label: {
@@ -361,6 +367,15 @@ export default function transformProps(
numberFormatter, numberFormatter,
); );
const centerX = width
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
: 50;
const centerY = height
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
: 50;
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
const echartOptions: EChartsCoreOption = { const echartOptions: EChartsCoreOption = {
grid: { grid: {
...defaultGrid, ...defaultGrid,
@@ -390,6 +405,7 @@ export default function transformProps(
color: theme.colorSplit, color: theme.colorSplit,
}, },
}, },
center: radarCenter,
splitArea: { splitArea: {
show: true, show: true,
areaStyle: { areaStyle: {

View File

@@ -23,6 +23,7 @@ import {
} from '../../../../spec/helpers/testing-library'; } from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core'; import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core'; import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { import {
LegendOrientation, LegendOrientation,
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
onFocusedSeries: jest.fn(), onFocusedSeries: jest.fn(),
}; };
function getLatestHeight() { function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1); const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined(); expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps]; const [props] = lastCall as [EchartsProps];
return props.height; return props;
}
function getLatestHeight() {
return getLatestEchartProps().height;
} }
test('observes extra control height changes when ResizeObserver is available', async () => { test('observes extra control height changes when ResizeObserver is available', async () => {
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
const clickHandler = props.eventHandlers?.click; const clickHandler = props.eventHandlers?.click;
if (clickHandler) { if (clickHandler) {
clickHandler({ clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name seriesName: 'Sales', // This is the metric name
data: ['Product A', 100], // X-axis value is 'Product A' data: ['Product A', 100], // X-axis value is 'Product A'
name: 'Product A', name: 'Product A',
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
} }
}); });
test('emits cross-filter on category value for horizontal bar clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'series',
seriesName: 'Sales',
data: [100, 'Product A'],
name: 'Product A',
dataIndex: 0,
});
await waitFor(
() => {
expect(setDataMaskMock).toHaveBeenCalled();
},
{ timeout: 500 },
);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('uses rendered categorical axis for query event handlers', () => {
render(
<EchartsTimeseries
{...defaultProps}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'xAxis.category',
);
cleanup();
mockEchart.mockReset();
render(
<EchartsTimeseries
{...defaultProps}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
'yAxis.category',
);
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
formData={{
...defaultFormData,
orientation: OrientationType.Horizontal,
}}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const labelClickHandler =
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
value: 'Product A',
} as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
col: 'category_column',
op: 'IN',
val: ['Product A'],
},
]);
});
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
const setDataMaskMock = jest.fn();
render(
<EchartsTimeseries
{...defaultProps}
emitCrossFilters
setDataMask={setDataMaskMock}
xAxis={{
label: 'category_column',
type: AxisType.Category,
}}
/>,
);
const clickHandler = getLatestEchartProps().eventHandlers?.click;
expect(clickHandler).toBeDefined();
clickHandler?.({
componentType: 'xAxis',
name: 'Product A',
});
await new Promise(resolve => setTimeout(resolve, 400));
expect(setDataMaskMock).not.toHaveBeenCalled();
});
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => { test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
const setDataMaskMock = jest.fn(); const setDataMaskMock = jest.fn();
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
const clickHandler = props.eventHandlers?.click; const clickHandler = props.eventHandlers?.click;
if (clickHandler) { if (clickHandler) {
clickHandler({ clickHandler({
componentType: 'series',
seriesName: 'Sales', seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp data: [1609459200000, 100], // Timestamp
name: '2021-01-01', name: '2021-01-01',
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
...defaultProps, ...defaultProps,
emitCrossFilters: true, emitCrossFilters: true,
setDataMask: setDataMaskMock, setDataMask: setDataMaskMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions groupby: [], // No dimensions
xAxis: { xAxis: {
label: 'category_column', label: 'category_column',
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
const clickHandler = props.eventHandlers?.click; const clickHandler = props.eventHandlers?.click;
if (clickHandler) { if (clickHandler) {
clickHandler({ clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A', name: 'Product A',
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
...defaultProps, ...defaultProps,
emitCrossFilters: true, emitCrossFilters: true,
onContextMenu: onContextMenuMock, onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions groupby: [], // No dimensions
xAxis: { xAxis: {
label: 'category_column', label: 'category_column',
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
expect(contextMenuHandler).toBeDefined(); expect(contextMenuHandler).toBeDefined();
if (contextMenuHandler) { if (contextMenuHandler) {
await contextMenuHandler({ await contextMenuHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A', name: 'Product A',

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
DTTM_ALIAS, DTTM_ALIAS,
BinaryQueryObjectFilterClause, BinaryQueryObjectFilterClause,
@@ -27,12 +27,15 @@ import {
LegendState, LegendState,
ensureIsArray, ensureIsArray,
} from '@superset-ui/core'; } from '@superset-ui/core';
import type { ViewRootGroup } from 'echarts/types/src/util/types'; import type {
ECElementEvent,
ViewRootGroup,
} from 'echarts/types/src/util/types';
import type GlobalModel from 'echarts/types/src/model/Global'; import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component'; import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types'; import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart'; import Echart from '../components/Echart';
import { TimeseriesChartTransformedProps } from './types'; import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series'; import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls'; import { ExtraControls } from '../components/ExtraControls';
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions) // Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis = const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category; !hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
) {
return categoryAxisValue;
}
}
if (typeof name === 'string' || typeof name === 'number') {
return name;
}
return undefined;
},
[categoryAxisValueIndex],
);
const eventHandlers: EventHandlers = { const eventHandlers: EventHandlers = {
click: props => { click: props => {
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior) // Cross-filter by dimension (original behavior)
const { seriesName: name } = props; const { seriesName: name } = props;
handleChange(name); handleChange(name);
} else if (canCrossFilterByXAxis && props.name != null) { } else if (canCrossFilterByXAxis && props.componentType === 'series') {
// Cross-filter by X-axis value when no dimensions (issue #25334). // Cross-filter by X-axis value when no dimensions (issue #25334)
// Use `name` (the category-axis value) instead of `data[0]`: for const categoryAxisValue = getCategoryAxisValue(
// horizontal bars the data tuple is value-first, so `data[0]` would props.data,
// be the metric value rather than the category (issue #41102). props.name,
handleXAxisChange(props.name); );
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
} }
}, TIMER_DURATION); }, TIMER_DURATION);
}, },
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
let crossFilter; let crossFilter;
if (hasDimensions) { if (hasDimensions) {
crossFilter = getCrossFilterDataMask(seriesName); crossFilter = getCrossFilterDataMask(seriesName);
} else if (canCrossFilterByXAxis && eventParams.name != null) { } else if (
// Use `name` (the category-axis value), not `data[0]`, so horizontal canCrossFilterByXAxis &&
// bars cross-filter on the category and not the metric (issue #41102). eventParams.componentType === 'series'
crossFilter = getXAxisCrossFilterDataMask(eventParams.name); ) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
} }
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
}, },
}; };
const handleXAxisLabelClick = useCallback(
(event: ECElementEvent) => {
const { value } = event;
if (
canCrossFilterByXAxis &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
}
},
[canCrossFilterByXAxis, handleXAxisChange],
);
const categoryAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
query: `${categoryAxis}.category`,
handler: handleXAxisLabelClick,
},
],
[categoryAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = { const zrEventHandlers: EventHandlers = {
dblclick: params => { dblclick: params => {
// clear single click timer // clear single click timer
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
width={width} width={width}
echartOptions={echartOptions} echartOptions={echartOptions}
eventHandlers={eventHandlers} eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers} zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues} selectedValues={selectedValues}
vizType={formData.vizType} vizType={formData.vizType}

View File

@@ -82,6 +82,11 @@ export default function buildQuery(formData: QueryFormData) {
? formData.time_compare ? formData.time_compare
: []; : [];
// When comparing against prior periods, optionally keep each shifted series at
// its full time range instead of truncating it to the main series' range.
const time_compare_full_range =
time_offsets.length > 0 && Boolean(formData.time_compare_full_range);
return [ return [
{ {
...baseQueryObject, ...baseQueryObject,
@@ -92,6 +97,7 @@ export default function buildQuery(formData: QueryFormData) {
// todo: move `normalizeOrderBy to extractQueryFields` // todo: move `normalizeOrderBy to extractQueryFields`
orderby: normalizeOrderBy(baseQueryObject).orderby, orderby: normalizeOrderBy(baseQueryObject).orderby,
time_offsets, time_offsets,
time_compare_full_range,
/* Note that: /* Note that:
1. The resample, rolling, cum, timeCompare operators should be after pivot. 1. The resample, rolling, cum, timeCompare operators should be after pivot.
2. Resample must come before rolling so that imputed values are 2. Resample must come before rolling so that imputed values are

View File

@@ -381,6 +381,15 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare); const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap); const inverted = invert(verboseMap);
// With the "full range" time-shift option, offset series are outer-joined onto
// the main series, which inserts null rows into the main series wherever the
// comparison period has data the current period lacks. Connect nulls so the
// main line stays continuous (matching the default left-join appearance) rather
// than fragmenting at every inserted gap.
const timeCompareFullRange = Boolean(
chartProps.rawFormData?.time_compare_full_range,
);
const offsetLineWidths: { [key: string]: number } = {}; const offsetLineWidths: { [key: string]: number } = {};
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels // For horizontal bar charts, calculate min/max from data to avoid cutting off labels
@@ -478,7 +487,7 @@ export default function transformProps(
colorScaleKey, colorScaleKey,
{ {
area, area,
connectNulls: derivedSeries, connectNulls: derivedSeries || timeCompareFullRange,
filterState, filterState,
seriesContexts, seriesContexts,
markerEnabled, markerEnabled,
@@ -889,6 +898,10 @@ export default function transformProps(
name: xAxisTitle, name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin), nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle', nameLocation: 'middle',
...(xAxisType === AxisType.Category &&
groupBy.length === 0 && {
triggerEvent: true,
}),
axisLabel: { axisLabel: {
// When rotation is applied on time axes, hideOverlap can // When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already // aggressively hide the last label. Rotated labels already

View File

@@ -0,0 +1,223 @@
/**
* 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 '../../../../spec/helpers/testing-library';
import type { EChartsCoreOption } from 'echarts/core';
import Echart from './Echart';
import type { EchartsProps } from '../types';
type Handler = (params: unknown) => void;
type Listener = {
query?: string;
handler: Handler;
};
const listeners: Record<string, Listener[]> = {};
const mockChart = {
dispatchAction: jest.fn(),
dispose: jest.fn(),
getOption: jest.fn(() => ({})),
getZr: jest.fn(() => ({
off: jest.fn(),
on: jest.fn(),
})),
off: jest.fn((name: string, handler?: Handler) => {
if (!handler) {
delete listeners[name];
return;
}
listeners[name] = (listeners[name] || []).filter(
listener => listener.handler !== handler,
);
}),
on: jest.fn(
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
listeners[name] = listeners[name] || [];
listeners[name].push(
handler
? { query: queryOrHandler as string, handler }
: { handler: queryOrHandler as Handler },
);
},
),
resize: jest.fn(),
setOption: jest.fn(),
};
jest.mock('echarts/core', () => ({
init: jest.fn(() => mockChart),
registerLocale: jest.fn(),
use: jest.fn(),
}));
jest.mock('echarts/charts', () => ({
BarChart: 'BarChart',
BoxplotChart: 'BoxplotChart',
CustomChart: 'CustomChart',
FunnelChart: 'FunnelChart',
GaugeChart: 'GaugeChart',
GraphChart: 'GraphChart',
HeatmapChart: 'HeatmapChart',
LineChart: 'LineChart',
PieChart: 'PieChart',
RadarChart: 'RadarChart',
SankeyChart: 'SankeyChart',
ScatterChart: 'ScatterChart',
SunburstChart: 'SunburstChart',
TreeChart: 'TreeChart',
TreemapChart: 'TreemapChart',
}));
jest.mock('echarts/components', () => ({
AriaComponent: 'AriaComponent',
DataZoomComponent: 'DataZoomComponent',
GraphicComponent: 'GraphicComponent',
GridComponent: 'GridComponent',
LegendComponent: 'LegendComponent',
MarkAreaComponent: 'MarkAreaComponent',
MarkLineComponent: 'MarkLineComponent',
TitleComponent: 'TitleComponent',
ToolboxComponent: 'ToolboxComponent',
TooltipComponent: 'TooltipComponent',
VisualMapComponent: 'VisualMapComponent',
}));
jest.mock('echarts/features', () => ({
LabelLayout: 'LabelLayout',
}));
jest.mock('echarts/renderers', () => ({
CanvasRenderer: 'CanvasRenderer',
}));
const initialState = {
common: {
locale: 'en',
},
dashboardState: {
isRefreshing: false,
},
};
const defaultProps: EchartsProps = {
echartOptions: { series: [] } as EChartsCoreOption,
height: 100,
refs: {},
width: 100,
};
const renderEchart = (props: Partial<EchartsProps> = {}) => (
<Echart {...defaultProps} {...props} />
);
const trigger = (name: string) => {
(listeners[name] || []).forEach(listener => listener.handler({}));
};
beforeEach(() => {
Object.keys(listeners).forEach(name => {
delete listeners[name];
});
Object.values(mockChart).forEach(value => {
if (jest.isMockFunction(value)) {
value.mockClear();
}
});
});
test('replaces stale query event handlers without clearing regular event handlers', async () => {
const regularClickHandler = jest.fn();
const firstQueryHandler = jest.fn();
const secondQueryHandler = jest.fn();
const { rerender } = render(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [
{
handler: firstQueryHandler,
name: 'click',
query: 'xAxis.category',
},
],
}),
{ initialState, useRedux: true },
);
await waitFor(() =>
expect(mockChart.on).toHaveBeenCalledWith(
'click',
'xAxis.category',
firstQueryHandler,
),
);
rerender(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [
{
handler: secondQueryHandler,
name: 'click',
query: 'xAxis.category',
},
],
}),
);
await waitFor(() =>
expect(mockChart.on).toHaveBeenCalledWith(
'click',
'xAxis.category',
secondQueryHandler,
),
);
trigger('click');
expect(regularClickHandler).toHaveBeenCalledTimes(1);
expect(firstQueryHandler).not.toHaveBeenCalled();
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
regularClickHandler.mockClear();
secondQueryHandler.mockClear();
rerender(
renderEchart({
eventHandlers: {
click: regularClickHandler,
},
queryEventHandlers: [],
}),
);
await waitFor(() =>
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
);
trigger('click');
expect(regularClickHandler).toHaveBeenCalledTimes(1);
expect(firstQueryHandler).not.toHaveBeenCalled();
expect(secondQueryHandler).not.toHaveBeenCalled();
});

View File

@@ -64,7 +64,12 @@ import {
MarkLineComponent, MarkLineComponent,
} from 'echarts/components'; } from 'echarts/components';
import { LabelLayout } from 'echarts/features'; import { LabelLayout } from 'echarts/features';
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types'; import {
EchartsHandler,
EchartsProps,
EchartsStylesProps,
QueryEventHandlers,
} from '../types';
import { DEFAULT_LOCALE } from '../constants'; import { DEFAULT_LOCALE } from '../constants';
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides'; import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
@@ -132,6 +137,7 @@ function Echart(
height, height,
echartOptions, echartOptions,
eventHandlers, eventHandlers,
queryEventHandlers,
zrEventHandlers, zrEventHandlers,
selectedValues = {}, selectedValues = {},
refs, refs,
@@ -147,6 +153,7 @@ function Echart(
} }
const [didMount, setDidMount] = useState(false); const [didMount, setDidMount] = useState(false);
const chartRef = useRef<EChartsType>(); const chartRef = useRef<EChartsType>();
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
const currentSelection = useMemo( const currentSelection = useMemo(
() => Object.keys(selectedValues) || [], () => Object.keys(selectedValues) || [],
[selectedValues], [selectedValues],
@@ -196,11 +203,19 @@ function Echart(
useEffect(() => { useEffect(() => {
if (didMount) { if (didMount) {
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
chartRef.current?.off(name, handler);
});
Object.entries(eventHandlers || {}).forEach(([name, handler]) => { Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.off(name); chartRef.current?.off(name);
chartRef.current?.on(name, handler); chartRef.current?.on(name, handler);
}); });
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
chartRef.current?.on(name, query, handler);
});
previousQueryEventHandlers.current = queryEventHandlers || [];
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => { Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.getZr().off(name); chartRef.current?.getZr().off(name);
chartRef.current?.getZr().on(name, handler); chartRef.current?.getZr().on(name, handler);
@@ -336,7 +351,15 @@ function Echart(
} }
} }
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls // eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]); }, [
didMount,
echartOptions,
eventHandlers,
queryEventHandlers,
zrEventHandlers,
theme,
vizType,
]);
// Clear tooltip on refresh start to avoid stale content (#39247) // Clear tooltip on refresh start to avoid stale content (#39247)
useEffect(() => { useEffect(() => {

View File

@@ -34,6 +34,7 @@ import {
} from '@superset-ui/core'; } from '@superset-ui/core';
import type { EChartsCoreOption, EChartsType } from 'echarts/core'; import type { EChartsCoreOption, EChartsType } from 'echarts/core';
import type { TooltipMarker } from 'echarts/types/src/util/format'; import type { TooltipMarker } from 'echarts/types/src/util/format';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import { StackControlsValue } from './constants'; import { StackControlsValue } from './constants';
export type EchartsStylesProps = { export type EchartsStylesProps = {
@@ -51,6 +52,7 @@ export interface EchartsProps {
width: number; width: number;
echartOptions: EChartsCoreOption; echartOptions: EChartsCoreOption;
eventHandlers?: EventHandlers; eventHandlers?: EventHandlers;
queryEventHandlers?: QueryEventHandlers;
zrEventHandlers?: EventHandlers; zrEventHandlers?: EventHandlers;
selectedValues?: Record<number, string>; selectedValues?: Record<number, string>;
forceClear?: boolean; forceClear?: boolean;
@@ -105,6 +107,12 @@ export type LegendFormData = {
export type EventHandlers = Record<string, { (props: any): void }>; export type EventHandlers = Record<string, { (props: any): void }>;
export type QueryEventHandlers = {
name: string;
query: string;
handler: (props: ECElementEvent) => void;
}[];
export enum LabelPositionEnum { export enum LabelPositionEnum {
Top = 'top', Top = 'top',
Left = 'left', Left = 'left',

View File

@@ -24,6 +24,7 @@ import {
EchartsRadarChartProps, EchartsRadarChartProps,
EchartsRadarFormData, EchartsRadarFormData,
} from '../../src/Radar/types'; } from '../../src/Radar/types';
import { LegendOrientation } from '../../src/types';
interface RadarIndicator { interface RadarIndicator {
name: string; name: string;
@@ -202,3 +203,58 @@ describe('legend sorting', () => {
]); ]);
}); });
}); });
describe('radar center positioning', () => {
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
const props = new ChartProps({
formData: {
...formData,
showLegend: true,
legendMargin: 100,
...overrides,
},
width: 800,
height: 600,
queriesData,
theme: supersetTheme,
});
const result = transformProps(props as EchartsRadarChartProps);
const { center } = result.echartOptions.radar as {
center: [string, string];
};
return {
x: parseFloat(center[0]),
y: parseFloat(center[1]),
};
};
test('keeps the center when the legend is hidden', () => {
const { x, y } = getCenter({ showLegend: false });
expect(x).toBe(50);
expect(y).toBe(50);
});
test('shifts the center right (away from the legend) when legend is on the left', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Left });
expect(x).toBeGreaterThan(50);
expect(y).toBe(50);
});
test('shifts the center left (away from the legend) when legend is on the right', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Right });
expect(x).toBeLessThan(50);
expect(y).toBe(50);
});
test('shifts the center down (away from the legend) when legend is on the top', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Top });
expect(x).toBe(50);
expect(y).toBeGreaterThan(50);
});
test('shifts the center up (away from the legend) when legend is on the bottom', () => {
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Bottom });
expect(x).toBe(50);
expect(y).toBeLessThan(50);
});
});

View File

@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
}); });
const { echartOptions } = transformProps(chartProps); const { echartOptions } = transformProps(chartProps);
const xAxis = echartOptions.xAxis as { type: string }; const xAxis = echartOptions.xAxis as {
triggerEvent?: boolean;
type: string;
};
expect(xAxis.type).toBe(AxisType.Category); expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.triggerEvent).toBe(true);
}); });
test('temporal x coltype wires the time formatter and Time axis', () => { test('temporal x coltype wires the time formatter and Time axis', () => {

View File

@@ -27,7 +27,7 @@
], ],
"dependencies": { "dependencies": {
"@math.gl/web-mercator": "^4.1.0", "@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.1", "mapbox-gl": "^3.25.0",
"maplibre-gl": "^5.24.0", "maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1", "react-map-gl": "^8.1.1",
"supercluster": "^8.0.1" "supercluster": "^8.0.1"

View File

@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject; let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject; const { extras = {} } = baseQueryObject;
const postProcessing: PostProcessingRule[] = []; const postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. Without it the totals row's percent-metric
// columns are keyed `metric` instead of `%metric`, so the footer renders
// 0.000%. We reuse only this rule and not the full `postProcessing` array,
// which may also contain a time-comparison operator that must not run on
// the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray( const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare, formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit'); ).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
orderby = [[metrics[0], false]]; orderby = [[metrics[0], false]];
} }
// add postprocessing for percent metrics only when in aggregation mode // add postprocessing for percent metrics only when in aggregation mode
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
const calculationMode: PercentMetricCalculationMode =
(formData.percent_metric_calculation as PercentMetricCalculationMode) ||
'row_limit';
if (percentMetrics && percentMetrics.length > 0) { if (percentMetrics && percentMetrics.length > 0) {
const percentMetricsLabelsWithTimeComparison = isTimeComparison( const percentMetricsLabelsWithTimeComparison = isTimeComparison(
formData, formData,
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
getMetricLabel, getMetricLabel,
); );
if (calculationMode === 'all_records') { contributionPostProcessing = {
postProcessing.push({ operation: 'contribution',
operation: 'contribution', options: {
options: { columns: percentMetricLabels,
columns: percentMetricLabels, rename_columns: percentMetricLabels.map(m => `%${m}`),
rename_columns: percentMetricLabels.map(m => `%${m}`), },
}, };
}); postProcessing.push(contributionPostProcessing);
} else {
postProcessing.push({
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
});
}
} }
// Add the operator for the time comparison if some is selected // Add the operator for the time comparison if some is selected
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
columns: [], columns: [],
row_limit: 0, row_limit: 0,
row_offset: 0, row_offset: 0,
post_processing: [], // Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined, order_desc: undefined,
orderby: undefined, orderby: undefined,
}); });

View File

@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
expect(queries).toHaveLength(1); expect(queries).toHaveLength(1);
expect(queries[0].post_processing).toEqual([]); expect(queries[0].post_processing).toEqual([]);
}); });
test('should reapply contribution op to totals query in row_limit mode', () => {
// Regression test for #37627: with a percent metric and Show Summary
// (show_totals) enabled, the totals query must rename percent-metric
// columns (`metric` -> `%metric`) so the footer can look them up.
// Otherwise the totals row renders 0.000%.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const contributionRule = {
operation: 'contribution',
options: {
columns: ['sum_sales'],
rename_columns: ['%sum_sales'],
},
};
expect(queries[1]).toMatchObject({
columns: [],
post_processing: [contributionRule],
});
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
time_compare: ['1 year ago'],
comparison_type: 'values',
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const formData = {
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: ['count'],
percent_metrics: [],
groupby: ['category'],
show_totals: true,
};
const { queries } = buildQuery(formData);
expect(queries).toHaveLength(2);
expect(queries[1].post_processing).toEqual([]);
});
}); });
describe('Testing for server pagination with search filter', () => { describe('Testing for server pagination with search filter', () => {

View File

@@ -632,6 +632,35 @@ function processFile(filepath) {
} }
} }
/**
* Application source trees that must be authored in TypeScript. Matches the
* top-level `src/` directory as well as each package/plugin `src/` directory.
*/
const TS_ONLY_SOURCE_PATTERN =
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
/**
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
* added under the application source trees (including test files). Build
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
* `webpack.config.js`) live outside these trees and are intentionally allowed.
*
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
*/
function checkTypeScriptOnlySource(candidateFiles) {
candidateFiles.forEach(file => {
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${file}: frontend source must be TypeScript. ` +
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
`TypeScript; no new .js/.jsx files in src/).`,
);
errorCount += 1;
}
});
}
/** /**
* Main function * Main function
*/ */
@@ -666,6 +695,22 @@ function main() {
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants /packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
]; ];
// Enforce TypeScript-only source. Run this on the raw file list (before the
// ignore patterns below strip out tests/stories) so that e.g. a new
// `*.test.jsx` is still rejected.
const tsOnlyCandidates =
args.length === 0
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
ignore: [
'**/node_modules/**',
'**/esm/**',
'**/lib/**',
'**/dist/**',
],
})
: args.map(f => f.replace(/^superset-frontend\//, ''));
checkTypeScriptOnlySource(tsOnlyCandidates);
// If no files specified, check all // If no files specified, check all
if (files.length === 0) { if (files.length === 0) {
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', { files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
@@ -706,22 +751,23 @@ function main() {
if (files.length === 0) { if (files.length === 0) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('No files to check.'); console.log('No files to check.');
return; } else {
// eslint-disable-next-line no-console
console.log(
`Checking ${files.length} files for Superset custom rules...\n`,
);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
} }
// eslint-disable-next-line no-console
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`\n${errorCount} errors, ${warningCount} warnings`); console.log(`\n${errorCount} errors, ${warningCount} warnings`);
@@ -740,4 +786,5 @@ module.exports = {
checkNoFaIcons, checkNoFaIcons,
checkI18nTemplates, checkI18nTemplates,
checkUntranslatedStrings, checkUntranslatedStrings,
checkTypeScriptOnlySource,
}; };

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import type { Column, GridApi } from 'ag-grid-community'; import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
import { act, fireEvent, render } from 'spec/helpers/testing-library'; import { act, fireEvent, render } from 'spec/helpers/testing-library';
import { Header } from './Header'; import { Header } from './Header';
import { PIVOT_COL_ID } from './constants'; import { PIVOT_COL_ID } from './constants';
@@ -38,9 +38,70 @@ jest.mock('@superset-ui/core/components/Icons', () => {
}; };
}); });
class MockApi extends EventTarget { class MockColumn {
private colListeners = new Map<string, Set<Function>>();
sortValue: string | null = 'asc';
sortIndexValue: number | null = null;
getColId() {
return '123';
}
isPinnedLeft() {
return true;
}
isPinnedRight() {
return false;
}
isVisible() {
return true;
}
getSort() {
return this.sortValue;
}
getSortIndex() {
return this.sortIndexValue;
}
addEventListener(eventType: string, listener: Function) {
if (!this.colListeners.has(eventType)) {
this.colListeners.set(eventType, new Set());
}
this.colListeners.get(eventType)!.add(listener);
}
removeEventListener(eventType: string, listener: Function) {
this.colListeners.get(eventType)?.delete(listener);
}
triggerEvent(eventType: string) {
this.colListeners.get(eventType)?.forEach(listener => listener({}));
}
}
class MockOtherColumn extends MockColumn {
getColId() {
return 'other-col';
}
}
class MockApi {
mockColumn = new MockColumn();
otherColumn = new MockOtherColumn();
getAllDisplayedColumns() { getAllDisplayedColumns() {
return []; return [this.mockColumn, this.otherColumn];
}
getColumns() {
return [this.mockColumn, this.otherColumn];
} }
isDestroyed() { isDestroyed() {
@@ -48,48 +109,76 @@ class MockApi extends EventTarget {
} }
} }
const mockApi = new MockApi();
const mockedProps = { const mockedProps = {
displayName: 'test column', displayName: 'test column',
setSort: jest.fn(), progressSort: jest.fn(),
enableSorting: true, enableSorting: true,
column: { column: mockApi.mockColumn as any as Column,
getColId: () => '123', api: mockApi as any as GridApi,
isPinnedLeft: () => true, } as unknown as IHeaderParams;
isPinnedRight: () => false,
getSort: () => 'asc',
getSortIndex: () => null,
} as any as Column,
api: new MockApi() as any as GridApi,
};
test('renders display name for the column', () => { test('renders display name for the column', () => {
const { queryByText } = render(<Header {...mockedProps} />); const { queryByText } = render(<Header {...mockedProps} />);
expect(queryByText(mockedProps.displayName)).toBeInTheDocument(); expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
}); });
test('sorts by clicking a column header', () => { test('calls progressSort without shiftKey on click', () => {
const { getByText, queryByTestId } = render(<Header {...mockedProps} />); const { getByText } = render(<Header {...mockedProps} />);
fireEvent.click(getByText(mockedProps.displayName)); fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false); expect(mockedProps.progressSort).toHaveBeenCalledWith(false);
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
}); });
test('synchronizes the current sort when sortChanged event occurred', async () => { test('calls progressSort with shiftKey on shift-click', () => {
const { findByTestId } = render(<Header {...mockedProps} />); const { getByText } = render(<Header {...mockedProps} />);
fireEvent.click(getByText(mockedProps.displayName), { shiftKey: true });
expect(mockedProps.progressSort).toHaveBeenCalledWith(true);
});
test('synchronizes sort icon when columnStateUpdated fires on column', async () => {
const { findByTestId, queryByTestId } = render(<Header {...mockedProps} />);
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
act(() => { act(() => {
mockedProps.api.dispatchEvent(new Event('sortChanged')); mockApi.mockColumn.triggerEvent('columnStateUpdated');
}); });
const sortAsc = await findByTestId('mock-sort-asc'); const sortAsc = await findByTestId('mock-sort-asc');
expect(sortAsc).toBeInTheDocument(); expect(sortAsc).toBeInTheDocument();
}); });
test('shows sortIndex label when multi-sort is active', async () => {
const { findByText } = render(<Header {...mockedProps} />);
act(() => {
mockApi.mockColumn.sortIndexValue = 1;
mockApi.otherColumn.sortValue = 'desc';
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
const label = await findByText('2');
expect(label).toBeInTheDocument();
});
test('hides sortIndex label when multi-sort is cleared', async () => {
const { queryByText } = render(<Header {...mockedProps} />);
act(() => {
mockApi.mockColumn.sortIndexValue = 1;
mockApi.otherColumn.sortValue = 'desc';
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
act(() => {
mockApi.mockColumn.sortIndexValue = null;
mockApi.otherColumn.sortValue = null;
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
expect(queryByText('2')).not.toBeInTheDocument();
});
test('disable menu when enableFilterButton is false', () => { test('disable menu when enableFilterButton is false', () => {
const { queryByText, queryByTestId } = render( const { queryByText, queryByTestId } = render(
<Header {...mockedProps} enableFilterButton={false} />, <Header {...mockedProps} enableFilterButton={false} />,
@@ -99,18 +188,39 @@ test('disable menu when enableFilterButton is false', () => {
}); });
test('hide display name for PIVOT_COL_ID', () => { test('hide display name for PIVOT_COL_ID', () => {
const pivotColumn = new MockColumn();
(pivotColumn as any).getColId = () => PIVOT_COL_ID;
const { queryByText } = render( const { queryByText } = render(
<Header <Header {...mockedProps} column={pivotColumn as any as Column} />,
{...mockedProps}
column={
{
getColId: () => PIVOT_COL_ID,
isPinnedLeft: () => true,
isPinnedRight: () => false,
getSortIndex: () => null,
} as any as Column
}
/>,
); );
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument(); expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
}); });
test('does not attach click handler when enableSorting is false', () => {
const { getByText } = render(
<Header {...mockedProps} enableSorting={false} />,
);
const cell = getByText(mockedProps.displayName).closest(
'.ag-header-cell-label',
);
expect(cell).not.toHaveAttribute('role', 'button');
});
test('does not call progressSort on click when enableSorting is false', () => {
const progressSort = jest.fn();
const { getByText } = render(
<Header {...mockedProps} enableSorting={false} progressSort={progressSort} />,
);
fireEvent.click(getByText(mockedProps.displayName));
expect(progressSort).not.toHaveBeenCalled();
});
test('does not render sort icons when enableSorting is false', () => {
const { queryByTestId } = render(
<Header {...mockedProps} enableSorting={false} />,
);
expect(queryByTestId('mock-sort')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
});

View File

@@ -16,32 +16,16 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { import { useCallback, useEffect, useState } from 'react';
type MouseEvent, import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme'; import { styled, useTheme } from '@apache-superset/core/theme';
import type { Column, GridApi } from 'ag-grid-community';
import { Icons } from '@superset-ui/core/components/Icons'; import { Icons } from '@superset-ui/core/components/Icons';
import { PIVOT_COL_ID } from './constants'; import { PIVOT_COL_ID } from './constants';
import { HeaderMenu } from './HeaderMenu'; import { HeaderMenu } from './HeaderMenu';
interface Params {
enableFilterButton?: boolean;
enableSorting?: boolean;
displayName: string;
column: Column;
api: GridApi;
setSort: (sort: string | null, multiSort: boolean) => void;
}
const SORT_DIRECTION = [null, 'asc', 'desc'];
const HeaderCell = styled.div` const HeaderCell = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
@@ -87,30 +71,26 @@ const IconPlaceholder = styled.div`
top: 0; top: 0;
`; `;
export const Header: React.FC<Params> = ({ export const Header: React.FC<IHeaderParams> = ({
enableFilterButton, enableFilterButton,
enableSorting, enableSorting,
displayName, displayName,
setSort, progressSort,
column, column,
api, api,
}: Params) => { }: IHeaderParams) => {
const theme = useTheme(); const theme = useTheme();
const colId = column.getColId(); const colId = column.getColId();
const pinnedLeft = column.isPinnedLeft(); const pinnedLeft = column.isPinnedLeft();
const pinnedRight = column.isPinnedRight(); const pinnedRight = column.isPinnedRight();
const sortOption = useRef<number>(0);
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]); const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
const [currentSort, setCurrentSort] = useState<string | null>(null); const [currentSort, setCurrentSort] = useState<SortDirection>(null);
const [sortIndex, setSortIndex] = useState<number | null>(); const [sortIndex, setSortIndex] = useState<number | null>();
const onSort = useCallback( const onSort = useCallback(
(event: MouseEvent) => { (event: React.MouseEvent) => {
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length; progressSort(event.shiftKey);
const sort = SORT_DIRECTION[sortOption.current];
setSort(sort, event.shiftKey);
setCurrentSort(sort);
}, },
[setSort], [progressSort],
); );
const onVisibleChange = useCallback( const onVisibleChange = useCallback(
(isVisible: boolean) => { (isVisible: boolean) => {
@@ -123,24 +103,22 @@ export const Header: React.FC<Params> = ({
[api], [api],
); );
const onSortChanged = useCallback(() => { const syncSortState = useCallback(() => {
const hasMultiSort = api const hasMultiSort = api
.getAllDisplayedColumns() .getAllDisplayedColumns()
.some(c => c.getSortIndex()); .some(c => c.getColId() !== colId && c.getSort() !== null);
const updatedSortIndex = column.getSortIndex();
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
setCurrentSort(column.getSort() ?? null); setCurrentSort(column.getSort() ?? null);
setSortIndex(hasMultiSort ? updatedSortIndex : null); setSortIndex(hasMultiSort ? column.getSortIndex() : null);
}, [api, column]); }, [api, column, colId]);
useEffect(() => { useEffect(() => {
api.addEventListener('sortChanged', onSortChanged); column.addEventListener('columnStateUpdated', syncSortState);
return () => { return () => {
if (api.isDestroyed()) return; if (api.isDestroyed()) return;
api.removeEventListener('sortChanged', onSortChanged); column.removeEventListener('columnStateUpdated', syncSortState);
}; };
}, [api, onSortChanged]); }, [column, syncSortState]);
return ( return (
<> <>

View File

@@ -0,0 +1,277 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatProvider from './ChatProvider';
import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost';
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
render(<ChatHost />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('ChatPanelHost renders the panel when open in panel mode', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatPanelHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
});
test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
// In panel mode the floating panel is suppressed (ChatPanelHost owns that slot).
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
// The trigger stays rendered so the user can reopen after collapsing.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
// Trigger remains visible even when closed — it's the user's only way back.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
);
render(<ChatHost />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatHost />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a component that throws during render', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
);
expect(() => render(<ChatHost />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
);
render(<ChatHost />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different id takes over', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'fixed.chat', name: 'Fixed Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Fixed Panel</div>,
);
jest.restoreAllMocks();
});
// Different id: boundary key changes, latch resets, fix renders.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,133 @@
/**
* 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 { type ComponentType, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { useChat } from '.';
const CHAT_EDGE_MARGIN = 24;
/**
* Returns an onError handler that shows a toast on crash, once per chat id.
*/
function useCrashNotifier(chatId: string | undefined) {
const notifiedFor = useRef<string | undefined>(undefined);
return (error: Error) => {
if (!chatId) return;
logging.error('[chat] provider crashed', error);
if (notifiedFor.current !== chatId) {
notifiedFor.current = chatId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
}
/**
* Wraps a component in an ErrorBoundary, keyed by chat id so the boundary
* resets when a different chat takes over.
*/
const ChatBoundary = ({
component: Component,
onError,
}: {
component: ComponentType;
onError: (error: Error) => void;
}) => (
<ErrorBoundary showMessage={false} onError={onError}>
<Component />
</ErrorBoundary>
);
/**
* Renders the chat panel content in panel mode. Fills its container height.
*/
export const ChatPanelHost = () => {
const { chat, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
display: flex;
flex-direction: column;
height: 100%;
`}
>
<ChatBoundary key={chat.id} component={panel} onError={onError} />
</div>
);
};
/**
* Renders the chat trigger and, when the panel is open in floating mode, the
* floating panel overlay. The trigger is always visible when a chat is
* registered; the panel overlay is suppressed in panel mode.
*/
export const ChatFloatingHost = () => {
const theme = useTheme();
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !trigger || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Separate boundaries so a crashing panel cannot take the trigger down
with it — the trigger is the user's only way back.
*/}
{panelOpen && mode !== 'panel' && (
<ChatBoundary
key={`panel-${chat.id}`}
component={panel}
onError={onError}
/>
)}
<ChatBoundary
key={`trigger-${chat.id}`}
component={trigger}
onError={onError}
/>
</div>
);
};

View File

@@ -0,0 +1,257 @@
/**
* 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 { createElement } from 'react';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('returns the singleton instance', () => {
expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance());
});
test('getChat returns undefined when no chat is registered', () => {
expect(ChatProvider.getInstance().getChat()).toBeUndefined();
});
test('registerChat sets the registration and returns the descriptor copy', () => {
const provider = ChatProvider.getInstance();
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(provider.getChat()).toEqual(descriptor);
disposable.dispose();
});
test('the last-registered chat wins and logs a warning', () => {
const provider = ChatProvider.getInstance();
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('second.chat');
expect(warn.mock.calls[0][0]).toContain('first.chat');
warn.mockRestore();
});
test('re-registering with a different id replaces the active chat', () => {
const provider = ChatProvider.getInstance();
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
expect(provider.getChat()?.id).toBe('first.chat');
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('disposing the registration clears it', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
expect(provider.getChat()).toBeUndefined();
});
test('disposing twice fires unregister only once', () => {
const provider = ChatProvider.getInstance();
const unregistered = jest.fn();
provider.onDidUnregisterChat(unregistered);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
disposable.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const provider = ChatProvider.getInstance();
const registered = jest.fn();
const unregistered = jest.fn();
provider.onDidRegisterChat(registered);
provider.onDidUnregisterChat(unregistered);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
disposable.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('open and close toggle the panel state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
provider.open();
expect(provider.isOpen()).toBe(true);
provider.close();
expect(provider.isOpen()).toBe(false);
});
test('open fires once; duplicate open is a no-op', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.open();
expect(opened).toHaveBeenCalledTimes(1);
});
test('close fires once; duplicate close is a no-op', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.close();
provider.close();
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op when no chat is registered', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.open();
expect(provider.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
});
test('registering a second chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.open();
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
test('disposing the active chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a late registration does not inherit a stale open state', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates mode and fires event only on change', () => {
const provider = ChatProvider.getInstance();
const modeChanged = jest.fn();
provider.onDidChangeDisplayMode(modeChanged);
provider.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
provider.setDisplayMode('panel');
expect(provider.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('state reflects changes after registration and open', () => {
const provider = ChatProvider.getInstance();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
expect(provider.isOpen()).toBe(true);
expect(provider.getChat()?.id).toBe('acme.chat');
});
test('reset clears all state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.setDisplayMode('panel');
provider.reset();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
expect(provider.getDisplayMode()).toBe('floating');
});

View File

@@ -0,0 +1,209 @@
/**
* 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 { ComponentType } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import {
LocalStorageKeys,
getItem,
setItem,
} from 'src/utils/localStorageHelpers';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/**
* Singleton manager for the chat provider.
* Handles registration, open/close state, and display mode.
*/
class ChatProvider {
private static instance: ChatProvider;
private chat: Chat | undefined;
private trigger: ComponentType | undefined;
private panel: ComponentType | undefined;
private opened: boolean;
private stateSubscribers = new Set<() => void>();
private registerEmitter = createEventEmitter<Chat>();
private unregisterEmitter = createEventEmitter<Chat>();
private openEmitter = createEventEmitter<void>();
private closeEmitter = createEventEmitter<void>();
private resizePanelEmitter = createEventEmitter<{ width: number }>();
private modeEmitter: ReturnType<typeof createValueEventEmitter<DisplayMode>>;
private constructor() {
const persisted = getItem(LocalStorageKeys.ChatState, {
open: false,
mode: 'floating',
});
const mode = (
persisted.mode === 'panel' ? 'panel' : 'floating'
) as DisplayMode;
this.opened = persisted.open === true;
this.modeEmitter = createValueEventEmitter<DisplayMode>(mode);
}
public static getInstance(): ChatProvider {
if (!ChatProvider.instance) {
ChatProvider.instance = new ChatProvider();
}
return ChatProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
private notifyState(): void {
setItem(LocalStorageKeys.ChatState, {
open: this.opened,
mode: this.modeEmitter.getCurrent(),
});
this.stateSubscribers.forEach(fn => fn());
}
private closePanel(): void {
this.opened = false;
this.closeEmitter.fire();
}
public registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable {
if (this.chat) {
// eslint-disable-next-line no-console
console.warn(
`[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`,
);
this.unregisterEmitter.fire(this.chat);
if (this.opened) this.closePanel();
}
this.chat = chat;
this.trigger = trigger;
this.panel = panel;
this.registerEmitter.fire(chat);
this.notifyState();
return new Disposable(() => {
if (this.chat !== chat) return;
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.unregisterEmitter.fire(chat);
if (this.opened) this.closePanel();
this.notifyState();
});
}
public getChat(): Chat | undefined {
return this.chat;
}
public getTrigger(): ComponentType | undefined {
return this.trigger;
}
public getPanel(): ComponentType | undefined {
return this.panel;
}
public open(): void {
if (this.opened || !this.chat) return;
this.opened = true;
this.openEmitter.fire();
this.notifyState();
}
public close(): void {
if (!this.opened || !this.chat) return;
this.closePanel();
this.notifyState();
}
public isOpen(): boolean {
return this.opened;
}
public getDisplayMode(): DisplayMode {
return this.modeEmitter.getCurrent();
}
public setDisplayMode(displayMode: DisplayMode): void {
if (displayMode === this.modeEmitter.getCurrent()) return;
this.modeEmitter.fire(displayMode);
this.notifyState();
}
public get onDidRegisterChat() {
return this.registerEmitter.subscribe;
}
public get onDidUnregisterChat() {
return this.unregisterEmitter.subscribe;
}
public get onDidOpen() {
return this.openEmitter.subscribe;
}
public get onDidClose() {
return this.closeEmitter.subscribe;
}
public get onDidChangeDisplayMode() {
return this.modeEmitter.subscribe;
}
public get onDidResizePanel() {
return this.resizePanelEmitter.subscribe;
}
public reset(): void {
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.opened = false;
this.registerEmitter = createEventEmitter<Chat>();
this.unregisterEmitter = createEventEmitter<Chat>();
this.openEmitter = createEventEmitter<void>();
this.closeEmitter = createEventEmitter<void>();
this.resizePanelEmitter = createEventEmitter<{ width: number }>();
this.modeEmitter = createValueEventEmitter<DisplayMode>('floating');
this.stateSubscribers.clear();
setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' });
}
}
export default ChatProvider;

View File

@@ -0,0 +1,68 @@
/**
* 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 { createElement } from 'react';
import { chat } from './index';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
});
test('registerChat makes the chat retrievable via getChat', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
chat.registerChat(descriptor, trigger, panel);
expect(chat.getChat()).toEqual(descriptor);
});
test('the last-registered chat wins when multiple are registered', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(chat.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('open and close toggle isOpen', () => {
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(chat.isOpen()).toBe(false);
chat.open();
expect(chat.isOpen()).toBe(true);
chat.close();
expect(chat.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(chat.getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates the display mode', () => {
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
});

View File

@@ -0,0 +1,82 @@
/**
* 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.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Extensions register via the public `chat.registerChat()` and the host owns
* mounting, open/close state, and the display mode. Only the last-registered
* chat is active at a time.
*
* The public namespace (`chat`) is exposed to extensions on `window.superset`.
* `useChat` is host-internal and NOT part of the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import memoizeOne from 'memoize-one';
import type { chat as chatApi } from '@apache-superset/core';
import ChatProvider from './ChatProvider';
export { ChatFloatingHost, ChatPanelHost } from './ChatHost';
const provider = ChatProvider.getInstance();
const buildSnapshot = memoizeOne(
(
open: boolean,
mode: chatApi.DisplayMode,
chat: chatApi.Chat | undefined,
trigger: ReturnType<typeof provider.getTrigger>,
panel: ReturnType<typeof provider.getPanel>,
) => ({ open, mode, chat, trigger, panel }),
);
const getSnapshot = () =>
buildSnapshot(
provider.isOpen(),
provider.getDisplayMode(),
provider.getChat(),
provider.getTrigger(),
provider.getPanel(),
);
/**
* Host-internal hook. Returns the current open/mode state and the active chat
* (trigger, panel, descriptor).
*/
export const useChat = () =>
useSyncExternalStore(provider.subscribe, getSnapshot);
export const chat: typeof chatApi = {
registerChat: provider.registerChat.bind(provider),
getChat: provider.getChat.bind(provider),
onDidRegisterChat: provider.onDidRegisterChat,
onDidUnregisterChat: provider.onDidUnregisterChat,
open: provider.open.bind(provider),
close: provider.close.bind(provider),
isOpen: provider.isOpen.bind(provider),
onDidOpen: provider.onDidOpen,
onDidClose: provider.onDidClose,
getDisplayMode: provider.getDisplayMode.bind(provider),
setDisplayMode: provider.setDisplayMode.bind(provider),
onDidChangeDisplayMode: provider.onDidChangeDisplayMode,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: provider.onDidResizePanel,
};

View File

@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
}); });
test('handles errors in event listeners gracefully', () => {
const manager = EditorProviders.getInstance();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const errorListener = jest.fn(() => {
throw new Error('Listener error');
});
const successListener = jest.fn();
manager.onDidRegister(errorListener);
manager.onDidRegister(successListener);
manager.registerProvider(createMockEditor(), createMockEditorComponent());
// Both listeners should have been called
expect(errorListener).toHaveBeenCalledTimes(1);
expect(successListener).toHaveBeenCalledTimes(1);
// Error should have been logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in event listener:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('reset clears all providers and language mappings', () => { test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance(); const manager = EditorProviders.getInstance();

View File

@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core'; import type { editors } from '@apache-superset/core';
import { Disposable } from '../models'; import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage; type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider; type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent; type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent; type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
/**
* Listener function type for events.
*/
type Listener<T> = (e: T) => void; type Listener<T> = (e: T) => void;
/**
* Simple event emitter for editor provider lifecycle events.
*/
class EventEmitter<T> {
private listeners: Set<Listener<T>> = new Set();
/**
* Subscribe to this event.
* @param listener The listener function to call when the event is fired.
* @returns A Disposable to unsubscribe from the event.
*/
subscribe(listener: Listener<T>): Disposable {
this.listeners.add(listener);
return new Disposable(() => {
this.listeners.delete(listener);
});
}
/**
* Fire the event with the given data.
* @param data The event data to pass to listeners.
*/
fire(data: T): void {
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in event listener:', error);
}
});
}
}
/** /**
* Singleton manager for editor providers. * Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations. * Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/ */
private languageToProvider: Map<EditorLanguage, string> = new Map(); private languageToProvider: Map<EditorLanguage, string> = new Map();
/** private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
/** private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set(); private syncListeners: Set<() => void> = new Set();
@@ -226,8 +184,11 @@ class EditorProviders {
* @param listener The listener function. * @param listener The listener function.
* @returns A Disposable to unsubscribe. * @returns A Disposable to unsubscribe.
*/ */
public onDidRegister(listener: Listener<EditorRegisteredEvent>): Disposable { public onDidRegister(
return this.registerEmitter.subscribe(listener); listener: Listener<EditorRegisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.registerEmitter.subscribe(listener, thisArgs);
} }
/** /**
@@ -237,8 +198,9 @@ class EditorProviders {
*/ */
public onDidUnregister( public onDidUnregister(
listener: Listener<EditorUnregisteredEvent>, listener: Listener<EditorUnregisteredEvent>,
thisArgs?: unknown,
): Disposable { ): Disposable {
return this.unregisterEmitter.subscribe(listener); return this.unregisterEmitter.subscribe(listener, thisArgs);
} }
/** /**
@@ -248,6 +210,8 @@ class EditorProviders {
this.providers.clear(); this.providers.clear();
this.languageToProvider.clear(); this.languageToProvider.clear();
this.syncListeners.clear(); this.syncListeners.clear();
this.registerEmitter = createEventEmitter<EditorRegisteredEvent>();
this.unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
} }
} }

View File

@@ -18,130 +18,39 @@
*/ */
/** /**
* @fileoverview Implementation of the editors API for Superset. * @fileoverview Host implementation of the `editors` contribution type.
* *
* This module provides the runtime implementation of the editor registration * Extensions register via the public `editors.registerEditor()` and the host
* and resolution functions declared in the API types. * resolves the appropriate provider per language, falling back to the built-in
* AceEditorProvider when no extension is registered.
*
* The public namespace (`editors`) is exposed to extensions on `window.superset`.
* `EditorHost` is the host-internal component for rendering editors and is NOT
* part of the public `@apache-superset/core` API.
*/ */
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import { editors as editorsApi } from '@apache-superset/core'; import { editors as editorsApi } from '@apache-superset/core';
import { Disposable } from '../models';
import EditorProviders from './EditorProviders'; import EditorProviders from './EditorProviders';
type EditorLanguage = editorsApi.EditorLanguage; export type { EditorHostProps } from './EditorHost';
type Editor = editorsApi.Editor; export { default as EditorHost } from './EditorHost';
type EditorProvider = editorsApi.EditorProvider; export { default as AceEditorProvider } from './AceEditorProvider';
type EditorComponent = editorsApi.EditorComponent;
type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent;
type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent;
/** const provider = EditorProviders.getInstance();
* Register an editor provider as a module-level side effect.
* Takes the editor descriptor directly rather than looking it up
* from a manifest by ID.
*
* @param editor The editor descriptor.
* @param component The React component implementing the editor.
* @returns A Disposable to unregister the provider.
*/
export const registerEditor = (
editor: Editor,
component: EditorComponent,
): Disposable => {
const providers = EditorProviders.getInstance();
return providers.registerProvider(editor, component);
};
/** export const useEditor = (language: editorsApi.EditorLanguage) =>
* Get the editor provider for a specific language. useSyncExternalStore(
* Returns the extension's editor if registered, otherwise undefined. provider.subscribe,
* () => provider.getProvider(language),
* @param language The language to get an editor for
* @returns The editor provider or undefined if no extension provides one
*/
export const getEditor = (
language: EditorLanguage,
): EditorProvider | undefined => {
const manager = EditorProviders.getInstance();
return manager.getProvider(language);
};
/**
* Check if an extension has registered an editor for a language.
*
* @param language The language to check
* @returns True if an extension provides an editor for this language
*/
export const hasEditor = (language: EditorLanguage): boolean => {
const manager = EditorProviders.getInstance();
return manager.hasProvider(language);
};
/**
* Get all registered editor providers.
*
* @returns Array of all registered editor providers
*/
export const getAllEditors = (): EditorProvider[] => {
const manager = EditorProviders.getInstance();
return manager.getAllProviders();
};
/**
* Event fired when an editor is registered.
* Subscribe to this event to react when extensions register new editors.
*/
export const onDidRegisterEditor = (
listener: (e: EditorRegisteredEvent) => void,
): Disposable => {
const manager = EditorProviders.getInstance();
return manager.onDidRegister(listener);
};
/**
* Event fired when an editor is unregistered.
* Subscribe to this event to react when extensions unregister editors.
*/
export const onDidUnregisterEditor = (
listener: (e: EditorUnregisteredEvent) => void,
): Disposable => {
const manager = EditorProviders.getInstance();
return manager.onDidUnregister(listener);
};
/**
* Hook that returns the editor provider for a specific language and re-renders when it changes.
*
* @param language The language to get an editor for
* @returns The editor provider or undefined if no extension provides one
*/
export const useEditor = (
language: EditorLanguage,
): EditorProvider | undefined => {
const manager = EditorProviders.getInstance();
return useSyncExternalStore(
manager.subscribe,
() => manager.getProvider(language),
() => undefined, () => undefined,
); );
};
/**
* Editors API object for use in the extension system.
*/
export const editors: typeof editorsApi = { export const editors: typeof editorsApi = {
registerEditor, registerEditor: provider.registerProvider.bind(provider),
getEditor, getEditor: provider.getProvider.bind(provider),
hasEditor, hasEditor: provider.hasProvider.bind(provider),
getAllEditors, getAllEditors: provider.getAllProviders.bind(provider),
onDidRegisterEditor, onDidRegisterEditor: provider.onDidRegister.bind(provider),
onDidUnregisterEditor, onDidUnregisterEditor: provider.onDidUnregister.bind(provider),
}; };
export { EditorProviders };
// Component exports
export { default as EditorHost } from './EditorHost';
export type { EditorHostProps } from './EditorHost';
export { default as AceEditorProvider } from './AceEditorProvider';

View File

@@ -27,11 +27,13 @@ export const core: typeof coreType = {
}; };
export * from './authentication'; export * from './authentication';
export * from './chat';
export * from './commands'; export * from './commands';
export * from './editors'; export * from './editors';
export * from './extensions'; export * from './extensions';
export * from './menus'; export * from './menus';
export * from './models'; export * from './models';
export * from './navigation';
export * from './sqlLab'; export * from './sqlLab';
export * from './utils'; export * from './utils';
export * from './views'; export * from './views';

View File

@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react'; import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core'; import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models'; import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem; type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu; type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener); return () => syncListeners.delete(listener);
}; };
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>(); const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>(); const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const menuCache = new Map<string, Menu | undefined>(); const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => { const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear(); menuCache.clear();
syncListeners.forEach(l => l()); syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event)); registerEmitter.fire(event);
}; };
const notifyUnregister = (event: MenuItemUnregisteredEvent) => { const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear(); menuCache.clear();
syncListeners.forEach(l => l()); syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event)); unregisterEmitter.fire(event);
}; };
const registerMenuItem: typeof menusApi.registerMenuItem = ( const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,14 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = ( export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void, listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => { thisArgs?: unknown,
registerListeners.add(listener); ): Disposable => registerEmitter.subscribe(listener, thisArgs);
return new Disposable(() => registerListeners.delete(listener));
};
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem = export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => { (
unregisterListeners.add(listener); listener: (e: MenuItemUnregisteredEvent) => void,
return new Disposable(() => unregisterListeners.delete(listener)); thisArgs?: unknown,
}; ): Disposable => unregisterEmitter.subscribe(listener, thisArgs);
export const menus: typeof menusApi = { export const menus: typeof menusApi = {
registerMenuItem, registerMenuItem,

View File

@@ -0,0 +1,124 @@
/**
* 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.
*/
// Reset module state between tests so currentPage is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyLocationChanged('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyLocationChanged updates the current page type', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
notifyLocationChanged('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyLocationChanged fires listeners on page type change', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/explore/');
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyLocationChanged('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyLocationChanged('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyLocationChanged('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/add');
expect(navigation.getPage()).toBe('explore');
});

View File

@@ -0,0 +1,94 @@
/**
* 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.
*/
/**
* @fileoverview Host-internal implementation of the `navigation` namespace.
*
* Derives the current {@link Page} from the browser location by matching
* against {@link RoutePaths}. Call {@link useNavigationTracker} once in the
* app shell to keep the page in sync with React Router.
*/
import { useEffect, useRef } from 'react';
import { useLocation, matchPath } from 'react-router-dom';
import type { navigation as navigationApi } from '@apache-superset/core';
import { RoutePaths } from '../../views/routePaths';
import { Disposable } from '../models';
import { createValueEventEmitter } from '../utils';
type Page = navigationApi.Page;
/** Maps route path patterns to their corresponding Page type. */
const PAGE_ROUTES: { path: string; page: Page }[] = [
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
{ path: RoutePaths.SQLLAB, page: 'sqllab' },
{ path: RoutePaths.CHART_ADD, page: 'explore' },
{ path: RoutePaths.CHART_LIST, page: 'chart_list' },
{ path: RoutePaths.EXPLORE, page: 'explore' },
{ path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' },
{ path: RoutePaths.DATASET_LIST, page: 'dataset_list' },
{ path: RoutePaths.DATASET_ADD, page: 'dataset' },
{ path: RoutePaths.DATASET, page: 'dataset' },
];
function derivePage(pathname: string): Page {
for (const { path, page } of PAGE_ROUTES) {
if (matchPath(pathname, { path, exact: false })) return page;
}
return 'home';
}
const pageEmitter = createValueEventEmitter<Page>(
derivePage(window.location.pathname),
);
/** Updates the current page from a pathname. No-op when the page is unchanged. */
export const notifyLocationChanged = (pathname: string): void => {
const next = derivePage(pathname);
if (next === pageEmitter.getCurrent()) return;
pageEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: unknown,
): Disposable => pageEmitter.subscribe(listener, thisArgs);
/** Synchronizes the navigation module with React Router. Call once in the app shell. */
export const useNavigationTracker = () => {
const location = useLocation();
const prevPathname = useRef<string | null>(null);
useEffect(() => {
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyLocationChanged(location.pathname);
}
}, [location.pathname]);
};
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};

View File

@@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
import { Database, Disposable } from '../models'; import { Database, Disposable } from '../models';
import { createActionListener } from '../utils'; import { createActionListener } from '../storeUtils';
import { import {
Panel, Panel,
Tab, Tab,

View File

@@ -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.
*/
import type { common as core } from '@apache-superset/core';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
export function createActionListener<V, A = unknown>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
valueParser: (action: A, state: RootState) => V | null | undefined,
thisArgs?: unknown,
): core.Disposable {
const boundListener = thisArgs ? listener.bind(thisArgs as object) : listener;
const unsubscribe = listenerMiddleware.startListening({
predicate,
effect: action => {
const state = store.getState();
// The predicate already ensures the action matches type A at runtime.
const value = valueParser(action as unknown as A, state);
if (value != null) {
boundListener(value);
}
},
});
return {
dispose: () => {
unsubscribe();
},
};
}

View File

@@ -17,33 +17,54 @@
* under the License. * under the License.
*/ */
import type { common as core } from '@apache-superset/core'; import type { common as core } from '@apache-superset/core';
import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
export function createActionListener<V>( type Listener<T> = (e: T) => unknown;
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
valueParser: (action: AnyAction, state: RootState) => V | null | undefined,
thisArgs?: any,
): core.Disposable {
const boundListener = thisArgs ? listener.bind(thisArgs) : listener;
const unsubscribe = listenerMiddleware.startListening({ /** A stateless event emitter exposing a VS Code-style `event` subscriber. */
predicate, export interface EventEmitter<T> {
effect: (action: AnyAction) => { /** Notifies every current subscriber with `value`. */
const state = store.getState(); fire(value: T): void;
const value = valueParser(action, state); /** Registers a listener; returns a Disposable that removes it. */
// Skip calling listener if valueParser returns null/undefined subscribe: core.Event<T>;
if (value != null) { }
boundListener(value);
}
},
});
/** An event emitter that also retains the last fired value. */
export interface ValueEventEmitter<T> extends EventEmitter<T> {
/** Returns the value last passed to {@link fire} (or the initial value). */
getCurrent(): T;
}
/**
* Creates a stateless event emitter. Listeners registered via `event` receive
* every subsequent `fire`; a returned Disposable removes the listener.
*/
export function createEventEmitter<T>(): EventEmitter<T> {
const listeners = new Set<Listener<T>>();
const subscribe: core.Event<T> = (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return { dispose: () => listeners.delete(bound) };
};
return { return {
dispose: () => { fire: value => listeners.forEach(fn => fn(value)),
unsubscribe(); subscribe,
}, };
}
/**
* Creates a value event emitter seeded with `initial`. Behaves like
* {@link createEventEmitter} but also tracks the last fired value, readable
* via `getCurrent` — useful for state that is both observed and queried.
*/
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
const { fire, subscribe } = createEventEmitter<T>();
let current = initial;
return {
fire: value => {
current = value;
fire(value);
},
subscribe,
getCurrent: () => current,
}; };
} }

View File

@@ -24,11 +24,12 @@
* Extensions register views as side effects at import time. * Extensions register views as side effects at import time.
*/ */
import React, { ReactElement, useSyncExternalStore } from 'react'; import React, { ComponentType, useSyncExternalStore } from 'react';
import type { views as viewsApi } from '@apache-superset/core'; import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary'; import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder'; import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models'; import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type View = viewsApi.View; type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent; type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -36,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
const viewRegistry: Map< const viewRegistry: Map<
string, string,
{ view: View; location: string; provider: () => ReactElement } { view: View; location: string; component: ComponentType }
> = new Map(); > = new Map();
const locationIndex: Map<string, Set<string>> = new Map(); const locationIndex: Map<string, Set<string>> = new Map();
@@ -47,29 +48,29 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener); return () => syncListeners.delete(listener);
}; };
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>(); const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>(); const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const viewsCache = new Map<string, View[] | undefined>(); const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => { const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear(); viewsCache.clear();
syncListeners.forEach(l => l()); syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event)); registerEmitter.fire(event);
}; };
const notifyUnregister = (event: ViewUnregisteredEvent) => { const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear(); viewsCache.clear();
syncListeners.forEach(l => l()); syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event)); unregisterEmitter.fire(event);
}; };
const registerView: typeof viewsApi.registerView = ( const registerView: typeof viewsApi.registerView = (
view: View, view: View,
location: string, location: string,
provider: () => ReactElement, component: ComponentType,
): Disposable => { ): Disposable => {
const { id } = view; const { id } = view;
viewRegistry.set(id, { view, location, provider }); viewRegistry.set(id, { view, location, component });
const ids = locationIndex.get(location) ?? new Set(); const ids = locationIndex.get(location) ?? new Set();
ids.add(id); ids.add(id);
@@ -83,12 +84,16 @@ const registerView: typeof viewsApi.registerView = (
}); });
}; };
export const resolveView = (id: string): ReactElement => { export const resolveView = (id: string): React.ReactElement => {
const provider = viewRegistry.get(id)?.provider; const entry = viewRegistry.get(id);
if (!provider) { if (!entry) {
return React.createElement(ExtensionPlaceholder, { id }); return React.createElement(ExtensionPlaceholder, { id });
} }
return React.createElement(ErrorBoundary, null, provider()); return React.createElement(
ErrorBoundary,
null,
React.createElement(entry.component),
);
}; };
const getViews: typeof viewsApi.getViews = ( const getViews: typeof viewsApi.getViews = (
@@ -116,17 +121,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = ( export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void, listener: (e: ViewRegisteredEvent) => void,
): Disposable => { ): Disposable => registerEmitter.subscribe(listener);
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = ( export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void, listener: (e: ViewUnregisteredEvent) => void,
): Disposable => { ): Disposable => unregisterEmitter.subscribe(listener);
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
export const views: typeof viewsApi = { export const views: typeof viewsApi = {
registerView, registerView,

View File

@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
expect(getByTestId('chart-container')).toBeInTheDocument(); expect(getByTestId('chart-container')).toBeInTheDocument();
}); });
test('chart height is reduced on first render in expanded state (guards against useEffect regression)', () => {
const DESCRIPTION_HEIGHT = 60;
const CHART_HEIGHT = 300;
// Matches the DEFAULT_HEADER_HEIGHT constant in Chart.tsx.
const DEFAULT_HEADER_HEIGHT = 22;
// Stabilise getHeaderHeight(): emotion injects margin-bottom CSS during
// React's commit phase, so getComputedStyle returns different values in
// initial renders vs re-renders. Mock it to always return empty so
// getHeaderHeight() consistently falls back to DEFAULT_HEADER_HEIGHT.
const getComputedStyleSpy = jest
.spyOn(window, 'getComputedStyle')
.mockReturnValue({
getPropertyValue: () => '',
} as unknown as CSSStyleDeclaration);
// JSDOM doesn't compute layout, so mock offsetHeight to simulate a real
// description element with height.
const offsetHeightSpy = jest
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.classList.contains('slice_description')
? DESCRIPTION_HEIGHT
: 0;
});
// Suppress all passive effects to simulate the first-paint moment — the
// point at which the original useEffect bug caused clipping. useLayoutEffect
// (the fix) runs synchronously before paint and is intentionally NOT mocked
// here. If the implementation were reverted to useEffect, this spy would
// prevent the height measurement and the assertion below would fail.
const useEffectSpy = jest
.spyOn(global.React, 'useEffect')
.mockImplementation(() => {});
const { container } = setup(
{ height: CHART_HEIGHT },
{
charts: {
...defaultState.charts,
[queryId]: {
...defaultState.charts[queryId],
// ChartOverlay renders with an inline height style when loading —
// this is the observable proxy for getChartHeight() without real layout.
chartStatus: 'loading',
},
},
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [queryId]: true },
},
},
);
const chartHeight = parseInt(
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
.style.height,
10,
);
// useLayoutEffect must have measured and applied descriptionHeight
// synchronously. If useEffect were used instead, descriptionHeight would
// still be 0 here (suppressed by useEffectSpy) and chartHeight would equal
// CHART_HEIGHT - DEFAULT_HEADER_HEIGHT rather than the value below.
expect(chartHeight).toBe(
CHART_HEIGHT - DEFAULT_HEADER_HEIGHT - DESCRIPTION_HEIGHT,
);
useEffectSpy.mockRestore();
getComputedStyleSpy.mockRestore();
offsetHeightSpy.mockRestore();
});
test('should not show a close button on chart error banners', () => { test('should not show a close button on chart error banners', () => {
const { queryByRole } = setup( const { queryByRole } = setup(
{}, {},

View File

@@ -20,6 +20,7 @@ import cx from 'classnames';
import { import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useRef, useRef,
useMemo, useMemo,
useState, useState,
@@ -318,13 +319,9 @@ const Chart = (props: ChartProps) => {
[dispatch, props.id, sliceVizType], [dispatch, props.id, sliceVizType],
); );
useEffect(() => { useLayoutEffect(() => {
if (isExpanded) { if (isExpanded && descriptionRef.current) {
const descHeight = setDescriptionHeight(descriptionRef.current.offsetHeight);
isExpanded && descriptionRef.current
? descriptionRef.current?.offsetHeight
: 0;
setDescriptionHeight(descHeight);
} else { } else {
setDescriptionHeight(0); setDescriptionHeight(0);
} }
@@ -484,7 +481,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id; (formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback( const exportTable = useCallback(
(format: string, isFullCSV: boolean, isPivot = false) => { async (format: string, isFullCSV: boolean, isPivot = false) => {
const logAction = const logAction =
format === 'csv' format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART ? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -559,24 +556,48 @@ const Chart = (props: ChartProps) => {
} }
: baseOwnState; : baseOwnState;
exportChart({ try {
formData: await exportChart({
exportFormData as unknown as import('@superset-ui/core').QueryFormData, formData:
resultType, exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultFormat: format, resultType,
force: true, resultFormat: format,
ownState: exportOwnState, force: true,
onStartStreamingExport: shouldUseStreaming ownState: exportOwnState,
? (exportParams: JsonObject) => { onStartStreamingExport: shouldUseStreaming
setIsStreamingModalVisible(true); ? (exportParams: JsonObject) => {
startExport({ setIsStreamingModalVisible(true);
...(exportParams as Record<string, unknown>), startExport({
filename, ...(exportParams as Record<string, unknown>),
expectedRows: actualRowCount, filename,
} as Parameters<typeof startExport>[0]); expectedRows: actualRowCount,
} } as Parameters<typeof startExport>[0]);
: null, }
}); : null,
});
} catch (error) {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
boundActionCreators.addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
boundActionCreators.addDangerToast(errorMessage);
}
}
}, },
[ [
sliceSliceId, sliceSliceId,
@@ -588,6 +609,7 @@ const Chart = (props: ChartProps) => {
chartState, chartState,
props.id, props.id,
boundActionCreators.logEvent, boundActionCreators.logEvent,
boundActionCreators.addDangerToast,
queriesResponse, queriesResponse,
startExport, startExport,
resetExport, resetExport,

View File

@@ -22,6 +22,8 @@ import {
extractLabel, extractLabel,
getAppliedColumnsWithFallback, getAppliedColumnsWithFallback,
getCrossFilterIndicator, getCrossFilterIndicator,
IndicatorStatus,
selectNativeIndicatorsForChart,
} from './selectors'; } from './selectors';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
expect(result).toEqual(new Set(['age', 'name'])); expect(result).toEqual(new Set(['age', 'name']));
}); });
test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
const chart = {
queriesResponse: [
{
applied_filters: [],
},
{
applied_filters: [{ column: 'age' }, { column: 'name' }],
},
],
};
const result = getAppliedColumnsWithFallback(chart);
expect(result).toEqual(new Set(['age', 'name']));
});
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => { test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
const chart = { const chart = {
queriesResponse: [{ applied_filters: [] }], queriesResponse: [{ applied_filters: [] }],
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
); );
expect(result).toEqual(new Set(['query_column'])); expect(result).toEqual(new Set(['query_column']));
}); });
test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
const chartId = 987;
const nativeFilters = {
filter1: {
id: 'filter1',
name: 'Age',
type: NativeFilterType.NativeFilter,
chartsInScope: [chartId],
targets: [{ column: { name: 'age' } }],
},
} as any;
const dataMask = {
filter1: {
id: 'filter1',
filterState: { value: '25' },
extraFormData: {},
},
} as any;
const chart = {
queriesResponse: [
{ rejected_filters: [] },
{ rejected_filters: [{ column: 'age' }] },
],
};
const result = selectNativeIndicatorsForChart(
nativeFilters,
dataMask,
chartId,
chart,
[],
);
expect(result).toEqual([
{
column: 'age',
name: 'Age',
path: ['filter1'],
status: IndicatorStatus.Incompatible,
value: '25',
},
]);
});

View File

@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
})); }));
}; };
const getQueryFilterMetadata = (
chart: any,
metadataKey: 'applied_filters' | 'rejected_filters',
) =>
ensureIsArray(chart?.queriesResponse).flatMap(
queryResponse =>
(metadataKey === 'applied_filters'
? queryResponse?.applied_filters
: queryResponse?.rejected_filters) || [],
);
const getAppliedColumns = (chart: any): Set<string> => const getAppliedColumns = (chart: any): Set<string> =>
new Set( new Set(
(chart?.queriesResponse?.[0]?.applied_filters || []).map( getQueryFilterMetadata(chart, 'applied_filters').map(
(filter: any) => filter.column, (filter: any) => filter.column,
), ),
); );
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
chartId?: number, chartId?: number,
): Set<string> => { ): Set<string> => {
// First try to get from query response (preferred source of truth) // First try to get from query response (preferred source of truth)
const queryAppliedFilters = const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
chart?.queriesResponse?.[0]?.applied_filters || [];
if (queryAppliedFilters.length > 0) { if (queryAppliedFilters.length > 0) {
return new Set(queryAppliedFilters.map((filter: any) => filter.column)); return new Set(queryAppliedFilters.map((filter: any) => filter.column));
} }
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
const getRejectedColumns = (chart: any): Set<string> => const getRejectedColumns = (chart: any): Set<string> =>
new Set( new Set(
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) => getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
getColumnLabel(filter.column), getColumnLabel(filter.column),
), ),
); );

View File

@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers'; import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState'; import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants'; import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
import { import {
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// Update document title when dashboard title changes // Update document title when dashboard title changes
useEffect(() => { useEffect(() => {
if (pageTitle) { if (pageTitle) {
document.title = pageTitle; document.title = sanitizeDocumentTitle(pageTitle);
} }
}, [pageTitle]); }, [pageTitle]);

View File

@@ -66,6 +66,7 @@ import {
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils'; } from 'src/logger/LogUtils';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import cx from 'classnames'; import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction'; import * as chartActions from 'src/components/Chart/chartAction';
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources'; import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
// Update document title when slice name changes // Update document title when slice name changes
useEffect(() => { useEffect(() => {
if (props.sliceName) { if (props.sliceName) {
document.title = props.sliceName; document.title = sanitizeDocumentTitle(props.sliceName);
} }
}, [props.sliceName]); }, [props.sliceName]);

View File

@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
} }
}, [addDangerToast, latestQueryFormData, permalinkChartState]); }, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => { const handleExportError = useCallback(
(error: unknown) => {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
addDangerToast(errorMessage);
}
},
[addDangerToast],
);
const exportCSV = useCallback(async () => {
if (!canDownloadCSV) return null; if (!canDownloadCSV) return null;
// Determine row count for streaming threshold check // Determine row count for streaming threshold check
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
filename = `${safeChartName}${timestamp}.csv`; filename = `${safeChartName}${timestamp}.csv`;
} }
return exportChart({ try {
formData: latestQueryFormData as QueryFormData, await exportChart({
ownState, formData: latestQueryFormData as QueryFormData,
resultType: 'full', ownState,
resultFormat: 'csv', resultType: 'full',
onStartStreamingExport: shouldUseStreaming resultFormat: 'csv',
? exportParams => { onStartStreamingExport: shouldUseStreaming
if (exportParams.url) { ? exportParams => {
setIsStreamingModalVisible(true); if (exportParams.url) {
startExport({ setIsStreamingModalVisible(true);
...exportParams, startExport({
url: exportParams.url, ...exportParams,
filename, url: exportParams.url,
expectedRows: actualRowCount, filename,
exportType: exportParams.exportType as 'csv' | 'xlsx', expectedRows: actualRowCount,
}); exportType: exportParams.exportType as 'csv' | 'xlsx',
});
}
} }
} : null,
: null, });
}); } catch (error) {
handleExportError(error);
}
return null;
}, [ }, [
canDownloadCSV, canDownloadCSV,
latestQueryFormData, latestQueryFormData,
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
streamingThreshold, streamingThreshold,
slice, slice,
startExport, startExport,
handleExportError,
]); ]);
const exportCSVPivoted = useCallback( const exportCSVPivoted = useCallback(async () => {
() => if (!canDownloadCSV) {
canDownloadCSV return null;
? exportChart({ }
formData: latestQueryFormData as QueryFormData, try {
ownState, await exportChart({
resultType: 'post_processed', formData: latestQueryFormData as QueryFormData,
resultFormat: 'csv', ownState,
}) resultType: 'post_processed',
: null, resultFormat: 'csv',
[canDownloadCSV, latestQueryFormData, ownState], });
); } catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportJson = useCallback( const exportJson = useCallback(async () => {
() => if (!canDownloadCSV) {
canDownloadCSV return null;
? exportChart({ }
formData: latestQueryFormData as QueryFormData, try {
ownState, await exportChart({
resultType: 'results', formData: latestQueryFormData as QueryFormData,
resultFormat: 'json', ownState,
}) resultType: 'results',
: null, resultFormat: 'json',
[canDownloadCSV, latestQueryFormData, ownState], });
); } catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportExcel = useCallback( const exportExcel = useCallback(async () => {
() => if (!canDownloadCSV) {
canDownloadCSV return null;
? exportChart({ }
formData: latestQueryFormData as QueryFormData, try {
ownState, await exportChart({
resultType: 'results', formData: latestQueryFormData as QueryFormData,
resultFormat: 'xlsx', ownState,
}) resultType: 'results',
: null, resultFormat: 'xlsx',
[canDownloadCSV, latestQueryFormData, ownState], });
); } catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const copyLink = useCallback(async () => { const copyLink = useCallback(async () => {
try { try {
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
label: dataExportLabel(t('Export to .CSV')), label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />, icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV, disabled: !canDownloadCSV,
onClick: () => { onClick: async () => {
// Use 'results' to export the *current view* (as opposed to 'full'). // Use 'results' to export the *current view* (as opposed to 'full').
// Pass ownState so client/UI state (e.g., filters) can be respected when supported. // Pass ownState so client/UI state (e.g., filters) can be respected when supported.
if ( if (
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name || 'current_view', slice?.slice_name || 'current_view',
); );
} else { } else {
exportChart({ try {
formData: latestQueryFormData as QueryFormData, await exportChart({
ownState, formData: latestQueryFormData as QueryFormData,
resultType: 'results', ownState,
resultFormat: 'csv', resultType: 'results',
}); resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
} }
setIsDropdownVisible(false); setIsDropdownVisible(false);
dispatch( dispatch(
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
exportCSVPivoted, exportCSVPivoted,
exportExcel, exportExcel,
exportJson, exportJson,
handleExportError,
latestQueryFormData, latestQueryFormData,
onOpenInEditor, onOpenInEditor,
onOpenPropertiesModal, onOpenPropertiesModal,

View File

@@ -0,0 +1,150 @@
/**
* 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 { ComponentType } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { useExploreAdditionalActionsMenu } from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
...jest.requireActual('src/explore/exploreUtils'),
exportChart: jest.fn(),
getChartKey: jest.fn(() => 'test_chart_key'),
}));
const mockExportChart = exploreUtils.exportChart as jest.Mock;
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (component: ComponentType) => component,
useToasts: () => ({
addDangerToast: mockAddDangerToast,
addSuccessToast: jest.fn(),
}),
}));
jest.mock('src/logger/actions', () => ({
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
getChartMetadataRegistry: jest.fn(() => ({
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
})),
}));
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'pivot_table_v2',
},
canDownloadCSV: true,
slice: { slice_id: 1, slice_name: 'Test Chart' },
ownState: {},
dashboards: [],
onOpenInEditor: jest.fn(),
onOpenPropertiesModal: jest.fn(),
showReportModal: jest.fn(),
setCurrentReportDeleting: jest.fn(),
};
type TestComponentProps = typeof defaultProps;
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
const TestComponent = (props: TestComponentProps) => {
const [menu] = useExploreAdditionalActionsMenu(
props.latestQueryFormData as HookParams[0],
props.canDownloadCSV,
props.slice as HookParams[2],
props.onOpenInEditor,
props.onOpenPropertiesModal,
props.ownState as HookParams[5],
props.dashboards as HookParams[6],
props.showReportModal,
props.setCurrentReportDeleting,
);
return <div>{menu}</div>;
};
beforeEach(() => {
jest.clearAllMocks();
mockExportChart.mockResolvedValue(undefined);
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to original .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(
<TestComponent
{...defaultProps}
latestQueryFormData={{
datasource: '1__table',
viz_type: 'table',
}}
ownState={{}}
/>,
{ useRedux: true },
);
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
userEvent.click(await screen.findByText('Export to .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});

View File

@@ -18,6 +18,11 @@
*/ */
import { exportChart } from '.'; import { exportChart } from '.';
jest.mock('src/utils/export', () => ({
...jest.requireActual('src/utils/export'),
downloadBlob: jest.fn(),
}));
// Mock pathUtils to control app root prefix // Mock pathUtils to control app root prefix
jest.mock('src/utils/pathUtils', () => ({ jest.mock('src/utils/pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path), ensureAppRoot: jest.fn((path: string) => path),
@@ -27,6 +32,7 @@ jest.mock('src/utils/pathUtils', () => ({
jest.mock('@superset-ui/core', () => ({ jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'), ...jest.requireActual('@superset-ui/core'),
SupersetClient: { SupersetClient: {
postBlob: jest.fn(),
postForm: jest.fn(), postForm: jest.fn(),
get: jest.fn().mockResolvedValue({ json: {} }), get: jest.fn().mockResolvedValue({ json: {} }),
post: jest.fn().mockResolvedValue({ json: {} }), post: jest.fn().mockResolvedValue({ json: {} }),
@@ -41,6 +47,14 @@ jest.mock('@superset-ui/core', () => ({
const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils'); const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core'); const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
const { downloadBlob } = jest.requireMock('src/utils/export');
const mockBlob = new Blob(['test data'], { type: 'text/csv' });
const createMockExportResponse = (headers: Headers = new Headers()) => ({
headers,
blob: jest.fn().mockResolvedValue(mockBlob),
});
// Minimal formData that won't trigger legacy API (useLegacyApi = false) // Minimal formData that won't trigger legacy API (useLegacyApi = false)
const baseFormData = { const baseFormData = {
@@ -113,22 +127,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
expect(callArgs.exportType).toBe('xlsx'); expect(callArgs.exportType).toBe('xlsx');
}); });
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot // Regression test for the double-prefix bug: SupersetClient.postBlob adds appRoot
// internally via getUrl(), so the URL passed must NOT already be prefixed. // internally via getUrl(), so the URL passed must NOT already be prefixed.
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => { test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core'); const { SupersetClient } = jest.requireMock('@superset-ui/core');
const appRoot = '/analytics'; const appRoot = '/analytics';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
await exportChart({ await exportChart({
formData: baseFormData, formData: baseFormData,
resultFormat: 'csv', resultFormat: 'csv',
}); });
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1); expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0]; const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/api/v1/chart/data'); expect(url).toBe('/api/v1/chart/data');
expect(url).not.toContain(appRoot); expect(url).not.toContain(appRoot);
expect(downloadBlob).toHaveBeenCalled();
}); });
test('exportChart passes csv exportType for CSV exports', async () => { test('exportChart passes csv exportType for CSV exports', async () => {
@@ -240,9 +256,10 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true'); expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
}); });
test('exportChart legacy API calls postForm with relative URL', async () => { test('exportChart legacy API calls postBlob with relative URL', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core'); const { SupersetClient } = jest.requireMock('@superset-ui/core');
ensureAppRoot.mockImplementation((path: string) => path); ensureAppRoot.mockImplementation((path: string) => path);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
getChartMetadataRegistry.mockReturnValue({ getChartMetadataRegistry.mockReturnValue({
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
@@ -259,10 +276,11 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
resultType: 'full', resultType: 'full',
}); });
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1); expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0]; const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/superset/explore_json/?csv=true'); expect(url).toBe('/superset/explore_json/?csv=true');
expect(url).not.toMatch(/^https?:\/\//); expect(url).not.toMatch(/^https?:\/\//);
expect(downloadBlob).toHaveBeenCalled();
}); });
test('exportChart legacy API includes force param when force=true', async () => { test('exportChart legacy API includes force param when force=true', async () => {
@@ -289,3 +307,187 @@ test('exportChart legacy API includes force param when force=true', async () =>
const callArgs = onStartStreamingExport.mock.calls[0][0]; const callArgs = onStartStreamingExport.mock.calls[0][0];
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true'); expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
}); });
test('exportChart successfully exports chart as CSV', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
resultType: 'full',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.csv'),
);
});
test('exportChart successfully exports chart as Excel', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'xlsx',
resultType: 'results',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.xlsx'),
);
});
test('exportChart throws error with status 413 when payload is too large', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Payload Too Large', {
status: 413,
statusText: 'Payload Too Large',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 413,
message: expect.stringContaining('413'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart throws error with status 500 for server errors', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Internal Server Error', {
status: 500,
statusText: 'Internal Server Error',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'json',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('500'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart enhances errors without status property', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const genericError = new Error('Network error');
SupersetClient.postBlob.mockRejectedValue(genericError);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('Network error'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart uses streaming export when onStartStreamingExport is provided', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockStreamingHandler = jest.fn();
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
onStartStreamingExport: mockStreamingHandler as unknown as null,
});
expect(mockStreamingHandler).toHaveBeenCalledTimes(1);
expect(mockStreamingHandler).toHaveBeenCalledWith(
expect.objectContaining({
url: '/api/v1/chart/data',
exportType: 'csv',
}),
);
expect(SupersetClient.postBlob).not.toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart generates correct filename with timestamp', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringMatching(
/^chart_export_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv$/,
),
);
jest.spyOn(global, 'Date').mockRestore();
});
test('exportChart uses filename from Content-Disposition header', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse(
new Headers({
'Content-Disposition': 'attachment; filename="export.zip"',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'export.zip');
});
test('exportChart uses zip extension when Content-Type is application/zip', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const mockResponse = createMockExportResponse(
new Headers({
'Content-Type': 'application/zip',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
'chart_export_2025-01-14T12-34-56.zip',
);
jest.spyOn(global, 'Date').mockRestore();
});

View File

@@ -34,6 +34,7 @@ import { availableDomains } from 'src/utils/hostNamesConfig';
import { safeStringify } from 'src/utils/safeStringify'; import { safeStringify } from 'src/utils/safeStringify';
import { optionLabel } from 'src/utils/common'; import { optionLabel } from 'src/utils/common';
import { ensureAppRoot } from 'src/utils/pathUtils'; import { ensureAppRoot } from 'src/utils/pathUtils';
import { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import { import {
DISABLE_INPUT_OPERATORS, DISABLE_INPUT_OPERATORS,
@@ -398,11 +399,54 @@ export const exportChart = async ({
exportSource: 'chart', exportSource: 'chart',
}); });
} else { } else {
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends // Use AJAX blob download instead of form submission to enable error handling.
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
// appRoot — so the URL must NOT be pre-prefixed here. // appRoot — so the URL must NOT be pre-prefixed here.
SupersetClient.postForm(url as string, { try {
form_data: safeStringify(payload), const response = await SupersetClient.postBlob(url as string, {
}); form_data: safeStringify(payload),
});
const extension = resultFormat === 'xlsx' ? 'xlsx' : resultFormat;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, -5);
const fallbackFilename = `chart_export_${timestamp}.${extension}`;
const filename = getFilenameFromResponse(response, fallbackFilename);
const blob = await response.blob();
downloadBlob(blob, filename);
} catch (error) {
if (error instanceof Response) {
const responseError = new Error(
`HTTP ${error.status} ${error.statusText}`,
) as Error & {
status: number;
statusText: string;
response: Response;
};
responseError.status = error.status;
responseError.statusText = error.statusText;
responseError.response = error;
throw responseError;
}
const exportError = error as Error & {
status?: number;
originalError?: unknown;
};
if (!exportError.status) {
const enhancedError = new Error(
exportError.message || 'Export failed',
) as Error & { status: number; originalError: unknown };
enhancedError.status = 500;
enhancedError.originalError = error;
throw enhancedError;
}
throw error;
}
} }
}; };

View File

@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
version: '1.0.0', version: '1.0.0',
dependencies: [], dependencies: [],
remoteEntry: '', remoteEntry: '',
extensionDependencies: [],
...overrides, ...overrides,
}; };
} }

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => { test('renders without crashing', () => {
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up // Verify the global superset object is set up
expect((window as any).superset).toBeDefined(); expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined(); expect((window as any).superset.authentication).toBeDefined();
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined(); expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined(); expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined(); expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => { test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser, initialState: mockInitialStateNoUser,
}); });
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => { test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser, initialState: mockInitialStateNoUser,
}); });
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, { const { rerender } = render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, { render(<ExtensionsStartup />, {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}); });
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>, </ExtensionsStartup>,
{ {
useRedux: true, useRedux: true,
useRouter: true,
initialState: mockInitialState, initialState: mockInitialState,
}, },
); );

View File

@@ -17,41 +17,32 @@
* under the License. * under the License.
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core'; import * as supersetCore from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { import {
authentication, authentication,
chat,
core, core,
commands, commands,
editors, editors,
extensions, extensions,
menus, menus,
navigation,
useNavigationTracker,
sqlLab, sqlLab,
views, views,
} from 'src/core'; } from 'src/core';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store'; import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader'; import ExtensionsLoader from './ExtensionsLoader';
import 'src/extensions/Namespaces';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children, children,
}) => { }) => {
useNavigationTracker();
const userId = useSelector<RootState, number | undefined>( const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId, ({ user }) => user.userId,
); );
@@ -59,15 +50,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
useEffect(() => { useEffect(() => {
if (userId == null) return; if (userId == null) return;
// Provide the implementations for @apache-superset/core // Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = { window.superset = {
...supersetCore, ...supersetCore,
authentication, authentication,
chat,
core, core,
commands, commands,
editors, editors,
extensions, extensions,
menus, menus,
navigation,
sqlLab, sqlLab,
views, views,
}; };

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface Namespaces {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: Namespaces;
}
}

View File

@@ -243,6 +243,7 @@ export const accessTokenField = ({
validationErrors, validationErrors,
db, db,
isEditMode, isEditMode,
isValidating,
default_value, default_value,
description, description,
}: FieldPropTypes) => ( }: FieldPropTypes) => (
@@ -250,6 +251,7 @@ export const accessTokenField = ({
id="access_token" id="access_token"
name="access_token" name="access_token"
required={required} required={required}
isValidating={isValidating}
visibilityToggle={!isEditMode} visibilityToggle={!isEditMode}
value={db?.parameters?.access_token} value={db?.parameters?.access_token}
validationMethods={{ onBlur: getValidation }} validationMethods={{ onBlur: getValidation }}

View File

@@ -33,6 +33,7 @@ export const TableCatalog = ({
getValidation, getValidation,
validationErrors, validationErrors,
db, db,
isValidating,
isPublic = true, isPublic = true,
}: FieldPropTypes) => { }: FieldPropTypes) => {
const tableCatalog = db?.catalog || []; const tableCatalog = db?.catalog || [];
@@ -53,6 +54,7 @@ export const TableCatalog = ({
<ValidatedInput <ValidatedInput
className="catalog-name-input" className="catalog-name-input"
required={required} required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }} validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name} errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')} placeholder={t('Enter a name for this sheet')}
@@ -86,6 +88,7 @@ export const TableCatalog = ({
<ValidatedInput <ValidatedInput
className="catalog-name-url" className="catalog-name-url"
required={required} required={required}
isValidating={isValidating}
validationMethods={{ onBlur: getValidation }} validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url} errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')} placeholder={t('Paste the shareable Google Sheet URL here')}

View File

@@ -49,11 +49,13 @@ export const validatedInputField = ({
validationErrors, validationErrors,
db, db,
field, field,
isValidating,
}: FieldPropTypes) => ( }: FieldPropTypes) => (
<ValidatedInput <ValidatedInput
id={field} id={field}
name={field} name={field}
required={required} required={required}
isValidating={isValidating}
value={db?.parameters?.[field as keyof DatabaseParameters]} value={db?.parameters?.[field as keyof DatabaseParameters]}
validationMethods={{ onBlur: getValidation }} validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]} errorMessage={validationErrors?.[field]}

View File

@@ -18,18 +18,21 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { JsonObject } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme'; import { styled } from '@apache-superset/core/theme';
import { Alert } from '@apache-superset/core/components';
import { import {
Form, Form,
FormLabel, FormLabel,
Col, Col,
Row, Row,
LabeledErrorBoundInput,
Icons,
Tooltip, Tooltip,
} from '@superset-ui/core/components'; } from '@superset-ui/core/components';
import { Input } from '@superset-ui/core/components/Input'; import { Input } from '@superset-ui/core/components/Input';
import { Radio } from '@superset-ui/core/components/Radio'; import { Radio } from '@superset-ui/core/components/Radio';
import { Icons } from '@superset-ui/core/components/Icons'; import { DatabaseObject, CustomEventHandlerType } from '../types';
import { DatabaseObject, FieldPropTypes } from '../types';
import { AuthType } from '.'; import { AuthType } from '.';
const StyledDiv = styled.div` const StyledDiv = styled.div`
@@ -48,50 +51,73 @@ const StyledFormItem = styled(Form.Item)`
margin-bottom: 0 !important; margin-bottom: 0 !important;
`; `;
const StyledInputPassword = styled(Input.Password)` interface SSHTunnelFormProps {
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`}; db: DatabaseObject | null;
`; onSSHTunnelParametersChange: CustomEventHandlerType;
setSSHTunnelLoginMethod: (method: AuthType) => void;
isValidating?: boolean;
validationErrors?: JsonObject | null;
getValidation: () => void;
}
const SSHTunnelForm = ({ const SSHTunnelForm = ({
db, db,
onSSHTunnelParametersChange, onSSHTunnelParametersChange,
setSSHTunnelLoginMethod, setSSHTunnelLoginMethod,
}: { isValidating = false,
db: DatabaseObject | null; validationErrors,
onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange']; getValidation,
setSSHTunnelLoginMethod: (method: AuthType) => void; }: SSHTunnelFormProps) => {
}) => {
const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password); const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password);
const sshErrors = validationErrors?.ssh_tunnel || {};
const sshSectionError = sshErrors?._error;
return ( return (
<Form> <Form>
{sshSectionError && (
<StyledRow gutter={16}>
<Col xs={24}>
<Alert
type="error"
showIcon
message={sshSectionError}
data-test="ssh-tunnel-section-error"
/>
</Col>
</StyledRow>
)}
<StyledRow gutter={16}> <StyledRow gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<StyledDiv> <StyledDiv>
<FormLabel htmlFor="server_address" required> <LabeledErrorBoundInput
{t('SSH Host')} id="server_address"
</FormLabel>
<Input
name="server_address" name="server_address"
type="text" label={t('SSH Host')}
required
placeholder={t('e.g. 127.0.0.1')} placeholder={t('e.g. 127.0.0.1')}
value={db?.ssh_tunnel?.server_address || ''} value={db?.ssh_tunnel?.server_address || ''}
onChange={onSSHTunnelParametersChange} onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_address}
isValidating={isValidating}
data-test="ssh-tunnel-server_address-input" data-test="ssh-tunnel-server_address-input"
/> />
</StyledDiv> </StyledDiv>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<StyledDiv> <StyledDiv>
<FormLabel htmlFor="server_port" required> <LabeledErrorBoundInput
{t('SSH Port')} id="server_port"
</FormLabel>
<Input
name="server_port" name="server_port"
label={t('SSH Port')}
required
placeholder={t('22')} placeholder={t('22')}
type="number" type="number"
value={db?.ssh_tunnel?.server_port} value={db?.ssh_tunnel?.server_port}
onChange={onSSHTunnelParametersChange} onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.server_port}
isValidating={isValidating}
data-test="ssh-tunnel-server_port-input" data-test="ssh-tunnel-server_port-input"
/> />
</StyledDiv> </StyledDiv>
@@ -100,15 +126,17 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}> <StyledRow gutter={16}>
<Col xs={24}> <Col xs={24}>
<StyledDiv> <StyledDiv>
<FormLabel htmlFor="username" required> <LabeledErrorBoundInput
{t('Username')} id="username"
</FormLabel>
<Input
name="username" name="username"
type="text" label={t('Username')}
required
placeholder={t('e.g. Analytics')} placeholder={t('e.g. Analytics')}
value={db?.ssh_tunnel?.username || ''} value={db?.ssh_tunnel?.username || ''}
onChange={onSSHTunnelParametersChange} onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.username}
isValidating={isValidating}
data-test="ssh-tunnel-username-input" data-test="ssh-tunnel-username-input"
/> />
</StyledDiv> </StyledDiv>
@@ -148,16 +176,20 @@ const SSHTunnelForm = ({
<StyledRow gutter={16}> <StyledRow gutter={16}>
<Col xs={24}> <Col xs={24}>
<StyledDiv> <StyledDiv>
<FormLabel htmlFor="password" required> <LabeledErrorBoundInput
{t('SSH Password')} id="password"
</FormLabel>
<StyledInputPassword
name="password" name="password"
label={t('SSH Password')}
required
visibilityToggle
placeholder={t('e.g. ********')} placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.password || ''} value={db?.ssh_tunnel?.password || ''}
onChange={onSSHTunnelParametersChange} onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.password}
isValidating={isValidating}
data-test="ssh-tunnel-password-input" data-test="ssh-tunnel-password-input"
iconRender={visible => iconRender={(visible: boolean) =>
visible ? ( visible ? (
<Tooltip title={t('Hide password.')}> <Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined /> <Icons.EyeInvisibleOutlined />
@@ -182,30 +214,47 @@ const SSHTunnelForm = ({
<FormLabel htmlFor="private_key" required> <FormLabel htmlFor="private_key" required>
{t('Private Key')} {t('Private Key')}
</FormLabel> </FormLabel>
<Input.TextArea <StyledFormItem
name="private_key" validateStatus={
placeholder={t('Paste Private Key here')} isValidating
value={db?.ssh_tunnel?.private_key || ''} ? 'validating'
onChange={onSSHTunnelParametersChange} : sshErrors?.private_key
data-test="ssh-tunnel-private_key-input" ? 'error'
rows={4} : 'success'
/> }
help={sshErrors?.private_key}
hasFeedback={isValidating || !!sshErrors?.private_key}
>
<Input.TextArea
name="private_key"
placeholder={t('Paste Private Key here')}
value={db?.ssh_tunnel?.private_key || ''}
onChange={onSSHTunnelParametersChange}
onBlur={getValidation}
data-test="ssh-tunnel-private_key-input"
rows={4}
/>
</StyledFormItem>
</StyledDiv> </StyledDiv>
</Col> </Col>
</StyledRow> </StyledRow>
<StyledRow gutter={16}> <StyledRow gutter={16}>
<Col xs={24}> <Col xs={24}>
<StyledDiv> <StyledDiv>
<FormLabel htmlFor="private_key_password" required> <LabeledErrorBoundInput
{t('Private Key Password')} id="private_key_password"
</FormLabel>
<StyledInputPassword
name="private_key_password" name="private_key_password"
label={t('Private Key Password')}
required
visibilityToggle
placeholder={t('e.g. ********')} placeholder={t('e.g. ********')}
value={db?.ssh_tunnel?.private_key_password || ''} value={db?.ssh_tunnel?.private_key_password || ''}
onChange={onSSHTunnelParametersChange} onChange={onSSHTunnelParametersChange}
validationMethods={{ onBlur: getValidation }}
errorMessage={sshErrors?.private_key_password}
isValidating={isValidating}
data-test="ssh-tunnel-private_key_password-input" data-test="ssh-tunnel-private_key_password-input"
iconRender={visible => iconRender={(visible: boolean) =>
visible ? ( visible ? (
<Tooltip title={t('Hide password.')}> <Tooltip title={t('Hide password.')}>
<Icons.EyeInvisibleOutlined /> <Icons.EyeInvisibleOutlined />

View File

@@ -1212,26 +1212,40 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input', 'ssh-tunnel-server_address-input',
); );
expect(SSHTunnelServerAddressInput).toHaveValue(''); expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost'); fireEvent.change(SSHTunnelServerAddressInput, {
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId( const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input', 'ssh-tunnel-server_port-input',
); );
expect(SSHTunnelServerPortInput).toHaveValue(null); expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22'); fireEvent.change(SSHTunnelServerPortInput, {
expect(SSHTunnelServerPortInput).toHaveValue(22); target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId( const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input', 'ssh-tunnel-username-input',
); );
expect(SSHTunnelUsernameInput).toHaveValue(''); expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test'); fireEvent.change(SSHTunnelUsernameInput, {
expect(SSHTunnelUsernameInput).toHaveValue('test'); target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId( const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input', 'ssh-tunnel-password-input',
); );
expect(SSHTunnelPasswordInput).toHaveValue(''); expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass'); fireEvent.change(SSHTunnelPasswordInput, {
expect(SSHTunnelPasswordInput).toHaveValue('pass'); target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
}); });
test('properly interacts with SSH Tunnel form textboxes', async () => { test('properly interacts with SSH Tunnel form textboxes', async () => {
@@ -1250,26 +1264,40 @@ describe('DatabaseModal', () => {
'ssh-tunnel-server_address-input', 'ssh-tunnel-server_address-input',
); );
expect(SSHTunnelServerAddressInput).toHaveValue(''); expect(SSHTunnelServerAddressInput).toHaveValue('');
userEvent.type(SSHTunnelServerAddressInput, 'localhost'); fireEvent.change(SSHTunnelServerAddressInput, {
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'); target: { value: 'localhost' },
});
await waitFor(() =>
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
);
const SSHTunnelServerPortInput = screen.getByTestId( const SSHTunnelServerPortInput = screen.getByTestId(
'ssh-tunnel-server_port-input', 'ssh-tunnel-server_port-input',
); );
expect(SSHTunnelServerPortInput).toHaveValue(null); expect(SSHTunnelServerPortInput).toHaveValue(null);
userEvent.type(SSHTunnelServerPortInput, '22'); fireEvent.change(SSHTunnelServerPortInput, {
expect(SSHTunnelServerPortInput).toHaveValue(22); target: { value: '22' },
});
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
const SSHTunnelUsernameInput = screen.getByTestId( const SSHTunnelUsernameInput = screen.getByTestId(
'ssh-tunnel-username-input', 'ssh-tunnel-username-input',
); );
expect(SSHTunnelUsernameInput).toHaveValue(''); expect(SSHTunnelUsernameInput).toHaveValue('');
userEvent.type(SSHTunnelUsernameInput, 'test'); fireEvent.change(SSHTunnelUsernameInput, {
expect(SSHTunnelUsernameInput).toHaveValue('test'); target: { value: 'test' },
});
await waitFor(() =>
expect(SSHTunnelUsernameInput).toHaveValue('test'),
);
const SSHTunnelPasswordInput = screen.getByTestId( const SSHTunnelPasswordInput = screen.getByTestId(
'ssh-tunnel-password-input', 'ssh-tunnel-password-input',
); );
expect(SSHTunnelPasswordInput).toHaveValue(''); expect(SSHTunnelPasswordInput).toHaveValue('');
userEvent.type(SSHTunnelPasswordInput, 'pass'); fireEvent.change(SSHTunnelPasswordInput, {
expect(SSHTunnelPasswordInput).toHaveValue('pass'); target: { value: 'pass' },
});
await waitFor(() =>
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
);
}); });
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => { test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
@@ -1364,7 +1392,10 @@ describe('DatabaseModal', () => {
}), }),
); );
const textboxes = screen.getAllByRole('textbox'); // Wait for step 2 to render
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0]; const hostField = textboxes[0];
const portField = screen.getByRole('spinbutton'); const portField = screen.getByRole('spinbutton');
const databaseNameField = textboxes[1]; const databaseNameField = textboxes[1];
@@ -1380,15 +1411,20 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeDisabled(); expect(connectButton).toBeDisabled();
userEvent.type(hostField, 'localhost'); fireEvent.change(hostField, { target: { value: 'localhost' } });
userEvent.type(portField, '5432'); fireEvent.blur(hostField);
userEvent.type(databaseNameField, 'postgres'); fireEvent.change(portField, { target: { value: '5432' } });
userEvent.type(usernameField, 'testdb'); fireEvent.blur(portField);
userEvent.type(passwordField, 'demoPassword'); fireEvent.change(databaseNameField, { target: { value: 'postgres' } });
fireEvent.blur(databaseNameField);
fireEvent.change(usernameField, { target: { value: 'testdb' } });
fireEvent.blur(usernameField);
fireEvent.change(passwordField, { target: { value: 'demoPassword' } });
fireEvent.blur(passwordField);
await waitFor(() => expect(connectButton).toBeEnabled()); await waitFor(() => expect(connectButton).toBeEnabled());
expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument(); await waitFor(() => expect(portField).toHaveValue(5432));
expect(hostField).toHaveValue('localhost'); expect(hostField).toHaveValue('localhost');
expect(portField).toHaveValue(5432); expect(portField).toHaveValue(5432);
expect(databaseNameField).toHaveValue('postgres'); expect(databaseNameField).toHaveValue('postgres');
@@ -1397,10 +1433,48 @@ describe('DatabaseModal', () => {
expect(connectButton).toBeEnabled(); expect(connectButton).toBeEnabled();
userEvent.click(connectButton); userEvent.click(connectButton);
// Verify that validation was called during the form interaction
// Note: With the optimized validation, redundant calls on the same db state are skipped
await waitFor(() => { await waitFor(() => {
expect( expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length, fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(5); ).toBeGreaterThan(0);
});
});
test('does not fire redundant validation on blur when db has not changed', async () => {
setup();
userEvent.click(
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const textboxes = await screen.findAllByRole('textbox');
const hostField = textboxes[0];
// Type a value and blur - should trigger validation
fireEvent.change(hostField, { target: { value: 'localhost' } });
fireEvent.blur(hostField);
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
});
// Blur again without changing the value - should NOT trigger another validation
fireEvent.focus(hostField);
fireEvent.blur(hostField);
// Wait a tick to ensure no additional calls are made
await waitFor(() => {
expect(
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
).toEqual(1);
}); });
}); });
}); });

View File

@@ -668,6 +668,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
hasValidated, hasValidated,
setHasValidated, setHasValidated,
] = useDatabaseValidation(); ] = useDatabaseValidation();
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false); const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
const [showCTAbtns, setShowCTAbtns] = useState(false); const [showCTAbtns, setShowCTAbtns] = useState(false);
const [dbName, setDbName] = useState(''); const [dbName, setDbName] = useState('');
@@ -775,6 +776,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const handleClearValidationErrors = useCallback(() => { const handleClearValidationErrors = useCallback(() => {
setValidationErrors(null); setValidationErrors(null);
setHasValidated(false); setHasValidated(false);
lastValidatedDbSnapshotRef.current = null;
clearError(); clearError();
}, [setValidationErrors, setHasValidated, clearError]); }, [setValidationErrors, setHasValidated, clearError]);
@@ -851,6 +853,16 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange], [onChange],
); );
const handleTextChange = useCallback(
({ target }: { target: HTMLInputElement }) => {
onChange(ActionType.TextChange, {
name: target.name,
value: target.value,
});
},
[onChange],
);
const handleChangeWithValidation = useCallback( const handleChangeWithValidation = useCallback(
( (
actionType: ActionType, actionType: ActionType,
@@ -862,6 +874,21 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
[onChange, handleClearValidationErrors], [onChange, handleClearValidationErrors],
); );
const getBlurValidation = useCallback(async () => {
const currentDbSnapshot = JSON.stringify(db);
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
return [];
}
const result = await getValidation(db);
// Only cache after a request that produced a usable response. ``null``
// signals an unexpected/network failure, in which case we leave the
// snapshot untouched so the next blur retries.
if (result !== null) {
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
}
return result;
}, [db, getValidation]);
const onClose = () => { const onClose = () => {
setDB({ type: ActionType.Reset }); setDB({ type: ActionType.Reset });
setHasConnectedDb(false); setHasConnectedDb(false);
@@ -940,7 +967,15 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
} }
const errors = await getValidation(dbToUpdate, true); const errors = await getValidation(dbToUpdate, true);
if (!isEmpty(validationErrors) || errors?.length) { // ``getValidation`` returns ``[]`` on success, a field-keyed object
// for blocking errors (e.g. the duplicate ``database_name`` check),
// and ``null`` for stale or unexpected responses. During save we
// cannot proceed without a usable result, so treat ``null`` as
// blocking too — only ``[]`` is a clean pass.
const hasReturnedErrors =
errors === null ||
(Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors));
if (!isEmpty(validationErrors) || hasReturnedErrors) {
addDangerToast( addDangerToast(
t('Connection failed, please check your connection settings.'), t('Connection failed, please check your connection settings.'),
); );
@@ -1845,7 +1880,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
name: target.name, name: target.name,
value: target.value, value: target.value,
}); });
handleClearValidationErrors();
}} }}
setSSHTunnelLoginMethod={(method: AuthType) => setSSHTunnelLoginMethod={(method: AuthType) =>
setDB({ setDB({
@@ -1853,6 +1887,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
payload: { login_method: method }, payload: { login_method: method },
}) })
} }
isValidating={isValidating}
validationErrors={validationErrors}
getValidation={getBlurValidation}
/> />
); );
@@ -1928,13 +1965,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}); });
}} }}
onParametersChange={handleParametersChange} onParametersChange={handleParametersChange}
onChange={({ target }: { target: HTMLInputElement }) => onChange={handleTextChange}
handleChangeWithValidation(ActionType.TextChange, { getValidation={getBlurValidation}
name: target.name,
value: target.value,
})
}
getValidation={() => getValidation(db)}
validationErrors={validationErrors} validationErrors={validationErrors}
getPlaceholder={getPlaceholder} getPlaceholder={getPlaceholder}
clearValidationErrors={handleClearValidationErrors} clearValidationErrors={handleClearValidationErrors}

View File

@@ -243,7 +243,7 @@ test('handles create dashboard button click', async () => {
const createButton = screen.getByRole('button', { name: /dashboard$/i }); const createButton = screen.getByRole('button', { name: /dashboard$/i });
await userEvent.click(createButton); await userEvent.click(createButton);
expect(assignMock).toHaveBeenCalledWith('/dashboard/new'); expect(assignMock).toHaveBeenCalledWith('/dashboard/new/');
locationSpy.mockRestore(); locationSpy.mockRestore();
}); });

View File

@@ -203,7 +203,7 @@ function DashboardTable({
name: t('Dashboard'), name: t('Dashboard'),
buttonStyle: 'secondary', buttonStyle: 'secondary',
onClick: () => { onClick: () => {
navigateTo('/dashboard/new', { assign: true }); navigateTo('/dashboard/new/', { assign: true });
}, },
}, },
{ {

View File

@@ -57,7 +57,7 @@ const LABELS = {
const REDIRECTS = { const REDIRECTS = {
create: { create: {
[WelcomeTable.Charts]: '/chart/add', [WelcomeTable.Charts]: '/chart/add',
[WelcomeTable.Dashboards]: '/dashboard/new', [WelcomeTable.Dashboards]: '/dashboard/new/',
// navigateTo() applies the application root internally; keep this // navigateTo() applies the application root internally; keep this
// relative so the prefix isn't added twice. // relative so the prefix isn't added twice.
[WelcomeTable.SavedQueries]: '/sqllab?new=true', [WelcomeTable.SavedQueries]: '/sqllab?new=true',

View File

@@ -89,7 +89,7 @@ const dropdownItems = [
}, },
{ {
label: 'Dashboard', label: 'Dashboard',
url: '/dashboard/new', url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard', icon: 'fa-fw fa-dashboard',
perm: 'can_write', perm: 'can_write',
view: 'Dashboard', view: 'Dashboard',

View File

@@ -24,7 +24,7 @@ import {
userEvent, userEvent,
waitFor, waitFor,
} from 'spec/helpers/testing-library'; } from 'spec/helpers/testing-library';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core';
import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import RightMenu from './RightMenu'; import RightMenu from './RightMenu';
import { GlobalMenuDataOptions, RightMenuProps } from './types'; import { GlobalMenuDataOptions, RightMenuProps } from './types';
@@ -105,7 +105,7 @@ const dropdownItems = [
}, },
{ {
label: 'Dashboard', label: 'Dashboard',
url: '/dashboard/new', url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard', icon: 'fa-fw fa-dashboard',
perm: 'can_write', perm: 'can_write',
view: 'Dashboard', view: 'Dashboard',
@@ -401,17 +401,35 @@ test('Logs out and clears local storage item redux', async () => {
expect(localStorage.getItem('redux')).not.toBeNull(); expect(localStorage.getItem('redux')).not.toBeNull();
expect(sessionStorage.getItem('login_attempted')).not.toBeNull(); expect(sessionStorage.getItem('login_attempted')).not.toBeNull();
await userEvent.hover(await screen.findByText(/Settings/i)); // Mock the Cache API so we can assert the namespaced store is purged.
const cacheGlobal = global as unknown as { caches?: CacheStorage };
const priorCaches = cacheGlobal.caches;
const deleteMock = jest.fn().mockResolvedValue(true);
cacheGlobal.caches = { delete: deleteMock } as unknown as CacheStorage;
// Simulate user clicking the logout button try {
const logoutButton = await screen.findByText('Logout'); await userEvent.hover(await screen.findByText(/Settings/i));
await userEvent.click(logoutButton);
// Wait for local and session storage to be cleared // Simulate user clicking the logout button
await waitFor(() => { const logoutButton = await screen.findByText('Logout');
expect(localStorage.getItem('redux')).toBeNull(); await userEvent.click(logoutButton);
expect(sessionStorage.getItem('login_attempted')).toBeNull();
}); // Wait for local and session storage to be cleared
await waitFor(() => {
expect(localStorage.getItem('redux')).toBeNull();
expect(sessionStorage.getItem('login_attempted')).toBeNull();
});
// The namespaced Cache API store is purged on logout.
expect(deleteMock).toHaveBeenCalledWith(CACHE_KEY);
} finally {
// Restore the global so an early assertion failure cannot leak the mock
// into other tests.
if (priorCaches === undefined) {
delete cacheGlobal.caches;
} else {
cacheGlobal.caches = priorCaches;
}
}
}); });
test('shows logout button when not embedded', async () => { test('shows logout button when not embedded', async () => {

View File

@@ -28,6 +28,7 @@ import {
getExtensionsRegistry, getExtensionsRegistry,
isFeatureEnabled, isFeatureEnabled,
FeatureFlag, FeatureFlag,
CACHE_KEY,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { import {
styled, styled,
@@ -232,7 +233,7 @@ const RightMenu = ({
}, },
{ {
label: t('Dashboard'), label: t('Dashboard'),
url: '/dashboard/new', url: '/dashboard/new/',
icon: ( icon: (
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} /> <Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
), ),
@@ -353,6 +354,14 @@ const RightMenu = ({
try { try {
window.localStorage.removeItem('redux'); window.localStorage.removeItem('redux');
window.sessionStorage.removeItem('login_attempted'); window.sessionStorage.removeItem('login_attempted');
// Purge the namespaced Cache API store so cached GET responses are not
// retained on the device after the session ends. Best-effort: the
// returned promise is not awaited since logout navigates away.
if (typeof caches !== 'undefined') {
caches.delete(CACHE_KEY).catch(() => {
/* best-effort: ignore cache deletion failures */
});
}
} catch (error) { } catch (error) {
console.warn('Failed to clear storage on logout:', error); console.warn('Failed to clear storage on logout:', error);
} }

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import {
RoleNameField,
PermissionsField,
UsersField,
GroupsField,
} from './RoleFormItems';
jest.mock('./utils', () => ({
fetchPermissionOptions: jest.fn(),
fetchGroupOptions: jest.fn(),
}));
jest.mock('../groups/utils', () => ({
fetchUserOptions: jest.fn(),
}));
const addDangerToast = jest.fn();
test('RoleNameField renders label and input', () => {
render(<RoleNameField />);
expect(screen.getByText('Role Name')).toBeInTheDocument();
expect(screen.getByTestId('role-name-input')).toBeInTheDocument();
});
test('PermissionsField renders label and select', () => {
render(<PermissionsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('PermissionsField renders loading state', () => {
render(<PermissionsField addDangerToast={addDangerToast} loading />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('UsersField renders label and select', () => {
render(<UsersField addDangerToast={addDangerToast} loading={false} />);
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByTestId('roles-select')).toBeInTheDocument();
});
test('GroupsField renders label and select', () => {
render(<GroupsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Groups')).toBeInTheDocument();
expect(screen.getByTestId('groups-select')).toBeInTheDocument();
});

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useCallback } from 'react';
import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components'; import { FormItem, Input, AsyncSelect } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation'; import { t } from '@apache-superset/core/translation';
import { fetchUserOptions } from '../groups/utils'; import { fetchUserOptions } from '../groups/utils';
@@ -44,51 +45,69 @@ export const RoleNameField = () => (
export const PermissionsField = ({ export const PermissionsField = ({
addDangerToast, addDangerToast,
loading = false, loading = false,
}: AsyncOptionsFieldProps) => ( }: AsyncOptionsFieldProps) => {
<FormItem name="rolePermissions" label={t('Permissions')}> const options = useCallback(
<AsyncSelect (filterValue: string, page: number, pageSize: number) =>
mode="multiple" fetchPermissionOptions(filterValue, page, pageSize, addDangerToast),
name="rolePermissions" [addDangerToast],
placeholder={t('Select permissions')} );
options={(filterValue, page, pageSize) =>
fetchPermissionOptions(filterValue, page, pageSize, addDangerToast)
}
loading={loading}
getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
data-test="permissions-select"
/>
</FormItem>
);
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => ( return (
<FormItem name="roleUsers" label={t('Users')}> <FormItem name="rolePermissions" label={t('Permissions')}>
<AsyncSelect <AsyncSelect
name="roleUsers" mode="multiple"
mode="multiple" name="rolePermissions"
placeholder={t('Select users')} placeholder={t('Select permissions')}
options={(filterValue, page, pageSize) => options={options}
fetchUserOptions(filterValue, page, pageSize, addDangerToast) loading={loading}
} getPopupContainer={trigger => trigger.closest('.ant-modal-content')}
loading={loading} data-test="permissions-select"
data-test="roles-select" />
/> </FormItem>
</FormItem> );
); };
export const UsersField = ({ addDangerToast, loading }: UsersFieldProps) => {
const options = useCallback(
(filterValue: string, page: number, pageSize: number) =>
fetchUserOptions(filterValue, page, pageSize, addDangerToast),
[addDangerToast],
);
return (
<FormItem name="roleUsers" label={t('Users')}>
<AsyncSelect
name="roleUsers"
mode="multiple"
placeholder={t('Select users')}
options={options}
loading={loading}
data-test="roles-select"
/>
</FormItem>
);
};
export const GroupsField = ({ export const GroupsField = ({
addDangerToast, addDangerToast,
loading = false, loading = false,
}: AsyncOptionsFieldProps) => ( }: AsyncOptionsFieldProps) => {
<FormItem name="roleGroups" label={t('Groups')}> const options = useCallback(
<AsyncSelect (filterValue: string, page: number, pageSize: number) =>
mode="multiple" fetchGroupOptions(filterValue, page, pageSize, addDangerToast),
name="roleGroups" [addDangerToast],
placeholder={t('Select groups')} );
options={(filterValue, page, pageSize) =>
fetchGroupOptions(filterValue, page, pageSize, addDangerToast) return (
} <FormItem name="roleGroups" label={t('Groups')}>
loading={loading} <AsyncSelect
data-test="groups-select" mode="multiple"
/> name="roleGroups"
</FormItem> placeholder={t('Select groups')}
); options={options}
loading={loading}
data-test="groups-select"
/>
</FormItem>
);
};

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