Compare commits

..
Author SHA1 Message Date
Mike BridgeandClaude Opus 4.7 1c8d414494 debug(activity-view): drop @has_access on debug shell route
The previous @has_access decorator on ActivityDebugView.show was
redirecting authenticated-but-not-authorized requests to the home
page (FAB's access-denied default), which is what the user was seeing
instead of the React app. Then @login_required was tried briefly but
crashed because flask-login's redirect endpoint isn't wired up under
this app's auth backend.

Simplest fix: no decorator on the shell route. The shell page exposes
no data of its own — the React component fires API calls to
/api/v1/{resource}/{uuid}/activity/ which gate access via
raise_for_ownership on the path entity. Anonymous users would see
the React UI with 401 errors inline, which is correct UX for a debug
tool.

Verified: GET /activity-debug/dashboard/<uuid>/ → 200 + SPA shell HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 466cd5ffae debug(activity-view): register Flask route to serve React shell
The React route at /activity-debug/{resource}/{uuid} was returning
a JSON 404 on fresh page-load because Superset's SPA model doesn't
use a Flask catch-all — every React-router path needs a corresponding
Flask view that calls render_app_template(). Adding the missing
piece.

* superset/views/activity_debug.py — new ActivityDebugView (uses
  BaseSupersetView). Single @expose("/<resource>/<uuid>/") returning
  the React shell; the in-app router (superset-frontend/src/views/
  routes.tsx) handles the actual page rendering. @has_access keeps
  it gated by login.
* superset/initialization/__init__.py — appbuilder.add_view_no_menu
  registration alongside the other SPA shell views.

Throwaway by design; both edits tagged with "Throwaway: sc-107283"
comments for easy removal when the activity-view feature ships.

Verified: GET /activity-debug/dashboard/<uuid>/ returns 302 to
/login when unauthenticated (correct), 200 with the SPA shell when
logged in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 69e782a601 debug(activity-view): throwaway React debug UI for activity timelines
Adds /activity-debug/{dashboard,chart,dataset}/{uuid} for visual
verification of the sc-107283 activity-view endpoints. Lets you eyeball
the response shape without building a curl/jq habit:

* Controls for include / page / page_size / since / until
* Per-record cards showing entity_kind chip, source badge, kind tag,
  tombstone affordance, issued_at, changed_by, summary, entity_uuid,
  version_uuid, path, from/to values, impact
* Deleted entities render with strikethrough + "deleted" tag
* Soft-deleted state surfaces as an orange tag (currently always null
  until sc-103157 lands deleted_at)

**Throwaway by design**: when the activity-view feature ships, delete
the lazy import + route entry from superset-frontend/src/views/routes.tsx
and remove the superset-frontend/src/pages/ActivityDebug/ directory.
Both edits are tagged with "Throwaway: sc-107283" comments so they're
easy to find.

The component uses @superset-ui/core/components (Card, Tag, Radio, Input,
Space, Typography, Loading, Empty) per CLAUDE.md conventions. TypeScript
clean, ruff/eslint/oxlint clean, no any types. Uses a native HTML
<select> for page_size — the @superset-ui Select wrapper's stricter prop
types weren't worth fighting for a debug tool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 ec851849fb refactor(activity-view): JSON entity_kind uses user-facing labels
Breaking change to the (still-unmerged) activity-view JSON contract:
``entity_kind`` now emits ``"dashboard"`` / ``"chart"`` / ``"dataset"``
instead of the Python class names ``"Dashboard"`` / ``"Slice"`` /
``"SqlaTable"``. The class names are developer-facing artifacts that
leaked the model layer (e.g. ``"Slice"`` predates the UI rename to
"chart"; ``"SqlaTable"`` predates "dataset"). User-facing JSON should
speak user language.

Implementation: a new ``_USER_FACING_KIND`` map translates at JSON
serialization time only (in ``_decorate_records``). Internal code keeps
the Python class-name form (``model_cls.__name__``) for dispatch — the
existing ``_NAME_COLUMN``, ``_NOT_FOUND_EXC``, ``_API_KIND_LABEL``,
``_can_read``, ``_compute_impact``, etc. all key off class names and
are unchanged. The translation happens at the single ``record["entity_kind"]
= ...`` assignment.

Schema validator ``ACTIVITY_ENTITY_KINDS`` updated to the new tuple.
Integration tests' response-shape assertions renamed via bulk sed; unit
tests testing internal helpers are unchanged (they operate on internal
api_kind / class names).

UPDATING.md example payload updated. Spec updates (spec.md, data-model.md,
contracts/activity-view.yaml) committed separately to the spec repo.

Full suite: 66 unit + 35 integration + 1 xfailed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 36203af96d feat(activity-view): T037/T038 observability + T050 cross-coupling check
T037 — Instrument the five phases of get_activity with stats_logger
timing metrics under the `superset.activity_view.<kind>.<phase>_ms`
key convention (plan §D-17). Wrap each phase with a stats_timing
context manager:

* superset.activity_view.<kind>.relationship_resolution_ms (_resolve_scope)
* superset.activity_view.<kind>.fetch_ms (_fetch_change_records)
* superset.activity_view.<kind>.visibility_filter_ms
* superset.activity_view.<kind>.denormalize_ms
* superset.activity_view.<kind>.decorate_ms

`<kind>` is the lowercased path-entity model class name (dashboard /
slice / sqlatable), enabling per-endpoint-family Grafana panels.

T038 — Add request- and response-shape attributes via the existing
counter/gauge interface:

* superset.activity_view.<kind>.requests.include_<value>  (incr)
* superset.activity_view.<kind>.requests.has_since_filter_<true|false>  (incr)
* superset.activity_view.<kind>.page_size  (gauge)
* superset.activity_view.<kind>.record_count  (gauge — post-visibility-filter)
* superset.activity_view.<kind>.related_entity_count.charts  (gauge)
* superset.activity_view.<kind>.related_entity_count.datasets  (gauge)

Confirmed no PII: entity names, diff content, user identifiers — none
flow into the metric layer. Only counts and shape tags.

T050 — Cross-coupling sanity test (unit-scope): asserts
_METRIC_PREFIX == "superset.activity_view" so a code review catches
accidental drift from sc-103156's eventual "superset.versioning.*"
sibling namespace. Both endpoint families belong to one Grafana panel
under "superset.versioning.* OR superset.activity_view.*".

Full suite: 66 unit + 35 integration + 1 xfailed (T044 sc-103156
restore-kind dependency, unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 3a1667522a test(activity-view): four more polish tasks — T039/T042/T044/T051
* T042 test_activity_marks_hard_deleted_chart_with_tombstone (D-15)
  Edit a chart, hard-delete it via db.session.delete(chart) + commit,
  hit dashboard activity. Asserts tombstoned record has
  entity_deleted=True, entity_uuid=None, entity_name preserved from
  the last shadow row. The fixture's _cleanup tolerates missing slices
  (uses Slice.id.in_(slice_ids) which silently skips).

* T044 test_activity_surfaces_dashboard_restore_event (AV-015) — xfail
  AV-015 requires sc-103156's restore code to emit a synthetic
  kind="restore" change record with to_value carrying the source
  version_uuid + label. sc-103156's restore_version() currently does
  not — the diff capture surfaces the field changes as kind="field".
  The activity layer correctly passes through whatever kind sc-103156
  emits; the test will start passing once the upstream emission lands.
  Marked strict=True so the day sc-103156 emits, this fails-to-fail
  and we know to revisit.

* T051 test_activity_excludes_records_after_retention_prune (AV-010)
  Edit a chart (creates a new version_transaction), backdate that
  transaction's issued_at past the 30-day retention cutoff, run
  _prune_old_versions_impl(retention_days=30). Assert the prune
  removed ≥1 transaction AND the activity endpoint's filtered count
  decreased.

* T039 test_activity_pagination_is_deterministic_and_disjoint
  (SC-AV-002 pragmatic interpretation). The spec's stricter "no
  skip/duplicate under concurrent writes" is unprovable with offset
  pagination — new top-inserted records shift every later page by
  one. Cursor pagination would solve this (deferred per plan §D-10).
  Under offset, the testable guarantees are: (a) same request fired
  twice produces identical pages; (b) page N and page N+1 are
  disjoint under one request round. Both come from the
  (issued_at DESC, transaction_id DESC, sequence DESC) sort.

Full activity-view suite: 35 passed, 1 xfailed in 46s. The xfail is
T044 with the documented sc-103156 dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 fa73b00da6 test(activity-view): T040/T041 polish — ordering + page-size cap
Three of the Phase 6 polish tasks landed together:

* T040 test_activity_ordering_is_stable_by_issued_at_then_transaction_id
  — AV-006 deterministic-ordering contract. Iterates adjacent pairs in
  the response and asserts (issued_at, transaction_id) is monotonically
  non-increasing. Catches any sort-stability regression — random
  ordering would fail the pairwise check almost certainly.
* T041 test_activity_page_size_caps_returned_records_at_200 — pairs
  with the existing pagination_clamps test. The former confirms
  ?page_size=500 doesn't 400; this one confirms the response is
  bounded to ≤200 records as the OpenAPI contract guarantees.
* T053 verification: every fixture-mutating test in
  activity_view_tests.py was already following the
  try/finally + rollback convention (sweep verified 30 tests; zero
  non-conformant). No remediation needed; documenting the
  sweep result in the commit message.

Full activity-view suite: 32/32 integration + 65/65 unit tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 3a9f863194 refactor(activity-view): SQL review tidies — mappings + perf-seed docstring
Two structural changes from the second SQLAlchemy review. No behaviour
change; full activity-view suite (70 unit + 30 integration) still
green.

* Tidy 1 (Warning #2): switch _batch_chart_counts and
  _batch_datasets_used_by_charts from positional row-unpack to
  .mappings()-keyed access. Column-rename-safe — a future edit that
  reorders columns in the SELECT can't silently misassign values
  during the Python loop. Matches the existing convention in
  _select_change_rows_for_kinds.

* Tidy 2 (Suggestion #1): docstring on _seed_activity_history
  explaining why it intentionally commits without rollback (unlike
  the T053 convention in activity_view_tests.py). The seed IS the
  setup, not the unit under test — the endpoint reads a realistic
  post-commit state of the shadow tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 440e298c4f docs(activity-view): UPDATING.md note + T049 OpenAPI verification
Two doc-shaped tasks landed together because they're both about making
the activity-view endpoints discoverable by external consumers.

T047 — UPDATING.md: new section under the existing entity-version-history
entry documenting the three activity-view endpoints (dashboard / chart /
dataset), their query params (since / until / include / page / page_size),
the response shape with all DTO fields, the silent permission filter
(AV-008), tombstone behaviour (D-15), and the no-feature-flag / no-
new-tables impact statement. Mirrors the depth of the sc-103156
versioning section above it.

T048 satisfied by the UPDATING.md entry: the activity-view feature
adds no new config keys (no SUPERSET_* env vars, no feature flag), and
the per-endpoint API reference is auto-generated from the YAML
docstrings via FAB's OpenAPI integration. The `/swagger/v1` page picks
up the activity endpoints automatically — verified by the new tests
below. sc-103156 followed the same pattern (UPDATING.md only, no
standalone config doc).

T049 — Three new tests in TestActivityOpenApiSpec verify FAB's OpenAPI
generation includes the activity endpoints with the right shape:

* test_three_activity_paths_appear_in_openapi — the three
  /<uuid_str>/activity/ paths are surfaced in /api/v1/_openapi.
* test_activity_endpoints_document_query_params — since / until /
  include / page / page_size are all declared, and include's enum is
  exactly {"self", "related", "all"}.
* test_activity_endpoints_declare_200_response — 200 + 400/401/403/404
  are all declared response codes.

base_api_tests.py::TestOpenApiSpec::test_open_api_spec already
validates the full spec's structural correctness on every CI run, so
malformed YAML in the activity-view docstrings would have been caught
upstream. The new tests add activity-specific assertions about the
endpoints' presence and parameter shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 a2d5bd05d0 perf(activity-view): batch _datasets_used_by_chart for dashboard scope
Surfaced by T045's perf test (which failed at p95 4228ms vs 1500ms
budget) and an ad-hoc per-phase profile that pinpointed _resolve_scope
as accounting for ~1926ms of the wall clock.

The previous _resolve_dashboard_scope called _datasets_used_by_chart
once per slice on the dashboard. On a fixture with ~12 charts that's
12 sequential SQL round-trips for the same shape of query, each
returning a small result set. The new _batch_datasets_used_by_charts
takes the full set of slice_ids and returns
{slice_id: [(dataset_id, window), ...]} in one query; the singular
form _datasets_used_by_chart now forwards to the batch (used only by
_resolve_chart_scope which has a single slice).

Impact (measured on the same fixture):
* Wall-clock per request: 3955ms → 2257ms (43% faster)
* Total query count per request: 18,376 → 29 (driven by the
  identity-map populating during the warmup; the genuine count for
  the activity layer alone is ~13 SQL queries)
* T045 p95 over 50 runs: 4228ms → 2429ms

The remaining gap to the 1500ms budget is dominated by Python work
inside _fetch_change_records: post-filter and sort over ~26K
accumulated test-environment records. The spec's target scale is
"25 charts × 3 dataset windows" — the SQLite test environment has
accumulated far more state from prior test runs. T046 (index
migration) is deferred because the remaining bottleneck is Python-
side, not SQL-side; production data on Postgres with retention
pruning will not have this shape. Document and revisit when a
production-representative perf measurement is available.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 52a3ab0d71 test(activity-view): add T045 perf-validation tests
Two opt-in tests (SUPERSET_PERF_VALIDATION=1 to run, skipped otherwise)
under the existing PerfValidationTests class:

* test_av_sc001_activity_endpoint_p95_under_1500ms — seeds dense
  history on the birth_names dashboard (~12 chart edits, 5 dataset
  edits, 1 dashboard edit), then hits /api/v1/dashboard/<uuid>/activity/
  50 times after warmup and asserts p95 < 1500ms per SC-AV-001.

* test_av_sc003_save_path_p95_unaffected_by_activity_view — under the
  same fixture, runs 50 dashboard saves and re-measures the SC-004
  version-capture overhead (50ms p95 budget). Confirms the activity-
  view branch hasn't added a new save-path coupling that regresses
  sc-103156's save-path budget. PASSES on the current branch — read-
  only feature, no new save-path code paths.

Seed fixture differs from the spec's "25 charts × 3 dataset windows
each" target — the birth_names fixture has ~12 charts on a single
dataset, so we approximate by editing many charts plus the dataset
multiple times. A bespoke multi-dataset fixture would let us measure
against the spec's exact scale; that's future work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 4ead3f405f perf(activity-view): batch impact-count query to remove N+1
Surfaced by the SQLAlchemy review (Warning #1) and required before
T045's p95 perf budget can be met on dashboards with many historical
dataset edits.

Before: _decorate_records fired one COUNT(DISTINCT slice_id) query
per related dataset record via _compute_impact → _count_dashboard_
charts_pointing_at_dataset_at_tx. For a dashboard activity stream
with N dataset-edit records, that's N round-trips to compute impacts.

After: _decorate_records collects the distinct (dataset_id, target_tx)
pairs once via _collect_impact_pairs, fires a single batched query
via _batch_chart_counts that pulls the (slice, dataset, validity-
window) state for every relevant slice, and filters by validity in
Python per pair. The result is a {(dataset_id, target_tx): count}
mapping consumed by the new pure helper _impact_for_record per row.

Round-trip count drops from O(N records) to O(1) for the impact
calculation. The SQL stays small and dialect-portable — same per-kind
IN-clause + Python validity filter pattern as _fetch_change_records.

Trade-off documented in _batch_chart_counts' docstring: the SELECT
pulls (slice_id, datasource_id, two validity-window pairs) for every
slice ever on the dashboard whose dataset matches one of the
requested dataset_ids. For a busy dashboard with 100 slices and 50
versions each, that's ~5000 rows into Python vs N small COUNT scans.
For N > ~5 (which is typical) the batch wins.

Removed:
* _compute_impact (replaced by _impact_for_record + _collect_impact_pairs)
* _count_dashboard_charts_pointing_at_dataset_at_tx (replaced by
  _batch_chart_counts)

Test changes: the three _compute_impact unit tests (no-impact paths)
become six _impact_for_record tests (positive count + four no-impact
paths + zero-count → None). Five new _collect_impact_pairs tests
cover dashboard/chart/dataset path branching plus dedupe and empty.

Full suite: 27/27 integration + 65/65 unit (was 57; +8 from the
restructure). No semantic regression on either side of the cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 b96cb01670 docs(activity-view): document per-kind query rationale in _select_change_rows_for_kinds
No-op docstring change. The function fires one SELECT per entity_kind
with entity_id IN (...) instead of one query with tuple_(entity_kind,
entity_id).in_(...). The latter is supported by Postgres but its SQL
emission across MySQL and SQLite is uneven (some dialects emit literal
ROW expressions, some emit a transformed multi-column form, some don't
support it at all in older versions). Three round-trips per request is
the correct trade-off given Superset's multi-dialect requirement.

Documenting now so a future reader doesn't reach for the tuple-IN
"optimisation" and silently break MySQL or SQLite. Surfaced by the
SQLAlchemy review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 9d8a318f34 feat(activity-view): Phase 5 US3 — dataset /activity/ endpoint (T033-T036)
Implements the dataset activity stream. Per AV-004 datasets have no
transitive layer in V2: dataset activity = dataset's own edits only,
regardless of whether charts use the dataset. The chart/dashboard
endpoints surface dataset edits in their *related* streams (already
shipped); the dataset endpoint stays narrowly self-only.

* T033 DatasetRestApi.activity — third application of the shared
  resolve_endpoint_path_entity + parse_activity_query_params pattern.
  Endpoint body is ~10 lines of real logic, same shape as T016 and
  T028 but with SqlaTable as the path entity. The activity orchestrator
  already short-circuits the related-entity resolution for datasets
  (_resolve_scope returns [] for related when path_kind="SqlaTable"),
  so the dataset endpoint inherits the V2 semantics for free.

* T034 TestDatasetActivityView class added to activity_view_tests.py.

* T035 test_dataset_activity_excludes_chart_edits — AS-1 of US3 /
  AV-004 verbatim. Edit a chart that uses the dataset; assert the
  chart edit does NOT appear in the dataset's activity stream
  (datasets are read-only upstream of charts in V2).

* T036 test_dataset_activity_includes_dataset_self_edits — confirms
  the positive path: editing the dataset's own description surfaces
  a SqlaTable/self record.

* New test_dataset_activity_related_only_returns_empty — AV-004 in
  contract form: ?include=related on a dataset returns an empty
  result list and count=0 because there are no related entities to
  draw from.

Plus the standard boundary tests (404 for unknown UUID, 400 for
malformed UUID / invalid include, 403 for non-owner, 200 envelope
shape).

Full activity-view suite: 27/27 integration tests + 57/57 unit tests
green. All three endpoint families live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 6b2d75ca0b feat(activity-view): Phase 4 US2 — chart /activity/ endpoint (T028-T032)
Implements the chart cross-entity activity stream (US2). Builds on the
resolve_endpoint_path_entity helper from the prior tidy commit
(1d60513079), so the new endpoint is a small composition of: resolve
path entity → parse params → get_activity(Slice, ...) → serialize.

* T028 ChartRestApi.activity — @expose("/<uuid_str>/activity/") on the
  chart blueprint. Decorator stack mirrors list_versions; registered
  in include_route_methods. The MODEL_API_RW_METHOD_PERMISSION_MAP
  entry "activity": "write" added in Phase 3 already covers this
  endpoint family (the map is class-permission-agnostic).

* T029 TestChartActivityView class added to activity_view_tests.py,
  following the same patterns as TestDashboardActivityView.

* T030 test_chart_activity_includes_dataset_edit_as_related — Given
  a chart pointing at the birth_names dataset, When the dataset's
  description is edited, Then the chart activity stream surfaces a
  SqlaTable/related record (AS-1 of US2).

* T032 test_chart_activity_excludes_sibling_dashboards — Given the
  chart is on a dashboard, When the dashboard's title is edited,
  Then NO Dashboard records appear in the chart's activity. Per the
  spec's Relationship Traversal section: charts don't see sideways
  to dashboards they happen to be on.

* T031 (datasource-switch attribution) deferred — needs a second
  dataset fixture which the birth_names environment doesn't provide
  cleanly. Will land with a multi-dataset test harness in a follow-up.

Additional coverage: 404 for unknown UUID, 400 for malformed UUID,
400 for invalid include, 403 for non-owner, 200 envelope smoke,
chart-self-edit appears as source=self, include=self filter test.

Full suite: 19/19 integration tests + 57/57 unit tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 a412f1d9e6 refactor(activity-view): extract resolve_endpoint_path_entity helper
Make the change easy, then make the easy change. Phase 4 (T028 chart
/activity/ endpoint, T033 dataset endpoint) duplicates the UUID-parse
+ find_active_by_uuid + raise_for_ownership dance from the existing
DashboardRestApi.activity. Extracting now keeps the next two endpoints
single-line affairs.

* New PathEntityResponseError carries a pre-built error Response.
  Subclassing Exception (not ValueError) so the endpoint's catch is
  precise and the standard hierarchy stays unpolluted.
* New resolve_endpoint_path_entity(api, model_cls, uuid_str) runs the
  three-step preflight and raises PathEntityResponseError with the
  right 400/404/403 response carried inside.
* DashboardRestApi.activity refactored to use the helper. 12 lines of
  imports + try/except boilerplate collapse to a single try/except
  block that maps every preflight failure to its response.

No behaviour change: 10/10 dashboard activity integration tests still
green. Pure structural change, per Beck's tidy-first discipline (the
T028 endpoint code follows in the next commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:25 -06:00
Mike BridgeandClaude Opus 4.7 f840ce9813 refactor(activity-view): Phase 3 tidies from clean-code review
Five structural changes — no behaviour change — applied as one commit
per the tidy-first discipline. All from the clean-code review of
e4070a4716.

* Tidy 1 — Fix _fetch_change_records sort key. Was calling .timestamp()
  on issued_at with an `else 0` fallback; the fallback was dead defense
  (column is non-null per sc-103156 schema) and .timestamp() introduces
  non-determinism on tz-naive datetimes. Sort on the datetimes directly.

* Tidy 2 — parse_activity_query_params now raises ActivityParamsError
  (subclass of ValueError) instead of returning (Optional[dict],
  Optional[str]). The tuple was forcing every caller into a defensive
  `if error or params is None: return self.response_400(message=error
  or "Invalid query parameters")`. The new shape — `try: params =
  parse(...); except ActivityParamsError as exc: return
  response_400(str(exc))` — is shorter, type-safe, and the contract
  is enforced at the boundary.

* Tidy 3 — Test helper now uses Flask client's query_string= parameter
  instead of f-string concatenation. Handles URL-encoding correctly
  for the day a test passes a value containing & / = / + / etc.

* Tidy 4 — get_activity pipeline collapses to a single rolling
  `records` variable instead of the mid-stream `raw / visible_raw /
  enriched / visible` naming. Each function call's name documents
  what the step does; no intermediate variable names needed.

* Tidy 5 — Extracted four per-parameter parsers: _parse_optional_iso,
  _parse_include, _parse_page, _parse_page_size. The "parse one
  parameter" concept now has a name. Cost: four small helpers (each
  10-15 lines, one job). Benefit: parse_activity_query_params is a
  10-line table-driven dispatcher.

Test changes: parser unit tests now use pytest.raises(ActivityParamsError)
instead of unpacking the (params, error) tuple. Added one test
confirming ActivityParamsError subclasses ValueError so the standard
library exception hierarchy still catches it. Total unit tests: 57.
Integration tests still 10/10 green.

Deferred per the review: the UUID-parse + entity-find + ownership-
check dance is duplicated between activity / list_versions / get_version
and will grow to T028 / T033. Refactor when T028 lands — three real
callers > one prospective one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 4360ad7101 feat(activity-view): Phase 3 US1 — dashboard /activity/ endpoint
Implements the dashboard cross-entity activity stream (US1 MVP).
Closes T015-T018, T023-T024, T026 from sc-107283 tasks.md. T019-T022
(complex fixture choreography) and T025 (RBAC fixture for restricted
chart access) deferred to a follow-up; T025's logical coverage is
provided by the new unit tests for _can_read kind dispatch.

* T015 Rename _dashboard_related_scope → _resolve_dashboard_scope and
  _chart_related_scope → _resolve_chart_scope, parallel to _resolve_scope
  / _resolve_path_entity. Aligns the codebase with the task spec names.
* T016 New activity(uuid_str) method on DashboardRestApi:
  @expose("/<uuid_str>/activity/"), @protect + @safe + @statsd_metrics
  + @event_logger. Same row-level ownership check pattern as
  /versions/. Registered in include_route_methods and mapped to "write"
  in MODEL_API_RW_METHOD_PERMISSION_MAP so the can_write Dashboard
  permission gates access (Alpha-non-owner gets 403 from
  raise_for_ownership, not 404 from missing permission).
* New parse_activity_query_params helper in activity.py — shared parser
  for since/until/include/page/page_size used by all three endpoint
  families. Tolerates the Z suffix Python <3.11 fromisoformat rejects.
  Silently clamps page_size to the contract max instead of rejecting.

Two correctness/scale bugs found and fixed under integration-test load:

* SQLite SQLITE_MAX_EXPR_DEPTH (1000) was tripping on dashboards with
  many slices × many historical attachment windows. _fetch_change_records
  used to emit one OR-clause per (entity_kind, entity_id, window) tuple;
  now it issues one SELECT per kind with entity_id IN (...) and filters
  by exact windows in Python via _row_within_any_window. SQL shape is
  proportional to the number of kinds (≤3); the per-entity window
  precision is preserved.

* _merge_entity_windows now unions overlapping/touching windows within
  each entity via _union_windows. Sequential fixture loads create many
  redundant Continuum shadow rows; without merging, the unfiltered
  windows still produce many OR branches downstream.

* Pipeline-ordering fix in get_activity: visibility filter runs BEFORE
  decoration. Decoration strips entity_id (not in API contract) and the
  filter needs it — and dropping invisible records early also avoids
  paying for name lookup + tombstone probes on records the requester
  can't see (AV-008's silent-filter contract).

Tests:

* tests/integration_tests/versioning/activity_view_tests.py — 10 tests
  for TestDashboardActivityView covering: 404 for unknown UUID, 400 for
  malformed UUID / invalid include / invalid since, 403 for non-owner,
  200 envelope smoke, chart-edit-appears-as-related, include=self
  filter, include=related filter, page_size clamping.
* tests/unit_tests/versioning/test_activity.py — grew from 30 to 56
  tests. New coverage: parse_activity_query_params (7 cases), _can_read
  per-kind dispatch (4 cases — covers T025 at unit scope), _union_windows
  (9 parametrized cases), _merge_entity_windows window-union case,
  _row_within_any_window (6 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 9f9dee0bec refactor(activity-view): Phase 2 tidies + unit tests for pure helpers
Five structural changes — no behaviour change — applied as one commit
per the tidy-first discipline (separate from the prior feat commits
that landed the primitives and orchestrator):

* Add tests/unit_tests/versioning/test_activity.py — 30 tests for the
  pure-function helpers: _intersect_windows (11 boundary cases),
  _resolve_scope branching (per kind + per include mode), entity-
  window merging, AV-012 summary headlines, changed_by projection,
  kind-translation round-trip, _can_read fall-through, _compute_impact
  no-impact paths. Runs in <500ms, no DB, no Flask. The DB-touching
  helpers wait for the integration suite (Phase 3+).

* Hoist _datasets_used_by_chart(slice_id) out of the inner attachment-
  window loop in _dashboard_related_scope. Previously a chart with N
  attachment windows fired N queries for identical data; now once per
  slice. Removes a perf cliff at the historical-rich case the spec
  explicitly calls out (US1 AS-3).

* Delete the unused requesting_user parameter from
  _filter_records_by_visibility and from get_activity. It was never
  threaded through to the security manager calls (which read the user
  from Flask-Login implicitly) — speculative future-shape that the
  reader had to mentally trace through. If CLI/Celery bypass becomes
  necessary, add it then with a real call site.

* Delete the unused module-level logger. No observability lands here
  until T037/T038; reintroduce when those tasks add real logger calls.

* Fix the file docstring — it claimed _resolve_version_tables was
  reused; it isn't. Trimmed to (find_active_by_uuid,
  derive_version_uuid).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 fa54708276 feat(activity-view): Phase 2 orchestrator + get_activity (T011-T013)
Completes Phase 2 of sc-107283. The activity-view query layer now has
one public entry point — get_activity(model_cls, entity_uuid, ...) —
plus the supporting visibility filter and DTO decorator.

* T011 _filter_records_by_visibility — silent permission filter per
  AV-008. Batch-resolves live entities per kind, dispatches
  can_access_dashboard / can_access_chart / can_access_datasource.
  Tombstoned entities pass through (the decorator handles deleted
  messaging; no navigable entity_uuid is exposed). _can_read helper
  isolates the per-kind predicate dispatch.

* T012 _decorate_records — synthesizes the full ActivityRecord shape:
  entity_kind (API form), entity_uuid (live lookup; null for
  tombstones), entity_deleted, entity_deletion_state, source ("self"
  vs "related"), summary (AV-012 headline), impact (via T010),
  version_uuid (derive_version_uuid), changed_by (User projection).
  Strips internal-only columns (entity_id, sequence, raw user cols)
  from the final shape.

* T013 get_activity — top-level orchestrator. Branches by include
  ("self"/"related"/"all") via _resolve_scope; for related,
  _dashboard_related_scope walks charts-on-dashboard then datasets-
  used-by-chart and clips chart-on-dataset windows by attachment
  windows. _chart_related_scope skips the dashboard hop. Datasets
  return empty related scope per AV-004. _merge_entity_windows
  collapses repeated (kind, id) entries to keep the OR-clause in
  _fetch_change_records compact. Pagination is post-visibility-filter
  per AV-008; defaults page_size=25, clamps to 200.

Tasks T004-T014 in tasks.md updated to [X]. The next phase opens user
story implementation (T015 dashboard scope helper + T016 endpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 5750cd4fef feat(activity-view): Phase 2 primitives (T004-T010, T014)
Adds the foundational query layer for the cross-entity activity view
in superset/versioning/activity.py. Pure helpers + single-purpose
shadow-table queries; no public entry point yet (T011-T013 follow).

* T004 _resolve_path_entity — UUID lookup via find_active_by_uuid;
  typed 404 dispatch by model class (DashboardNotFoundError /
  ChartNotFoundError / DatasetNotFoundError).
* T005 _charts_attached_to_dashboard — (slice_id, window) tuples from
  dashboard_slices_version, op=2 rows excluded.
* T006 _datasets_used_by_chart — (datasource_id, window) tuples from
  slices_version, filtered to datasource_type='table'.
* T007 _intersect_windows — pure half-open window intersection;
  end=None acts as positive infinity. Unit-verified.
* T008 _fetch_change_records — workhorse query joining version_changes
  + version_transaction + ab_user, OR-clause over (kind, id, [windows]),
  ordered by (issued_at DESC, transaction_id DESC, sequence DESC) per
  AV-006. No DB-level pagination here — AV-008's silent permission
  filter means count is computed after the visibility filter, so
  pagination happens in T013.
* T009 _denormalize_entity_names — batch shadow lookup keyed by
  (api_kind, entity_id, transaction_id); validity-strategy predicate.
  Helper _resolve_names_for_kind extracted to keep cyclomatic
  complexity under ruff's C901 threshold.
* T010 _compute_impact — dashboard+dataset path yields
  {"charts": N}; all other path/related shapes yield None per
  data-model.md §"impact computation".
* T014 _check_entity_tombstones — live-row existence + soft-delete
  state probe; tolerates the pre-sc-103157 absence of a deleted_at
  column via hasattr-style introspection on Table.c.

Kind translation isolated at the module boundary: version_changes
stores lowercase ("chart"/"dashboard"/"dataset"); the ActivityRecord
DTO returns class names ("Slice"/"Dashboard"/"SqlaTable"). The
_TABLE_KIND_TO_API / _API_KIND_TO_TABLE mappings translate at the
two crossings.

Phase 2 orchestration layer (T011-T013) lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 47b804a7dd refactor(activity-view): enforce schema contracts (review polish)
Apply clean-code review polish on top of the Phase 1 scaffold before
Phase 2 builds on this surface:

* Replace magic-string field descriptions with ``validate.OneOf`` on
  ``entity_kind``, ``source``, ``entity_deletion_state``, and ``kind``.
  Canonical lists hoisted to module-level tuples (``ACTIVITY_*``).
* Type ``path`` as ``fields.List(fields.String())`` and ``impact`` as
  a new ``ActivityImpactSchema`` — replaces two ``fields.Raw`` ``Any``s
  whose shape was previously documented only in prose.
* Resolve the ``ActivityChangedBySchema`` docstring contradiction —
  the schema deliberately omits ``username`` so it is no longer
  honest to call it "identical shape to VersionChangedBySchema".
* Fix the ``activity.py`` module docstring: one polymorphic
  ``get_activity`` function, not three entry points.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike BridgeandClaude Opus 4.7 8f1ec17150 feat(activity-view): scaffold Phase 1 setup (T001/T002/T003)
* T001 — new ``superset/versioning/activity.py`` module with ASF
  header and docstring describing the cross-entity activity-view query
  layer (companion to ``queries.py``). Empty otherwise; Phase 2 fills
  it with the shared query helpers.
* T002 — empty test file
  ``tests/integration_tests/versioning/activity_view_tests.py`` with
  ASF header, module docstring naming the three test classes that will
  follow, and a ``SupersetTestCase`` import. Phase 3+ fills it with
  test classes.
* T003 — three Marshmallow schemas added to
  ``superset/versioning/schemas.py``:
  * ``ActivityChangedBySchema`` — User subset on each record.
  * ``ActivityRecordSchema`` — one change record (per data-model.md).
  * ``ActivityResponseSchema`` — envelope ``{result, count}``.
  Fields mirror data-model.md §"``ActivityRecord`` DTO" line-for-line;
  metadata descriptions are FAB/Swagger-ready.

No new endpoints, no listeners, no DB writes — pure scaffolding. The
three schemas are unused until Phase 3 (T016) wires the first endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:24 -06:00
Mike Bridge d333510e83 refactor(versioning): consolidate three iterative migrations into one
Squashes the three migrations that captured the versioning schema as
the feature was developed:

* ``56cd24c07170 add_entity_version_history_tables`` — version_transaction
  + dashboards_version / slices_version / tables_version parent shadows.
* ``e1f3c5a7b9d0 add_version_changes_table`` — the field-level diff log.
* ``f7a2b3c4d5e6 spike_add_child_continuum_shadow_tables`` —
  table_columns_version / sql_metrics_version / dashboard_slices_version
  child shadows.

Combined into a single migration ``2026-05-28_19-50_56cd24c07170_
add_versioning_tables.py`` that creates the same 8 tables in dependency
order (sequence + version_transaction first, parent shadows, version_
changes, child shadows last) and drops them in reverse on downgrade.

Why:
* All three migrations represent one logical feature ("enable
  versioning") — applying any subset leaves a broken intermediate
  state.
* The third migration was literally named "spike_..."; the iterative
  shape was a development artifact, not a design intent.
* Downstream operators get one migration to apply / reverse and one
  file to review.
* The dev DB was rolled back through the three downgrades, the new
  migration was applied via ``superset db upgrade``, and column-count
  spot checks confirmed byte-identical schema (4/9/21/26/35/19/15/5).

The ``revision`` hash is reused from the original first migration
(``56cd24c07170``) for chain continuity. ``down_revision`` stays
``2bee73611e32`` (sc-105349 composite-PK tip), unchanged from the
original chain's head.

Net: -70 lines (605 deleted, 535 added), same schema, one Alembic step
instead of three.
2026-05-28 15:37:20 -06:00
Mike Bridge b16ac7c21e refactor(versioning): leaf-level diff for JSON-blob fields (Shape B)
The diff engine now recurses into nested dicts inside JSON-blob fields
(``json_metadata``, ``Slice.params`` non-list sub-keys, layout node
``meta``) via a shared ``_recursive_leaf_diff`` helper, emitting one
``ChangeRecord`` per atomic leaf change rather than one record carrying
the whole top-level sub-tree on both sides.

Concretely, a dashboard header text change from "VERSION 2!" to
"HEADER!" used to emit a single record at path ``["edit", "header",
"HEADER-id-1"]`` with the entire layout node duplicated on both sides
of the diff. It now emits a single record at path ``["edit", "header",
"HEADER-id-1", "text"]`` with just the changed string as ``from_value``
/ ``to_value``. Multi-leaf edits produce one record per leaf.

Each call site passes its own depth cap via a named constant:

* ``_LAYOUT_META_DIFF_DEPTH = 3`` — layout meta is presentation state
  (text, sizes, colors), shallow by nature.
* ``_JSON_METADATA_DIFF_DEPTH = 6`` — native filter configuration can
  go five levels deep (``defaultDataMask.filterState.value``).
* ``_SLICE_PARAMS_DIFF_DEPTH = 6`` — adhoc filter sub-queries and pivot
  options can be similarly deep.

A cap is a usefulness bound (granularity that's meaningful in a
timeline), not a safety bound. Cap-on-dict-vs-dict emits a debug log so
production tuning can see when a cap is too tight for typical data.

Lists are treated as opaque leaves: positional paths break under
reorder. Lists with stable identity (adhoc filters, metrics, dataset
columns) already have natural-key walkers (``_diff_list_by_natural_key``)
that emit per-element records with the right identity; those are
unchanged.

Backward compatibility:
* Top-level scalar fields, child-collection records (column/metric),
  M2M slice membership, and layout add/remove/move records all keep
  their existing path/from/to shape. The change is scoped to JSON-blob
  recursion.
* All 56 existing diff unit tests pass without modification. 13 new
  tests cover the Shape B behaviour (helper directly, type mismatches,
  opaque list policy, depth cap, nested ``json_metadata``, layout
  edit, ``Slice.params`` deep unknown key, cross-node aggregation).
* Existing integration tests in ``change_records_tests.py`` assert on
  scalar-field changes only — unaffected.

Restoration is unaffected: the restore path reads from Continuum
shadow tables, not from ``version_changes``. This change is purely
about audit-log granularity.

Spec coverage: tasks.md T047d (added in spec repo). Rationale: plan.md
"Key Design Decision: Leaf-level recursive diff for JSON-blob fields
(Shape B)". Conventions: data-model.md "Leaf-level recursion".
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 863c73ec53 test(versioning): lock in flag_modified-suppresses-onupdate invariant (W2)
The ``_pin_audit_columns`` helper added in 1b0083bc03 relies on a
SA-version-dependent behavior: calling ``attributes.flag_modified(parent,
"changed_by_fk")`` causes the next UPDATE statement to use the
in-memory value rather than invoking the column's ``onupdate=callable``.
This is the mechanism that prevents a stale ``g.user.id`` from being
written into the parent's ``changed_by_fk`` when the synthetic
flag-flush triggers an UPDATE during an autoflush at a time when the
test user has already been deleted from ``ab_user`` — the cascade that
broke CI in sc-103156 T062.

Four unit tests, no app context required (uses a minimal in-memory
SQLite engine + declarative_base):

* ``test_flag_modified_suppresses_onupdate_callable``: the positive
  invariant — the in-memory value lands in the DB and ``onupdate`` is
  NOT called. If SA ever changes this semantic, the test fails.
* ``test_onupdate_does_fire_without_flag_modified``: negative sanity
  check — confirms the test setup is realistic. Without
  ``flag_modified``, ``onupdate`` fires as expected.
* ``test_pin_audit_columns_skips_missing_attribute``: the helper
  tolerates parents without audit columns (e.g., non-AuditMixin
  models). No raise.
* ``test_pin_audit_columns_tolerates_invalid_request_error``: SA
  raises ``InvalidRequestError`` from ``flag_modified`` when the
  attribute is unloaded in instance state (the freshly-constructed
  ``session.new`` case). The helper must catch and skip — this is the
  production-path safety net mentioned in the docstring.

Uses ``expire_on_commit=False`` for the positive invariant test so the
column stays loaded; the production-path case where the attribute is
expired is the ``InvalidRequestError`` branch covered separately.

Closes review item W2 from the sqlalchemy-review pass on
sc-103156-versioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 9c9cb3a5f0 test(models): lock in UUIDMixin._coerce_uuid contract (S3)
Four unit tests pin the behavior the validator added in 46f138326b:

* Valid UUID-format strings get coerced to ``uuid.UUID`` instances —
  the primary contract that makes importers, test fixtures, and ad-hoc
  ``Model(uuid=...)`` construction see a consistent type.
* Already-``UUID`` values pass through unchanged (the validator must
  not re-wrap; ``is`` identity check pins this).
* Non-UUID-shaped strings pass through unchanged. This keeps test
  mocks that use placeholder strings (e.g.
  ``tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py::test_dashboard_schema``
  with ``"dashboard-uuid-7"``) working. Without this contract, those
  tests would break under the validator and need to migrate to
  ``uuid.uuid4()``. If the contract is ever tightened to raise, this
  test catches the regression and signals the migration cost.
* ``None`` passes through unchanged.

Closes review item S3 from the sqlalchemy-review pass on
sc-103156-versioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 1d78dd1ad0 refactor(versioning): thread pre-resolved entity through list/get_version (W3)
``ChartRestApi.list_versions`` (and the dashboard / dataset siblings)
resolves the entity via ``VersionDAO.find_active_by_uuid`` to run the
``raise_for_ownership`` check from T056, then calls
``VersionDAO.list_versions(model_cls, entity_uuid)`` — which internally
runs the SAME ``find_active_by_uuid`` query a second time. The lookup
filters on ``uuid`` (a unique non-PK column), so it isn't served from
SQLAlchemy's identity map — every call is a real SELECT.

Add an optional ``entity=`` keyword to ``list_versions``,
``get_version``, and ``resolve_version_uuid`` so callers that have
already resolved the path entity can skip the redundant lookup. The
public signature stays backward-compatible (kwarg-only).

Threading ``entity`` through ``get_version`` also avoids a third
redundant lookup inside the ``resolve_version_uuid`` call it makes.

Net: 6 fewer SELECT queries on the ``/versions/`` and
``/versions/<uuid>/`` hot paths (1 per call × 6 endpoints). All
``find_active_by_uuid`` callsites in the API layer were already
present from T056; this commit just plumbs them through.

Closes review item W3 from the sqlalchemy-review pass on
sc-103156-versioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 fb78c8670d test(versioning): defensive setUp/tearDown for TestDatasetRestoreApi
Reset session state (``rollback`` + ``expire_all``) before and after each
test in this class. Defensive hygiene against the Postgres-only multi-
test cascade documented in spec task T062 — a previous test in the
full-suite ordering can leave Continuum's shadow-table session attributes
in a state where the restore command's ``@transaction`` boundary raises
mid-flush and the API returns 422 "Dataset could not be updated."

Note: this *doesn't* fix the cascade itself (the polluter is writing to
the DB, not just the session; reproducer remains 3 failures on Postgres
full-suite ordering). The root cause is queued as T062. This change just
guarantees that whatever state this class' tests inherit, they start
from a clean session view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 4a2ab1704d test(versioning): convert version-history test cleanup to try/finally
The version-history integration tests follow the codebase's dominant
pattern: a trailing ``# Cleanup`` block at the end of each test that
restores the fixture entity's mutated scalar fields (``description``,
``slice_name``, ``dashboard_title``). The pattern is brittle — when any
intermediate assertion fails, the cleanup is never reached and the
fixture stays polluted with the test's last edit, cascading state into
later tests in the same suite run.

This bit hard in CI's full-suite Postgres ordering: a failure in
``test_restore_applies_scalar_field`` left ``description="restore-test
v2"`` on the birth_names dataset, polluting downstream tests including
the RLS virtual-dataset cascade. The same shape is latent in the chart
and dashboard files.

Convert every mutating test in the three version-history files to wrap
the mutation block in ``try:`` and the restoration in ``finally:``,
with a ``db.session.rollback()`` at the top of the finally to clear any
half-flushed state left by a 422 response. Re-query the entity by id
inside the finally so the restore writes against a live object rather
than a stale handle the failing path may have expired or detached.

Pure hygiene improvement — doesn't fix the underlying Postgres failure
chain (still triggered by ``model_tests.py``'s interaction with later
tests in the full-suite ordering), but prevents this set of tests from
amplifying any future cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 3314c2cd4b fix(versioning): align uuid-as-string assertions with UUIDMixin coercion
The ``@validates("uuid")`` coercion added in 46f138326b now ensures
``UUIDMixin.uuid`` always returns a ``uuid.UUID`` instance after
assignment, regardless of input type. This is the right semantic
(matches what ``UUIDType`` would emit on a fresh DB read) but it
broke three pre-existing tests that compared the attribute against
string literals — those assertions only passed before because the
post-INSERT refresh path either didn't run or ran inconsistently for
the model under test.

Update those three assertions to compare ``UUID`` to ``UUID`` so the
test contract matches the now-consistent attribute shape:

* ``test_import_database`` and ``test_import_database_no_creds`` —
  ``database.uuid`` now matches the coerced ``UUID`` literal.
* ``test_load_parquet_table_sets_uuid_on_new_table`` — ``tbl.uuid``
  same treatment.

Also harden the validator to pass non-UUID-shaped strings through
unchanged (instead of raising), so test mocks that use placeholder
strings like ``"dashboard-uuid-7"`` keep working — the SQL bind layer
will surface a clearer error if such a value is ever written to the
DB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 279d6a4484 fix(versioning): coerce string uuid values in UUIDMixin.uuid setter
``sqlalchemy_utils.UUIDType`` only coerces in ``process_bind_param`` /
``process_result_value`` — string-to-UUID conversion happens on SQL
write / read, not on Python attribute assignment. Importers and ad-hoc
construction (e.g., ``SqlMetric(uuid="00000000-...")`` in the
``test_import_dataset`` unit test) leave the in-memory attribute as a
``str`` until the next DB round-trip refreshes it.

For non-versioned mappers a post-INSERT attribute expiration usually
refreshes the value before the caller reads it. With SQLAlchemy-
Continuum versioning on a child mapper (``TableColumn``, ``SqlMetric``),
the expire-on-INSERT behaviour shifts enough that the assertion
``metric.uuid == uuid.UUID(...)`` fails with a string-vs-UUID inequality.
Bisection confirmed: removing ``__versioned__`` from either child
restores the implicit coercion path; adding it reproduces the failure.

Rather than chase the Continuum/SA flush interaction, add a defensive
``@validates("uuid")`` to ``UUIDMixin`` so the attribute is always a
``UUID`` at runtime regardless of where the value came from. This
aligns the in-memory shape with what ``UUIDType`` would emit on a
fresh DB read, and the coercion is the same one the SQL layer would
apply on the next bind anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 2685b15d5c fix(versioning): pin audit columns when force-flagging parent dirty
``_force_parent_dirty_on_child_change`` calls ``flag_modified`` on a
versioned parent so Continuum records a parent shadow row whenever a
versioned child changes. The resulting UPDATE on ``tables`` /
``dashboards`` / ``slices`` inherits ``AuditMixin``'s
``onupdate=get_user_id`` on ``changed_by_fk`` and ``onupdate=datetime.now``
on ``changed_on`` — SQLAlchemy calls ``get_user_id()`` at flush time
and stamps whoever ``g.user`` resolves to.

When the flush is autoflush-triggered during a *previous* test's
teardown (or the fixture cleanup between two tests), ``g.user`` can
still point at a user that's already been deleted from ``ab_user``.
The parent UPDATE then fails the FK to ``ab_user``, surfacing as
``IntegrityError: FOREIGN KEY constraint failed`` /
``ForeignKeyViolation``. In CI's full-suite ordering this fires from
two distinct places — ``test_rls_filter_alters_no_role_user_birth_names_query``
teardown and ``TestDatasetRestoreApi::test_restore_applies_scalar_field``
mid-restore (the latter returning 422 "Dataset could not be updated.").

Fix: also ``flag_modified`` ``changed_by_fk`` and ``changed_on`` on
the parent. The columns now have dirty attribute history, so
SQLAlchemy uses the in-memory (previously-committed, valid) values
instead of invoking ``onupdate``. The synthetic parent UPDATE carries
the existing audit values; the FK violation goes away. The behaviour
on a real user-driven save is unchanged — those code paths go through
the normal write path with a live ``g.user`` and the audit columns
update as before.

Extracted ``_pin_audit_columns`` helper to keep the caller under
ruff's ``C901`` complexity ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:20 -06:00
Mike BridgeandClaude Opus 4.7 eba4363eeb chore(versioning): ruff-format fix on dataset restore-test target filter
CI's ruff-format would collapse the two-clause if expression onto a
single line — local ruff at v0.9.7 reformats the same way. Pure
formatting, no behavior change. Counterpart of ``6de8811873``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 3138526816 test(versioning): skip DELETE rows when picking restore target in dataset test
Same fix as ``7caf9862be`` for the dashboard restore test. CI's
integration DB accumulates Continuum shadow rows across fixture teardown
cycles, and ``TestDatasetRestoreApi::test_restore_applies_scalar_field``
picks the first row whose ``description`` matches the original. In CI
that match can land on an ``operation_type=2`` (DELETE) row from a prior
test run; Continuum's ``revert()`` then restores the dataset to its
deleted state and the restore API returns 422 "Dataset could not be
updated."

Filter ``operation_type != 2`` so the test only ever targets INSERT or
UPDATE shadow rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 b7c6181e38 feat(versioning): row-level ownership on list_versions and get_version (T056)
The SIP's *Authorization layering* commitment promises that all nine
version endpoints enforce both model-level access (``@protect()``) AND
row-level access (``security_manager.raise_for_ownership(entity)``).
Restore endpoints already do this via ``BaseRestoreVersionCommand.validate``,
but ``list_versions`` and ``get_version`` (six endpoints across chart,
dashboard, and dataset) skipped the row-level check — a non-owning user
with ``can_write`` on the model could list or read the shadow rows of
any entity they could reach by UUID.

Each affected handler now looks up the entity via
``VersionDAO.find_active_by_uuid`` (which already runs inside the DAO),
returns 404 if it's gone, then calls ``raise_for_ownership`` and maps a
``SupersetSecurityException`` to 403. Admins bypass automatically via
the existing FAB mechanism. The redundant lookup hits the session
identity map on the second call, so the cost is one PK read.

Tests:

- Add ``test_list_versions_denies_non_owner`` (chart, dashboard, dataset)
  and ``test_restore_denies_non_owner`` (chart) — Alpha has ``can_write``
  on each model but isn't an owner of the admin-owned fixture, so the
  row-level branch is the only thing that can reject. The dataset row-
  level test is alongside the existing model-level Gamma test, which
  only exercises the ``@protect()`` denial path.
- Unskip the three placeholder tests on chart and dashboard that were
  blocked waiting for this gap to close (``@pytest.mark.skip(reason="...
  no built-in no-write user to exercise the 403 branch...")``).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 442c87f30f temp(versioning): update DashboardList snapshot for added `uuid` column
Pairs with ``25f1922b2d temp(versioning): demo version-history dropdowns``,
which adds ``uuid`` to the dashboard-list page's ``select_columns`` so the
per-row VersionHistoryDropdown can call ``/api/v1/dashboard/<uuid>/versions/``.
``DashboardList.test.tsx`` asserts the list-fetch URL via
``toMatchInlineSnapshot``, so the snapshot needs the new column. Revert
this entry along with the dropdown component (same lifecycle as the temp
parent commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 f730e968b9 test(versioning): skip DELETE rows when picking restore target in dashboard test
The integration SQLite DB accumulates Continuum shadow rows across
fixture teardown cycles. ``TestDashboardRestoreApi::test_restore_applies_scalar_field``
picks the first version row whose snapshot matches the original title,
and that match can land on an ``operation_type=2`` (DELETE) row left
over from a prior test run. Continuum's ``revert()`` then dutifully
restores the entity to its deleted state — the dashboard row is gone
after the restore returns 200, and the post-restore re-query fails
with ``NoResultFound``.

Filter ``operation_type != 2`` so the test only ever targets
INSERT or UPDATE shadow rows, which is also what a real user would
pick from the version-history dropdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike Bridge b449889e69 fix(versioning): narrow phantom-dirty filter to session.dirty only
Previous fix (9c2391d1b4) gated the force-parent-dirty hook on
is_modified(child) for ALL session collections (dirty/new/deleted).
That was over-restrictive: is_modified checks attribute history,
and deletion is a state transition with no attribute history —
so deleted children evaluated as not-modified and the parent
wasn't flagged. The change-records listener then didn't see the
deletion and no removal record was emitted.

Symptom: test_restore_emits_full_child_diff_in_one_transaction
failed expecting a column-removed change record after a restore
that removed the column; instead only the parent's scalar fields
appeared in observed paths.

Refine: apply the is_modified filter ONLY to persistent rows in
session.dirty. session.new (creation) and session.deleted
(removal) are always real content changes by virtue of their
session-collection membership — no is_modified check needed (and
in deletion's case, the check returns the wrong answer).
2026-05-28 15:37:19 -06:00
Mike Bridge ed4c1792ad chore(versioning): import-sort autofix on daos/version.py (I001)
Pre-commit (previous) flagged I001 unsorted-imports on the
backward-compat façade. Two queries imports merged into one
block (the aliased ``derive_version_uuid as _derive_version_uuid``
moves inline rather than living in its own block), and the
restore-side names sorted: ``_RESTORE_RELATIONS``,
``_stamp_audit_fields_for_restore``, ``restore_version``.

Pure mechanical reformatting; no behaviour change.
2026-05-28 15:37:19 -06:00
Mike Bridge 2c080ab6a5 fix(versioning): force-parent-dirty only when child has real column changes
_force_parent_dirty_on_child_change was firing whenever ANY
TableColumn or SqlMetric of the parent appeared in
session.dirty / new / deleted — even when the child was there for
non-content reasons:

- Lazy-load side effects when a relationship is touched
- M2M relationship-cascade artifacts (e.g. RLS setUp doing
  rls_entry.tables.extend([dataset]) triggers cascade behavior
  that pulls children into the session)
- AuditMixin auto-bumps from earlier code paths
- Reverter side passes during restore

Force-touching the parent in those cases produced an incidental
UPDATE tables SET description=…, changed_on=…, changed_by_fk=…
whose changed_by_fk value or autoflush ordering tripped FK
integrity on some dialects. Symptoms:

- test_rls_filter_alters_no_role_user_birth_names_query → FK
  IntegrityError on autoflush during a query
- test_restore_applies_scalar_field → 422 "Dataset could not be
  updated" during restore

Fix: gate on Continuum's is_modified(child), which returns True
only when a non-excluded versioned column on the child has
SQLAlchemy attribute-history changes. New objects (session.new)
and genuinely-modified rows still flag the parent; phantom-dirty
rows do not.

The intended hook semantics — "child edit forces a parent shadow
row" — are preserved: a column-description edit through the
dataset API still triggers is_modified True, still flags the
parent. See test_dataset_column_edit_creates_parent_version.
2026-05-28 15:37:19 -06:00
Mike Bridge 9ff18b3e48 fix(versioning): flag `description instead of uuid` in force-parent-dirty
flag_modified(parent, "uuid") was producing FK integrity failures via
the column's BLOB/BINARY round-trip: SQLAlchemy logs the param as
``<memory at 0x…>`` and the UUID round-trip doesn't always match the
in-memory value byte-for-byte. Symptom: in scenarios where the parent
is already going to flush (Reverter applying historical state during
restore, RLS test triggering autoflush during a query), our added
``uuid`` UPDATE column tripped the FK check.

Pick ``description`` instead — plain Text column on all three
versioned parent classes (Dashboard, Slice, SqlaTable), no
TypeDecorator, no marshaling layer. Flagging it round-trips its
current value safely. Fallback chain ``description → uuid → col_keys[0]``
keeps the original deterministic-pick property for forks/subclasses
that excluded ``description``.

Should unblock test_restore_applies_scalar_field and the
test_rls_filter_alters_no_role_user_birth_names_query autoflush
error.
2026-05-28 15:37:19 -06:00
Mike Bridge 2731b099f1 chore(versioning): pre-commit autofixes (ruff 0.9.7) — round 2
- factory.py: TID251 banned ``import json``; switch to
  ``from superset.utils import json`` (project convention).
- factory.py: ruff format reflow on _matches_previous_version.
- version_restore.py: ruff format collapse on restore_version call.

CI was pinning a different ruff version than my local uvx default;
re-ran against ruff==0.9.7 (the version in requirements/development.txt)
which surfaced these.
2026-05-28 15:37:19 -06:00
Mike Bridge 771dd4bcd9 fix(versioning): replace session-state guard with InvalidRequestError catch
The previous attempt (d0520f6766) was too aggressive: skipping when
parent is in session.dirty/new/deleted bypassed the
persistent-and-clean case the hook EXISTS for. Some upstream code
paths put the dataset in session.dirty *before* this listener fires
(API controllers touching audit fields, etc.), so the
session-membership pre-check made us silently no-op on the very
scenario the hook needs to handle. CI symptom:
test_dataset_column_edit_creates_parent_version showed before=317,
after=317 (parent shadow not written).

Restore the unconditional flag_modified and catch the specific
InvalidRequestError that fires only for the session.new case
(uuid default callable hasn't populated state yet). Other states
fall through to the original behavior:
- persistent + clean → flag_modified succeeds, parent goes dirty,
  Continuum picks it up, SkipUnmodifiedPlugin keeps the row via
  _has_dirty_versioned_children. ✓
- persistent + dirty → flag_modified is harmless (already dirty).
- session.new → InvalidRequestError, skip (parent INSERTs anyway).
- session.deleted → flag_modified may or may not raise; if it does,
  we skip; if not, the delete dominates.

Should unblock test_dataset_column_edit_creates_parent_version,
test_get_version_returns_historical_snapshot_with_children, and
test_restore_with_column_edits_reverts_columns.
2026-05-28 15:37:19 -06:00
Mike Bridge 4af88e65c0 chore(versioning): apply pre-commit autofixes (ruff + auto-walrus)
- ruff: import sort + E501 reflow on the parent-state guard in
  baseline.py
- ruff format: function-signature collapse and join-chain reflow in
  queries.py
- auto-walrus: two ``entity_kind = …; if … is not None:`` patterns
  in queries.py converted to assignment-expressions
2026-05-28 15:37:19 -06:00
Mike Bridge 560567b64c fix(versioning): retention task FK violation on cross-entity transactions
When one ORM flush touches multiple versioned entities (dashboard +
slice + dataset all save at tx=X), each gets a shadow row sharing
that tx. If only the dashboard is later edited at tx=Y, the
dashboard row at tx=X is closed (end_tx=Y) while slice/dataset rows
stay live at tx=X. Retention then preserves tx=X (slice/dataset are
live there) and prunes tx=Y. The dashboard's closed row at tx=X
survives step 1, then its end_transaction_id=Y trips the FK when
step 2 deletes version_transaction row Y.

Fix: extend the shadow-row delete to also match end_transaction_id
IN tx_ids. Live rows have end_tx=NULL so they're never matched by
either predicate. Closed rows that touch a pruned tx at either
endpoint are pruned together — consistent with retention semantics
(any tx in the row's lifespan is gone, so the row's chain is broken
anyway).

Unblocks test_retention_prunes_old_rows on sqlite, mysql, postgres.
2026-05-28 15:37:19 -06:00
Mike Bridge cbb4e8c788 fix(versioning): skip non-clean parents in force-parent-dirty hook
The force-parent-dirty listener was calling attributes.flag_modified
on every parent reachable from a dirty child — including parents
themselves in session.new (e.g. brand-new SqlaTable + brand-new
TableColumns from POST /api/v1/dataset/). flag_modified rejects
unloaded attributes, and a session.new SqlaTable's uuid (default=uuid4
fires at flush time) is unloaded until then. CI caught this with
InvalidRequestError cascading into 422s across dataset creation /
upload / Playwright dataset specs.

The hook is only needed for the persistent-and-clean case (child
edited, parent's own scalars untouched, dropdown otherwise empty).
Anything in session.new will flush anyway; anything in session.dirty
is already flagged; session.deleted shouldn't be touched. Short-
circuit before the flag_modified call.

Unblocks test-sqlite, test-mysql, test-postgres (previous), and
playwright dataset specs.
2026-05-28 15:37:19 -06:00
Mike Bridge 43abeb4447 refactor(versioning): apply cross-PR review feedback (#39977 H1/M3/M5)
Three small follow-ups surfaced by aminghadersohi's review of the
SoftDeleteMixin PR (#39977) that apply equally here:

- H1: cache _child_to_parent_registry() with functools.cache. Called
  twice per save flush; mapping depends only on import-time model
  classes, so unbounded cache is the right shape (no invalidation).
- M5: tighten _CHILD_BASELINE_HANDLERS type from dict[str, Any] to
  dict[str, Callable[[Session, Any, int], None]] via a named alias.
  Mypy now catches a future broken handler signature.
- M3/M4: explain the inline-import pattern once in the module
  docstrings of baseline.py and changes.py. Both modules use
  pylint disable=import-outside-toplevel uniformly because they
  load during init_versioning() before mappers are configured;
  the per-callsite "why" comments would just repeat the same
  reason. Module-level explanation + a hint to comment unusual
  cases is the cleaner shape.

M6 (listener placement) doesn't apply — init_versioning() already
runs inside init_app_in_ctx(). M8 (loose OpenAPI schema in
*/api.py docstrings) is real but its own change.
2026-05-28 15:37:19 -06:00
Mike Bridge 6e1b7896e7 docs(versioning): record why SkipUnmodifiedPlugin doesn't clean up orphan version_transaction rows inline
Extends the existing docstring note ("the orphan is swept by retention")
with the reasoning behind not cleaning it up in the same flush. The
inline-delete is appealing in principle but would couple this plugin
to the change-records listener's buffer state via the ON DELETE
CASCADE on ``version_changes.transaction_id``: both listeners would
have to agree that the flush produced nothing before the version_transaction
row could be dropped safely. The orphan's ~40-byte storage cost +
retention's correct-by-construction handling (orphans have no parent
shadow, so they're never in the "preserve" set) make the coordination
overhead not worth it.

Captures the design decision in the file where the next reader will
look for it.
2026-05-28 15:37:19 -06:00
Mike Bridge 18d134605d tidy(versioning): reading-order shuffle in baseline.py (newspaper-article order)
Pure file shuffle, zero behaviour change. Reorders ``baseline.py`` so it
reads top-down by level of abstraction (newspaper-article rule): the
public entry point at the top, supporting helpers descending below.

Before: 14 private helpers, then ``register_baseline_listener`` at the
bottom. A reader opening the file met the leaf builders first and had
to accumulate context before finding the call site.

After (top-down):

  - Entry point: ``register_baseline_listener`` + inner ``capture_baseline``
  - High-level helpers used by ``capture_baseline``:
      ``_force_parent_dirty_on_child_change``,
      ``_collect_parents_to_baseline``,
      ``_child_to_parent_registry``,
      ``_version_table_for``,
      ``_shadow_row_count``,
      ``_insert_baseline_and_children``
  - Mid-level builders:
      ``_insert_baseline_row``,
      ``_baseline_children_for_parent``
  - Per-entity child handlers + their dispatch table:
      ``_baseline_dataset_children``,
      ``_baseline_dashboard_children``,
      ``_CHILD_BASELINE_HANDLERS``
  - Leaf builders:
      ``_insert_child_baseline_rows``,
      ``_baseline_attached_slices``,
      ``_insert_synthetic_slice_baseline``

Three section-divider comments mark the abstraction levels. The
``_CHILD_BASELINE_HANDLERS`` dict literal stays after its referenced
handlers (module-level literals evaluate at import time and need names
already bound); a comment now flags this constraint.

Function bodies are byte-for-byte unchanged; ``git log -L`` on any
function shows only its relocation. 96 unit tests pass.
2026-05-28 15:37:19 -06:00
Mike Bridge 6fcd762cd3 tidy(versioning): extract read_row_outside_flush helper
baseline.py:_insert_baseline_row and changes.py:_read_pre_state both
issued the same "read a single row through ``session.connection()``
inside ``with session.no_autoflush:``" pattern. Same five-line block,
same intent ("read the pre-flush state without triggering the in-flight
edit's flush").

Promoted to ``superset.versioning.utils.read_row_outside_flush(session,
table, entity_id)``. Companion to ``single_flush_scope`` — they sit
next to each other in utils.py and frame the two directions of the
"don't autoflush mid-listener" pattern.

Returns ``dict[str, Any]`` (or ``None``) so callers can't accidentally
hold a cursor-bound ``RowMapping`` past the listener boundary. Both
call sites get shorter by ~5 lines.

Also picks up Decimal stringification in the changes.py docstring
update (was listed in the W4 commit but the docstring still said
"(datetime, UUID, bytes)" — now matches the implementation).

Behaviour unchanged. 96 unit tests pass.
2026-05-28 15:37:19 -06:00
Mike Bridge 6fa48f8bc2 tidy(versioning): extract shared helpers between list_versions and get_version
After the SRP split (8c9cf36) put both functions in the same module
~150 lines apart, their overlap became visible: same JOIN of
version_table → version_transaction → ab_user, same baseline-first
ordering, same user-row → ``changed_by`` projection, same lookup
``_ENTITY_KIND_BY_CLASS_NAME.get(model_cls.__name__)``. About 30 lines
of duplication.

Five small helpers extracted at the module top:

- ``_resolve_version_tables(model_cls)`` returns ``(ver_tbl, tx_tbl, user_tbl)``
- ``_version_with_tx_user_join(ver_tbl, tx_tbl, user_tbl)`` builds the join
- ``_baseline_first_ordering(ver_tbl)`` returns the order-by tuple
- ``_user_select_cols(user_tbl)`` returns the user-column list with
  ``user_id`` as the stable label (normalises the prior asymmetry
  where ``list_versions`` labelled it ``user_id`` and ``get_version``
  labelled it ``_user_id`` to dodge a column-name collision — the
  ``user_id`` label collides with neither)
- ``_changed_by_from_row(row)`` projects user columns onto the API shape
- ``_entity_kind_for(model_cls)`` resolves the change-records taxonomy lookup

Both call sites get shorter and read what they do (build query / project
user / build row) rather than how. Behavior unchanged; no test changes.

Also two small inline tidyings while in the file:

- Replace the ternary
  ``changes_by_tx = list_change_records_batch(...) if entity_kind else {}``
  with an explicit two-line if-statement in both functions. The ternary
  buries the decision; the if-statement reads as one thought.
- Inline the one-shot ``meta_cols`` set declaration in ``get_version``
  into the ``if col.name in {...}`` check that uses it three lines later.

Net: about 110 lines → about 80 lines across the two functions, plus
a small helper section at the top.
2026-05-28 15:37:19 -06:00
Mike Bridge 5d19a34b2d refactor(versioning): sqlalchemy-review follow-ups (W1–W8)
Cleanup pass from the SQLAlchemy + migration code review. Eight items,
all in the "warnings / suggestions" tier — no behaviour change visible
to the API, but each closes a real correctness, perf, or maintainability
concern surfaced in review.

baseline.py
- Delete unused ``_get_user_id`` (W1). The function wrapped a broad
  ``except Exception:  # noqa: S110`` swallow that hid bugs; grep
  confirmed no callers anywhere. The legitimate audit-field paths
  (``row.get("changed_by_fk")`` etc.) already drive the
  ``version_transaction.user_id`` write.
- Batch ``_baseline_attached_slices`` from O(N) round-trips to
  three queries (W2): one membership SELECT, one existing-shadow
  SELECT, one bulk live-row SELECT for the missing ids. The previous
  per-slice ``COUNT(*)`` + ``SELECT`` was a measurable first-save
  hotspot on dashboards with many charts. Drops the now-unused
  ``_slice_has_shadow`` helper.
- Pick a stable column name for ``flag_modified`` in
  ``_force_parent_dirty_on_child_change`` (W3). ``uuid`` is on all
  three versioned parent classes and excluded by none, so the
  flagged attribute is deterministic across SQLAlchemy versions /
  mapper-config orders instead of depending on
  ``versioned_column_properties(parent)[0]``. Falls back to the
  first available column for forks that exclude ``uuid``.

changes.py
- Add ``Decimal`` handling to ``_jsonable`` (W4) — ``json.dumps``
  rejects ``Decimal``, so any numeric column (e.g. ``SqlMetric.currency``
  contents, or fork/plugin Decimal columns) would crash the bulk
  insert. Stringify rather than ``float()`` to preserve precision;
  the diff engine compares ``from_value`` / ``to_value`` by string
  equality after this coercion so both sides round-trip identically.

queries.py
- Promote the inline ``{0: "baseline", 1: "update", 2: "delete"}``
  dict to module-level ``_OP_TYPE_LABELS`` (W7). The literal was
  duplicated across ``list_versions`` and ``get_version``; the third
  caller is one bug fix away.
- Comment on ``resolve_version_uuid``'s Python-side ``derive_version_uuid``
  loop (W8) — no portable SQL form for UUIDv5 across PostgreSQL /
  MySQL / SQLite, iteration count is bounded by the retention
  window. Flags the place to revisit if retention is ever disabled
  (``=0``) on a heavily-edited entity.

migrations/2026-05-01_23-36 (composite-PK)
- Belt-and-braces guard in ``_downgrade_mysql_table`` (W6): asserts
  ``t.name in AFFECTED_TABLES`` before interpolating into the
  backtick-quoted ALTER statements. The invariant was already
  structurally implied (callers iterate ``AFFECTED_TABLES``), but
  making it load-bearing means a future refactor can't slip an
  arbitrary table name through.

(W5 was verified-no-change: grepped ``tests/`` for ``metadata.create_all``
callers that exercise versioning tables; none. The cascade-FK
gap on ``version_changes.transaction_id`` is already documented
in ``tests/integration_tests/versioning/change_records_tests.py:27-32``.)

62 versioning unit tests pass.
2026-05-28 15:37:19 -06:00
Mike Bridge 00604b92d9 refactor(versioning): split VersionDAO into queries + restore modules
VersionDAO carried five distinct concerns under one class — UUID
derivation, version metadata queries, change-record loading,
single-version snapshot retrieval, and restore orchestration. Bob's
"and" test (the clean-code review flagged this as the next structural
fix after the dead-code purge) gives ~600 lines of "queries about
versioned state of one entity AND the workflow that mutates it."

Splits the read and write sides into purpose-built modules:

- ``superset/versioning/queries.py`` — UUID derivation
  (``VERSION_UUID_NAMESPACE``, ``derive_version_uuid``) + read-side
  helpers (``find_active_by_uuid``, ``current_version_number``,
  ``current_live_transaction_id``, ``current_live_version_uuid``,
  ``list_versions``, ``resolve_version_uuid``, ``get_version``,
  ``list_change_records_batch``). ~475 lines.

- ``superset/versioning/restore.py`` — write-side (``restore_version``,
  ``_stamp_audit_fields_for_restore``, ``_RESTORE_RELATIONS``).
  ~140 lines. Depends only on ``queries.find_active_by_uuid`` and
  ``utils.single_flush_scope``.

- ``superset/daos/version.py`` — collapsed to an ~85-line backward-compat
  façade that re-exports both modules under a single ``VersionDAO``
  class via ``staticmethod`` aliases. The module also re-exports
  ``VERSION_UUID_NAMESPACE`` and ``derive_version_uuid`` at module level
  so the ~10 existing callers (api.py handlers, command classes, the
  ETag emitter, integration tests) don't have to change their imports.
  New code is encouraged to import from the sub-modules directly.

The functions themselves are unchanged byte-for-byte aside from
internal call sites being rewritten from ``VersionDAO.foo`` to the bare
function name (since they now live as module-level functions, not
class methods).

One unit-test mock target moved: ``test_restore_version_returns_none_for_unknown_entity``
now patches ``superset.versioning.restore.find_active_by_uuid`` (the
actual call site) instead of ``VersionDAO.find_active_by_uuid`` (which
is now just an alias).

Each of the three modules now has one reason to change. When the
sc-103157 soft-delete pass adds the ``deleted_at IS NULL`` filter to
``find_active_by_uuid``, it touches only ``queries.py``. When a
per-entity-type restore Strategy replaces the string-keyed
``_RESTORE_RELATIONS`` dispatch, it touches only ``restore.py``.
2026-05-28 15:37:19 -06:00
Mike Bridge cb13498f6a temp(versioning): strip URL params from dashboard restore navigation; regen lockfile
DashboardList demo dropdown previously instructed the user to "Reload
the page to see the change" after a restore. The URL the user
returns to may still carry ``?native_filters_key=…`` /
``permalink_key`` / ``form_data_key`` from a prior session — those
point at server-cached snapshots (in ``key_value`` and the
filter-state cache) captured before the restore. On rehydration the
cached state is merged on top of the restored ``json_metadata``,
masking the rollback (e.g. dashboard-level colour-scheme restore
appears not to take effect).

Replaces the alert + manual reload with a direct ``window.location.href``
navigation to ``/superset/dashboard/<uuid>/`` — drops all URL params,
forcing hydration from the freshly restored DB state.

Also regenerates ``package-lock.json`` to pick up the ``zod 4.4.1 →
4.4.3`` bump that master's ``package.json`` already reflects.

(``temp(versioning)`` prefix per the demo dropdown's status — this
file is not part of V1 scope per ADR-005; the V2 UI SIP owns the
actual restore UI surface.)
2026-05-28 15:37:19 -06:00
Mike Bridge 15abdbc678 refactor(versioning): rename find_active_by_uuid public + collapse restore commands onto BaseRestoreVersionCommand
Two coupled clean-code review fixes:

(1) Rename ``VersionDAO._find_active_entity_by_uuid`` →
``find_active_by_uuid``. The leading-underscore + three
``# pylint: disable=protected-access`` suppressions in the restore
commands were the smell of a wrongly-private API. The method is a
perfectly reasonable public DAO operation; dropping the underscore
removes the suppressions.

(2) Collapse ``RestoreChartVersionCommand``, ``RestoreDashboardVersionCommand``,
``RestoreDatasetVersionCommand`` onto a shared
``BaseRestoreVersionCommand`` (``superset/commands/version_restore.py``).
The three classes were textbook copy-paste — identical except for
the model class and three exception types. Each subclass now declares
``model_cls`` + ``not_found_exc`` + ``forbidden_exc`` and overrides
``run()`` with one ``@transaction(reraise=<failed_exc>)``-decorated
line delegating to ``self._do_restore()``. ~80 lines per file →
~45 lines per file; one shared workflow instead of three drift sources.

The api.py imports of ``RestoreChartVersionCommand`` /
``RestoreDashboardVersionCommand`` / ``RestoreDatasetVersionCommand`` are
unchanged — public class names preserved.
2026-05-28 15:37:19 -06:00
Mike Bridge 769d1670e2 refactor(versioning): purge dataset_snapshots dead code + fix get_version bug
The full-Continuum spike (ADR-004 revised) replaced the JSON-snapshot
restore path with Continuum's native Reverter and removed the
``dataset_snapshots`` / ``dashboard_snapshots`` tables from the
migration chain. Seven VersionDAO methods and two module-level
helpers that read/wrote those tables stayed in the code anyway and
went unused — dead code that looked live.

Worse, ``VersionDAO.get_version`` still read from
``dataset_snapshots`` in its SqlaTable branch. On any environment
where the snapshot tables don't exist (current production behavior),
``GET /api/v1/dataset/<uuid>/versions/<version_uuid>/`` raised
``OperationalError``. The branch is rewritten to read column and
metric state from Continuum's child shadow tables
(``table_columns_version`` / ``sql_metrics_version``) via the
existing ``_shadow_rows_valid_at`` helper.

Deleted:
- ``_deserialize_snapshot_value`` (module helper)
- ``_coerce_snapshot_list`` (module helper)
- ``RESTORE_EXCLUDE_FIELDS`` (constant — only referenced by deleted code
  and a docstring)
- ``VersionDAO._restore_dataset_children``
- ``VersionDAO._parse_slice_ids_json``
- ``VersionDAO._apply_dashboard_slices``
- ``VersionDAO._restore_dashboard_children``
- ``VersionDAO._apply_snapshot_children``

The corresponding ~17 unit tests in
``tests/unit_tests/daos/test_version_dao.py`` are removed alongside.

Stale docstring references in ``versioning/changes.py`` and
``versioning/diff.py`` that pointed at the retired snapshot tables are
also cleaned up.

Also strips an 8-line comment block in ``restore_version`` that
duplicated the docstring of ``_stamp_audit_fields_for_restore``.

Net: −290 lines from ``daos/version.py``; a production-shape bug
fixed; dead code that looked live is gone.
2026-05-28 15:37:19 -06:00
Mike Bridge 2c7ba5c3d5 refactor(versioning): single_flush_scope context manager + single-revert restore
VersionDAO.restore_version previously called Continuum's Reverter
once per relation in a split-revert loop with flush + expire between
calls. That closed an autoflush race in the Reverter when multiple
relations were reverted at once, but split one logical restore across
multiple Continuum transactions — and once the change-records listener
was wired up, the listener's tx-dedup guard skipped the second pass,
silently dropping child-addition records from version_changes. A
restore that re-added a calculated column would render as an empty
"Baseline" entry in the dropdown.

Replaces the split-revert with a single ``target_version.revert(relations=relations)``
call wrapped in a new ``single_flush_scope(db.session)`` context
manager (``superset/versioning/utils.py``). The context manager
suppresses autoflush inside the block and issues one trailing flush
on clean exit; on exception, the trailing flush is skipped so the
session's normal rollback path handles cleanup. Same autoflush window
closed, one Continuum transaction instead of N, the change-records
listener sees the complete shadow state in one after_flush pass.

The wrapper carries the full autoflush-race / cascade-add rationale
in its docstring so the restore_version call site can be a short
6-line block referencing it.

Integration coverage: ``test_restore_emits_full_child_diff_in_one_transaction``.
2026-05-28 15:37:19 -06:00
Mike Bridge eb3bcf0d3d feat(versioning): force-parent-dirty on versioned-child change
SQLAlchemy doesn't mark a parent as dirty when only its children
(``TableColumn`` / ``SqlMetric`` on ``SqlaTable``) are modified.
Continuum's UnitOfWork only creates operations for entities in
``session.dirty``, so a column-only edit produces shadow rows in
``table_columns_version`` but no parent shadow row in
``tables_version``. ``VersionDAO.list_versions`` queries the parent
shadow, so the version dropdown is empty for child-only saves —
exactly the failure mode reported when "I edited a column description
but no version appeared."

Extends ``register_baseline_listener`` with a new before-flush hook
``_force_parent_dirty_on_child_change`` that walks the existing
``_child_to_parent_registry`` and ``attributes.flag_modified(parent,
<first non-excluded versioned column>)`` whenever a versioned child
is dirty / new / deleted but the parent's own scalars haven't been
touched. The flag puts the parent in ``session.dirty`` so Continuum's
UoW creates a parent UPDATE operation; the resulting shadow row's
scalar columns mirror the previous version (only the children
actually changed), and the row exists to anchor the transaction in
the parent's version chain.

``SkipUnmodifiedPlugin._is_no_op_update`` is updated in this commit's
predecessor to recognize the "scalars match but children dirty" case
via ``_has_dirty_versioned_children`` so the forced parent UPDATE
isn't skipped.

Integration coverage: ``test_dataset_column_edit_creates_parent_version``.
2026-05-28 15:37:19 -06:00
Mike Bridge 0850679edd feat(versioning): SkipUnmodifiedPlugin audit-key normalize for Dashboard.json_metadata
Continuum's no-op suppression compared post-flush column values
byte-for-byte against the previous live shadow row. For
``Dashboard.json_metadata`` that produced false-positive version rows
on saves where the user authored nothing — the frontend re-stamps
``map_label_colors`` (regenerated from the ``LabelsColorMap``
singleton) on every save, plus ``chart_configuration`` /
``global_chart_configuration`` / ``show_chart_timestamps`` /
``color_namespace`` (derived from the current chart set), so two
consecutive identical saves produce different bytes for the column.
The diff engine already excluded those keys via
``DASHBOARD_JSON_METADATA_AUDIT_KEYS`` when computing change records;
the skip-plugin diverged.

Adds a ``_COLUMN_NORMALIZERS`` registry keyed on
``(class_name, column_name)`` that maps to a per-column normalizer
applied to both pre- and post-image before equating. The first
entry parses ``Dashboard.json_metadata`` as JSON and drops the
audit-key set before comparing. The same registry is the extension
point for analogous transient fields on charts and datasets.

Promotes ``_DASHBOARD_JSON_METADATA_AUDIT_KEYS`` to a public name
(``DASHBOARD_JSON_METADATA_AUDIT_KEYS``) so the skip-plugin can import
it from ``superset.versioning.diff`` without reaching across a
leading-underscore boundary.

Integration coverage: ``test_map_label_colors_only_change_does_not_create_version``.
2026-05-28 15:37:19 -06:00
Mike Bridge 6cc0853952 fix(importer): use ORM relationship assignment for dashboard_slices
The v1 import pipeline previously wrote dashboard ↔ chart membership
via raw Core DML (``db.session.execute(delete(dashboard_slices)…)`` +
``db.session.execute(insert(dashboard_slices)…)``). With Continuum's
M2M tracker enabled by the versioning feature, those Core writes
emit malformed shadow INSERTs into ``dashboard_slices_version`` —
the tracker can't see the composite-PK columns through the Core
layer and produces rows with only ``(transaction_id, operation_type)``
populated, triggering a ``NOT NULL`` violation on
``(dashboard_id, slice_id)``.

Rewrites both import paths (``ImportAssetsCommand._import`` in
``commands/importers/v1/assets.py`` and ``ImportDashboardsCommand._import``
in ``commands/dashboard/importers/v1/__init__.py``) to use ORM-level
``dashboard.slices = [...]`` reassignment followed by an explicit
``db.session.flush()``. The explicit flush is necessary to land the
M2M rows before any subsequent autoflush fires an inner-flush event
handler that would reset the relationship change (cf. the SAWarning
``Attribute history events accumulated on N previously clean instances
within inner-flush event handlers have been reset``).

The unit tests previously called ``_import`` directly twice in the same
session — production wraps ``run()`` in ``@transaction`` so each invocation
gets its own DB+Continuum transaction. Added ``db.session.commit()`` between
calls in ``test_import_adds_dashboard_charts``,
``test_import_removes_dashboard_charts``, and
``test_dashboard_import_with_overwrite_replaces_charts`` so the tests
mirror production semantics; otherwise the second call's M2M shadow
inserts conflict with the first call's on
``UNIQUE (dashboard_id, slice_id, transaction_id)``.
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 96018dab71 temp(versioning): demo version-history dropdowns + French i18n
Adds debug-only ``VersionHistoryDropdown`` widgets to the chart,
dashboard, and dataset list pages so the version surface can be
exercised from the UI during the spike. Each row's actions column
gets a clock-icon dropdown that fetches ``/api/v1/{resource}/<uuid>/
versions/`` on click, lists the ten most recent versions with a
formatted change-log summary, and offers per-version restore via
``POST .../versions/<uuid>/restore``.

Strings are wrapped in ``t('...')`` with placeholder formatting
(e.g. ``t('Added %(kind)s "%(name)s"', { kind, name })``) so
translators can reorder verbs and nouns rather than concatenating
fragments. ``KIND_LABELS`` is a static map keying English layout
kinds (``chart``, ``row``, ``column``, ``tab``, ``markdown``, etc.)
to ``t(...)``-extractable labels. Empty change lists render as
"Baseline" rather than "No changes recorded" since the empty case
is overwhelmingly the ``operation_type=0`` baseline row.

Locale-aware date rendering: ``new Date(iso).toLocaleString(lang)``
where ``lang`` comes from ``document.documentElement.lang`` (set
by ``src/views/App.tsx`` from the bootstrap ``locale``), so dates
follow the user's chosen Superset locale rather than the browser's.

French translations for the new strings are appended to
``superset/translations/fr/LC_MESSAGES/messages.po`` (Ajouté,
Supprimé, Modifié, Version initiale, kind labels, …). Run
``npm run build-translation`` and ``pybabel compile -l fr`` to
regenerate the JSON / MO packs.

This commit is **demo-only** per ADR-005 (V1 is backend-only). It
is intentionally marked ``temp`` so it can be reverted before the
PR splits — the production V1 ships without UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 39cfda37e7 test(versioning): integration tests for SkipUnmodifiedPlugin (FR-026)
Locks in the no-op-suppression behavior implemented by
``SkipUnmodifiedPlugin`` (which lives in ``superset/versioning/factory.py``
shipping with the foundation commit). Five integration tests:

1. Owners-only edit doesn't mint a version row — exercises the
   case where every dirty column is an excluded relationship.
2. Re-save with identical scalar values doesn't mint a row —
   exercises the json_metadata re-serialise path where
   ``set_dash_metadata`` rewrites the column to a different byte
   sequence with identical parsed content; the plugin must compare
   post-flush values against the prior shadow row to detect this.
3. Real scalar change DOES mint a row — guards against the plugin
   over-suppressing.
4. Same assertion on a Slice (covers the ``String`` column path on
   a different entity type).
5. ``json_metadata`` sub-key edit DOES mint a row — covers the
   ``MediumText`` column path past the plugin's content-equality
   check.

Tests are designed so a column-type change in the parent entities
(e.g. flipping ``json_metadata`` from ``MediumText`` to ``JSON``)
will fail one of these if the plugin's Python ``!=`` comparison
breaks for the new type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 47a1c9fa95 feat(versioning): ETag helper module + integration tests (T055)
Helper module that derives the strong-validator ``ETag`` value from
an entity's current live ``version_uuid`` and attaches it to a
Flask response. Two functions:

- ``set_version_etag(response, version_uuid)`` — direct path used by
  PUT handlers that already compute ``new_version_uuid`` (see the
  REST API commit two prior). Cheap; no extra query.
- ``set_version_etag_by_uuid(response, model_cls, entity_uuid)`` —
  used by version endpoints that operate on ``entity_uuid``; looks
  up ``entity_id`` then derives ``version_uuid`` via ``VersionDAO``.
  Costs one extra ``SELECT id WHERE uuid = ?``; documented in the
  docstring so callers prefer the cheap variant when they have the
  id already.

Integration tests cover all three entity types and four endpoint
shapes (entity GET, save PUT, version-list GET, single-version GET)
plus the entity-with-no-versions edge case (header is correctly
absent).

The ETag is wired into the API endpoints in the REST-API commit
(group 3) and the CORS ``expose_headers: ["ETag"]`` ships with the
retention commit (group 4) since both touch ``superset/config.py``.
Locking enforcement (``If-Match`` → 412) is explicitly NOT in this
change — deferred to the follow-up UI SIP per Open Question §7.
``ETag`` is informational in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 963f059df3 feat(versioning): time-based retention via Celery beat (FR-007)
Adds a scheduled Celery task that prunes version history older than
``SUPERSET_VERSION_HISTORY_RETENTION_DAYS`` (default 30; settable
via env var; ``0`` disables retention entirely).

**Task** — ``superset.tasks.version_history_retention.prune_old_versions``:

1. Computes ``cutoff = utcnow() - timedelta(days=N)``.
2. Selects ``version_transaction.id`` rows with ``issued_at <
   cutoff`` and filters out any tx whose parent shadow includes a
   live row (``end_transaction_id IS NULL``). The live row is the
   only preservation rule — closed historical rows including the
   baseline (``operation_type=0``) age out. Per-entity minimum-history
   floor is an open question tracked in ``future-work.md``.
3. Deletes rows owned by surviving txs in each parent shadow
   table (``dashboards_version`` / ``slices_version`` /
   ``tables_version``).
4. Deletes child-shadow rows for the same transactions
   (``table_columns_version`` / ``sql_metrics_version`` /
   ``dashboard_slices_version``).
5. Drops the surviving ``version_transaction`` rows. The
   ``version_changes`` rows cascade via the FK from the previous
   commit.

Idempotent and safely retried on partial failure.

**Schedule** — ``superset/config.py`` adds the task to the default
``CeleryConfig.beat_schedule`` (nightly at 03:00). Operators who
override ``CeleryConfig`` in their ``superset_config.py`` need to
merge this entry — see UPDATING.md.

Also adds ``"expose_headers": ["ETag"]`` to the default
``CORS_OPTIONS`` so cross-origin browser clients can read the
``ETag`` header introduced in the next commit. (Co-located here
because both touch ``superset/config.py``; the ETag mechanism
itself ships in the next commit.)

**Auto-discovery** — ``superset/tasks/celery_app.py`` adds
``version_history_retention`` to its late-imports so Celery's
auto-discovery picks up the task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 24eef8c984 feat(versioning): REST API endpoints + restore commands
Exposes the version surface as three new endpoints per entity type
(chart, dashboard, dataset), each carrying the standard Superset
decorator stack (``@protect()``, ``@safe``, ``@statsd_metrics``,
``@event_logger.log_this_with_context``) so they appear in FAB's
``action_log`` alongside other audited operations.

| Method | Path | Purpose |
|---|---|---|
| GET  | ``/api/v1/{resource}/<uuid>/versions/`` | List version history (oldest-first; per entry: ``version_uuid``, ``version_number``, ``transaction_id``, ``operation_type``, ``issued_at``, ``changed_by``, ``changes`` array) |
| GET  | ``/api/v1/{resource}/<uuid>/versions/<version_uuid>/`` | Read-only snapshot of the entity at the requested version (scalar fields plus ``columns`` / ``metrics`` for datasets) |
| POST | ``/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore`` | Replay the snapshot onto the live entity via Continuum's ``Reverter`` (non-destructive — produces a new version row stamping the restoring user via the standard save path) |

``<version_uuid>`` is a deterministic ``UUIDv5(entity_uuid,
transaction_id)`` so it's stable across replicas and retention
pruning. Authorisation reuses the resource's existing ``can_write``
permission; workspace admins can list / restore any entity.

**Restore commands** — ``superset/commands/{chart,dashboard,dataset}/
restore_version.py`` wrap ``VersionDAO.restore_version`` in the
standard ``@transaction()`` boundary. The command resolves the
``Reverter`` once per related collection (split-revert pattern, with
``flush + expire`` between calls) so a multi-relation restore
doesn't trip Continuum's autoflush race that would otherwise mark
half the collection as ``state.deleted=True`` mid-revert.

**Save responses** — ``PUT /api/v1/{resource}/<pk>`` is updated to
include ``old_version`` / ``new_version`` (0-based numbers),
``old_transaction_id`` / ``new_transaction_id`` (stable across
pruning), and ``old_version_uuid`` / ``new_version_uuid`` body
fields so callers can correlate a save with its resulting version
row. The ``ETag`` response header in the next commit is built on
top of this, but the body fields stay — they predate the header
and remain useful for clients that don't read response headers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 7ec3091d55 feat(versioning): change records + diff engine
Adds a structured per-field change log alongside the foundational
shadow tables. Each save flush emits zero or more ``version_changes``
rows describing what changed relative to the previous version, with
shape ``[{kind, path, from_value, to_value, sequence}]`` keyed to
``version_transaction.id`` (FR-016 .. FR-021).

**Schema** — ``version_changes`` table, FK to ``version_transaction``
with ``ON DELETE CASCADE`` so retention drops dependent records
without explicit cleanup. Composite unique index on
``(transaction_id, entity_kind, entity_id, sequence)`` so the
listener can write monotonically and downstream readers see a
deterministic order.

**Diff engine** (``superset/versioning/diff.py``) — pure-function
diffing of pre-/post-state pairs:

- ``diff_scalar_fields`` for ordinary columns; emits one record per
  changed field with JSON-safe ``from_value`` / ``to_value``.
- ``diff_json_field`` for ``json_metadata`` and ``params``, walking
  the parsed structure and emitting per-sub-key records. Honours
  an ``exclude_keys`` set
  (``_DASHBOARD_JSON_METADATA_AUDIT_KEYS``: ``chart_configuration``,
  ``global_chart_configuration``, ``map_label_colors``,
  ``show_chart_timestamps``, ``color_namespace``;
  ``_CHART_PARAMS_AUDIT_KEYS``) so frontend-stamped sub-keys that
  mutate on every save don't dominate the change log (FR-022).
- ``diff_dashboard_layout`` walks ``position_json`` structurally
  and emits ``[verb, kind, id]`` records (verbs ``add``, ``remove``,
  ``move``, ``edit``; kinds from a ``CHART``/``ROW``/``COLUMN``/etc.
  → english map) so a UI can render "Added chart 'Foo'" without
  re-parsing JSON. ``HEADER_ID`` is suppressed because it duplicates
  the ``dashboard_title`` scalar record.
- ``fold_dashboard_layout_with_chart_changes`` deduplicates layout
  records against M2M / chart-membership records by UUID so an
  add-and-attach doesn't appear twice.
- ``_values_equivalent`` treats ``None`` and ``""`` as equal; this
  matches the save path's habit of normalising nullable strings to
  the empty string.

**Listener** — ``superset/versioning/changes.py`` registers a
``before_flush`` listener that captures pre-state for each dirty
entity and an ``after_flush`` listener that runs the diff engine
against the post-state and writes ``version_changes`` rows under
the resolved ``transaction_id``. Tracks processed transaction ids
on ``session.info`` so re-firings within a single transaction
(autoflush triggered by mid-commit queries) don't double-insert and
trip the unique constraint. Reads child rows via raw SELECT against
``table_columns`` / ``sql_metrics`` rather than ``dataset.columns``
because the live collection is stale during the restore path's raw
DELETE+INSERT cycle.

**Endpoint surface** — ``VersionDAO.list_change_records_batch``
batches the lookup across multiple transactions with a single
``WHERE transaction_id IN (...)`` query so the version-list
endpoint avoids N+1 round-trips. ``list_versions`` / ``get_version``
return entries with a populated ``changes`` array (empty for
``operation_type=0`` baseline rows).

**Tests** — ``test_diff.py`` covers the diff engine shape (39
unit cases across scalar, JSON, layout, child-collection, and
fold paths). ``change_records_tests.py`` exercises the listener
end-to-end with realistic save flows. ``perf_validation_tests.py``
is the T044 harness for SC-002/3/4 (list endpoint p95 < 1s,
restore < 3s, save overhead < 50ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 b68fea17af feat(versioning): foundation — Continuum capture + parent/child shadow tables + VersionDAO
Adds SQLAlchemy-Continuum as a dependency and wires it as the
canonical capture mechanism for chart, dashboard, and dataset edits.

**Schema** — three Alembic migrations, leaving the chain at one
foundation revision plus one child-shadow revision:

- ``version_transaction`` (renamed from Continuum's default
  ``transaction``; SQL-reserved-word workaround) carries the per-save
  ``user_id`` / ``issued_at`` and is the join target for all shadow
  rows. Auto-incrementing PK; user_id has no FK so import / Celery /
  CLI saves can write rows without an active Flask user.
- Parent shadow tables for the three entity types:
  ``dashboards_version``, ``slices_version``, ``tables_version``.
- Child shadow tables for dataset children + dashboard M2M:
  ``table_columns_version``, ``sql_metrics_version``,
  ``dashboard_slices_version`` (composite PK on the M2M shadow,
  matching the live ``dashboard_slices`` reshape from
  sc-105349-composite-association-pks).

**Models** — ``Dashboard``, ``Slice``, ``SqlaTable`` (and dataset
children ``TableColumn`` / ``SqlMetric``) gain ``__versioned__``
class attributes. The exclude lists carry both M2M relationships
(``owners``, ``roles``, ``dashboards``) and the ``AuditMixin``
columns (``changed_on`` / ``created_on`` / ``changed_by_fk`` /
``created_by_fk`` plus ``last_saved_at`` / ``last_saved_by_fk``
on ``Slice``) so auto-bumped audit fields cannot trigger a
version row on their own (FR-025).

**Plugins** — ``superset/versioning/factory.py`` ships three
Continuum plugins:

- ``VersionTransactionFactory`` renames the transaction table and
  appends the unconditional ``user_id`` column.
- ``VersioningFlaskPlugin`` sources the acting user from Superset's
  ``g.user`` rather than ``flask_login.current_user`` (Superset's
  JWT auth populates ``g.user`` but leaves ``current_user``
  anonymous on API routes).
- ``SkipUnmodifiedPlugin`` filters Continuum's UPDATE operations,
  marking content-equivalent re-saves as ``processed=True`` so they
  don't mint no-op shadow rows (FR-026; see follow-up commits for
  the test). Lives in this commit because it shares the file with
  the other plugins.

**Save-path glue** — a ``before_flush`` baseline listener
(``superset/versioning/baseline.py``) inserts an ``operation_type=0``
shadow row the first time a pre-existing entity is saved, including
the slice-baseline-under-dashboard pattern that gives the dashboard
M2M shadow a row to join against. ``UpdateDashboardCommand`` wraps
its body in ``no_autoflush`` so ``process_tab_diff`` /
``process_native_filter_diff`` don't fire intermediate flushes that
would mint extra version rows. ``DatasetDAO.update_columns`` is
rewritten as a natural-key upsert keyed on ``column_name`` so child
edits flow through ORM events Continuum sees.

**DAO** — ``superset/daos/version.py`` exposes the read API used by
the version endpoints in the next commits:
``current_version_number`` (0-based index, unstable under retention
pruning), ``current_live_transaction_id`` (stable across pruning),
``current_live_version_uuid`` (deterministic UUIDv5), plus
``list_versions`` / ``get_version`` / ``restore_version`` and a
batch ``list_change_records_batch`` for N+1 avoidance.

**Initialization** — ``superset/initialization/__init__.py`` wires
``init_versioning()`` after ``make_versioned()`` runs and the
versioned mappers are configured. Registers the baseline listener
plus the change-record listener (the latter's body lives in the
next commit but the registration site is here because it shares
the init function).

**Tests** — version-capture and version-list integration tests for
each entity type, plus a ``VersionDAO`` unit test suite. Retention
test uses a backdated ``issued_at`` so it can drive
``_prune_old_versions_impl`` synchronously.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:19 -06:00
Mike BridgeandClaude Opus 4.7 887d49cbac fix(migration): address aminghadersohi review feedback (sc-105349)
Three improvements from @aminghadersohi's review on
apache/superset#39859:

1. **`fk["name"]` unguarded in ``_downgrade_mysql_table`` re-add loop**

   The drop loop gates on ``if fk_name := fk.get("name"):`` but the
   re-add loop accessed ``fk["name"]`` unconditionally in an f-string.
   MySQL/InnoDB always assigns FK names, so this branch was defensive,
   but the asymmetry was confusing. Symmetrized via ``continue`` at the
   top of the re-add loop.

2. **``ondelete`` whitelist before raw-SQL interpolation**

   The value comes from MySQL's ``information_schema`` (not user
   input), but interpolating a reflected string into raw SQL without a
   guard left a "what if an unexpected value appears" footgun. Added
   ``_VALID_ONDELETE_ACTIONS`` (the four SQL-standard actions) and a
   ``RuntimeError`` when an unexpected value is reflected.

3. **Direct ALTER on PostgreSQL for tables with pre-existing UNIQUE**

   ``recreate="always"`` is dialect-agnostic — on PostgreSQL it
   triggers ``CREATE TABLE AS SELECT → DROP → RENAME`` holding
   ``ACCESS EXCLUSIVE`` for the full table-copy duration. For a
   multi-million-row ``dashboard_slices``, that lock window can be
   noticeable. The reflected UNIQUE constraint has a stable name on
   PostgreSQL (default ``<table>_<cols>_key`` convention), so dropping
   it directly and then running structural change as direct ALTER
   avoids the copy entirely.

   The reflected UNIQUE name is wrapped in a new
   ``_drop_redundant_unique_by_name()`` helper. Postgres takes the
   direct path; MySQL keeps ``recreate="always"`` because InnoDB binds
   FKs to the UNIQUE's underlying index for back-reference (``DROP
   CONSTRAINT`` on the UNIQUE there raises ``ERROR 1553``); SQLite
   keeps ``recreate="always"`` because unnamed UNIQUEs reflect with
   ``name=None`` and can't be dropped by name.

Verified end-to-end: downgrade-then-upgrade against MySQL with
~12M total junction rows seeded completes in ~1m 41s (within the
range of the prior measurements).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:37:02 -06:00
Mike BridgeandClaude Opus 4.7 8f5a230c84 fix(migration): skip alter_column nullable=False on non-SQLite (sc-105349)
Justin Park (@justinpark) reported on apache/superset#39859:

  MySQLdb.OperationalError: (1832, "Cannot change column 'dashboard_id':
  used in a foreign key constraint 'fk_dashboard_roles_dashboard_id_dashboards'")

Root cause: ``batch_op.alter_column(fk1, nullable=False)`` for the six
non-UNIQUE association tables emits ``ALTER COLUMN`` on a column that
participates in an FK constraint. MySQL 8 rejects this with ERROR 1832
when the table has data — even when the change is just ``NULL`` →
``NOT NULL`` and the column is already part of a freshly-added
composite primary key (which InnoDB has just made implicitly NOT NULL
anyway). The error fires on populated tables only; CI's ``test-mysql``
shard runs against empty tables and so didn't catch this, while a
real production-shaped install does.

The ``alter_column`` was only ever needed for SQLite, where composite
``PRIMARY KEY`` does not promote constituent columns to ``NOT NULL``
(a long-standing SQLite quirk — only ``INTEGER PRIMARY KEY`` does).
PostgreSQL and MySQL implicitly promote PK columns to ``NOT NULL`` as
part of ``ADD PRIMARY KEY``, so the explicit step is unnecessary on
both — and on MySQL it's actively broken on populated tables.

Fix: extract the ``alter_column`` pair into a helper
``_enforce_not_null_for_sqlite()`` that no-ops on Postgres and MySQL.
Both branches of the per-table upgrade (the ``recreate="always"`` path
for the two UNIQUE-bearing tables, and the direct-ALTER path for the
other six) now call the helper instead of inlining the
``alter_column``.

Verified end-to-end: downgrade-then-upgrade against MySQL with
~12M total junction rows (10M dashboard_slices + 1M each
slice_user/dashboard_user + 100K dashboard_roles) completes in
1m 39s with no ERROR 1832. The 44 in-memory SQLite tests still pass.

Considered Justin's alternative (drop FKs on MySQL across all eight
tables, unifying the two branches) but rejected as more invasive —
it would require capturing FK metadata and explicitly re-creating
the FKs for the six non-recreate tables, since they don't go through
the ``copy_from`` path that re-creates FKs automatically. The
SQLite-only approach is more targeted: it removes the operation that
MySQL rejects rather than working around the rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:36:58 -06:00
Mike BridgeandClaude Opus 4.7 aaa40761b1 feat(scripts): add --dirty-duplicates-pct to seed_junction_load.py
Extends the stress-test seed script with an optional duplicate-row
injection step, used to measure the empirical cost of the migration's
``_dedupe_by_min_id`` phase.

Usage: after running the normal seed at a given scale, add
``--dirty-duplicates-pct 5`` (or any non-zero value) to inject that
percentage of duplicate ``(fk1, fk2)`` rows into each non-UNIQUE
junction (slice_user, dashboard_user, dashboard_roles —
dashboard_slices is skipped because its UNIQUE constraint, present
both pre- and post-migration, rejects duplicates).

Pre-condition: requires the DB to be at the pre-migration revision
(33d7e0e21daa). The post-migration composite PK rejects duplicates,
so attempting to inject on the upgraded schema errors out.

Empirical result on MySQL @ 10M dashboard_slices + ~2.1M other
junction rows + 105K injected duplicates (5% on the 3 non-UNIQUE
tables):
  Upgrade time: 1m 36s vs clean baseline 1m 37s
  → dedupe cost is within measurement noise; the table-scan that
    the migration already performs dominates whether or not
    duplicates exist.

This empirically confirms what the cost-model predicted: the
``_dedupe_by_min_id`` GROUP BY scan is the dominant cost of that
phase, and the actual per-duplicate DELETE is negligible.

NULL-FK injection deliberately skipped — would require altering the
six non-UNIQUE FK columns from NOT NULL back to nullable (the
migration's downgrade keeps them NOT NULL by design), which adds
per-backend ALTER complexity for a code path that's structurally
identical in cost shape (DELETE WHERE col IS NULL is the same scan
shape as the dedupe scan).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 c2a761e44d build(scripts): add stress-test data generator for migration timing
Add ``scripts/seed_junction_load.py``, a backend-agnostic script that
bulk-inserts synthetic parent rows (dashboards, slices, users, roles,
tables, dbs) and many-to-many junction rows for the four largest
association tables targeted by the composite-PK migration:
``dashboard_slices``, ``slice_user``, ``dashboard_user``,
``dashboard_roles``.

Designed for measuring migration runtime at varying scales — run with
a series of size flags (100K / 1M / 5M / 10M for the target table)
and time the migration at each scale to verify the predicted
``O(N log N)`` extrapolation against real numbers.

Properties:
- **Reproducible**: deterministic cross-product walk through parent IDs
  produces a stable pair sequence; re-running is replayable.
- **Idempotent**: re-running with the same target is a no-op; with a
  higher target, only new rows are added.
- **Backend-agnostic**: connects via Superset's standard ``DATABASE_*``
  env vars (or ``SUPERSET__SQLALCHEMY_DATABASE_URI``). Branches on
  dialect for ``BINARY(16)`` vs ``UUID`` vs TEXT/BLOB UUID columns.
- **Batched**: bulk INSERT 10K rows per statement.
- **Per-phase timing**: logs elapsed wall time for the parents phase,
  the junctions phase as a whole, and per junction-table.
- **Avoidance set**: loads existing junction pairs into a Python set
  so re-runs on top of pre-existing data don't collide on the
  uniqueness constraint.

Usage (inside the Superset container):

    docker exec superset-superset-1 \\
        /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
        --dashboard-slices 1000000

Defaults target a "large multi-team install" shape: 1M
``dashboard_slices``, 100K each ``slice_user`` / ``dashboard_user``,
10K ``dashboard_roles``. Override per-table via flags.

Tested locally on MySQL (the user's current eval stack):
- 200/100/100/50 row mini-run produced expected counts.
- Re-running at the same target is a no-op (idempotent).
- ``--dry-run`` plans without writing.

Junction tables not yet covered (``sqlatable_user``, ``rls_filter_*``,
``report_schedule_user``) are typically small in production and
require additional parent seeding (RLS filters, report schedules)
that wasn't worth the scope here. Adding them is straightforward by
extending ``JUNCTIONS`` and writing the corresponding parent seeder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 acfdd22c21 fix(docker): MySQL examples DB + EXAMPLES_PORT override (sc-105349)
Fix two follow-on issues reported when starting the dev stack with
docker-compose-mysql.yml:

1. ``superset-init`` step 4 (load-examples) fails with
   ``MySQLdb.OperationalError: (2002, "Can't connect to server on 'db'")``
   because the analytics-examples DB connection inherits ``EXAMPLES_PORT=5432``
   (Postgres port) from ``docker/.env``. The override flipped
   ``DATABASE_DIALECT`` to ``mysql+mysqldb`` but left the EXAMPLES_*
   group on Postgres defaults, so the URI became
   ``mysql+mysqldb://examples:examples@db:5432/examples`` — MySQL
   container has no listener on 5432.

   Fix: add ``EXAMPLES_HOST/PORT/DB/USER/PASSWORD`` and a complete
   ``SUPERSET__SQLALCHEMY_EXAMPLES_URI`` to the ``mysql-env`` anchor.

2. The Postgres init scripts under
   ``docker/docker-entrypoint-initdb.d/`` (``cypress-init.sh``,
   ``examples-init.sh``) get mounted on the MySQL container too —
   compose merges volume lists. They invoke ``psql`` which doesn't
   exist in the MySQL image, abort with ``psql: command not found``,
   and prevent the ``examples`` DB from being created.

   Fix: add a MySQL-specific init script
   ``docker/mysql-init/examples-init.sql`` that creates the
   ``examples`` database and user, and mount it at
   ``/docker-entrypoint-initdb.d`` in the override. Compose's
   later-takes-precedence rule on duplicate volume targets displaces
   the Postgres init dir, so the MySQL container only sees the
   MySQL-compatible script.

   (Used a plain duplicate-target mount rather than the ``!override``
   tag because pre-commit's ``check-yaml`` doesn't recognize Compose's
   custom YAML tags.)

Recovery for an existing failed MySQL stack: ``docker compose -f
docker-compose.yml -f docker-compose-mysql.yml down``, then
``docker volume rm superset_db_home_mysql`` (so the new init script
runs on the next fresh boot), then ``up`` again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 1fc7b17dfa build(docker): add MySQL compose override for dialect-swap evaluation
Adds ``docker-compose-mysql.yml``, a compose-override file that swaps
the default Postgres metadata DB for MySQL 8 with one extra ``-f``
flag:

  docker compose -f docker-compose.yml -f docker-compose-mysql.yml up

Useful for evaluating dialect-specific behaviour (e.g., the runtime
cost of DDL migrations on a deployment whose production metadata DB
is MySQL — the question raised by review feedback on this PR).

Mirrors the connection settings used by CI's ``test-mysql`` shard:
``mysql+mysqldb`` dialect, charset ``utf8mb4`` with binary_prefix.
Host port defaults to 13306 (configurable via ``DATABASE_PORT_MYSQL``)
to avoid colliding with a native MySQL install on 3306.

A separate volume (``db_home_mysql``) keeps MySQL data isolated from
the Postgres ``db_home`` volume, so switching between the two with
``-f`` flag toggles doesn't corrupt either side.

The Postgres-specific init scripts under
``docker/docker-entrypoint-initdb.d/`` are not mounted on the MySQL
service (they are postgres-only). Examples / cypress fixtures still
load via ``superset-init``'s post-startup steps, which run
``superset load-examples`` against whichever metadata DB is in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 08cfcf8fd4 docs(UPDATING): add MySQL-targeted maintenance-window queries (sc-105349)
Mirror of the PostgreSQL diagnostic queries added in 11148779ed,
adapted for MySQL/InnoDB. One important difference: InnoDB rebuilds
the clustered index on every PK change, so all eight tables undergo
a full table rebuild on MySQL — not just the two that go through
the explicit ``recreate="always"`` path. The lock-window estimate
query is updated to cover all eight rather than just two, and the
"migration_path" column makes the rebuild expectation explicit
("direct ALTER (still rebuilds InnoDB clustered index)").

Other notes:
- ``information_schema.TABLES.TABLE_ROWS`` is an InnoDB estimate,
  analogous to PostgreSQL's ``reltuples``; documented inline.
- ``KEY_COLUMN_USAGE`` carries both sides of the FK in a single
  row on MySQL, so the external-FK pre-flight check is simpler
  than the PostgreSQL version (no joins between three views).
- The aggregated dedupe query is portable standard SQL; included
  verbatim for copy-paste convenience.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 4eb42d7bdf docs(UPDATING): add Postgres-targeted maintenance-window queries (sc-105349)
Add a "Sizing the maintenance window on PostgreSQL" sub-section to the
operator runbook. The simple per-table COUNT/duplicate/NULL queries
that were already there are dialect-portable but only count rows;
operators on PostgreSQL with large deployments need to characterize
the migration's runtime cost before scheduling it.

Adds four diagnostic queries:

- Per-table size, row count (from pg_class.reltuples), and which
  migration path each table will take (recreate-rewrite vs direct
  ALTER). Sizes the work concretely.
- Aggregated duplicate-row roll-up: dup_groups + total rows_dropped
  per table. Replaces eight separate per-table queries with one
  consolidated result for audit/dump-before-apply decisions.
- External-FK pre-flight check (the same one the migration runs at
  upgrade time and aborts on). Lets operators surface any blocking
  external reference ahead of the maintenance window. Should be
  empty on a stock install.
- Lock-window estimate for the two full-rewrite tables, using
  pg_relation_size and a conservative 100 MB/s rewrite throughput
  assumption. The other six use direct ALTER and are dominated by
  composite-index build time (seconds for low-millions-of-rows
  tables).

Prompted by reviewer feedback on apache/superset#39859 from a large
deployment asking how to size the maintenance window. The original
pre-flight queries are kept for cross-dialect operators (MySQL,
SQLite) since the new queries use PostgreSQL-specific catalog views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 9f03047913 fix(migration): rebase down_revision onto 33d7e0e21daa (sc-105349)
CI cypress + playwright shards were red with:

  ERROR [flask_migrate] Error: Multiple head revisions are present
  for given argument 'head'

The recent rebase onto master pulled in
``33d7e0e21daa_add_semantic_layers_and_views.py`` (from PR #37815,
"semantic layer extension"), which had been authored against
``ce6bd21901ab`` as its parent — the same parent our migration
referenced. After the rebase both migrations point at
``ce6bd21901ab``, producing two heads and breaking ``flask db
upgrade head`` for any downstream consumer (CI's Cypress / Playwright
shards spin up a real Superset instance via ``superset db upgrade``,
which is why those shards failed first; the integration shards run
against a precomputed schema and didn't surface this).

Fix: chain our migration after the semantic-layer migration by
pointing ``down_revision`` at ``33d7e0e21daa``. The chain is now
linear:

    ... → ce6bd21901ab → 33d7e0e21daa (semantic layers)
                          → 2bee73611e32 (composite PK, this PR)

Verified with ``superset db heads`` (returns single head
``2bee73611e32``) and the local migration test suite (44 passed,
1 skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 7fe649ef68 fix(migration): explicit NOT NULL on FK columns for SQLite (sc-105349)
Found by running fresh-install + round-trip against a real SQLite DB:
6 of the 8 affected tables had FK columns that were originally
declared nullable. PostgreSQL and MySQL implicitly promote the
constituent columns of an ``ALTER TABLE ... ADD PRIMARY KEY`` to
``NOT NULL``; SQLite does not (it's a long-standing SQLite quirk —
only ``INTEGER PRIMARY KEY`` enforces NOT NULL on a composite-PK
column). Result: a fresh SQLite install would accept
``INSERT INTO dashboard_slices (NULL, 5)`` despite both columns
being part of the composite PK.

Our integration tests previously masked this: the test fixture seeds
columns with ``nullable=False``, so the post-upgrade NOT NULL
assertion passed regardless of whether the migration enforced it.

Fix: add explicit ``batch_op.alter_column(fk, nullable=False)`` for
both FK columns inside the per-table batch_alter_table block. On
PostgreSQL and MySQL this is a no-op (PK already implies NOT NULL);
on SQLite it adds the missing NOT NULL declaration so a fresh
install matches the data-model.md "After" contract.

Verified end-to-end:
- Postgres + MySQL: column shape unchanged (still NOT NULL)
- SQLite fresh install + round-trip: all 8 tables now have NOT NULL
  on FK columns, ``INSERT (NULL, 5)`` correctly rejected with
  IntegrityError on dashboard_slices, dashboard_user, sqlatable_user

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 e4e0b4b144 fix(migration): MySQL downgrade FK + AUTO_INCREMENT (sc-105349)
Two MySQL-only failures in the downgrade path, found by running the
full migration history against a fresh MySQL 8 container:

1. ``MySQLdb.OperationalError: (1553, "Cannot drop index 'PRIMARY':
   needed in a foreign key constraint")``. InnoDB uses the composite
   PK index to back the FK on the leftmost column. The downgrade
   tried to drop the composite PK before dropping the FKs, orphaning
   the FK's backing index. PostgreSQL and SQLite create separate
   indexes for FK columns and don't trip on this.

2. ``Field 'id' doesn't have a default value`` on subsequent INSERT.
   ``sa.Identity(always=False)`` only emits ``AUTO_INCREMENT`` on
   MySQL when the column is created with ``primary_key=True`` — our
   portable path adds the column first then creates the PK separately,
   so MySQL leaves the column without auto-generation. Existing rows
   would all collide on id=0; future inserts fail because no default.
   Postgres' ``GENERATED BY DEFAULT AS IDENTITY`` and SQLite's
   ``INTEGER PRIMARY KEY`` rowid alias don't have this gap.

Fix: extract ``_downgrade_mysql_table()`` that emits the canonical
MySQL idiom — drop FKs, then a single ALTER combining
``DROP PRIMARY KEY, ADD COLUMN id INT NOT NULL AUTO_INCREMENT,
ADD PRIMARY KEY (id)`` (which backfills existing rows with sequential
ids and preserves AUTO_INCREMENT), restore the redundant UNIQUE on
the 2 tables that originally had it, and re-add the FKs with their
original names. Postgres and SQLite keep the existing portable
``batch_alter_table`` path.

Raw SQL is unavoidable for the combined-ALTER form; per the
constitution it's allowed for dialect-specific DDL with no SQLA
equivalent, with triple-quoted strings for legibility.

Verified end-to-end: upgrade → downgrade → upgrade against a fresh
MySQL 8 container with INSERT-without-id sanity check showing the
restored ``id`` column auto-increments correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 bb996a578e fix(migration): drop FKs before recreate on MySQL (sc-105349)
CI test-mysql failed with:

  MySQLdb.OperationalError: (1826, "Duplicate foreign key constraint
  name 'fk_dashboard_slices_slice_id_slices'")

Root cause: MySQL scopes foreign-key constraint names per-database,
not per-table (PostgreSQL and SQLite scope per-table). The
``batch_alter_table(... recreate="always", copy_from=...)`` path
used for ``dashboard_slices`` and ``report_schedule_user`` builds
``_alembic_tmp_<table>`` carrying the original FK names from
``copy_from`` while the original table still holds those names — MySQL
rejects the temp-table creation with ERROR 1826.

Fix: on MySQL only, drop the original FK constraints by name before
the ``batch_alter_table`` runs. The ``copy_from`` re-creates them on
the rebuilt table with their original names, so the post-migration
shape is unchanged. On PostgreSQL and SQLite the original code path
still runs unchanged.

Local SQLite tests (44 passed, 1 skipped) still pass; CI will validate
on MySQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 828fd40eec refactor(migration): build pre-flight SQL via SQLAlchemy core (review)
Address Beto's review comments on apache/superset#39859: replace
``sa.text(f"...")`` SQL construction in the three pre-flight helpers
(``_delete_null_fk_rows``, ``_dedupe_by_min_id``, ``_assert_no_duplicates``)
with SQLAlchemy core constructs (``sa.delete``, ``sa.select``,
``sa.func``, ``.subquery()``, ``.notin_()``).

A small ``_table_clause()`` helper builds a lightweight ``TableClause``
exposing the columns the queries reference; the three helpers consume
it. Removes all ``# noqa: S608`` comments — they are no longer needed
because there is no string-interpolated SQL.

Verified the compiled SQL is identical on Postgres, MySQL, and SQLite,
including the MySQL ERROR 1093 workaround (the inner aggregation is
wrapped in a derived table via ``.subquery()``, producing
``... NOT IN (SELECT keep_id FROM (SELECT min(id) ...) AS keep_min)``).

Also drops the redundant ``f`` prefix on the two non-interpolating
lines of the ``_check_no_external_fks_to_id`` error message.

44 migration tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 229ed98a1b docs(migration): address SQLAlchemy review follow-ups
Four operator-experience improvements from the second review pass:

1. ``TABLES_WITH_NULLABLE_FKS`` is now explicitly documented as an
   informational set that is not consulted at runtime; the comment
   explains the previous ``dashboard_roles`` omission was the bug
   that motivated the always-run cleanup.
2. ``_delete_null_fk_rows`` docstring updated to match the
   "always run" semantics (was still claiming "called only on tables
   in TABLES_WITH_NULLABLE_FKS").
3. ``_check_no_external_fks_to_id`` now documents its scope
   limitation: ``Inspector.get_table_names()`` returns the default
   schema only, so cross-schema FKs in non-standard multi-schema
   PostgreSQL deployments would not be caught. The single-schema
   case (Superset's documented deployment) is fully covered.
4. ``_dedupe_by_min_id`` now logs a sample of up to 10 discarded
   ``(fk1, fk2, id)`` tuples at WARN before deletion, so operators
   can audit which rows the ``MIN(id)`` policy drops. The keep-
   original policy is correct in practice but discards later
   re-grants on ownership tables; the sample makes that visible.
5. ``UPDATING.md`` documents the upgrade/downgrade primary-key
   name divergence (``pk_<table>`` vs ``<table>_pkey``) so
   operators using schema-comparison tools don't mistake it for
   migration drift.

No schema or runtime-behaviour changes. All 44 migration tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 7b2c2d3b00 fix(migration): always run NULL-FK cleanup; correct RLS test parent name
Two cleanups from PR review:

1. ``dashboard_roles.dashboard_id`` was created nullable in revision
   e11ccdd12658 but was missing from ``TABLES_WITH_NULLABLE_FKS``. A
   production database with a stray NULL ``dashboard_id`` row would have
   failed the PK-add with a cryptic constraint violation. Fix by running
   the NULL-FK cleanup on every affected table — it is a no-op DELETE on
   tables whose FK columns are already NOT NULL, and it eliminates the
   risk of further drift in the hardcoded set. ``dashboard_roles`` is
   added to the documentation set; the runtime now does not consult it.

2. The unit-test parent-table name for ``rls_filter_roles`` and
   ``rls_filter_tables`` was ``rls_filter`` (does not exist) instead of
   the real parent ``row_level_security_filters``. Test passes either
   way (the in-memory FK is self-consistent), but the parameter is now
   accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
Mike BridgeandClaude Opus 4.7 f537472291 refactor(db): composite PK on M2M association tables (sc-105349)
Replace synthetic id INTEGER PRIMARY KEY with composite PRIMARY KEY (fk1, fk2)
on the eight pure-junction tables: dashboard_roles, dashboard_slices,
dashboard_user, report_schedule_user, rls_filter_roles, rls_filter_tables,
slice_user, sqlatable_user. The redundant UNIQUE(fk1, fk2) on dashboard_slices
and report_schedule_user is dropped (subsumed by the new PK).

Migration handles dialect quirks: copy_from for tables with pre-existing
UNIQUE (so SQLite's anonymous-constraint reflection doesn't matter), wrapped-
subquery dedupe for MySQL (ERROR 1093), sa.Identity(always=False) on downgrade
to backfill the restored id column without NOT NULL violations, and distinct
PK names per direction (pk_<table> on upgrade, <table>_pkey on downgrade) to
avoid round-trip index-name collisions on Postgres.

ORM Table() definitions updated to match. UPDATING.md entry added with
operator runbook (BI-tool impact, pre-flight inventory queries, dedupe-row-
loss notice, pg_dump workaround, FK-NOT-NULL downgrade asymmetry note).

Tests: 8 schema-shape assertions (post-upgrade), 8 duplicate-rejection unit
tests, 8 distinct-pair sanity tests, 1 round-trip + idempotency test
(in-memory SQLite via Alembic MigrationContext).

Continuum-restore verification against the new shape is out of scope for this
PR; it is the responsibility of the versioning epic (sc-103156).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 13:39:09 -06:00
293 changed files with 20255 additions and 18831 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ runs:
echo "PYTHON_VERSION=${{ inputs.python-version }}" >> $GITHUB_ENV
fi
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: ${{ inputs.cache }}
+3 -3
View File
@@ -26,7 +26,7 @@ runs:
- name: Set up QEMU
if: ${{ inputs.build == 'true' }}
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
with:
# Pin the binfmt image to a specific QEMU release. The default
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
@@ -39,12 +39,12 @@ runs:
- name: Set up Docker Buildx
if: ${{ inputs.build == 'true' }}
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Try to login to DockerHub
if: ${{ inputs.login-to-dockerhub == 'true' }}
continue-on-error: true
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ inputs.dockerhub-user }}
password: ${{ inputs.dockerhub-token }}
+2 -3
View File
@@ -10,7 +10,7 @@ runs:
steps:
- name: Setup Node Env
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@v4
with:
node-version: '20'
@@ -21,9 +21,8 @@ runs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: ${{ inputs.from-npm == 'false' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v4
with:
persist-credentials: false
repository: apache-superset/supersetbot
path: supersetbot
+30 -30
View File
@@ -10,7 +10,7 @@ updates:
schedule:
interval: "daily"
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
ignore:
@@ -59,7 +59,7 @@ updates:
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "pip"
@@ -76,7 +76,7 @@ updates:
- pip
- dependabot
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: ".github/actions"
@@ -85,7 +85,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/docs/"
@@ -110,7 +110,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-websocket/"
@@ -121,7 +121,7 @@ updates:
- dependabot
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-websocket/utils/client-ws-app/"
@@ -133,7 +133,7 @@ updates:
open-pull-requests-limit: 10
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
# Now for all of our plugins and packages!
@@ -147,7 +147,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
@@ -159,7 +159,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
@@ -171,7 +171,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
@@ -186,7 +186,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
@@ -198,7 +198,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
@@ -210,7 +210,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
@@ -222,7 +222,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
@@ -234,7 +234,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-table/"
@@ -249,7 +249,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
@@ -261,7 +261,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
@@ -273,7 +273,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
@@ -285,7 +285,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
@@ -297,7 +297,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
@@ -309,7 +309,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
@@ -321,7 +321,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
@@ -333,7 +333,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
@@ -345,7 +345,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
@@ -357,7 +357,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
@@ -373,7 +373,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/generator-superset/"
@@ -385,7 +385,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
@@ -397,7 +397,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
@@ -414,7 +414,7 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-switchboard/"
@@ -426,4 +426,4 @@ updates:
open-pull-requests-limit: 5
versioning-strategy: increase
cooldown:
default-days: 7
default-days: 5
+43
View File
@@ -0,0 +1,43 @@
name: Cancel Duplicates
on:
workflow_run:
workflows:
- "Miscellaneous"
types:
- requested
jobs:
cancel-duplicate-runs:
name: Cancel duplicate workflow runs
runs-on: ubuntu-24.04
permissions:
actions: write
contents: read
steps:
- name: Check number of queued tasks
id: check_queued
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPO: ${{ github.repository }}
run: |
get_count() {
echo $(curl -s -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPO/actions/runs?status=$1" | \
jq ".total_count")
}
count=$(( `get_count queued` + `get_count in_progress` ))
echo "Found $count unfinished jobs."
echo "count=$count" >> $GITHUB_OUTPUT
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
if: steps.check_queued.outputs.count >= 20
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Cancel duplicate workflow runs
if: steps.check_queued.outputs.count >= 20
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
pip install click requests typing_extensions python-dateutil
python ./scripts/cancel_github_workflows.py
@@ -26,8 +26,6 @@ jobs:
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check and notify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
-4
View File
@@ -6,9 +6,6 @@ on:
pull_request_review_comment:
types: [created]
permissions:
contents: read
jobs:
check-permissions:
if: |
@@ -78,7 +75,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 1
- name: Run Claude PR Action
-2
View File
@@ -32,8 +32,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
-4
View File
@@ -28,8 +28,6 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: "Dependency Review"
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
continue-on-error: true
@@ -52,8 +50,6 @@ jobs:
steps:
- name: "Checkout Repository"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/
+1 -5
View File
@@ -95,11 +95,7 @@ jobs:
# in the context of push (using multi-platform build), we need to pull the image locally
- name: Docker pull
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
run: |
for i in 1 2 3; do
docker pull $IMAGE_TAG && break
[ $i -lt 3 ] && sleep 30
done
run: docker pull $IMAGE_TAG
- name: Print docker stats
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
@@ -34,8 +34,6 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
-2
View File
@@ -22,8 +22,6 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
@@ -0,0 +1,83 @@
name: Cleanup ephemeral envs (PR close) [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# The new Superset Showtime workflow handles cleanup automatically.
# See .github/workflows/showtime.yml and showtime-cleanup.yml for replacements.
# Migration guide: https://github.com/mistercrunch/superset-showtime
on:
pull_request_target:
types: [closed]
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
steps:
- name: "Check for secrets"
id: check
shell: bash
run: |
if [ -n "${AWS_ACCESS_KEY_ID}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
AWS_ACCESS_KEY_ID: ${{ (secrets.AWS_ACCESS_KEY_ID != '' && secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}
ephemeral-env-cleanup:
needs: config
if: needs.config.outputs.has-secrets
name: Cleanup ephemeral envs
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
- name: Delete ECS service
if: steps.describe-services.outputs.active == 'true'
id: delete-service
run: |
aws ecs delete-service \
--cluster superset-ci \
--service pr-${{ github.event.number }}-service \
--force
- name: Login to Amazon ECR
if: steps.describe-services.outputs.active == 'true'
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Delete ECR image tag
if: steps.describe-services.outputs.active == 'true'
id: delete-image-tag
run: |
aws ecr batch-delete-image \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-${{ github.event.number }}
- name: Comment (success)
if: steps.describe-services.outputs.active == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
github.rest.issues.createComment({
issue_number: ${{ github.event.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ **DEPRECATED WORKFLOW** - Ephemeral environment shutdown and build artifacts deleted. Please migrate to the new Superset Showtime system for future PRs.'
})
+350
View File
@@ -0,0 +1,350 @@
name: Ephemeral env workflow [DEPRECATED]
# ⚠️ DEPRECATION NOTICE ⚠️
# This workflow is deprecated and will be removed in a future version.
# Please use the new Superset Showtime workflow instead:
# - Use label "🎪 trigger-start" instead of "testenv-up"
# - Showtime provides better reliability and easier management
# - See .github/workflows/showtime.yml for the replacement
# - Migration guide: https://github.com/mistercrunch/superset-showtime
# Example manual trigger:
# gh workflow run ephemeral-env.yml --ref fix_ephemerals --field label_name="testenv-up" --field issue_number=666
on:
pull_request_target:
types:
- labeled
workflow_dispatch:
inputs:
label_name:
description: 'Label name to simulate label-based /testenv trigger'
required: true
default: 'testenv-up'
issue_number:
description: 'Issue or PR number'
required: true
jobs:
ephemeral-env-label:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-label
cancel-in-progress: true
name: Evaluate ephemeral env label trigger
runs-on: ubuntu-24.04
permissions:
pull-requests: write
outputs:
slash-command: ${{ steps.eval-label.outputs.result }}
feature-flags: ${{ steps.eval-feature-flags.outputs.result }}
sha: ${{ steps.get-sha.outputs.sha }}
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Check for the "testenv-up" label
id: eval-label
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
LABEL_NAME="${INPUT_LABEL_NAME}"
else
LABEL_NAME="${{ github.event.label.name }}"
fi
echo "Evaluating label: $LABEL_NAME"
if [[ "$LABEL_NAME" == "testenv-up" ]]; then
echo "result=up" >> $GITHUB_OUTPUT
else
echo "result=noop" >> $GITHUB_OUTPUT
fi
env:
INPUT_LABEL_NAME: ${{ github.event.inputs.label_name }}
- name: Get event SHA
id: get-sha
if: steps.eval-label.outputs.result == 'up'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
let prSha;
// If event is workflow_dispatch, use the issue_number from inputs
if (context.eventName === "workflow_dispatch") {
const prNumber = "${{ github.event.inputs.issue_number }}";
if (!prNumber) {
console.log("No PR number found.");
return;
}
// Fetch PR details using the provided issue_number
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prSha = pr.head.sha;
} else {
// If it's not workflow_dispatch, use the PR head sha from the event
prSha = context.payload.pull_request.head.sha;
}
console.log(`PR SHA: ${prSha}`);
core.setOutput("sha", prSha);
- name: Looking for feature flags in PR description
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
id: eval-feature-flags
if: steps.eval-label.outputs.result == 'up'
with:
script: |
const description = context.payload.pull_request
? context.payload.pull_request.body || ''
: context.payload.inputs.pr_description || '';
const pattern = /FEATURE_(\w+)=(\w+)/g;
let results = [];
[...description.matchAll(pattern)].forEach(match => {
const config = {
name: `SUPERSET_FEATURE_${match[1]}`,
value: match[2],
};
results.push(config);
});
return results;
- name: Reply with confirmation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
if: steps.eval-label.outputs.result == 'up'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const action = '${{ steps.eval-label.outputs.result }}';
const user = context.actor;
const runId = context.runId;
const workflowUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
const issueNumber = context.payload.pull_request
? context.payload.pull_request.number
: context.payload.inputs.issue_number;
if (!issueNumber) {
throw new Error("Issue number is not available.");
}
const body = `⚠️ **DEPRECATED WORKFLOW** ⚠️\n\n@${user} This workflow is deprecated! Please use the new **Superset Showtime** system instead:\n\n` +
`- Replace "testenv-up" label with "🎪 trigger-start"\n` +
`- Better reliability and easier management\n` +
`- See https://github.com/mistercrunch/superset-showtime for details\n\n` +
`Processing your ephemeral environment request [here](${workflowUrl}). Action: **${action}**.` +
` More information on [how to use or configure ephemeral environments]` +
`(https://superset.apache.org/docs/contributing/howtos/#github-ephemeral-environments)`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
ephemeral-docker-build:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-build
cancel-in-progress: true
needs: ephemeral-env-label
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: ephemeral-docker-build
runs-on: ubuntu-24.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ needs.ephemeral-env-label.outputs.sha }} : ${{steps.get-sha.outputs.sha}} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.ephemeral-env-label.outputs.sha }}
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
build: "true"
install-docker-compose: "false"
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Build ephemeral env image
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
supersetbot docker \
--push \
--load \
--preset ci \
--platform linux/amd64 \
--context-ref "$RELEASE" \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Load, tag and push image to ECR
id: push-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: superset-ci
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
ephemeral-env-up:
needs: [ephemeral-env-label, ephemeral-docker-build]
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
name: Spin up an ephemeral environment
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-2
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
- name: Check target image exists in ECR
id: check-image
continue-on-error: true
env:
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecr describe-images \
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
--repository-name superset-ci \
--image-ids imageTag=pr-$PR_NUMBER-ci
- name: Fail on missing container image
if: steps.check-image.outcome == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
script: |
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
github.rest.issues.createComment({
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: errMsg
});
core.setFailed(errMsg);
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
with:
task-definition: .github/workflows/ecs-task-definition.json
container-name: superset-ci
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
- name: Update env vars in the Amazon ECS task definition
run: |
cat <<< "$(jq '.containerDefinitions[0].environment += ${{ needs.ephemeral-env-label.outputs.feature-flags }}' < ${{ steps.task-def.outputs.task-definition }})" > ${{ steps.task-def.outputs.task-definition }}
- name: Describe ECS service
id: describe-services
run: |
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${INPUT_ISSUE_NUMBER}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Create ECS service
id: create-service
if: steps.describe-services.outputs.active != 'true'
env:
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
run: |
aws ecs create-service \
--cluster superset-ci \
--service-name pr-$PR_NUMBER-service \
--task-definition superset-ci \
--launch-type FARGATE \
--desired-count 1 \
--platform-version LATEST \
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
- name: Deploy Amazon ECS task definition
id: deploy-task
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
cluster: superset-ci
wait-for-service-stability: true
wait-for-minutes: 10
- name: List tasks
id: list-tasks
run: |
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${INPUT_ISSUE_NUMBER}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
env:
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
- name: Get network interface
id: get-eni
run: |
echo "eni=$(aws ecs describe-tasks --cluster superset-ci --tasks ${{ steps.list-tasks.outputs.task }} | jq '.tasks[0].attachments[0].details | map(select(.name=="networkInterfaceId"))[0].value')" >> $GITHUB_OUTPUT
- name: Get public IP
id: get-ip
run: |
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
- name: Comment (success)
if: ${{ success() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
});
- name: Comment (failure)
if: ${{ failure() }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{github.token}}
script: |
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
github.rest.issues.createComment({
issue_number: issue_number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'
})
+3 -14
View File
@@ -6,8 +6,7 @@ on:
- "master"
- "[0-9].[0-9]*"
pull_request:
branches:
- "**"
types: [synchronize, opened, reopened, ready_for_review]
permissions:
contents: read
@@ -16,19 +15,12 @@ jobs:
validate-all-ghas:
runs-on: ubuntu-24.04
permissions:
contents: read
# Required for the zizmor action to upload its SARIF results to
# GitHub code scanning (advanced-security is enabled by default).
security-events: write
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: '20'
@@ -37,6 +29,3 @@ jobs:
- name: Run Script
run: bash .github/workflows/github-action-validator.sh
- name: Check for security issues on GHA workflows
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-24.04
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- uses: actions/labeler@v6
with:
sync-labels: true
+1 -3
View File
@@ -20,9 +20,7 @@ jobs:
- name: Check for latest tag
id: latest-tag
run: |
source ./scripts/tag_latest_release.sh $(echo ${GITHUB_EVENT_RELEASE_TAG_NAME}) --dry-run
env:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
source ./scripts/tag_latest_release.sh $(echo ${{ github.event.release.tag_name }}) --dry-run
- name: Configure Git
run: |
-6
View File
@@ -6,9 +6,6 @@ on:
- "master"
- "[0-9].[0-9]*"
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -30,12 +27,9 @@ jobs:
if: needs.config.outputs.has-secrets
name: Bump version and publish package(s)
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# pulls all commits (needed for lerna / semantic release to correctly version)
fetch-depth: 0
- name: Get tags and filter trigger tags
+1 -1
View File
@@ -102,7 +102,7 @@ jobs:
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |
echo "::notice::Maintainer ${GITHUB_ACTOR} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
echo "::notice::Maintainer ${{ github.actor }} triggered deploy for PR ${PULL_REQUEST_NUMBER}"
pip install --upgrade superset-showtime
showtime version
+1 -10
View File
@@ -27,9 +27,6 @@ concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -48,13 +45,7 @@ jobs:
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
build-deploy:
needs: config
# For workflow_run triggers, only deploy when the triggering run originated
# from this repository (not a fork), ensuring the checked-out code and any
# local actions executed with deploy credentials are trusted.
if: >-
needs.config.outputs.has-secrets &&
(github.event_name != 'workflow_run' ||
github.event.workflow_run.head_repository.full_name == github.repository)
if: needs.config.outputs.has-secrets
name: Build & Deploy
runs-on: ubuntu-24.04
steps:
+1 -7
View File
@@ -16,9 +16,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_sha || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
linkinator:
# See docs here: https://github.com/marketplace/actions/linkinator
@@ -28,8 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
@@ -102,8 +97,7 @@ jobs:
# Only runs if integration tests succeeded
if: >
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository
github.event.workflow_run.conclusion == 'success'
name: Build (after integration tests)
runs-on: ubuntu-24.04
defaults:
@@ -53,7 +53,7 @@ jobs:
- name: Upload coverage reports to Codecov
if: steps.check.outputs.superset-extensions-cli
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
file: ./coverage.xml
flags: superset-extensions-cli
+1 -4
View File
@@ -16,9 +16,6 @@ concurrency:
env:
TAG: apache/superset:GHA-${{ github.run_id }}
permissions:
contents: read
jobs:
frontend-build:
runs-on: ubuntu-24.04
@@ -131,7 +128,7 @@ jobs:
run: npx nyc merge coverage/ merged-output/coverage-summary.json
- name: Upload Code Coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: javascript
use_oidc: true
@@ -70,7 +70,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,mysql
verbose: true
@@ -164,7 +164,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,postgres
verbose: true
@@ -219,7 +219,7 @@ jobs:
run: |
./scripts/python_tests.sh
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,sqlite
verbose: true
@@ -79,7 +79,7 @@ jobs:
run: |
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,presto
verbose: true
@@ -150,7 +150,7 @@ jobs:
pip install -e .[hive]
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,hive
verbose: true
@@ -56,7 +56,7 @@ jobs:
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
with:
flags: python,unit
verbose: true
+2 -11
View File
@@ -21,9 +21,6 @@ on:
options:
- 'true'
- 'false'
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -45,8 +42,6 @@ jobs:
if: needs.config.outputs.has-secrets
name: docker-release
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
@@ -56,7 +51,6 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Setup Docker Environment
@@ -83,9 +77,8 @@ jobs:
INPUT_RELEASE: ${{ github.event.inputs.release }}
INPUT_FORCE_LATEST: ${{ github.event.inputs.force-latest }}
INPUT_GIT_REF: ${{ github.event.inputs.git-ref }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
RELEASE="${{ github.event.release.tag_name }}"
FORCE_LATEST=""
EVENT="${{github.event_name}}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -121,7 +114,6 @@ jobs:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
fetch-depth: 0
- name: Use Node.js 20
@@ -136,12 +128,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_RELEASE: ${{ github.event.inputs.release }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
export GITHUB_ACTOR=""
git fetch --all --tags
git checkout master
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
RELEASE="${{ github.event.release.tag_name }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# in the case of a manually-triggered run, read release from input
RELEASE="${INPUT_RELEASE}"
-2
View File
@@ -33,8 +33,6 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+4 -9
View File
@@ -12,16 +12,11 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
uses: actions/first-interaction@v3
continue-on-error: true
with:
repo_token: ${{ github.token }}
issue_message: |-
Congrats on opening your first issue and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
pr_message: |-
repo-token: ${{ github.token }}
pr-message: |-
Congrats on making your first PR and thank you for contributing to Superset! :tada: :heart:
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
We hope to see you in our [Slack](https://apache-superset.slack.com/) community too! Not signed up? Use our [Slack App](http://bit.ly/join-superset-slack) to self-register.
-11
View File
@@ -158,14 +158,3 @@ repos:
language: system
files: ^superset/config\.py$
pass_filenames: false
- id: zizmor
name: zizmor (GHA security audit)
entry: zizmor
language: python
additional_dependencies: [zizmor==1.25.2]
files: ^\.github/
types: [yaml]
pass_filenames: false
# Advisory until pre-existing findings are resolved; remove
# --no-exit-codes to make this hook blocking.
args: [--no-exit-codes, .github/]
+1 -1
View File
@@ -113,7 +113,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
# Some bash scripts needed throughout the layers
COPY --chmod=755 docker/*.sh /app/docker/
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN pip install --no-cache-dir --upgrade uv
# Using uv as it's faster/simpler than pip
RUN uv venv /app/.venv
+354
View File
@@ -24,6 +24,120 @@ assists people when migrating to a new version.
## Next
### Entity version history for charts, dashboards, and datasets
Saves of charts, dashboards, and datasets now automatically produce a version history — browsable and restorable via new API endpoints. No frontend UI in this release; the backend plumbing is the deliverable.
**New endpoints** (per entity type — same pattern for `chart`, `dashboard`, and `dataset`):
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/{resource}/<uuid>/versions/` | List the entity's version history (0-based `version_number`, `version_uuid`, `issued_at`, `changed_by`) |
| `GET` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/` | Get a single version snapshot (scalar fields at that version; plus `columns` / `metrics` for datasets) |
| `POST` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore` | Restore the entity to the state captured by that version |
`<version_uuid>` is a deterministic `UUIDv5` derived from the entity's UUID and the Continuum transaction id — stable across replicas and retention pruning. Authorisation reuses the resource's existing `can_write` permission; workspace admins can list/restore any entity.
**Version response shape — `changes` array:**
Each entry returned by `GET /api/v1/{resource}/<uuid>/versions/` and `GET .../versions/<version_uuid>/` includes a `changes` array describing what changed relative to the previous version:
```json
"changes": [
{"kind": "field", "path": "slice_name", "from_value": "Old", "to_value": "New"}
]
```
The array is empty for baseline (`operation_type=0`) transactions. `kind` enumerates structured record types (`field`, layout-walker records for dashboards, dataset child diffs for `TableColumn` / `SqlMetric`); `path` is a dotted JSON-pointer-style locator; `from_value` / `to_value` are JSON-safe scalars or compact records.
**Save-response and ETag headers:**
- Save responses (`PUT /api/v1/{resource}/<pk>`) include `old_version_uuid` and `new_version_uuid` body fields so the client can correlate a save with its resulting version row.
- All entity GETs (`GET /api/v1/{chart,dashboard,dataset}/<pk>`), version-list GETs, single-version GETs, and save responses emit an `ETag: "<version_uuid>"` header reflecting the entity's current live version. The default `CORS_OPTIONS` now sets `expose_headers: ["ETag"]` so cross-origin browser clients can read the header. **No `If-Match` enforcement in v1**`ETag` is informational; concurrent-edit detection is deferred to a follow-up SIP.
- **Operators overriding `CORS_OPTIONS` in `superset_config.py` MUST include `"expose_headers": ["ETag"]`** (or merge with the default) for cross-origin clients to read the ETag. A bare `CORS_OPTIONS = {"origins": [...]}` will silently drop the expose-headers default.
**Behaviour changes on save:**
- Every save of a chart, dashboard, or dataset produces one new version row. Rows preserve the full post-save state (scalar fields for all three entity types; `TableColumn` / `SqlMetric` children for datasets; `dashboard_slices` chart membership for dashboards — children versioned via SQLAlchemy-Continuum shadow tables `table_columns_version`, `sql_metrics_version`, and `dashboard_slices_version`).
- First save after an entity already exists in the DB creates a retroactive baseline version so the UI can show "what this looked like before I edited it."
- Tags, owners, and roles are **not** versioned in v1 (ADR-005). A restore leaves those at their live values.
**New config key:**
| Key | Default | Purpose |
|---|---|---|
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Versions older than this many days are pruned by a nightly Celery beat task (`superset.tasks.version_history_retention.prune_old_versions`). Each entity's live row (`end_transaction_id IS NULL`) is always preserved; closed historical rows including the baseline age out with the rest. Set to `0` to disable retention entirely. |
**Impact on external integrations:**
- New tables populated on every save — `dashboards_version`, `slices_version`, `tables_version` (parent shadow tables for the three entity types), `table_columns_version`, `sql_metrics_version`, `dashboard_slices_version` (child shadow tables), plus the shared `version_transaction` and `version_changes` tables. External tooling that queries Superset's DB directly will see writes to these tables proportional to save traffic.
- Existing entity endpoints (`GET`/`PUT /api/v1/{chart,dashboard,dataset}/<pk>`) gain an `ETag` response header and the save response gains `old_version_uuid` / `new_version_uuid` body fields. No existing fields are removed or repurposed.
- Version capture is always active — no feature flag.
### Cross-entity activity stream for charts, dashboards, and datasets
A read-only companion to the version-history endpoints (above). Each entity type gains an `/activity/` endpoint that returns a chronological stream of edits — the entity's own edits plus, for dashboards and charts, transitive edits to related entities during their association windows.
**New endpoints** (per entity type):
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/v1/dashboard/<uuid>/activity/` | Dashboard own edits + edits to charts attached during their dashboard window + edits to datasets those charts pointed at during their chart window |
| `GET` | `/api/v1/chart/<uuid>/activity/` | Chart own edits + edits to datasets the chart pointed at during association |
| `GET` | `/api/v1/dataset/<uuid>/activity/` | Dataset own edits only (no transitive layer in V2) |
**Query parameters** (all optional):
| Param | Type | Default | Purpose |
|---|---|---|---|
| `since` | ISO 8601 datetime | — | Lower bound on `issued_at` |
| `until` | ISO 8601 datetime | — | Upper bound on `issued_at` |
| `include` | `self` \| `related` \| `all` | `all` | Filter to only the entity's own edits, only related edits, or both |
| `page` | integer ≥ 0 | `0` | 0-based page index |
| `page_size` | integer in `[1, 200]` | `25` | Records per page (clamped silently to 200) |
**Response shape:**
```json
{
"result": [
{
"version_uuid": "...",
"entity_kind": "chart",
"entity_uuid": "...",
"entity_name": "Top 10 Girls",
"entity_deleted": false,
"entity_deletion_state": null,
"source": "related",
"transaction_id": 1234,
"issued_at": "2026-05-26T12:00:00",
"changed_by": {"id": 5, "first_name": "Mike", "last_name": "Bridge"},
"kind": "filter",
"path": ["params", "adhoc_filters", "country"],
"from_value": null,
"to_value": "US",
"summary": "Chart filter changed: Top 10 Girls",
"impact": null
}
],
"count": 47
}
```
`count` is the total record count *after* the silent permission filter (see below), not the raw query size.
**Authorisation:** reuses the resource's existing `can_write` permission. Workspace admins can read any entity's activity stream. The endpoint runs `raise_for_ownership` on the path entity — non-owners get `403`.
**Silent permission filter (AV-008):** records whose source entity the requesting user can't read are silently dropped — no placeholder, no count contribution. The frontend cannot distinguish "no activity" from "you can't see this activity."
**Tombstones (AV-009 / D-15):** when an activity record references a hard-deleted source entity, the record still appears with `entity_deleted: true`, `entity_uuid: null`, and `entity_name` recovered from the last shadow row.
**Impact on external integrations:**
- Pure read-only. No new tables, no new columns, no migrations. Reads sc-103156's shadow tables and the `version_changes` table.
- No new save-path code paths — perf-validation gate confirms the activity-view branch does not regress sc-103156's SC-004 50ms-overhead budget.
- No feature flag; the endpoints are always available once sc-103156's version-history feature is enabled.
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
@@ -310,6 +424,246 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
}
```
### Composite primary keys on many-to-many association tables
The eight M:N association tables listed below have been changed from a synthetic surrogate `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on the two foreign-key columns. The `id` column is dropped, and the two tables that previously carried a redundant `UNIQUE (fk1, fk2)` constraint have that constraint removed (it is now subsumed by the composite primary key).
**Affected tables and their composite-PK column pairs:**
| Table | Composite PK |
|---|---|
| `dashboard_roles` | `(dashboard_id, role_id)` |
| `dashboard_slices` | `(dashboard_id, slice_id)` |
| `dashboard_user` | `(user_id, dashboard_id)` |
| `report_schedule_user` | `(user_id, report_schedule_id)` |
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
| `slice_user` | `(user_id, slice_id)` |
| `sqlatable_user` | `(user_id, table_id)` |
**Impact on external readers:** Any BI tool, custom report, backup script, or external integration that references these tables by their old surrogate `id` column (e.g., `SELECT id FROM dashboard_slices WHERE …`, `WHERE dashboard_slices.id IN (…)`) will break. Update such queries to project or filter on the FK pair (`dashboard_id, slice_id`) instead. The FK columns themselves are unchanged.
**Pre-flight inventory queries.** Before applying the upgrade, operators are encouraged to run the queries below against their database to assess what the migration will change. Two classes of pre-existing data are not preserved by the migration: duplicate `(fk1, fk2)` rows (the migration keeps `MIN(id)` and deletes the rest) and rows with `NULL` in either FK column (the migration deletes them, since FK columns are promoted to `NOT NULL` for the composite PK). Compliance- or audit-sensitive operators should also `\copy` (Postgres) or `SELECT … INTO OUTFILE` (MySQL) the affected rows for their own records before upgrading.
```sql
-- Duplicate (fk1, fk2) pairs (the migration will keep MIN(id) per group, delete the rest)
SELECT dashboard_id, role_id, COUNT(*) FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1;
SELECT dashboard_id, slice_id, COUNT(*) FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1;
SELECT user_id, dashboard_id, COUNT(*) FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1;
SELECT user_id, report_schedule_id, COUNT(*) FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1;
SELECT role_id, rls_filter_id, COUNT(*) FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1;
SELECT table_id, rls_filter_id, COUNT(*) FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1;
SELECT user_id, slice_id, COUNT(*) FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1;
SELECT user_id, table_id, COUNT(*) FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1;
-- Rows with a NULL in either FK (the migration will delete these)
SELECT COUNT(*) FROM dashboard_roles WHERE dashboard_id IS NULL OR role_id IS NULL;
SELECT COUNT(*) FROM dashboard_slices WHERE dashboard_id IS NULL OR slice_id IS NULL;
SELECT COUNT(*) FROM dashboard_user WHERE user_id IS NULL OR dashboard_id IS NULL;
SELECT COUNT(*) FROM report_schedule_user WHERE user_id IS NULL OR report_schedule_id IS NULL;
SELECT COUNT(*) FROM rls_filter_roles WHERE role_id IS NULL OR rls_filter_id IS NULL;
SELECT COUNT(*) FROM rls_filter_tables WHERE table_id IS NULL OR rls_filter_id IS NULL;
SELECT COUNT(*) FROM slice_user WHERE user_id IS NULL OR slice_id IS NULL;
SELECT COUNT(*) FROM sqlatable_user WHERE user_id IS NULL OR table_id IS NULL;
```
**Sizing the maintenance window on PostgreSQL.** The queries above are dialect-portable but only count rows. Operators on PostgreSQL can run the diagnostic queries below to characterize the migration's runtime cost ahead of time: per-table row count and on-disk size, an aggregated duplicate roll-up, the external-FK pre-flight check (the migration runs the same check and aborts if it returns rows), and a rewrite-time estimate for the two tables that go through the slower full-table-rebuild path.
```sql
-- Per-table size, row count, and which migration path each will take.
-- Two tables ("dashboard_slices", "report_schedule_user") have a
-- redundant UNIQUE constraint that the migration drops via a full
-- table rewrite (op.batch_alter_table(recreate="always")). The other
-- six use direct ALTER TABLE, which is much cheaper.
WITH affected(name, has_unique) AS (
VALUES
('dashboard_roles', false),
('dashboard_slices', true),
('dashboard_user', false),
('report_schedule_user', true),
('rls_filter_roles', false),
('rls_filter_tables', false),
('slice_user', false),
('sqlatable_user', false)
)
SELECT
a.name AS table_name,
CASE WHEN a.has_unique THEN 'recreate (full rewrite)'
ELSE 'direct ALTER' END AS migration_path,
c.reltuples::bigint AS estimated_rows,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
FROM affected a
JOIN pg_class c ON c.relname = a.name AND c.relkind = 'r'
ORDER BY pg_total_relation_size(c.oid) DESC;
```
```sql
-- Aggregated duplicate-row roll-up.
-- "dup_groups" is the number of (fk1, fk2) pairs that appear more
-- than once; "rows_dropped" is the total number of rows the
-- migration will delete during the dedupe pass (it keeps MIN(id) per
-- group and discards the rest).
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
ORDER BY rows_dropped DESC NULLS LAST;
```
```sql
-- External-FK pre-flight check.
-- The migration runs the equivalent check at upgrade time and aborts
-- if any external FK references one of the soon-to-be-removed `id`
-- columns. Running it ahead of time lets you discover (and migrate)
-- any such reference before the maintenance window. On a stock
-- Superset install this should return zero rows. (Default schema
-- only; multi-schema deployments need to broaden the lookup.)
SELECT
rc.constraint_name,
kcu.table_schema || '.' || kcu.table_name AS referencing_table,
kcu.column_name AS referencing_column,
ccu.table_name AS referenced_table,
ccu.column_name AS referenced_column
FROM information_schema.referential_constraints rc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = rc.constraint_name
AND kcu.constraint_schema = rc.constraint_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = rc.constraint_name
AND ccu.constraint_schema = rc.constraint_schema
WHERE ccu.table_name IN (
'dashboard_roles','dashboard_slices','dashboard_user',
'report_schedule_user','rls_filter_roles','rls_filter_tables',
'slice_user','sqlatable_user')
AND ccu.column_name = 'id';
```
```sql
-- Lock-window estimate for the two full-rewrite tables.
-- recreate="always" takes ACCESS EXCLUSIVE on the table for the full
-- rewrite. Use heap size combined with your hardware's effective
-- write throughput (~100-200 MB/s on commodity SSD; faster on NVMe)
-- to size the maintenance window. The other six tables use direct
-- ALTER and are dominated by composite-index build time, typically
-- seconds for tables in the low millions of rows.
SELECT
c.relname AS table_name,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_relation_size(c.oid) / 1024 / 1024 AS heap_size_mb,
ROUND(pg_relation_size(c.oid) / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
FROM pg_class c
WHERE c.relname IN ('dashboard_slices', 'report_schedule_user');
```
**Sizing the maintenance window on MySQL.** Equivalent diagnostic queries for MySQL/InnoDB. One important difference from PostgreSQL: InnoDB rebuilds the clustered index on every PK change, so *all eight* tables undergo a full table rebuild on MySQL — not just the two that go through the explicit `recreate="always"` path. The lock-window estimate query below therefore covers all eight tables.
```sql
-- Per-table size, row count, and which migration path each will take.
-- TABLE_ROWS is an InnoDB estimate (analogous to PostgreSQL's reltuples);
-- run SELECT COUNT(*) per table for an exact count if needed.
SELECT
TABLE_NAME AS table_name,
CASE WHEN TABLE_NAME IN ('dashboard_slices', 'report_schedule_user')
THEN 'recreate (explicit, drops UNIQUE)'
ELSE 'direct ALTER (still rebuilds InnoDB clustered index)'
END AS migration_path,
TABLE_ROWS AS estimated_rows,
CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1), ' MB') AS total_size,
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 1), ' MB') AS index_size
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
'slice_user', 'sqlatable_user'
)
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;
```
```sql
-- Aggregated duplicate-row roll-up. Same SQL as the PostgreSQL version
-- (standard SQL); included here for copy-paste convenience.
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
ORDER BY rows_dropped DESC;
```
```sql
-- External-FK pre-flight check. KEY_COLUMN_USAGE on MySQL carries
-- both sides of the FK in a single row, so this is simpler than the
-- PostgreSQL version. Should return zero rows on a stock install.
SELECT
CONSTRAINT_NAME,
CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS referencing_table,
COLUMN_NAME AS referencing_column,
REFERENCED_TABLE_NAME AS referenced_table,
REFERENCED_COLUMN_NAME AS referenced_column
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IN (
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
'slice_user', 'sqlatable_user'
)
AND REFERENCED_COLUMN_NAME = 'id';
```
```sql
-- Lock-window estimate for ALL EIGHT tables (InnoDB rebuilds the
-- clustered index on PK change, so even "direct ALTER" is a rewrite).
-- ADD PRIMARY KEY is INPLACE but not LOCK=NONE — it allows concurrent
-- reads but blocks writes. Use heap size combined with your effective
-- rebuild throughput (~100-200 MB/s on commodity SSD; higher on NVMe).
SELECT
TABLE_NAME AS table_name,
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS heap_size_mb,
ROUND(DATA_LENGTH / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
'slice_user', 'sqlatable_user'
)
ORDER BY DATA_LENGTH DESC;
```
**Restoring an old `pg_dump` (or equivalent) against the new schema.** A dump taken before the migration includes `INSERT` statements that populate the now-removed `id` column. Restoring such a dump against the post-migration schema will fail. The supported workaround is to dump only the schema and reference data, then re-create the M:N associations from application data after restore — for example with `pg_dump --exclude-table-data` (or per-table `--exclude-table-data=dashboard_slices` etc.) for the eight junction tables, restore the rest, then run a one-shot script that re-INSERTs `(fk1, fk2)` pairs derived from your application export. Operators who need to restore an old dump verbatim should restore against a pre-migration Superset and then re-run the upgrade.
**Intentional downgrade asymmetry.** The migration's `downgrade()` restores the surrogate `id` column and (for `dashboard_slices` and `report_schedule_user`) the original `UNIQUE (fk1, fk2)` constraint, but it does **not** restore the original `NULL`-allowed state on the FK columns — they remain `NOT NULL`. This is intentional: under SQLAlchemy's `secondary=` semantics, a `NULL` in either FK column of a junction table is meaningless (it cannot participate in either side of the relationship). Operators downgrading are not expected to need this restored. The asymmetry is documented for completeness so that round-trip schema diffs are not mistaken for migration bugs.
**Constraint-name divergence between upgrade and downgrade.** The composite primary key created on upgrade is named `pk_<table>` (Alembic's default for `op.create_primary_key("pk_<table>", ...)`), while the surrogate `id` primary key restored on downgrade is named `<table>_pkey` (PostgreSQL's default convention for `PrimaryKeyConstraint("id")`). The two names alternate so that a round-trip (upgrade → downgrade → upgrade) does not collide on a pre-existing constraint name. Operators using schema-comparison tools (e.g. `pg_diff`, `migra`) against a downgraded database may see this as drift versus a fresh-install schema. It is cosmetic — no application code references either constraint name.
## 6.0.0
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
+117
View File
@@ -0,0 +1,117 @@
# 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.
#
# Compose override that swaps the default Postgres metadata DB for MySQL 8.
# Useful for evaluating dialect-specific behaviour (e.g., DDL-migration
# cost on a deployment whose production metadata DB is MySQL).
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml up
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml down
#
# To switch back to Postgres, just drop the second `-f` flag — the MySQL
# data lives in a separate volume (`db_home_mysql`) so neither side is
# corrupted by switching dialects.
#
# Notes:
# - Mirrors the connection settings used by CI's `test-mysql` shard:
# dialect ``mysql+mysqldb``, charset utf8mb4 with binary_prefix.
# - Host port 13306 (configurable via DATABASE_PORT_MYSQL) to avoid
# colliding with a native MySQL install on 3306.
# - The Postgres-specific init scripts under
# docker/docker-entrypoint-initdb.d/ are not mounted (they are
# postgres-only); examples / cypress fixtures still load via
# `superset-init`'s post-startup steps.
# Shared environment override applied to every Superset-side service that
# connects to the metadata DB. ``environment:`` takes precedence over the
# values inherited from the env_file in docker-compose.yml.
x-mysql-env: &mysql-env
DATABASE_DIALECT: mysql+mysqldb
DATABASE_HOST: db
DATABASE_PORT: "3306"
DATABASE_DB: superset
DATABASE_USER: superset
DATABASE_PASSWORD: superset
SQLALCHEMY_DATABASE_URI: "mysql+mysqldb://superset:superset@db:3306/superset?charset=utf8mb4&binary_prefix=true"
# Override the analytics-examples DB connection too. ``EXAMPLES_PORT``
# in docker/.env is hardcoded to 5432 (the Postgres port); without
# this override the examples connection would try MySQL on 5432 and
# fail. The examples user/DB are created by docker/mysql-init/
# examples-init.sql on first MySQL boot.
EXAMPLES_HOST: db
EXAMPLES_PORT: "3306"
EXAMPLES_DB: examples
EXAMPLES_USER: examples
EXAMPLES_PASSWORD: examples
SUPERSET__SQLALCHEMY_EXAMPLES_URI: "mysql+mysqldb://examples:examples@db:3306/examples?charset=utf8mb4&binary_prefix=true"
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: superset
MYSQL_USER: superset
MYSQL_PASSWORD: superset
MYSQL_ROOT_PASSWORD: root
# The original 5432 port mapping is harmless on a MySQL container
# (nothing listens on 5432 inside it) but we add 13306->3306 so the
# MySQL port is reachable from the host without colliding with a
# native MySQL on 3306. Compose merges port lists.
ports:
- "127.0.0.1:${DATABASE_PORT_MYSQL:-13306}:3306"
# Override the init-scripts mount by re-binding the same target path
# to a MySQL-compatible directory. Compose merges volume lists by
# target path; later definitions win on conflict, so this displaces
# the Postgres-specific ``./docker/docker-entrypoint-initdb.d`` mount
# from docker-compose.yml. Without this, MySQL would try to run
# ``cypress-init.sh`` (which invokes ``psql``, not in the MySQL
# image), abort the init phase, and never create the ``examples``
# database. Add the MySQL data volume separately.
volumes:
- db_home_mysql:/var/lib/mysql
- ./docker/mysql-init:/docker-entrypoint-initdb.d
command:
- --default-authentication-plugin=caching_sha2_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -proot --silent"]
interval: 5s
timeout: 5s
retries: 20
superset:
environment: *mysql-env
superset-init:
environment: *mysql-env
superset-worker:
environment: *mysql-env
superset-worker-beat:
environment: *mysql-env
superset-node:
environment: *mysql-env
superset-tests-worker:
environment: *mysql-env
volumes:
db_home_mysql:
+32
View File
@@ -0,0 +1,32 @@
-- Licensed to the Apache Software Foundation (ASF) under one
-- or more contributor license agreements. See the NOTICE file
-- distributed with this work for additional information
-- regarding copyright ownership. The ASF licenses this file
-- to you under the Apache License, Version 2.0 (the
-- "License"); you may not use this file except in compliance
-- with the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the License is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations
-- under the License.
-- MySQL counterpart to docker/docker-entrypoint-initdb.d/examples-init.sh.
-- Creates the analytics-examples database and user that Superset's
-- ``load-examples`` command writes to. Mounted by docker-compose-mysql.yml
-- at /docker-entrypoint-initdb.d/ so the MySQL image's first-boot
-- entrypoint runs it automatically. (The Postgres init scripts under
-- docker/docker-entrypoint-initdb.d/ are NOT mounted on the MySQL
-- service — they invoke psql, which doesn't exist in the MySQL image.)
CREATE DATABASE IF NOT EXISTS examples
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
CREATE USER IF NOT EXISTS 'examples'@'%' IDENTIFIED BY 'examples';
GRANT ALL PRIVILEGES ON examples.* TO 'examples'@'%';
FLUSH PRIVILEGES;
+3 -7
View File
@@ -70,9 +70,9 @@
"@storybook/preview-api": "^8.6.18",
"@storybook/theming": "^8.6.15",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.40",
"@swc/core": "^1.15.33",
"antd": "^6.4.3",
"baseline-browser-mapping": "^2.10.32",
"baseline-browser-mapping": "^2.10.31",
"caniuse-lite": "^1.0.30001793",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -128,11 +128,7 @@
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19",
"swagger-client": "3.37.3",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"yaml": "1.10.3",
"uuid": "11.1.1"
"swagger-client": "3.37.3"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
+96 -81
View File
@@ -4033,86 +4033,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
"@swc/core-darwin-arm64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3"
integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==
"@swc/core-darwin-x64@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
"@swc/core-darwin-x64@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0"
integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==
"@swc/core-linux-arm-gnueabihf@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
"@swc/core-linux-arm-gnueabihf@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a"
integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==
"@swc/core-linux-arm64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
"@swc/core-linux-arm64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c"
integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==
"@swc/core-linux-arm64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
"@swc/core-linux-arm64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd"
integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==
"@swc/core-linux-ppc64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
"@swc/core-linux-ppc64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2"
integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==
"@swc/core-linux-s390x-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
"@swc/core-linux-s390x-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0"
integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==
"@swc/core-linux-x64-gnu@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
"@swc/core-linux-x64-gnu@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662"
integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==
"@swc/core-linux-x64-musl@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
"@swc/core-linux-x64-musl@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3"
integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==
"@swc/core-win32-arm64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
"@swc/core-win32-arm64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2"
integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==
"@swc/core-win32-ia32-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
"@swc/core-win32-ia32-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9"
integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==
"@swc/core-win32-x64-msvc@1.15.40":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
"@swc/core-win32-x64-msvc@1.15.33":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c"
integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
version "1.15.40"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
"@swc/core@^1.15.33", "@swc/core@^1.7.39":
version "1.15.33"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6"
integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.26"
optionalDependencies:
"@swc/core-darwin-arm64" "1.15.40"
"@swc/core-darwin-x64" "1.15.40"
"@swc/core-linux-arm-gnueabihf" "1.15.40"
"@swc/core-linux-arm64-gnu" "1.15.40"
"@swc/core-linux-arm64-musl" "1.15.40"
"@swc/core-linux-ppc64-gnu" "1.15.40"
"@swc/core-linux-s390x-gnu" "1.15.40"
"@swc/core-linux-x64-gnu" "1.15.40"
"@swc/core-linux-x64-musl" "1.15.40"
"@swc/core-win32-arm64-msvc" "1.15.40"
"@swc/core-win32-ia32-msvc" "1.15.40"
"@swc/core-win32-x64-msvc" "1.15.40"
"@swc/core-darwin-arm64" "1.15.33"
"@swc/core-darwin-x64" "1.15.33"
"@swc/core-linux-arm-gnueabihf" "1.15.33"
"@swc/core-linux-arm64-gnu" "1.15.33"
"@swc/core-linux-arm64-musl" "1.15.33"
"@swc/core-linux-ppc64-gnu" "1.15.33"
"@swc/core-linux-s390x-gnu" "1.15.33"
"@swc/core-linux-x64-gnu" "1.15.33"
"@swc/core-linux-x64-musl" "1.15.33"
"@swc/core-win32-arm64-msvc" "1.15.33"
"@swc/core-win32-ia32-msvc" "1.15.33"
"@swc/core-win32-x64-msvc" "1.15.33"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -5568,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.32"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.31"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
batch@0.6.1:
version "0.6.1"
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@4.18.1, lodash-es@^4.17.21:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash.debounce@^4, lodash.debounce@^4.0.8:
version "4.0.8"
@@ -9701,7 +9701,12 @@ lodash.uniq@^4.5.0:
resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz"
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
lodash@4.17.21, lodash@4.18.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
lodash@4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
version "4.18.1"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
@@ -14721,10 +14726,15 @@ utils-merge@1.0.1:
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@11.1.1, uuid@8.3.2, "uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^8.3.2:
version "11.1.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
uuid@8.3.2, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0":
version "14.0.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
uvu@^0.5.0:
version "0.5.6"
@@ -15129,9 +15139,9 @@ ws@^7.3.1:
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.18.0, ws@^8.2.3:
version "8.20.1"
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
version "8.18.3"
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
wsl-utils@^0.1.0:
version "0.1.0"
@@ -15199,7 +15209,12 @@ yaml-ast-parser@0.0.43:
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
yaml@1.10.2, yaml@1.10.3, yaml@^1.10.0:
yaml@1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^1.10.0:
version "1.10.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
+1
View File
@@ -100,6 +100,7 @@ dependencies = [
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.8.0, <31",
# newer pandas needs 0.9+
+5 -2
View File
@@ -208,7 +208,7 @@ kombu==5.5.3
# via celery
limits==5.1.0
# via flask-limiter
mako==1.3.12
mako==1.3.11
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -409,7 +409,10 @@ sqlalchemy==1.4.54
# flask-sqlalchemy
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-continuum
# sqlalchemy-utils
sqlalchemy-continuum==1.6.0
# via apache-superset (pyproject.toml)
sqlalchemy-utils==0.42.0
# via
# apache-superset (pyproject.toml)
@@ -451,7 +454,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.7.0
urllib3==2.6.3
# via
# -r requirements/base.in
# requests
+10 -6
View File
@@ -346,7 +346,6 @@ google-auth==2.43.0
# google-api-core
# google-auth-oauthlib
# google-cloud-bigquery
# google-cloud-bigquery-storage
# google-cloud-core
# pandas-gbq
# pydata-google-auth
@@ -361,7 +360,7 @@ google-cloud-bigquery==3.27.0
# apache-superset
# pandas-gbq
# sqlalchemy-bigquery
google-cloud-bigquery-storage==2.26.0
google-cloud-bigquery-storage==2.19.1
# via pandas-gbq
google-cloud-core==2.4.1
# via google-cloud-bigquery
@@ -507,7 +506,7 @@ limits==5.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
mako==1.3.12
mako==1.3.11
# via
# -c requirements/base-constraint.txt
# alembic
@@ -702,7 +701,7 @@ proto-plus==1.25.0
# via
# google-api-core
# google-cloud-bigquery-storage
protobuf==5.29.6
protobuf==4.25.8
# via
# google-api-core
# google-cloud-bigquery-storage
@@ -840,7 +839,7 @@ python-dotenv==1.2.2
# pydantic-settings
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.29
python-multipart==0.0.20
# via mcp
pytz==2025.2
# via
@@ -979,9 +978,14 @@ sqlalchemy==1.4.54
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-continuum
# sqlalchemy-utils
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-continuum==1.6.0
# via
# -c requirements/base-constraint.txt
# apache-superset
sqlalchemy-utils==0.42.0
# via
# -c requirements/base-constraint.txt
@@ -1072,7 +1076,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.7.0
urllib3==2.6.3
# via
# -c requirements/base-constraint.txt
# botocore
+1 -2
View File
@@ -33,7 +33,6 @@ PATTERNS = {
r"^setup\.py",
r"^pyproject\.toml$",
r"^requirements/.+\.txt",
r"^pyproject\.toml",
r"^.pylintrc",
],
"frontend": [
@@ -157,7 +156,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
def get_git_sha() -> str:
return os.getenv("GITHUB_SHA") or subprocess.check_output( # noqa: S603
["git", "rev-parse", "HEAD"] # noqa: S603, S607
["git", "rev-parse", "HEAD"] # noqa: S607
).strip().decode("utf-8")
+1 -1
View File
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
if [ -n "$output" ]; then echo "$output"; fi
[ -n "$output" ] && echo "$output"
else
echo "No JavaScript/TypeScript files to lint"
fi
+682
View File
@@ -0,0 +1,682 @@
#!/usr/bin/env python3
# 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.
#
# ----------------------------------------------------------------------
# Stress-test data generator for the composite-PK migration (sc-105349).
#
# Bulk-inserts synthetic parent rows and many-to-many junction rows for
# the eight association tables that the composite-PK migration touches.
# Useful for measuring migration runtime at varying scales — run this at
# 100K / 1M / 5M / 10M rows and time the migration at each scale to
# verify the O(N log N) extrapolation.
#
# Idempotent: rerunning with the same target is a no-op; rerunning with
# a higher target adds rows up to the new total. Batched bulk INSERTs
# (10K rows per statement) make it fast on Postgres, MySQL, and SQLite.
#
# Usage (inside the Superset container):
#
# docker exec superset-superset-1 \\
# /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
# --dashboard-slices 1000000 \\
# --slice-user 100000 \\
# --dashboard-user 100000
#
# Run with no flags for the defaults shown below. Use ``--dry-run`` to
# print the planned inserts without writing anything.
#
# The script connects via Superset's standard ``DATABASE_*`` env vars
# (or ``SUPERSET__SQLALCHEMY_DATABASE_URI`` if set), so it works
# automatically inside the Superset container regardless of which
# metadata DB backend is in use.
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from contextlib import contextmanager
from typing import Iterator
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy.engine import Connection, Engine
logger = logging.getLogger("seed_junction_load")
# Bulk INSERT batch size. Larger values = fewer statements but more memory.
BATCH = 10_000
# Default per-junction-table target row counts. Tuned to mimic the shape
# of a large multi-team Superset install. Override via CLI flags.
DEFAULTS: dict[str, int] = {
"dashboard_slices": 1_000_000,
"slice_user": 100_000,
"dashboard_user": 100_000,
"dashboard_roles": 10_000,
}
# (junction_table, fk1_col, fk2_col, parent1_table, parent2_table)
# parents reference id columns; we generate (fk1, fk2) pairs by sampling
# from the parents' existing IDs.
JUNCTIONS: list[tuple[str, str, str, str, str]] = [
("dashboard_slices", "dashboard_id", "slice_id", "dashboards", "slices"),
("slice_user", "user_id", "slice_id", "ab_user", "slices"),
("dashboard_user", "user_id", "dashboard_id", "ab_user", "dashboards"),
("dashboard_roles", "dashboard_id", "role_id", "dashboards", "ab_role"),
]
# Junction tables that originally carried ``UNIQUE(fk1, fk2)`` and therefore
# cannot accept duplicate ``(fk1, fk2)`` pairs even on the pre-migration
# (downgrade) schema. The other JUNCTIONS allow duplicates pre-migration.
JUNCTIONS_WITH_UNIQUE: set[str] = {"dashboard_slices", "report_schedule_user"}
# ----------------------------------------------------------------------
# Connection setup
# ----------------------------------------------------------------------
def build_engine() -> Engine:
"""Build a SQLAlchemy engine from Superset env vars."""
if uri := os.environ.get("SUPERSET__SQLALCHEMY_DATABASE_URI"):
logger.info("Using SUPERSET__SQLALCHEMY_DATABASE_URI from env")
return sa.create_engine(uri)
try:
dialect = os.environ["DATABASE_DIALECT"]
user = os.environ["DATABASE_USER"]
password = os.environ["DATABASE_PASSWORD"]
host = os.environ["DATABASE_HOST"]
port = os.environ["DATABASE_PORT"]
db = os.environ["DATABASE_DB"]
except KeyError as exc:
sys.exit(
f"Missing env var {exc}; either set DATABASE_DIALECT/USER/PASSWORD/"
f"HOST/PORT/DB or SUPERSET__SQLALCHEMY_DATABASE_URI before running."
)
uri = f"{dialect}://{user}:{password}@{host}:{port}/{db}"
logger.info(
"Built URI from DATABASE_* env vars (dialect=%s, host=%s)", dialect, host
)
return sa.create_engine(uri)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def uuid_value(dialect_name: str) -> bytes | str:
"""Return a UUID in the form the active dialect expects.
MySQL stores UUIDs as ``BINARY(16)`` (16 raw bytes); Postgres has a
native ``UUID`` type that accepts strings; SQLite stores them as
BLOB/TEXT and accepts either. Branching here keeps the seed script
backend-agnostic without depending on Superset's custom column types.
"""
if dialect_name.startswith("mysql"):
return uuid4().bytes
return str(uuid4())
@contextmanager
def time_phase(name: str) -> Iterator[None]:
"""Log elapsed wall time for a named phase."""
start = time.monotonic()
logger.info("[%s] starting", name)
try:
yield
finally:
elapsed = time.monotonic() - start
logger.info("[%s] done in %.2fs", name, elapsed)
def count_rows(conn: Connection, table: str) -> int:
return conn.scalar(sa.text(f"SELECT COUNT(*) FROM {table}")) or 0 # noqa: S608
def existing_ids(conn: Connection, table: str, limit: int | None = None) -> list[int]:
sql = f"SELECT id FROM {table} ORDER BY id" # noqa: S608
if limit is not None:
sql += f" LIMIT {limit}"
return [row[0] for row in conn.execute(sa.text(sql))]
# ----------------------------------------------------------------------
# Parent seeders
#
# Each function ensures the named parent table has at least ``target``
# rows by inserting synthetic ones with minimal-but-valid columns.
# Returns nothing; subsequent code reads back IDs via ``existing_ids``.
# ----------------------------------------------------------------------
def seed_dashboards(conn: Connection, target: int, dry_run: bool) -> None:
current = count_rows(conn, "dashboards")
if current >= target:
logger.info(
"dashboards: %d rows (target %d) — no insert needed", current, target
)
return
needed = target - current
logger.info("dashboards: %d%d (+%d)", current, target, needed)
if dry_run:
return
dialect = conn.engine.dialect.name
sql = sa.text(
"INSERT INTO dashboards (uuid, dashboard_title, slug, published) "
"VALUES (:uuid, :title, :slug, :published)"
)
for batch_start in range(0, needed, BATCH):
rows = [
{
"uuid": uuid_value(dialect),
"title": f"seed_dashboard_{current + i}",
"slug": f"seed-dashboard-{current + i}-{uuid4().hex[:8]}",
"published": False,
}
for i in range(batch_start, min(batch_start + BATCH, needed))
]
conn.execute(sql, rows)
logger.info(" dashboards: inserted %d / %d", batch_start + len(rows), needed)
def seed_dbs(conn: Connection, dry_run: bool) -> int:
"""Ensure at least one row exists in ``dbs`` (parent of ``tables``).
Returns the id to use as ``database_id`` when seeding ``tables``."""
ids = existing_ids(conn, "dbs", limit=1)
if ids:
return ids[0]
if dry_run:
return -1 # placeholder
dialect = conn.engine.dialect.name
logger.info("dbs: inserting one synthetic database (no rows present)")
conn.execute(
sa.text(
"INSERT INTO dbs (uuid, database_name, sqlalchemy_uri, expose_in_sqllab) "
"VALUES (:uuid, :name, :uri, :expose)"
),
{
"uuid": uuid_value(dialect),
"name": f"seed_db_{uuid4().hex[:8]}",
"uri": "sqlite:///seed.db",
"expose": False,
},
)
return existing_ids(conn, "dbs", limit=1)[0]
def seed_tables(conn: Connection, target: int, dry_run: bool) -> None:
current = count_rows(conn, "tables")
if current >= target:
logger.info("tables: %d rows (target %d) — no insert needed", current, target)
return
needed = target - current
logger.info("tables: %d%d (+%d)", current, target, needed)
if dry_run:
return
database_id = seed_dbs(conn, dry_run=False)
dialect = conn.engine.dialect.name
sql = sa.text(
"INSERT INTO tables (uuid, table_name, database_id) "
"VALUES (:uuid, :name, :db_id)"
)
for batch_start in range(0, needed, BATCH):
rows = [
{
"uuid": uuid_value(dialect),
"name": f"seed_table_{current + i}",
"db_id": database_id,
}
for i in range(batch_start, min(batch_start + BATCH, needed))
]
conn.execute(sql, rows)
logger.info(" tables: inserted %d / %d", batch_start + len(rows), needed)
def seed_slices(conn: Connection, target: int, dry_run: bool) -> None:
current = count_rows(conn, "slices")
if current >= target:
logger.info("slices: %d rows (target %d) — no insert needed", current, target)
return
needed = target - current
logger.info("slices: %d%d (+%d)", current, target, needed)
if dry_run:
return
# Slices reference tables.id; ensure at least one ``tables`` row exists
# so the FK is satisfiable (datasource_id is nullable but we set it for
# realism). The migration test doesn't care, but a real Superset that
# re-renders these slices does.
seed_tables(conn, target=1, dry_run=False)
table_id = existing_ids(conn, "tables", limit=1)[0]
dialect = conn.engine.dialect.name
sql = sa.text(
"INSERT INTO slices "
"(uuid, slice_name, datasource_id, datasource_type, viz_type) "
"VALUES (:uuid, :name, :ds_id, :ds_type, :viz)"
)
for batch_start in range(0, needed, BATCH):
rows = [
{
"uuid": uuid_value(dialect),
"name": f"seed_slice_{current + i}",
"ds_id": table_id,
"ds_type": "table",
"viz": "table",
}
for i in range(batch_start, min(batch_start + BATCH, needed))
]
conn.execute(sql, rows)
logger.info(" slices: inserted %d / %d", batch_start + len(rows), needed)
def seed_users(conn: Connection, target: int, dry_run: bool) -> None:
current = count_rows(conn, "ab_user")
if current >= target:
logger.info("ab_user: %d rows (target %d) — no insert needed", current, target)
return
needed = target - current
logger.info("ab_user: %d%d (+%d)", current, target, needed)
if dry_run:
return
sql = sa.text(
"INSERT INTO ab_user (first_name, last_name, username, email, active) "
"VALUES (:first, :last, :username, :email, :active)"
)
for batch_start in range(0, needed, BATCH):
rows = [
{
"first": "seed",
"last": f"user_{current + i}",
"username": f"seed_user_{current + i}_{uuid4().hex[:8]}",
"email": f"seed_user_{current + i}_{uuid4().hex[:8]}@example.invalid",
"active": True,
}
for i in range(batch_start, min(batch_start + BATCH, needed))
]
conn.execute(sql, rows)
logger.info(" ab_user: inserted %d / %d", batch_start + len(rows), needed)
def seed_roles(conn: Connection, target: int, dry_run: bool) -> None:
current = count_rows(conn, "ab_role")
if current >= target:
logger.info("ab_role: %d rows (target %d) — no insert needed", current, target)
return
needed = target - current
logger.info("ab_role: %d%d (+%d)", current, target, needed)
if dry_run:
return
sql = sa.text("INSERT INTO ab_role (name) VALUES (:name)")
for batch_start in range(0, needed, BATCH):
rows = [
{"name": f"seed_role_{current + i}_{uuid4().hex[:8]}"}
for i in range(batch_start, min(batch_start + BATCH, needed))
]
conn.execute(sql, rows)
logger.info(" ab_role: inserted %d / %d", batch_start + len(rows), needed)
# ----------------------------------------------------------------------
# Junction seeder
# ----------------------------------------------------------------------
def _load_existing_pairs(
conn: Connection, junction: str, fk1_col: str, fk2_col: str
) -> set[tuple[int, int]]:
"""Load existing ``(fk1, fk2)`` pairs from a junction table into a set.
Used so the seeder can skip them when generating new pairs (junction
tables enforce uniqueness on the FK pair). Memory is ~32 bytes/tuple
on CPython, so 10M existing pairs is ~320MB acceptable for a dev
machine. The junction / column names come from ``JUNCTIONS``, not
user input, so the f-string interpolation is safe.
"""
sql_text = f"SELECT {fk1_col}, {fk2_col} FROM {junction}" # noqa: S608
return {(row[0], row[1]) for row in conn.execute(sa.text(sql_text))}
def _generate_new_pairs(
p1_ids: list[int],
p2_ids: list[int],
existing_pairs: set[tuple[int, int]],
) -> Iterator[tuple[int, int]]:
"""Yield ``(fk1, fk2)`` pairs from the parent1 × parent2 cross-product
that are not already in ``existing_pairs``."""
for fk1 in p1_ids:
for fk2 in p2_ids:
if (fk1, fk2) not in existing_pairs:
yield (fk1, fk2)
def seed_junction(
conn: Connection,
junction: str,
fk1_col: str,
fk2_col: str,
parent1: str,
parent2: str,
target: int,
dry_run: bool,
) -> None:
"""Bulk-insert junction rows up to ``target`` rows total.
Generates ``(fk1, fk2)`` pairs by walking the cross-product of
parent1 IDs × parent2 IDs in row-major order, skipping pairs that
already exist. Walking the cross-product deterministically keeps
the script replayable: re-running with the same target is a no-op,
and re-running with a higher target appends new pairs in a stable
order regardless of how many runs preceded.
"""
current = count_rows(conn, junction)
if current >= target:
logger.info(
"%s: %d rows (target %d) — no insert needed", junction, current, target
)
return
needed = target - current
logger.info("%s: %d%d (+%d)", junction, current, target, needed)
if dry_run:
return
p1_ids = existing_ids(conn, parent1)
p2_ids = existing_ids(conn, parent2)
max_pairs = len(p1_ids) * len(p2_ids)
if max_pairs < target:
sys.exit(
f"Cannot reach {target} rows in {junction}: "
f"only {max_pairs} unique pairs available "
f"({len(p1_ids)} × {len(p2_ids)}). "
f"Increase parent targets and rerun."
)
existing_pairs: set[tuple[int, int]] = (
_load_existing_pairs(conn, junction, fk1_col, fk2_col) if current > 0 else set()
)
if existing_pairs:
logger.info(
" %s: loaded %d existing pairs into avoidance set",
junction,
len(existing_pairs),
)
insert_sql = sa.text(
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
f"VALUES (:fk1, :fk2)"
)
inserted = 0
batch: list[dict[str, int]] = []
for fk1, fk2 in _generate_new_pairs(p1_ids, p2_ids, existing_pairs):
batch.append({"fk1": fk1, "fk2": fk2})
inserted += 1
if len(batch) == BATCH or inserted == needed:
conn.execute(insert_sql, batch)
logger.info(" %s: inserted %d / %d", junction, inserted, needed)
batch = []
if inserted == needed:
return
if inserted < needed:
sys.exit(
f"Ran out of unique pairs at {inserted}/{needed} for {junction} "
f"(parents have {len(p1_ids)} × {len(p2_ids)} = {max_pairs} pairs, "
f"{len(existing_pairs)} already present)"
)
# ----------------------------------------------------------------------
# Orchestration
# ----------------------------------------------------------------------
def required_parent_count(target_pairs: int, other_parent: int) -> int:
"""How many rows we need in this parent so that
(this_parent × other_parent) target_pairs."""
if other_parent == 0:
# Bootstrapping: assume we'll create at least 1
other_parent = 1
return -(-target_pairs // other_parent) # ceil(target_pairs / other_parent)
def _compute_parent_requirements(targets: dict[str, int]) -> dict[str, int]:
"""For each parent table, return the minimum row count needed so that
parent1 × parent2 target for every junction it participates in.
Allocates ceil(sqrt(target)) rows per parent, balanced across the two
parents of each junction. The actual junction seeder will then walk
the cross-product to produce the target number of unique pairs.
"""
parent_req: dict[str, int] = {}
for junction, _, _, p1, p2 in JUNCTIONS:
target = targets.get(junction, 0)
if target == 0:
continue
sqrt_n = int(target**0.5) + 1
parent_req[p1] = max(parent_req.get(p1, 0), sqrt_n)
parent_req[p2] = max(parent_req.get(p2, 0), sqrt_n)
return parent_req
def _seed_parents(conn: Connection, parent_req: dict[str, int], dry_run: bool) -> None:
"""Seed parent tables in dependency order:
independent parents (ab_user, ab_role) first, then dashboards / slices /
tables (which transitively depend on dbs, seeded inside seed_tables)."""
if "ab_user" in parent_req:
seed_users(conn, parent_req["ab_user"], dry_run)
if "ab_role" in parent_req:
seed_roles(conn, parent_req["ab_role"], dry_run)
if "dashboards" in parent_req:
seed_dashboards(conn, parent_req["dashboards"], dry_run)
if "slices" in parent_req:
seed_slices(conn, parent_req["slices"], dry_run)
if "tables" in parent_req:
seed_tables(conn, parent_req["tables"], dry_run)
def _seed_all_junctions(
conn: Connection, targets: dict[str, int], dry_run: bool
) -> None:
for junction, fk1, fk2, p1, p2 in JUNCTIONS:
target = targets.get(junction, 0)
if target == 0:
continue
with time_phase(f"junction:{junction}"):
seed_junction(conn, junction, fk1, fk2, p1, p2, target, dry_run)
def inject_duplicates(
conn: Connection,
junction: str,
fk1_col: str,
fk2_col: str,
pct: float,
dry_run: bool,
) -> None:
"""Insert duplicate ``(fk1, fk2)`` rows on a non-UNIQUE junction table.
Used to stress-test the migration's ``_dedupe_by_min_id`` phase, which
is otherwise a no-op on cleanly-seeded data. Computes ``count =
current_rows * pct / 100`` and inserts that many rows by re-sampling
existing ``(fk1, fk2)`` pairs in row-major order. The synthetic
duplicates land on top of distinct existing pairs (one duplicate per
distinct pair, then wraps), so the migration's dedupe finds and
deletes them.
**Pre-condition: the table must NOT have UNIQUE on (fk1, fk2)**, i.e.,
the schema must be the pre-migration shape (after running
``superset db downgrade``). On the post-migration schema the composite
PK rejects duplicates and this function will error.
"""
if pct == 0:
return
current = count_rows(conn, junction)
count = int(current * pct / 100)
if count == 0:
logger.info(
"%s: 0 duplicates to inject (current=%d, pct=%g)",
junction,
current,
pct,
)
return
logger.info(
"%s: injecting %d duplicate rows (%g%% of %d existing)",
junction,
count,
pct,
current,
)
if dry_run:
return
select_sql = sa.text(
f"SELECT {fk1_col}, {fk2_col} FROM {junction} ORDER BY id LIMIT :n" # noqa: S608
)
sample = conn.execute(select_sql, {"n": count}).fetchall()
if not sample:
logger.warning("%s: no rows to duplicate (table is empty)", junction)
return
insert_sql = sa.text(
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
f"VALUES (:fk1, :fk2)"
)
inserted = 0
while inserted < count:
batch: list[dict[str, int]] = []
while len(batch) < BATCH and inserted < count:
row = sample[inserted % len(sample)]
batch.append({"fk1": row[0], "fk2": row[1]})
inserted += 1
conn.execute(insert_sql, batch)
logger.info(" %s: injected %d / %d duplicates", junction, inserted, count)
def _inject_dirty_data(conn: Connection, dirty_pct: float, dry_run: bool) -> None:
"""Inject duplicate rows on every non-UNIQUE seeded junction.
The two tables that originally carried ``UNIQUE(fk1, fk2)`` are
skipped because their composite-PK successor (and their pre-migration
UNIQUE constraint) both reject duplicate inserts.
"""
if dirty_pct == 0:
return
for junction, fk1, fk2, _, _ in JUNCTIONS:
if junction in JUNCTIONS_WITH_UNIQUE:
logger.info(
"%s: skipping duplicate injection (table has UNIQUE on FK pair)",
junction,
)
continue
with time_phase(f"dirty:{junction}"):
inject_duplicates(conn, junction, fk1, fk2, dirty_pct, dry_run)
def run(targets: dict[str, int], dry_run: bool, dirty_duplicates_pct: float) -> None:
engine = build_engine()
with engine.begin() as conn:
parent_req = _compute_parent_requirements(targets)
logger.info("Required parent row counts: %s", parent_req)
with time_phase("parents"):
_seed_parents(conn, parent_req, dry_run)
with time_phase("junctions"):
_seed_all_junctions(conn, targets, dry_run)
if dirty_duplicates_pct > 0:
with time_phase("dirty-duplicates"):
_inject_dirty_data(conn, dirty_duplicates_pct, dry_run)
# ----------------------------------------------------------------------
# CLI
# ----------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
for table, default in DEFAULTS.items():
parser.add_argument(
f"--{table.replace('_', '-')}",
type=int,
default=default,
help=f"target row count for {table} (default: {default:,})",
)
parser.add_argument(
"--dry-run",
"-n",
action="store_true",
help="print planned inserts without writing to the DB",
)
parser.add_argument(
"--dirty-duplicates-pct",
type=float,
default=0,
help=(
"after seeding distinct pairs, inject this percentage of duplicate "
"rows on each non-UNIQUE junction (slice_user, dashboard_user, "
"dashboard_roles). Stress-tests the migration's _dedupe_by_min_id "
"phase. Requires the DB to be at the pre-migration revision "
"(33d7e0e21daa) — the post-migration composite PK rejects "
"duplicates and this will error. Default: 0 (no duplicates)."
),
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="increase log verbosity",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
targets = {table: getattr(args, table) for table in DEFAULTS}
logger.info("Targets: %s", targets)
logger.info("Dry run: %s", args.dry_run)
logger.info("Dirty duplicates pct: %g", args.dirty_duplicates_pct)
with time_phase("total"):
run(
targets,
dry_run=args.dry_run,
dirty_duplicates_pct=args.dirty_duplicates_pct,
)
if __name__ == "__main__":
main()
-24
View File
@@ -1,24 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
module.exports = {
// @superset-ui/switchboard ships ES module syntax, so it must be
// transformed by babel rather than ignored as a node_modules dependency.
transformIgnorePatterns: ["/node_modules/(?!@superset-ui/switchboard)"],
};
-91
View File
@@ -1,91 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
validateEmbeddedDashboardId,
validateSupersetDomain,
findUnsafeSandboxExtras,
} from "./index";
describe("validateEmbeddedDashboardId", () => {
it("accepts a canonical uuid", () => {
expect(() =>
validateEmbeddedDashboardId("f4787a4f-2541-4f8a-9b5e-1e2d3c4b5a6f")
).not.toThrow();
});
it("accepts a simple hexadecimal id", () => {
expect(() => validateEmbeddedDashboardId("abc123")).not.toThrow();
});
it.each([
["../../evil"],
["a/b"],
["x?foo=bar"],
["x#frag"],
["a@b.com"],
["foo bar"],
["http://evil.com"],
[""],
["%2e%2e"],
])("rejects an id with an unexpected format: %p", (id) => {
expect(() => validateEmbeddedDashboardId(id)).toThrow();
});
});
describe("validateSupersetDomain", () => {
it.each([
["https://superset.example.com"],
["http://localhost:8088"],
// sub-path deployments are valid; the origin is what matters downstream
["https://example.com/superset"],
])("accepts a valid absolute URL: %p", (domain) => {
expect(() => validateSupersetDomain(domain)).not.toThrow();
});
it.each([
["superset.example.com"], // missing protocol
["not a url"],
[""],
["/relative/path"],
])("rejects a malformed domain: %p", (domain) => {
expect(() => validateSupersetDomain(domain)).toThrow(
"Invalid supersetDomain format"
);
});
});
describe("findUnsafeSandboxExtras", () => {
it("returns the tokens that relax iframe isolation", () => {
expect(
findUnsafeSandboxExtras([
"allow-forms",
"allow-top-navigation",
"allow-popups",
"allow-top-navigation-by-user-activation",
])
).toEqual(["allow-top-navigation", "allow-top-navigation-by-user-activation"]);
});
it("returns an empty array when all tokens are safe", () => {
expect(
findUnsafeSandboxExtras(["allow-forms", "allow-popups", "allow-downloads"])
).toEqual([]);
});
});
+2 -58
View File
@@ -50,9 +50,7 @@ export type UiConfigType = {
};
export type EmbedDashboardParams = {
/** The id provided by the embed configuration UI in Superset.
* This is the UUID generated by Superset's embed configuration and is
* expected to contain only hexadecimal characters and hyphens. */
/** The id provided by the embed configuration UI in Superset */
id: string;
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
supersetDomain: string;
@@ -114,48 +112,6 @@ export type EmbeddedDashboard = {
setThemeMode: (mode: ThemeMode) => void;
};
/**
* Validates that an embedded dashboard id has the expected format
* (the UUID produced by Superset's embed configuration). Throws on
* anything containing characters that are not part of that format.
*/
export function validateEmbeddedDashboardId(id: string): void {
if (typeof id !== 'string' || !/^[a-f0-9-]+$/i.test(id)) {
throw new Error('Invalid dashboard id format');
}
}
/**
* Validates that supersetDomain is a parseable absolute URL (it must include
* a protocol, e.g. https://superset.example.com). Throws otherwise. The
* domain's origin is what gets used as the postMessage targetOrigin, so it
* has to resolve to a well-formed origin.
*/
export function validateSupersetDomain(supersetDomain: string): void {
try {
// eslint-disable-next-line no-new
new URL(supersetDomain);
} catch {
throw new Error('Invalid supersetDomain format');
}
}
// Sandbox tokens that materially relax the iframe's isolation (for example,
// letting the embedded frame navigate the top-level page). They remain
// supported via iframeSandboxExtras for callers that genuinely need them.
const UNSAFE_SANDBOX_EXTRAS = [
'allow-top-navigation',
'allow-top-navigation-by-user-activation',
];
/**
* Returns any caller-provided sandbox tokens that relax the iframe's
* isolation, so they can be surfaced and not enabled unintentionally.
*/
export function findUnsafeSandboxExtras(extras: string[]): string[] {
return extras.filter(token => UNSAFE_SANDBOX_EXTRAS.includes(token));
}
/**
* Embeds a Superset dashboard into the page using an iframe.
*/
@@ -172,8 +128,6 @@ export async function embedDashboard({
referrerPolicy,
resolvePermalinkUrl,
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
validateEmbeddedDashboardId(id);
function log(...info: unknown[]) {
if (debug) {
console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info);
@@ -185,7 +139,6 @@ export async function embedDashboard({
if (supersetDomain.endsWith('/')) {
supersetDomain = supersetDomain.slice(0, -1);
}
validateSupersetDomain(supersetDomain);
function calculateConfig() {
let configNumber = 0;
@@ -242,13 +195,6 @@ export async function embedDashboard({
iframe.sandbox.add('allow-forms'); // for forms to submit
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
// additional sandbox props
const unsafeSandboxExtras = findUnsafeSandboxExtras(iframeSandboxExtras);
if (unsafeSandboxExtras.length > 0) {
console.warn(
'[superset-embedded-sdk] iframeSandboxExtras includes tokens that ' +
`relax the iframe's isolation: ${unsafeSandboxExtras.join(', ')}`,
);
}
iframeSandboxExtras.forEach((key: string) => {
iframe.sandbox.add(key);
});
@@ -270,9 +216,7 @@ export async function embedDashboard({
// we know the content window isn't null because we are in the load event handler.
iframe.contentWindow!.postMessage(
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
// Use the normalized origin (not the raw domain, which may carry a
// sub-path for sub-path deployments) as the postMessage targetOrigin.
new URL(supersetDomain).origin,
supersetDomain,
[theirPort],
);
log('sent message channel to the iframe');
+6 -6
View File
@@ -8020,9 +8020,9 @@
"peer": true
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
"engines": {
"node": ">=14.14"
}
@@ -14601,9 +14601,9 @@
"peer": true
},
"tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
},
"to-regex-range": {
"version": "5.0.1",
+129 -129
View File
@@ -194,7 +194,7 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.40",
"@swc/core": "^1.15.33",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -229,7 +229,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.32",
"baseline-browser-mapping": "^2.10.31",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -249,7 +249,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -296,7 +296,7 @@
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.5.0",
"webpack-sources": "^3.4.1",
"webpack-visualizer-plugin2": "^2.0.0"
},
"engines": {
@@ -7768,9 +7768,9 @@
}
},
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8034,9 +8034,9 @@
}
},
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8194,9 +8194,9 @@
}
},
"node_modules/@npmcli/package-json/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8470,9 +8470,9 @@
}
},
"node_modules/@nx/devkit/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12310,9 +12310,9 @@
}
},
"node_modules/@swc/core": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -12328,18 +12328,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.40",
"@swc/core-darwin-x64": "1.15.40",
"@swc/core-linux-arm-gnueabihf": "1.15.40",
"@swc/core-linux-arm64-gnu": "1.15.40",
"@swc/core-linux-arm64-musl": "1.15.40",
"@swc/core-linux-ppc64-gnu": "1.15.40",
"@swc/core-linux-s390x-gnu": "1.15.40",
"@swc/core-linux-x64-gnu": "1.15.40",
"@swc/core-linux-x64-musl": "1.15.40",
"@swc/core-win32-arm64-msvc": "1.15.40",
"@swc/core-win32-ia32-msvc": "1.15.40",
"@swc/core-win32-x64-msvc": "1.15.40"
"@swc/core-darwin-arm64": "1.15.33",
"@swc/core-darwin-x64": "1.15.33",
"@swc/core-linux-arm-gnueabihf": "1.15.33",
"@swc/core-linux-arm64-gnu": "1.15.33",
"@swc/core-linux-arm64-musl": "1.15.33",
"@swc/core-linux-ppc64-gnu": "1.15.33",
"@swc/core-linux-s390x-gnu": "1.15.33",
"@swc/core-linux-x64-gnu": "1.15.33",
"@swc/core-linux-x64-musl": "1.15.33",
"@swc/core-win32-arm64-msvc": "1.15.33",
"@swc/core-win32-ia32-msvc": "1.15.33",
"@swc/core-win32-x64-msvc": "1.15.33"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -12351,9 +12351,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
"integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
"cpu": [
"arm64"
],
@@ -12367,9 +12367,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
"integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
"cpu": [
"x64"
],
@@ -12383,9 +12383,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
"integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
"cpu": [
"arm"
],
@@ -12399,9 +12399,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
"integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
"cpu": [
"arm64"
],
@@ -12415,9 +12415,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
"integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
"cpu": [
"arm64"
],
@@ -12431,9 +12431,9 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
"integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
"cpu": [
"ppc64"
],
@@ -12447,9 +12447,9 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
"integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
"cpu": [
"s390x"
],
@@ -12463,9 +12463,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
"integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
"cpu": [
"x64"
],
@@ -12479,9 +12479,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
"integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
"cpu": [
"x64"
],
@@ -12495,9 +12495,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
"integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
"cpu": [
"arm64"
],
@@ -12511,9 +12511,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
"integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
"cpu": [
"ia32"
],
@@ -12527,9 +12527,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
"integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
"cpu": [
"x64"
],
@@ -12775,9 +12775,9 @@
}
},
"node_modules/@tufjs/models/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17209,9 +17209,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -17402,9 +17402,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -17416,7 +17416,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -17793,9 +17793,9 @@
}
},
"node_modules/cacache/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22661,9 +22661,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.1.tgz",
"integrity": "sha512-IK0s/+ShN0bkur5moKCu/lfx2D/9uIeozje8Wv2/XnYdmswa17pDg02aUuytEPb8Gf0eueiQFf/QsvOHHcvujg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -22848,9 +22848,9 @@
}
},
"node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -23258,15 +23258,15 @@
"license": "Apache-2.0"
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -23285,7 +23285,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -26595,9 +26595,9 @@
}
},
"node_modules/ignore-walk/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26856,9 +26856,9 @@
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -35946,9 +35946,9 @@
}
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -36615,9 +36615,9 @@
}
},
"node_modules/nx/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -38409,9 +38409,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
"dev": true,
"funding": [
{
@@ -38429,7 +38429,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -39002,9 +39002,9 @@
"license": "MIT"
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@@ -39423,9 +39423,9 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -44969,9 +44969,9 @@
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
@@ -47104,9 +47104,9 @@
"license": "MIT"
},
"node_modules/vm2": {
"version": "3.11.5",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz",
"integrity": "sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==",
"version": "3.11.3",
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.3.tgz",
"integrity": "sha512-DO1TTKuOc+veL11VNOvJwRab80mghFKE40Av3bl6pdXs11bdiDMuR73owy+dS2EsTZEvRUeBkkBuDVRjV/RgEw==",
"license": "MIT",
"dependencies": {
"acorn": "^8.15.0",
@@ -47889,9 +47889,9 @@
}
},
"node_modules/webpack-sources": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz",
"integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -48303,9 +48303,9 @@
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -48924,9 +48924,9 @@
}
},
"packages/generator-superset/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -50174,7 +50174,7 @@
"license": "Apache-2.0",
"dependencies": {
"@types/d3-scale": "^4.0.9",
"d3-cloud": "^1.2.9",
"d3-cloud": "^1.2.8",
"d3-scale": "^4.0.2"
},
"devDependencies": {
+4 -4
View File
@@ -277,7 +277,7 @@
"@storybook/test": "^8.6.18",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.15.40",
"@swc/core": "^1.15.33",
"@swc/plugin-emotion": "^14.10.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^9.3.4",
@@ -312,7 +312,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.32",
"baseline-browser-mapping": "^2.10.31",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^14.0.0",
@@ -332,7 +332,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -379,7 +379,7 @@
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.4",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.5.0",
"webpack-sources": "^3.4.1",
"webpack-visualizer-plugin2": "^2.0.0"
},
"peerDependencies": {
@@ -30,7 +30,7 @@
"dependencies": {
"chalk": "^5.6.2",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.1.2",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-syntax-highlighter": "^16.1.0",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
@@ -152,33 +152,3 @@ export async function selectOption(option: string, selectName?: string) {
);
await userEvent.click(item);
}
/**
* Select an option from a compact pill filter (new UI that replaced comboboxes).
* Clicks the pill button matching the label, then clicks the option in the panel.
*/
export async function selectPillOption(option: string, pillLabel?: string) {
let pill: HTMLElement;
if (pillLabel) {
// Find the pill whose text content includes the label
pill = await waitFor(() => {
const pills = screen.getAllByTestId('compact-filter-pill');
const match = pills.find(p => p.textContent?.includes(pillLabel));
if (!match)
throw new Error(`Could not find pill with label "${pillLabel}"`);
return match;
});
} else {
pill = await screen.findByTestId('compact-filter-pill');
}
await userEvent.click(pill);
// Wait for the option list to appear and click the item
const item = await waitFor(() => {
const listbox = document.querySelector('[role="listbox"]');
if (!listbox) throw new Error('No listbox found');
const opt = within(listbox as HTMLElement).getByText(option);
if (!opt) throw new Error(`Option "${option}" not found`);
return opt;
});
await userEvent.click(item);
}
@@ -17,7 +17,6 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, SupersetError } from '@superset-ui/core';
import { FC } from 'react';
import { useChartOwnerNames } from 'src/hooks/apiResources';
@@ -33,7 +32,7 @@ export type Props = {
stackTrace?: string;
} & Omit<ClientErrorObject, 'error'>;
const DEFAULT_CHART_ERROR = t('Data error');
const DEFAULT_CHART_ERROR = 'Data error';
export const ChartErrorMessage: FC<Props> = ({ chartId, error, ...props }) => {
// fetches the chart owners and adds them to the extra data of the error message
@@ -817,11 +817,8 @@ export function exploreJSON(
),
);
(queriesResponse as QueryData[]).forEach(response => {
const { warning } = response as QueryData & {
warning?: string | null;
};
if (warning) {
dispatch(addWarningToast(warning, { noDuplicate: true }));
if (response.warning) {
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
}
});
return dispatch(
@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { CardSortSelect } from './CardSortSelect';
const options = [
{ desc: false, id: 'title', label: 'Alphabetical', value: 'alphabetical' },
{
desc: true,
id: 'changed_on',
label: 'Recently modified',
value: 'recently_modified',
},
{
desc: false,
id: 'changed_on',
label: 'Least recently modified',
value: 'least_recently_modified',
},
];
test('pill always shows "Sort" label with no value suffix and no clear button', () => {
render(
<CardSortSelect
options={options}
onChange={jest.fn()}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
expect(screen.getByText('Sort')).toBeInTheDocument();
expect(screen.queryByText(/sort.*alphabetical/i)).not.toBeInTheDocument();
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('no clear button even when a non-default sort is active', () => {
render(
<CardSortSelect
options={options}
onChange={jest.fn()}
initialSort={[{ id: 'changed_on', desc: true }]}
/>,
);
expect(screen.getByText('Sort')).toBeInTheDocument();
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('clicking a sort option from the panel calls onChange with the correct id and desc', async () => {
const onChange = jest.fn();
render(
<CardSortSelect
options={options}
onChange={onChange}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
await userEvent.click(screen.getByTestId('compact-filter-pill'));
expect(screen.getByText('Recently modified')).toBeInTheDocument();
await userEvent.click(screen.getByText('Recently modified'));
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: true }]);
// Pill label stays "Sort" — value is in tooltip, not the label
expect(screen.getByText('Sort')).toBeInTheDocument();
});
test('selecting a different option from the panel calls onChange with correct args', async () => {
const onChange = jest.fn();
render(
<CardSortSelect
options={options}
onChange={onChange}
initialSort={[{ id: 'title', desc: false }]}
/>,
);
await userEvent.click(screen.getByTestId('compact-filter-pill'));
await userEvent.click(screen.getByText('Least recently modified'));
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: false }]);
});
@@ -16,13 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, useState } from 'react';
import { useState, useMemo } from 'react';
import { t } from '@apache-superset/core/translation';
import type { SelectOption } from './types';
import { styled } from '@apache-superset/core/theme';
import { FormLabel, Select } from '@superset-ui/core/components';
import { SELECT_WIDTH } from './utils';
import { CardSortSelectOption, SortColumn } from './types';
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
import CompactSelectPanel from './Filters/CompactSelectPanel';
import type { FilterHandler } from './Filters/types';
const SortContainer = styled.div`
display: inline-flex;
font-size: ${({ theme }) => theme.fontSizeSM}px;
align-items: center;
text-align: left;
width: ${SELECT_WIDTH}px;
`;
interface CardViewSelectSortProps {
onChange: (value: SortColumn[]) => void;
@@ -35,8 +42,6 @@ export const CardSortSelect = ({
onChange,
options,
}: CardViewSelectSortProps) => {
const panelRef = useRef<FilterHandler>(null);
const defaultSort =
(initialSort &&
options.find(
@@ -45,41 +50,44 @@ export const CardSortSelect = ({
)) ||
options[0];
const [currentValue, setCurrentValue] = useState<SelectOption>({
const [value, setValue] = useState({
label: defaultSort.label,
value: defaultSort.value,
});
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
const formattedOptions = useMemo(
() => options.map(option => ({ label: option.label, value: option.value })),
[options],
);
const handleSelect = (option: SelectOption | undefined) => {
if (!option) return;
const original = options.find(o => o.value === option.value);
if (original) {
setCurrentValue({ label: original.label, value: original.value });
onChange([{ id: original.id, desc: original.desc }]);
const handleOnChange = (selected: { label: string; value: string }) => {
setValue(selected);
const originalOption = options.find(
({ value }) => value === selected.value,
);
if (originalOption) {
const sortBy = [
{
id: originalOption.id,
desc: originalOption.desc,
},
];
onChange(sortBy);
}
};
return (
<span data-test="card-sort-select">
<CompactFilterTrigger
label={t('Sort')}
hasValue={false}
onClear={() => {}}
tooltipTitle={String(currentValue.label)}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
ref={panelRef}
selects={selectOptions}
value={currentValue}
onSelect={handleSelect}
isOpen={isOpen}
onClose={onClose}
/>
)}
</CompactFilterTrigger>
</span>
<SortContainer>
<Select
ariaLabel={t('Sort')}
header={<FormLabel>{t('Sort')}</FormLabel>}
labelInValue
onChange={handleOnChange}
options={formattedOptions}
showSearch
value={value}
data-test="card-sort-select"
/>
</SortContainer>
);
};
@@ -1,145 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactFilterTrigger from './CompactFilterTrigger';
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
const baseProps = {
label: 'Owner',
hasValue: false,
onClear: jest.fn(),
};
const defaultChildren = jest.fn(() => (
<div data-testid="filter-content">Filter content</div>
));
function renderTrigger(
props: Partial<
typeof baseProps & {
hasValue: boolean;
tooltipTitle?: string;
popupType?: 'listbox' | 'dialog';
}
> = {},
children = defaultChildren,
) {
return render(
<CompactFilterTrigger {...baseProps} {...props}>
{children}
</CompactFilterTrigger>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('renders the label', () => {
renderTrigger();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('renders as inactive pill with down chevron when hasValue is false', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
// No clear button when inactive
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('renders active state with clear icon when hasValue is true', () => {
renderTrigger({ hasValue: true });
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
});
test('clear icon has descriptive aria-label matching the filter name', () => {
renderTrigger({ hasValue: true });
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(clearIcon).toHaveAttribute('aria-label', 'Clear Owner filter');
});
test('clear icon is rendered inside the pill button', () => {
renderTrigger({ hasValue: true });
const pill = screen.getByTestId('compact-filter-pill');
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(pill).toContainElement(clearIcon);
});
test('toggles aria-expanded when pill is clicked', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
});
test('calls onClear when clear icon is clicked', async () => {
const onClear = jest.fn();
renderTrigger({ hasValue: true, onClear } as any);
const clearIcon = screen.getByTestId('compact-filter-clear');
await userEvent.click(clearIcon);
expect(onClear).toHaveBeenCalledTimes(1);
});
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
const { container } = renderTrigger();
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
});
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('calls children render prop with isOpen and onClose', async () => {
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
renderTrigger({}, children);
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(children).toHaveBeenCalledWith(
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
);
});
test('sets aria-haspopup to listbox by default', () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
});
test('sets aria-haspopup to dialog when popupType is dialog', () => {
renderTrigger({ popupType: 'dialog' });
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
});
test('closing dropdown resets aria-expanded to false', async () => {
renderTrigger();
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'true');
await userEvent.click(pill);
expect(pill).toHaveAttribute('aria-expanded', 'false');
});
@@ -1,198 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useEffect,
useRef,
useState,
type ReactNode,
type MouseEvent,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
export type FilterPanelRenderProps = {
isOpen: boolean;
onClose: () => void;
};
interface CompactFilterTriggerProps {
label: ReactNode;
hasValue: boolean;
onClear: () => void;
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
children: (props: FilterPanelRenderProps) => ReactNode;
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
tooltipTitle?: string;
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
* 'dialog' for form panels (date range, numerical range). */
popupType?: 'listbox' | 'dialog';
}
const FilterPill = styled.button<{ $active: boolean }>`
${({ theme, $active }) => css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeight}px;
padding: 0 ${theme.sizeUnit * 3}px;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
color: ${$active ? theme.colorPrimary : theme.colorText};
font-size: ${theme.fontSizeSM}px;
font-weight: ${$active ? 600 : 400};
cursor: pointer;
white-space: nowrap;
line-height: 1;
transition:
border-color 0.2s,
background 0.2s,
color 0.2s;
/* AntD anticon spans carry vertical-align: -0.125em from global styles.
align-self centers the span within the pill; the inner flex+align-items
centers the svg within the span. */
.anticon {
display: flex;
align-items: center;
align-self: center;
line-height: 0;
}
&:hover {
border-color: ${theme.colorPrimary};
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimary};
outline-offset: 2px;
}
`}
`;
const ActiveDot = styled.span`
${({ theme }) => css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${theme.colorPrimary};
flex-shrink: 0;
`}
`;
export default function CompactFilterTrigger({
label,
hasValue,
onClear,
children,
tooltipTitle,
popupType = 'listbox',
}: CompactFilterTriggerProps) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const theme = useTheme();
// Tracks whether tooltip should be suppressed after dropdown close.
// Brave (and some other browsers) fire a synthetic mouseover on newly-exposed
// elements when a popup disappears, triggering Tooltip onOpenChange(true)
// without real user intent. We suppress until the cursor actually leaves the
// pill (onMouseLeave), which is the first reliable "hover reset" signal.
const tooltipSuppressedRef = useRef(false);
// Close dropdown on window resize — AntD Dropdown doesn't reposition
// itself on resize so the panel ends up detached from the pill.
useEffect(() => {
if (!open) return;
const handleResize = () => setOpen(false);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [open]);
const handleClear = (e: MouseEvent) => {
e.stopPropagation();
onClear();
setOpen(false);
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
};
return (
<Dropdown
open={open}
onOpenChange={visible => {
setOpen(visible);
if (!visible) {
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
}
}}
trigger={['click']}
popupRender={() =>
children({ isOpen: open, onClose: () => setOpen(false) })
}
placement="bottomLeft"
destroyPopupOnHide
>
<Tooltip
title={tooltipTitle}
open={!!tooltipTitle && !open && tooltipOpen}
onOpenChange={visible => {
if (visible && tooltipSuppressedRef.current) return;
setTooltipOpen(visible && !!tooltipTitle && !open);
}}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
>
<FilterPill
$active={hasValue}
type="button"
data-test="compact-filter-pill"
aria-haspopup={popupType}
aria-expanded={open}
aria-label={typeof label === 'string' ? label : undefined}
onMouseLeave={() => {
tooltipSuppressedRef.current = false;
}}
>
{hasValue && <ActiveDot />}
<span>{label}</span>
{hasValue ? (
<Icons.CloseOutlined
iconSize="s"
iconColor={theme.colorPrimary}
onClick={handleClear}
data-test="compact-filter-clear"
aria-label={
typeof label === 'string'
? t('Clear %s filter', label)
: undefined
}
/>
) : (
<Icons.DownOutlined
iconSize="s"
iconColor={theme.colorTextSecondary}
/>
)}
</FilterPill>
</Tooltip>
</Dropdown>
);
}
@@ -1,339 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactSelectPanel from './CompactSelectPanel';
import type { FilterHandler } from './types';
const SMALL_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
];
const LARGE_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
{ label: 'David', value: 4 },
{ label: 'Eve', value: 5 },
{ label: 'Frank', value: 6 },
{ label: 'Grace', value: 7 },
];
beforeEach(() => {
jest.clearAllMocks();
});
test('renders options from selects prop', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
test('hides search input when selects.length is 6 or fewer', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
test('shows search input when selects.length exceeds 6', () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('shows search input when fetchSelects is provided', () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('filters static options by search term', async () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
});
test('calls onSelect with normalized option when an option is clicked', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith(undefined, true);
});
test('shows checkmark icon on selected option', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const aliceOption = screen
.getByText('Alice')
.closest('[role="option"]') as HTMLElement;
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
});
test('unselected options have aria-selected false', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const bobOption = screen
.getByText('Bob')
.closest('[role="option"]') as HTMLElement;
expect(bobOption).toHaveAttribute('aria-selected', 'false');
});
test('calls onClose after a selection is made', async () => {
const onClose = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
const onSelect = jest.fn();
const ref = createRef<FilterHandler>();
const { rerender } = render(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
act(() => {
ref.current?.clearFilter();
});
expect(onSelect).toHaveBeenCalledWith(undefined, true);
// Component is fully controlled — visual deselection follows when the
// parent passes value={undefined} after receiving the onSelect callback.
rerender(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('shows Loading text when loading prop is true', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
loading
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows No results when displayOptions is empty', () => {
render(
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
);
expect(screen.getByText('No results')).toBeInTheDocument();
});
test('renders options list with listbox role and accessible label', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
});
test('option items have option role', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
test('fetches and displays remote options via fetchSelects on mount', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [{ label: 'Remote User', value: 99 }],
totalCount: 1,
});
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Remote User')).toBeInTheDocument();
});
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
});
test('shows No results when fetchSelects returns empty data', async () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('shows No results when fetchSelects rejects', async () => {
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('selects option via keyboard Enter key', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
await userEvent.type(aliceOption, '{Enter}');
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('syncs selected state when external value prop changes', () => {
const { rerender } = render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
rerender(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});
@@ -1,318 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
useEffect,
type CSSProperties,
type RefObject,
} from 'react';
import { debounce } from 'lodash';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import {
Icons,
Input,
Constants,
type InputRef,
} from '@superset-ui/core/components';
import type { SelectOption, ListViewFilter as Filter } from '../types';
import type { FilterHandler } from './types';
// Show search box when there are more than this many static options.
const SEARCH_THRESHOLD = 6;
// Page size for async select fetches — large enough to avoid most pagination
// issues while still being a bounded request. Full infinite-load pagination
// is a future improvement.
const ASYNC_PAGE_SIZE = 200;
interface CompactSelectPanelProps {
selects?: Filter['selects'];
fetchSelects?: Filter['fetchSelects'];
value?: SelectOption;
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
onClose?: () => void;
isOpen?: boolean;
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
panelStyle?: CSSProperties;
/** External loading state from filter config */
loading?: boolean;
}
const PanelContainer = styled.div`
${({ theme }) => css`
min-width: 220px;
max-width: 320px;
max-height: 320px;
display: flex;
flex-direction: column;
border-radius: ${theme.borderRadiusLG}px;
background: ${theme.colorBgElevated};
box-shadow: ${theme.boxShadowSecondary};
padding: 0 0 ${theme.paddingXXS}px;
`}
`;
const SearchRow = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
${theme.paddingXXS}px;
`}
`;
const OptionList = styled.ul`
${({ theme }) => css`
margin: 0;
padding: ${theme.paddingXXS}px 0;
overflow-y: auto;
flex: 1;
list-style: none;
`}
`;
const OptionItem = styled.li<{ $active: boolean }>`
${({ theme, $active }) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
${theme.controlPaddingHorizontal}px;
line-height: ${theme.lineHeight};
cursor: pointer;
font-size: ${theme.fontSize}px;
color: ${theme.colorText};
border-radius: ${theme.borderRadiusSM}px;
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
transition: background 0.15s;
&:hover {
background: ${$active
? theme.colorPrimaryBgHover
: theme.colorFillTertiary};
outline: 2px solid ${theme.colorPrimary};
outline-offset: -2px;
}
`}
`;
const OptionLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
`;
const StatusText = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
text-align: center;
color: ${theme.colorTextDisabled};
font-size: ${theme.fontSizeSM}px;
`}
`;
function CompactSelectPanel(
{
selects = [],
fetchSelects,
value,
onSelect,
onClose,
isOpen,
loading: externalLoading,
panelStyle,
}: CompactSelectPanelProps,
ref: RefObject<FilterHandler>,
) {
const theme = useTheme();
const inputRef = useRef<InputRef>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
const [internalLoading, setInternalLoading] = useState(false);
const isLoading = externalLoading || internalLoading;
const debouncedSetSearch = useMemo(
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
[],
);
useEffect(
() => () => {
debouncedSetSearch.cancel();
},
[debouncedSetSearch],
);
// Focus search input when dropdown opens; reset search when it closes
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
if (isOpen) {
timeoutId = setTimeout(() => {
inputRef.current?.input?.focus({ preventScroll: true });
}, 100);
} else {
setSearch('');
setDebouncedSearch('');
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [isOpen]);
// Fetch remote options when debounced search changes
useEffect(() => {
if (!fetchSelects) return;
let cancelled = false;
setInternalLoading(true);
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
.then(result => {
if (!cancelled) setRemoteOptions(result?.data ?? []);
})
.catch(() => {
if (!cancelled) setRemoteOptions([]);
})
.finally(() => {
if (!cancelled) setInternalLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedSearch, fetchSelects]);
useImperativeHandle(ref, () => ({
clearFilter: () => {
setSearch('');
setDebouncedSearch('');
onSelect(undefined, true);
},
}));
const displayOptions = (
fetchSelects
? remoteOptions
: selects.filter(o => {
const label = typeof o.label === 'string' ? o.label : String(o.value);
return label.toLowerCase().includes(search.toLowerCase());
})
).filter(o => o != null);
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
const handleSelect = (opt: SelectOption, displayText?: string) => {
const isDeselect = value?.value === opt.value;
// Normalize to a plain string label for URL serialization:
// 1. String labels pass through unchanged.
// 2. ReactNode labels with a `title` field use that (set by callers for
// options like owner-select where label contains name + email JSX).
// 3. Fall back to DOM text content, then stringified value.
const label =
typeof opt.label === 'string'
? opt.label
: (opt.title ?? displayText ?? String(opt.value ?? ''));
const next = isDeselect ? undefined : { label, value: opt.value };
onSelect(next, isDeselect);
onClose?.();
};
return (
<PanelContainer style={panelStyle}>
{showSearch && (
<SearchRow>
<Input
ref={inputRef}
prefix={
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
}
placeholder={t('Search')}
value={search}
onChange={e => {
setSearch(e.target.value);
debouncedSetSearch(e.target.value);
}}
allowClear
css={css`
width: 100%;
box-shadow: none;
`}
/>
</SearchRow>
)}
<OptionList role="listbox" aria-label={t('Filter options')}>
{isLoading ? (
<StatusText>{t('Loading...')}</StatusText>
) : displayOptions.length === 0 ? (
<StatusText>{t('No results')}</StatusText>
) : (
displayOptions.map((opt, i) => {
const isActive = value?.value === opt.value;
const getDisplayText = (el: HTMLElement) =>
el.textContent?.trim() || undefined;
const isFirst = i === 0;
const isLast = i === displayOptions.length - 1;
return (
<OptionItem
key={opt.value}
$active={isActive}
role="option"
aria-selected={isActive}
tabIndex={0}
onClick={e =>
handleSelect(opt, getDisplayText(e.currentTarget))
}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(opt, getDisplayText(e.currentTarget));
} else if (e.key === 'ArrowDown' && !isLast) {
e.preventDefault();
(
e.currentTarget.nextElementSibling as HTMLElement | null
)?.focus();
} else if (e.key === 'ArrowUp' && !isFirst) {
e.preventDefault();
(
e.currentTarget
.previousElementSibling as HTMLElement | null
)?.focus();
}
}}
>
<OptionLabel>{opt.label}</OptionLabel>
{isActive && (
<Icons.CheckOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
)}
</OptionItem>
);
})
)}
</OptionList>
</PanelContainer>
);
}
export default forwardRef(CompactSelectPanel);
@@ -0,0 +1,112 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useState,
useMemo,
forwardRef,
useImperativeHandle,
RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Dayjs } from 'dayjs';
import { useLocale } from 'src/hooks/useLocale';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import {
AntdThemeProvider,
Loading,
FormLabel,
RangePicker,
} from '@superset-ui/core/components';
import type { BaseFilter, FilterHandler } from './types';
import { FilterContainer } from './Base';
import { RANGE_WIDTH } from '../utils';
interface DateRangeFilterProps extends BaseFilter {
onSubmit: (val: number[] | string[]) => void;
name: string;
dateFilterValueType?: 'unix' | 'iso';
}
type ValueState = [number, number] | [string, string] | null;
function DateRangeFilter(
{
Header,
initialValue,
onSubmit,
dateFilterValueType = 'unix',
}: DateRangeFilterProps,
ref: RefObject<FilterHandler>,
) {
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
const dayjsValue = useMemo((): [Dayjs, Dayjs] | null => {
if (!value || (Array.isArray(value) && !value.length)) return null;
return [extendedDayjs(value[0]), extendedDayjs(value[1])];
}, [value]);
const locale = useLocale();
useImperativeHandle(ref, () => ({
clearFilter: () => {
setValue(null);
onSubmit([]);
},
}));
if (locale === null) {
return <Loading position="inline-centered" />;
}
return (
<AntdThemeProvider locale={locale}>
<FilterContainer
data-test="date-range-filter-container"
vertical
justify="center"
align="start"
width={RANGE_WIDTH}
>
<FormLabel>{Header}</FormLabel>
<RangePicker
placeholder={[t('Start date'), t('End date')]}
showTime
value={dayjsValue}
onCalendarChange={(dayjsRange: [Dayjs, Dayjs]) => {
if (!dayjsRange?.[0]?.valueOf() || !dayjsRange?.[1]?.valueOf()) {
setValue(null);
onSubmit([]);
return;
}
const changeValue =
dateFilterValueType === 'iso'
? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
: [
dayjsRange[0]?.valueOf() ?? 0,
dayjsRange[1]?.valueOf() ?? 0,
];
setValue(changeValue as ValueState);
onSubmit(changeValue);
}}
/>
</FilterContainer>
</AntdThemeProvider>
);
}
export default forwardRef(DateRangeFilter);
@@ -1,80 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import FilterPopoverContent from './FilterPopoverContent';
beforeEach(() => {
jest.clearAllMocks();
});
test('renders children inside the wrapper', () => {
render(
<FilterPopoverContent>
<div data-test="inner-content">Inner content</div>
</FilterPopoverContent>,
);
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
});
test('renders the Apply button', () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('calls onClose when Apply button is clicked', async () => {
const onClose = jest.fn();
render(
<FilterPopoverContent onClose={onClose}>
<div>content</div>
</FilterPopoverContent>,
);
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('renders without onClose and clicking Apply does not throw', async () => {
render(
<FilterPopoverContent>
<div>content</div>
</FilterPopoverContent>,
);
// No onClose prop — click should not throw
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('visually hides label elements so pills remain accessible', () => {
render(
<FilterPopoverContent>
<label htmlFor="input">Date range</label>
<input id="input" />
</FilterPopoverContent>,
);
const label = screen.getByText('Date range');
// The label must be in the DOM for screen readers but visually hidden via CSS
expect(label).toBeInTheDocument();
const computedStyle = window.getComputedStyle(label);
// clip / overflow hidden pattern applied; position absolute is the key indicator
expect(computedStyle.position).toBe('absolute');
});
@@ -1,74 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
interface FilterPopoverContentProps {
children: ReactNode;
onClose?: () => void;
}
const Wrapper = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
/* Visually hide the redundant label the pill already shows it, but keep it
accessible to screen readers so filter inputs have a named context. */
label {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`}
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
`;
export default function FilterPopoverContent({
children,
onClose,
}: FilterPopoverContentProps) {
return (
<Wrapper>
{children}
<Footer>
<Button size="small" buttonStyle="primary" onClick={onClose}>
{t('Apply')}
</Button>
</Footer>
</Wrapper>
);
}
@@ -0,0 +1,267 @@
/**
* 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 { createRef } from 'react';
import {
render,
screen,
selectOption,
waitFor,
} from 'spec/helpers/testing-library';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
import SelectFilter from './Select';
import type { FilterHandler } from './types';
const mockUpdateFilterValue = jest.fn();
beforeEach(() => {
mockUpdateFilterValue.mockClear();
});
test('select filter with ReactNode label uses option title when serializing selection', async () => {
// Regression for sc-104554: the chart-list Owner filter renders options
// with ReactNode labels (name + email). The value passed to
// updateFilterValue is serialized into URL / filter state and re-used to
// render the filter pill on return. It must carry the plain-text name
// (from `title`) and not fall back to the numeric user id.
const ReactNodeLabel = (
<div>
<span>John Doe</span>
<span>john@example.com</span>
</div>
);
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: ReactNodeLabel,
value: 42,
title: 'John Doe',
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects,
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('John Doe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'John Doe',
value: 42,
});
});
});
test('select filter falls back to stringified value when no string label or title is available', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [
{
label: <span>123</span>,
value: 123,
},
],
totalCount: 1,
});
const filters = [
{
Header: 'Something',
key: 'something',
id: 'something',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
unfilteredLabel: 'All',
fetchSelects,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('123', 'Something');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: '123',
value: 123,
});
});
});
test('plain select with string label passes label through unchanged', async () => {
// Happy-path coverage for the typeof-string branch in onChange, exercised
// through the non-async Select wrapper (selects array, no fetchSelects).
const filters = [
{
Header: 'Status',
key: 'status',
id: 'status',
input: 'select' as const,
operator: ListViewFilterOperator.Equals,
unfilteredLabel: 'All',
selects: [
{ label: 'Published', value: 7 },
{ label: 'Draft', value: 8 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Published', 'Status');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Published',
value: 7,
});
});
});
test('plain select with ReactNode label uses option title when serializing selection', async () => {
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
// the non-async Select wrapper. Guards against the two wrappers ever
// diverging on antd's two-arg onChange shape.
const ReactNodeLabel = (
<div>
<span>Jane Roe</span>
<span>jane@example.com</span>
</div>
);
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await selectOption('Jane Roe', 'Owner');
await waitFor(() => {
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
label: 'Jane Roe',
value: 99,
});
});
});
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
// The isClear flag is what allows the parent (Filters/index) to suppress
// onFilterUpdate side-effects when the user clears the filter rather than
// picking a new value. Lock that contract in.
const mockOnSelect = jest.fn();
const ref = createRef<FilterHandler>();
render(
<SelectFilter
Header="Owner"
initialValue={{ label: 'John Doe', value: 42 }}
onSelect={mockOnSelect}
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
ref={ref}
/>,
);
ref.current?.clearFilter();
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
});
test('rehydrates filter pill from initialValue with plain-string label', async () => {
// The user-visible regression: after URL/state rehydration the filter pill
// must render the human-readable name, not the numeric user id. The fix
// ensures the persisted label is a string; this test asserts that string
// is what surfaces in the rendered combobox selection.
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owners',
input: 'select' as const,
operator: ListViewFilterOperator.RelationManyMany,
unfilteredLabel: 'All',
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
paginate: true,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owners',
operator: ListViewFilterOperator.RelationManyMany,
value: { label: 'John Doe', value: 42 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
@@ -0,0 +1,154 @@
/**
* 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 {
useState,
useMemo,
forwardRef,
useImperativeHandle,
type RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { Select, AsyncSelect, FormLabel } from '@superset-ui/core/components';
import { ListViewFilter as Filter, SelectOption } from '../types';
import type { BaseFilter, FilterHandler } from './types';
import { FilterContainer } from './Base';
import { SELECT_WIDTH } from '../utils';
interface SelectFilterProps extends BaseFilter {
fetchSelects?: Filter['fetchSelects'];
name?: string;
onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
optionFilterProps?: string[];
paginate?: boolean;
selects: Filter['selects'];
loading?: boolean;
dropdownStyle?: React.CSSProperties;
}
function SelectFilter(
{
Header,
name,
fetchSelects,
initialValue,
onSelect,
optionFilterProps,
selects = [],
loading = false,
dropdownStyle,
}: SelectFilterProps,
ref: RefObject<FilterHandler>,
) {
const [selectedOption, setSelectedOption] = useState(initialValue);
const onChange = (selected: SelectOption, option?: SelectOption) => {
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
// labeled-value as the first arg and the full option (which carries
// `title` and any other fields) as the second. Options may supply a
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
// filter). Since this object is serialized into the URL and rehydrated
// as the filter pill on return, we need a plain string. Prefer `title`
// (set by callers to the human-readable name) before falling back to
// the value.
onSelect(
selected
? {
label:
typeof selected.label === 'string'
? selected.label
: (option?.title ?? String(selected.value)),
value: selected.value,
}
: undefined,
);
setSelectedOption(selected);
};
const onClear = () => {
onSelect(undefined, true);
setSelectedOption(undefined);
};
useImperativeHandle(ref, () => ({
clearFilter: () => {
onClear();
},
}));
const fetchAndFormatSelects = useMemo(
() => async (inputValue: string, page: number, pageSize: number) => {
if (fetchSelects) {
const selectValues = await fetchSelects(inputValue, page, pageSize);
return {
data: selectValues.data,
totalCount: selectValues.totalCount,
};
}
return {
data: [],
totalCount: 0,
};
},
[fetchSelects],
);
const placeholder = t('Choose...');
return (
<FilterContainer
data-test="select-filter-container"
width={SELECT_WIDTH}
vertical
justify="center"
align="start"
>
<FormLabel>{Header}</FormLabel>
{fetchSelects ? (
<AsyncSelect
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select"
onChange={onChange}
onClear={onClear}
options={fetchAndFormatSelects}
optionFilterProps={optionFilterProps}
placeholder={placeholder}
dropdownStyle={dropdownStyle}
showSearch
value={selectedOption}
/>
) : (
<Select
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select"
labelInValue
onChange={onChange}
onClear={onClear}
options={selects}
placeholder={placeholder}
dropdownStyle={dropdownStyle}
showSearch
value={selectedOption}
loading={loading}
/>
)}
</FilterContainer>
);
}
export default forwardRef(SelectFilter);
@@ -1,251 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
import TimeRangeFilter from './TimeRange';
import type { FilterHandler } from './types';
// Suppress debounced evaluation — the initial useEffect handles the committed
// value; the debounced path is an optimistic UX enhancement, not a contract.
jest.mock('src/explore/exploreUtils', () => ({
...jest.requireActual('src/explore/exploreUtils'),
useDebouncedEffect: jest.fn(),
}));
jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
FRAME_OPTIONS: [
{ label: 'No filter', value: 'No filter' },
{ label: 'Custom', value: 'Custom' },
],
guessFrame: jest.fn().mockReturnValue('Custom'),
// 'No filter' is the string value of NO_TIME_RANGE constant
useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
}));
jest.mock(
'src/explore/components/controls/DateFilterControl/components',
() => ({
AdvancedFrame: () => <div data-test="advanced-frame" />,
CalendarFrame: () => <div data-test="calendar-frame" />,
CommonFrame: () => <div data-test="common-frame" />,
CustomFrame: ({ value }: { value: string }) => (
<div data-test="custom-frame">{value}</div>
),
}),
);
jest.mock(
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
() => ({
CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
}),
);
const VALID_RANGE = '2024-01-01 : 2024-01-31';
// Default successful response that fetchTimeRange and the Apply handler both use
const MOCK_TIME_RANGE_RESULT = {
json: {
result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
},
};
let getSpy: jest.SpyInstance;
beforeEach(() => {
getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
});
afterEach(() => {
getSpy.mockRestore();
});
function renderFilter(
props: Partial<{
value: string;
onSubmit: jest.Mock;
onClose: jest.Mock;
}> = {},
) {
const onSubmit = props.onSubmit ?? jest.fn();
const onClose = props.onClose ?? jest.fn();
return render(
<TimeRangeFilter
value={props.value ?? VALID_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
}
test('renders range type label, actual time range section, and footer buttons', () => {
renderFilter();
expect(screen.getByText('Range type')).toBeInTheDocument();
expect(screen.getByText('Actual time range')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('shows the custom frame when guessFrame returns Custom', () => {
renderFilter();
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
});
test('Apply is disabled until the API validates the initial value', async () => {
// Block resolution so we can observe disabled state
let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
getSpy.mockReturnValue(
new Promise(res => {
resolve = res;
}),
);
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
expect(apply).toBeDisabled();
act(() => {
resolve!(MOCK_TIME_RANGE_RESULT);
});
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is enabled when the API returns a valid result', async () => {
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is disabled when the API returns an error response', async () => {
getSpy.mockRejectedValue(new Error('Bad request'));
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
// Give fetchTimeRange time to reject and set validTimeRange=false
await waitFor(() => {
expect(apply).toBeDisabled();
});
});
test('Cancel button calls onClose', async () => {
const onClose = jest.fn();
renderFilter({ onClose });
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onSubmit([since, until]) and onClose when API succeeds', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith([
'2024-01-01T00:00:00',
'2024-01-31T23:59:59',
]);
});
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onClose but not onSubmit when the API call throws', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
// fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
getSpy
.mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in useEffect
.mockRejectedValueOnce(new Error('network')); // Apply button API call
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(onSubmit).not.toHaveBeenCalled();
});
test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without an API call', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
render(
<TimeRangeFilter
value={NO_TIME_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
const callsBefore = getSpy.mock.calls.length;
await userEvent.click(apply);
expect(onSubmit).toHaveBeenCalledWith(undefined);
expect(onClose).toHaveBeenCalledTimes(1);
// No extra API call for NO_TIME_RANGE — the button short-circuits
expect(getSpy.mock.calls.length).toBe(callsBefore);
});
test('clearFilter via ref calls onSubmit(undefined)', async () => {
const onSubmit = jest.fn();
const ref = createRef<FilterHandler>();
render(
<TimeRangeFilter
ref={ref}
value={VALID_RANGE}
onSubmit={onSubmit}
onClose={jest.fn()}
/>,
);
act(() => {
ref.current?.clearFilter();
});
expect(onSubmit).toHaveBeenCalledWith(undefined);
});
@@ -1,291 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
type RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import {
NO_TIME_RANGE,
SupersetClient,
fetchTimeRange,
} from '@superset-ui/core';
import rison from 'rison';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import {
Button,
Constants,
Divider,
Icons,
Select,
} from '@superset-ui/core/components';
import { useDebouncedEffect } from 'src/explore/exploreUtils';
import {
FRAME_OPTIONS,
guessFrame,
useDefaultTimeFilter,
} from 'src/explore/components/controls/DateFilterControl/utils';
import {
AdvancedFrame,
CalendarFrame,
CommonFrame,
CustomFrame,
} from 'src/explore/components/controls/DateFilterControl/components';
import { CurrentCalendarFrame } from 'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
import type { FrameType } from 'src/explore/components/controls/DateFilterControl/types';
import type { FilterHandler } from './types';
interface TimeRangeFilterProps {
value?: string;
onSubmit: (value: [string, string] | undefined) => void;
onClose: () => void;
}
const StyledRangeType = styled(Select)`
width: 272px;
`;
const ContentWrapper = styled.div`
${({ theme }) => css`
width: 600px;
padding: ${theme.sizeUnit * 3}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
.ant-row {
margin-top: 8px;
}
.ant-picker {
padding: 4px 17px 4px;
border-radius: 4px;
}
.ant-divider-horizontal {
margin: 16px 0;
}
.control-label {
font-size: ${theme.fontSizeSM}px;
line-height: 16px;
margin: 8px 0;
}
.section-title {
font-style: normal;
font-weight: ${theme.fontWeightStrong};
font-size: 15px;
line-height: 24px;
margin-bottom: 8px;
}
.control-anchor-to {
margin-top: 16px;
}
.control-anchor-to-datetime {
width: 217px;
}
.footer {
text-align: right;
}
`}
`;
const IconWrapper = styled.span`
span {
margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
vertical-align: middle;
}
.text {
vertical-align: middle;
}
.error {
color: ${({ theme }) => theme.colorError};
}
`;
function TimeRangeFilter(
{ value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
ref: RefObject<FilterHandler>,
) {
const defaultTimeFilter = useDefaultTimeFilter();
const value = valueProp ?? defaultTimeFilter;
const theme = useTheme();
// guessedFrame is only used for the initial useState — value is stable at
// mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
// always mounts fresh with the current committed value.
const guessedFrame = useMemo(() => guessFrame(value), [value]);
const [frame, setFrame] = useState<FrameType>(guessedFrame);
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [evalResponse, setEvalResponse] = useState(value);
const [validTimeRange, setValidTimeRange] = useState(false);
const [lastFetched, setLastFetched] = useState(value);
// Evaluate the committed value shown in "Actual time range".
useEffect(() => {
if (value === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
fetchTimeRange(value).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? value);
setValidTimeRange(true);
}
setLastFetched(value);
});
}, [value]);
// Debounced evaluation of the in-progress selection (drives "Actual time range").
useDebouncedEffect(
() => {
if (timeRangeValue === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setLastFetched(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
if (lastFetched !== timeRangeValue) {
fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? '');
setValidTimeRange(true);
}
setLastFetched(timeRangeValue);
});
}
},
Constants.SLOW_DEBOUNCE,
[timeRangeValue],
);
useImperativeHandle(ref, () => ({
clearFilter: () => {
onSubmit(undefined);
},
}));
function onChangeFrame(val: FrameType) {
if (val === NO_TIME_RANGE) {
setTimeRangeValue(NO_TIME_RANGE);
}
setFrame(val);
}
return (
<ContentWrapper>
<div className="control-label">{t('Range type')}</div>
<StyledRangeType
ariaLabel={t('Range type')}
options={FRAME_OPTIONS}
value={frame}
onChange={onChangeFrame}
/>
{frame !== 'No filter' && <Divider />}
{frame === 'Common' && (
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Calendar' && (
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Current' && (
<CurrentCalendarFrame
value={timeRangeValue}
onChange={setTimeRangeValue}
/>
)}
{frame === 'Advanced' && (
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Custom' && (
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
<Divider />
<div>
<div className="section-title">{t('Actual time range')}</div>
{validTimeRange && (
<div>
{evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
</div>
)}
{!validTimeRange && (
<IconWrapper className="warning">
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
<span className="text error">{evalResponse}</span>
</IconWrapper>
)}
</div>
<Divider />
<div className="footer">
<Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
{t('CANCEL')}
</Button>
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={async () => {
if (timeRangeValue === NO_TIME_RANGE) {
onSubmit(undefined);
onClose();
return;
}
// fetchTimeRange returns a formatted display string ("X ≤ col < Y"),
// not the raw since/until strings. Call the API directly to get them.
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
});
const since: string | undefined =
response?.json?.result[0]?.since;
const until: string | undefined =
response?.json?.result[0]?.until;
if (since !== undefined && until !== undefined) {
onSubmit([since, until]);
}
} catch {
// leave filter unchanged on error
}
onClose();
}}
>
{t('APPLY')}
</Button>
</div>
</ContentWrapper>
);
}
export default forwardRef(TimeRangeFilter);
@@ -17,7 +17,6 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
@@ -98,335 +97,7 @@ test('search filter passes autoComplete prop correctly', () => {
expect(input.autocomplete).toBe('new-password');
});
test('renders a compact pill trigger for select filters', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('select pill shows active state (clear button) when a value is selected', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [{ label: 'Alice', value: 1 }],
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { label: 'Alice', value: 1 },
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
const filters = [
{
Header: 'Owner',
key: 'owner',
id: 'owner',
input: 'select' as const,
operator: ListViewFilterOperator.RelationOneMany,
selects: [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
],
},
];
// Simulate cold URL load: value has only numeric value, no label in cache
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'owner',
operator: ListViewFilterOperator.RelationOneMany,
value: { value: 1 } as any,
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// The pill should be active (clear button visible) and the static label
// should be resolved as the tooltip source
expect(
screen.getByRole('button', { name: /clear owner filter/i }),
).toBeInTheDocument();
});
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Time range')).toBeInTheDocument();
});
test('datetime_range pill shows active state when a time range string is set', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last week',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Clear icon is inside the pill (not a separate button)
const pill = screen.getByTestId('compact-filter-pill');
const clearIcon = screen.getByTestId('compact-filter-clear');
expect(clearIcon).toBeInTheDocument();
expect(pill).toContainElement(clearIcon);
});
test('datetime_range pill is inactive when value is NO_TIME_RANGE', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'No filter',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
});
test('datetime_range pill shows the time range string as tooltip title', () => {
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last month',
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
// Pill is active and clear icon is inside
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
});
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
expect(screen.getByText('Age range')).toBeInTheDocument();
});
test('numerical_range pill shows active state when value is set', () => {
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'age_range',
operator: ListViewFilterOperator.Between,
value: [18, 65],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(
screen.getByRole('button', { name: /clear age range filter/i }),
).toBeInTheDocument();
});
test('datetime_range onClear calls updateFilterValue with undefined directly', async () => {
const updateFilterValue = jest.fn();
const filters = [
{
Header: 'Time range',
key: 'time_range',
id: 'time_range',
input: 'datetime_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'time_range',
operator: ListViewFilterOperator.Between,
value: 'Last week',
},
]}
updateFilterValue={updateFilterValue}
/>,
);
const clearIcon = screen.getByTestId('compact-filter-clear');
await userEvent.click(clearIcon);
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
});
test('numerical_range onClear calls updateFilterValue with undefined directly', async () => {
const updateFilterValue = jest.fn();
const filters = [
{
Header: 'Age range',
key: 'age_range',
id: 'age_range',
input: 'numerical_range' as const,
operator: ListViewFilterOperator.Between,
},
];
render(
<UIFilters
filters={filters}
internalFilters={[
{
id: 'age_range',
operator: ListViewFilterOperator.Between,
value: [18, 65],
},
]}
updateFilterValue={updateFilterValue}
/>,
);
const clearBtn = screen.getByRole('button', {
name: /clear age range filter/i,
});
await userEvent.click(clearBtn);
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
});
test('renders only the first search filter when multiple search filters are configured', () => {
test('renders multiple search filters with different inputName values', () => {
const filters = [
{
Header: 'Name',
@@ -454,8 +125,8 @@ test('renders only the first search filter when multiple search filters are conf
/>,
);
// Only the first search filter renders — one search box per page
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
expect(inputs).toHaveLength(1);
expect(inputs).toHaveLength(2);
expect(inputs[0].name).toBe('filter_name_search');
expect(inputs[1].name).toBe('description');
});
@@ -19,16 +19,12 @@
import {
createRef,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
RefObject,
} from 'react';
import { withTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type {
ListViewFilterValue as FilterValue,
@@ -37,13 +33,10 @@ import type {
SelectOption,
} from '../types';
import type { FilterHandler } from './types';
import { NO_TIME_RANGE } from '@superset-ui/core';
import SearchFilter from './Search';
import SelectFilter from './Select';
import DateRangeFilter from './DateRange';
import NumericalRangeFilter from './NumericalRange';
import TimeRangeFilter from './TimeRange';
import CompactFilterTrigger from './CompactFilterTrigger';
import CompactSelectPanel from './CompactSelectPanel';
import FilterPopoverContent from './FilterPopoverContent';
interface UIFiltersProps {
filters: Filters;
@@ -53,10 +46,7 @@ interface UIFiltersProps {
function UIFilters(
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
ref: RefObject<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>,
ref: RefObject<{ clearFilters: () => void }>,
) {
const filterRefs = useMemo(
() =>
@@ -64,320 +54,125 @@ function UIFilters(
[filters.length],
);
// Cache display labels for select filters so tooltip works after URL round-trip
// (URL serialization strips the label, leaving only the value).
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
{},
);
// Evaluated human-readable labels for datetime_range pills (e.g. "2024-05-01 : 2024-05-31").
const [timeRangeTooltips, setTimeRangeTooltips] = useState<
Record<number, string>
>({});
// On cold load, URL params restore values but not labels for fetchSelects filters.
// Fetch the first page of options and cache the matching label so the tooltip works.
useEffect(() => {
filters.forEach((filter, index) => {
if (filter.input !== 'select' || !filter.fetchSelects) return;
if (tooltipLabels[index]) return;
const val = internalFilters?.[index]?.value as SelectOption | undefined;
if (!val?.value) return;
filter.fetchSelects('', 0, 500).then(result => {
const match = result?.data?.find(
(s: SelectOption) => s.value === val.value,
);
if (match) {
const lbl =
typeof match.label === 'string'
? match.label
: String(match.value ?? '');
setTooltipLabels(prev => ({ ...prev, [index]: lbl }));
}
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalFilters]);
// Build datetime_range tooltips from the resolved [start, end] array value.
// Handles both ISO strings and unix-ms numbers.
useEffect(() => {
filters.forEach((filter, index) => {
if (filter.input !== 'datetime_range') return;
const val = internalFilters?.[index]?.value;
if (Array.isArray(val) && val.length === 2) {
const fmt = (v: unknown) => {
const d = new Date(v as string | number);
return isNaN(d.getTime())
? String(v)
: d.toISOString().replace('T', ' ').slice(0, 19);
};
const tooltip = `${fmt(val[0])} ${fmt(val[1])}`;
setTimeRangeTooltips(prev =>
prev[index] === tooltip ? prev : { ...prev, [index]: tooltip },
);
} else {
setTimeRangeTooltips(prev => {
if (!(index in prev)) return prev;
const next = { ...prev };
delete next[index];
return next;
});
}
});
}, [filters, internalFilters]);
const clearFilterAtIndex = useCallback(
(index: number) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
setTooltipLabels(prev => {
const next = { ...prev };
delete next[index];
return next;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateFilterValue],
);
useImperativeHandle(ref, () => ({
clearFilters: () => {
filterRefs.forEach((_, index) => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
filterRefs.forEach((filter: any) => {
filter.current?.clearFilter?.();
});
setTooltipLabels({});
setTimeRangeTooltips({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
clearFilterAtIndex(index);
filterRefs[index]?.current?.clearFilter?.();
}
},
}));
// Search always leads the filter bar regardless of declaration order.
// Only the first search filter renders; subsequent ones are skipped (see note below).
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
// Group Key on RLS) are not currently accessible via the filter bar. Those
// pages previously relied on multiple inline inputs. This is a known UX
// trade-off — revisit if admin workflows require additional search fields.
let searchFilterRendered = false;
// Render in two passes: search first, then all other filter types.
const renderFilter = (_: (typeof filters)[number], index: number) => {
const {
Header,
fetchSelects,
key,
id,
input,
selects,
toolTipDescription,
onFilterUpdate,
loading,
min,
max,
autoComplete,
inputName,
popupStyle,
dateFilterValueType,
} = filters[index];
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
const selectValue = initialValue as SelectOption | undefined;
// Prefer cached label (survives URL round-trips where only the value
// is preserved). Fall back to the static selects list for cold loads.
const cachedLabel = tooltipLabels[index];
const staticFallback = cachedLabel
? undefined
: selects?.find(s => s.value === selectValue?.value)?.label;
const tooltipTitle = !!selectValue
? cachedLabel ||
(typeof staticFallback === 'string' ? staticFallback : undefined)
: t('Choose...');
return (
<span key={key} data-test="select-filter-container">
<CompactFilterTrigger
label={Header}
hasValue={!!selectValue}
tooltipTitle={tooltipTitle}
onClear={() => clearFilterAtIndex(index)}
>
{({ isOpen, onClose }) => (
<CompactSelectPanel
return (
<>
{filters.map(
(
{
Header,
fetchSelects,
key,
id,
input,
optionFilterProps,
paginate,
selects,
toolTipDescription,
onFilterUpdate,
loading,
dateFilterValueType,
min,
max,
popupStyle,
autoComplete,
inputName,
},
index,
) => {
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
return (
<SelectFilter
ref={filterRefs[index]}
selects={selects}
Header={Header}
fetchSelects={fetchSelects}
value={initialValue as SelectOption | undefined}
loading={loading ?? false}
isOpen={isOpen}
onClose={onClose}
panelStyle={popupStyle}
initialValue={initialValue}
key={key}
name={id}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (option && !isClear) {
setTooltipLabels(prev => ({
...prev,
[index]:
typeof option.label === 'string'
? option.label
: String(option.value ?? ''),
}));
}
if (onFilterUpdate && !isClear) {
onFilterUpdate(option);
if (onFilterUpdate) {
// Filter change triggers both onChange AND onClear, only want to track onChange
if (!isClear) {
onFilterUpdate(option);
}
}
updateFilterValue(index, option);
}}
optionFilterProps={optionFilterProps}
paginate={paginate}
selects={selects}
loading={loading ?? false}
dropdownStyle={popupStyle}
/>
)}
</CompactFilterTrigger>
</span>
);
}
if (input === 'search' && typeof Header === 'string') {
if (searchFilterRendered) return null;
searchFilterRendered = true;
return (
<SearchFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={inputName ?? id}
toolTipDescription={toolTipDescription}
onSubmit={(value: string) => {
if (onFilterUpdate) {
onFilterUpdate(value);
}
updateFilterValue(index, value);
}}
autoComplete={autoComplete}
/>
);
}
if (input === 'datetime_range') {
// dateFilterValueType absent or 'unix': column stores unix ms (e.g. Query History start_time).
// 'iso': column stores ISO date strings (e.g. UsersList created_on, ActionLog dttm).
const isUnixType = !dateFilterValueType || dateFilterValueType === 'unix';
// initialValue may be [ms, ms] (unix), ["iso","iso"] (iso), or legacy string.
// Always reconstruct panelValue as "ISO : ISO" so the TimeRange panel
// can parse it as a Custom date range regardless of storage type.
let resolvedIsoRange: [string, string] | null = null;
if (Array.isArray(initialValue) && initialValue.length === 2) {
if (typeof initialValue[0] === 'number') {
resolvedIsoRange = [
new Date(initialValue[0]).toISOString(),
new Date(initialValue[1] as number).toISOString(),
];
} else if (typeof initialValue[0] === 'string') {
resolvedIsoRange = initialValue as [string, string];
}
}
const legacyStringVal =
!resolvedIsoRange &&
typeof initialValue === 'string' &&
initialValue !== NO_TIME_RANGE
? initialValue
: null;
const hasTimeValue = !!(resolvedIsoRange || legacyStringVal);
const panelValue =
resolvedIsoRange?.join(' : ') ?? legacyStringVal ?? undefined;
return (
<CompactFilterTrigger
key={key}
label={Header}
hasValue={hasTimeValue}
tooltipTitle={
hasTimeValue ? (timeRangeTooltips[index] ?? panelValue) : undefined
);
}
popupType="dialog"
onClear={() => {
updateFilterValue(index, undefined);
}}
>
{({ onClose }) => (
<TimeRangeFilter
ref={filterRefs[index]}
value={panelValue}
onClose={onClose}
onSubmit={value => {
if (!value) {
updateFilterValue(index, undefined);
} else if (isUnixType) {
// Convert ISO strings to unix ms for numeric columns
updateFilterValue(index, [
new Date(value[0]).getTime(),
new Date(value[1]).getTime(),
]);
} else {
if (input === 'search' && typeof Header === 'string') {
return (
<SearchFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={inputName ?? id}
toolTipDescription={toolTipDescription}
onSubmit={(value: string) => {
if (onFilterUpdate) {
onFilterUpdate(value);
}
updateFilterValue(index, value);
}
}}
/>
)}
</CompactFilterTrigger>
);
}
if (input === 'numerical_range') {
const hasRangeValue =
Array.isArray(initialValue) &&
initialValue.some(v => v !== null && v !== undefined);
const rangeTooltip = hasRangeValue
? (initialValue as (number | null | undefined)[])
.filter(v => v !== null && v !== undefined)
.join(' ')
: undefined;
return (
<CompactFilterTrigger
key={key}
label={Header}
hasValue={hasRangeValue}
tooltipTitle={rangeTooltip}
popupType="dialog"
onClear={() => {
updateFilterValue(index, undefined);
}}
>
{({ onClose }) => (
<FilterPopoverContent onClose={onClose}>
}}
autoComplete={autoComplete}
/>
);
}
if (input === 'datetime_range') {
return (
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
);
}
if (input === 'numerical_range') {
return (
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
</FilterPopoverContent>
)}
</CompactFilterTrigger>
);
}
return null;
};
return (
<>
{/* Search first */}
{filters.map((_, index) =>
filters[index].input === 'search'
? renderFilter(filters[index], index)
: null,
)}
{/* Then all other filter types */}
{filters.map((_, index) =>
filters[index].input !== 'search'
? renderFilter(filters[index], index)
: null,
);
}
return null;
},
)}
</>
);
@@ -301,19 +301,15 @@ describe('ListView', () => {
});
test('renders UI filters', () => {
// select and datetime_range filters render as compact pill buttons;
// search filter renders as a text input
const filterPills = screen.getAllByTestId('compact-filter-pill');
expect(filterPills).toHaveLength(3); // ID, Age, Time
const filterControls = screen.getAllByRole('combobox');
expect(filterControls).toHaveLength(2);
});
test('calls fetchData on filter', async () => {
// Click the ID compact pill to open its option panel
const idPill = screen.getByRole('button', { name: 'ID' });
await userEvent.click(idPill);
// Wait for and click the 'foo' option in the dropdown panel
const option = await screen.findByRole('option', { name: 'foo' });
// Handle select filter
const selectFilter = screen.getAllByRole('combobox')[0];
await userEvent.click(selectFilter);
const option = screen.getByText('foo');
await userEvent.click(option);
// Handle search filter
@@ -345,10 +341,7 @@ describe('ListView', () => {
initialSort: [{ id: 'something' }],
});
const sortSelectContainer = screen.getByTestId('card-sort-select');
const sortSelect = sortSelectContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
const sortSelect = screen.getByTestId('card-sort-select');
await userEvent.click(sortSelect);
const sortOption = screen.getByText('Alphabetical');
@@ -65,43 +65,13 @@ const ListViewStyles = styled.div`
.header {
display: flex;
align-items: center;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: ${theme.sizeUnit * 2}px;
row-gap: ${theme.sizeUnit * 2}px;
/* Search input — fixed width/height matching pill height, label hidden */
[data-test='search-filter-container'] {
width: ${theme.sizeUnit * 44}px;
flex-shrink: 0;
height: ${theme.controlHeight}px;
align-self: center;
/* Hide the FormLabel Flex wrapper entirely so it doesn't affect
the column's justify-content centering calculation. */
> .ant-flex {
display: none;
}
label {
display: none;
}
.ant-input-affix-wrapper {
width: 100%;
height: 100%;
}
}
/* Select filter pill wrappers make them proper flex items so the
inline-flex button inside doesn't introduce line-box quirks. */
[data-test='select-filter-container'] {
display: flex;
align-items: center;
align-self: center;
}
column-gap: ${theme.sizeUnit * 7}px;
row-gap: ${theme.sizeUnit * 4}px;
}
}
@@ -197,6 +167,7 @@ const bulkSelectColumnConfig = {
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
margin-top: ${theme.sizeUnit * 5 + 1}px;
white-space: nowrap;
display: inline-block;
@@ -221,29 +192,6 @@ const ViewModeContainer = styled.div`
`}
`;
const ClearAllButton = styled.button`
${({ theme }) => `
background: none;
border: none;
padding: 0 ${theme.sizeUnit}px;
color: ${theme.colorPrimary};
font-size: ${theme.fontSizeSM}px;
cursor: pointer;
white-space: nowrap;
line-height: ${theme.controlHeight}px;
&:hover:not(:disabled) {
color: ${theme.colorPrimaryHover};
text-decoration: underline;
}
&:disabled {
color: ${theme.colorTextDisabled};
cursor: not-allowed;
}
`}
`;
const EmptyWrapper = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 40}px 0;
@@ -408,14 +356,6 @@ export function ListView<T extends object = any>({
clearFilterById: (id: string) => void;
}>(null);
const hasActiveFilters = internalFilters.some(f => {
if (f.value === null || f.value === undefined || f.value === '')
return false;
if (Array.isArray(f.value))
return f.value.some(v => v !== null && v !== undefined && v !== '');
return true;
});
// Wire the optional external filtersRef to our internal filterControlsRef.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
@@ -481,21 +421,6 @@ export function ListView<T extends object = any>({
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
>
<span>
<ClearAllButton
type="button"
disabled={!hasActiveFilters}
onClick={() => filterControlsRef.current?.clearFilters()}
>
{t('Clear all')}
</ClearAllButton>
</span>
</Tooltip>
)}
</div>
</div>
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
@@ -37,7 +37,7 @@ export const useDashboardMetadataBar = (dashboardInfo: DashboardInfo) => {
type: MetadataType.Owner as const,
createdBy: getOwnerName(dashboardInfo.created_by) || t('Not available'),
owners:
dashboardInfo.owners.length > 0
dashboardInfo.owners && dashboardInfo.owners.length > 0
? dashboardInfo.owners.map(getOwnerName)
: t('None'),
createdOn: dashboardInfo.created_on_delta_humanized,
@@ -28,7 +28,6 @@ import {
Select,
AsyncSelect,
} from '@superset-ui/core/components';
import { getUserDisplayLabel } from 'src/features/users/utils';
import { FormValues, GroupModalProps } from './types';
import { createGroup, fetchUserOptions, updateGroup } from './utils';
@@ -95,7 +94,7 @@ function GroupListModal({
users:
group?.users?.map(user => ({
value: user.id,
label: getUserDisplayLabel(user),
label: user.username,
})) || [],
};
@@ -19,7 +19,6 @@
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import rison from 'rison';
import { getUserDisplayLabel } from 'src/features/users/utils';
import { FormValues } from './types';
export const createGroup = async (values: FormValues) => {
@@ -65,7 +64,7 @@ export const fetchUserOptions = async (
return {
data: results.map((user: any) => ({
value: user.id,
label: getUserDisplayLabel(user),
label: user.username,
})),
totalCount: response.json?.count ?? 0,
};
@@ -75,7 +75,7 @@ export const useLanguageMenuItems = ({
type: 'submenu' as const,
label: (
<span className="f16" aria-label={t('Languages')}>
<i className={`flag ${languages[locale]?.flag ?? 'us'}`} />
<i className={`flag ${languages[locale]?.flag ?? ''}`} />
</span>
),
icon: <Icons.CaretDownOutlined iconSize="xs" />,
@@ -63,16 +63,6 @@ jest.mock('@superset-ui/core', () => {
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('RoleListEditModal', () => {
beforeEach(() => {
(SupersetClient.get as jest.Mock).mockResolvedValue({
json: { count: 0, result: [] },
});
});
afterEach(() => {
jest.clearAllMocks();
});
const mockRole = {
id: 1,
name: 'Admin',
@@ -157,8 +147,8 @@ describe('RoleListEditModal', () => {
// Wait for user hydration to complete so setFieldsValue has populated
// the form with the fetched users before submitting.
await screen.findByText('John Doe');
await screen.findByText('Jane Smith');
await screen.findByText('johndoe');
await screen.findByText('janesmith');
fireEvent.change(screen.getByTestId('role-name-input'), {
target: { value: 'Updated Role' },
@@ -251,19 +241,16 @@ describe('RoleListEditModal', () => {
test('preserves missing IDs as numeric fallbacks on partial hydration', async () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
// Only return permission id=10, not id=20
return Promise.resolve({
json: {
count: 1,
result: [
{
id: 10,
permission_name: 'can_read',
view_menu_name: 'Dashboard',
permission: { name: 'can_read' },
view_menu: { name: 'Dashboard' },
},
],
},
@@ -297,11 +284,7 @@ describe('RoleListEditModal', () => {
mockToasts.addDangerToast.mockClear();
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
) {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
return Promise.reject(new Error('network error'));
}
if (endpoint?.includes('/api/v1/security/groups/')) {
@@ -371,26 +354,24 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
const query = rison.decode(endpoint.split('?q=')[1]) as Record<
string,
unknown
>;
const filters = query.filters as Array<{
col: string;
opr: string;
value: number[];
}>;
const ids = filters?.[0]?.value || [];
const result = ids.map((id: number) => ({
id,
permission: { name: `perm_${id}` },
view_menu: { name: `view_${id}` },
}));
return Promise.resolve({
json: {
result: roleA.permission_ids.map(pid => ({
id: pid,
permission_name: `perm_${pid}`,
view_menu_name: `view_${pid}`,
})),
},
});
}
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({
id: pid,
permission_name: `perm_${pid}`,
view_menu_name: `view_${pid}`,
})),
},
json: { count: result.length, result },
});
}
return Promise.resolve({ json: { count: 0, result: [] } });
@@ -407,7 +388,7 @@ describe('RoleListEditModal', () => {
await waitFor(() => {
const permCall = mockGet.mock.calls.find(([c]) =>
c.endpoint.includes(`/api/v1/security/roles/${roleA.id}/permissions/`),
c.endpoint.includes('/api/v1/security/permissions-resources/'),
);
expect(permCall).toBeTruthy();
});
@@ -427,16 +408,26 @@ describe('RoleListEditModal', () => {
await waitFor(() => {
const permCalls = mockGet.mock.calls.filter(([c]) =>
c.endpoint.includes(`/api/v1/security/roles/${roleB.id}/permissions/`),
c.endpoint.includes('/api/v1/security/permissions-resources/'),
);
expect(permCalls.length).toBeGreaterThan(0);
// Should request role B's IDs, not role A's
const query = rison.decode(
permCalls[0][0].endpoint.split('?q=')[1],
) as Record<string, unknown>;
const filters = query.filters as Array<{
col: string;
opr: string;
value: number[];
}>;
expect(filters[0].value).toEqual(roleB.permission_ids);
});
unmount();
mockGet.mockReset();
});
test('fetches permissions via role endpoint and groups by id for hydration', async () => {
test('fetches permissions and groups by id for hydration', async () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockResolvedValue({
json: {
@@ -451,11 +442,8 @@ describe('RoleListEditModal', () => {
expect(mockGet).toHaveBeenCalled();
});
// Permissions should be fetched via the role's permissions endpoint (no ID list in URL)
const permissionCall = mockGet.mock.calls.find(([call]) =>
call.endpoint.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
),
call.endpoint.includes('/api/v1/security/permissions-resources/'),
)?.[0];
const groupsCall = mockGet.mock.calls.find(([call]) =>
call.endpoint.includes('/api/v1/security/groups/'),
@@ -467,17 +455,26 @@ describe('RoleListEditModal', () => {
throw new Error('Expected hydration calls to be defined');
}
// Permission endpoint has no query params (role ID is in the path)
expect(permissionCall.endpoint).toBe(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
);
// Groups still use the id-in filter
const permissionQuery = permissionCall.endpoint.match(/\?q=(.+)/);
const groupsQuery = groupsCall.endpoint.match(/\?q=(.+)/);
expect(permissionQuery).toBeTruthy();
expect(groupsQuery).toBeTruthy();
if (!groupsQuery) {
throw new Error('Expected groups query params to be present');
if (!permissionQuery || !groupsQuery) {
throw new Error('Expected query params to be present');
}
expect(rison.decode(permissionQuery[1])).toEqual({
page_size: 100,
page: 0,
filters: [
{
col: 'id',
opr: 'in',
value: mockRole.permission_ids,
},
],
});
expect(rison.decode(groupsQuery[1])).toEqual({
page_size: 100,
page: 0,
@@ -30,11 +30,9 @@ import {
import {
BaseModalProps,
RoleForm,
RolePermissions,
SelectOption,
} from 'src/features/roles/types';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { SupersetClient } from '@superset-ui/core';
import { fetchPaginatedData } from 'src/utils/fetchOptions';
import { type UserObject } from 'src/pages/UsersList/types';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
@@ -51,7 +49,6 @@ import {
updateRoleUsers,
formatPermissionLabel,
} from './utils';
import { getUserDisplayLabel } from 'src/features/users/utils';
export interface RoleListEditModalProps extends BaseModalProps {
role: RoleObject;
@@ -165,38 +162,34 @@ function RoleListEditModal({
return;
}
let cancelled = false;
setLoadingRolePermissions(true);
permissionFetchSucceeded.current = false;
const filters = [{ col: 'id', opr: 'in', value: stablePermissionIds }];
SupersetClient.get({
endpoint: `/api/v1/security/roles/${id}/permissions/`,
})
.then(response => {
if (cancelled) return;
fetchPaginatedData({
endpoint: `/api/v1/security/permissions-resources/`,
pageSize: 100,
setData: (data: SelectOption[]) => {
permissionFetchSucceeded.current = true;
const result: RolePermissions[] = response.json.result ?? [];
setRolePermissions(
result.map(p => ({
value: p.id,
label: formatPermissionLabel(p.permission_name, p.view_menu_name),
})),
);
})
.catch(() => {
if (!cancelled) {
addDangerToast(t('There was an error loading permissions.'));
}
})
.finally(() => {
if (!cancelled) {
setLoadingRolePermissions(false);
}
});
return () => {
cancelled = true;
};
setRolePermissions(data);
},
filters,
setLoadingState: (loading: boolean) => setLoadingRolePermissions(loading),
loadingKey: 'rolePermissions',
addDangerToast,
errorMessage: t('There was an error loading permissions.'),
mapResult: (permission: {
id: number;
permission: { name: string };
view_menu: { name: string };
}) => ({
value: permission.id,
label: formatPermissionLabel(
permission.permission.name,
permission.view_menu.name,
),
}),
});
}, [addDangerToast, id, stablePermissionIds]);
useEffect(() => {
@@ -233,7 +226,7 @@ function RoleListEditModal({
if (!loadingRoleUsers && formRef.current) {
const userOptions = roleUsers.map(user => ({
value: user.id,
label: getUserDisplayLabel(user),
label: user.username,
}));
formRef.current.setFieldsValue({
roleUsers: userOptions,
@@ -322,7 +315,7 @@ function RoleListEditModal({
roleUsers:
roleUsers?.map(user => ({
value: user.id,
label: getUserDisplayLabel(user),
label: user.username,
})) || [],
roleGroups: group_ids.map(groupId => ({
value: groupId,
@@ -44,15 +44,6 @@ export const deleteUser = async (userId: number) =>
endpoint: `/api/v1/security/users/${userId}`,
});
export const getUserDisplayLabel = (user: {
first_name?: string;
last_name?: string;
username?: string;
}): string =>
[user.first_name, user.last_name].filter(Boolean).join(' ') ||
user.username ||
t('N/A');
export const atLeastOneRoleOrGroup =
(fieldToCheck: 'roles' | 'groups') =>
({
-2
View File
@@ -31,7 +31,6 @@ import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ru';
import 'dayjs/locale/ko';
import 'dayjs/locale/cs';
import 'dayjs/locale/sk';
import 'dayjs/locale/sl';
import 'dayjs/locale/nl';
@@ -51,7 +50,6 @@ export const LOCALE_MAPPING = {
pt_BR: () => import('antd/locale/pt_BR'),
ru: () => import('antd/locale/ru_RU'),
ko: () => import('antd/locale/ko_KR'),
cs: () => import('antd/locale/cs_CZ'),
sk: () => import('antd/locale/sk_SK'),
sl: () => import('antd/locale/sl_SI'),
nl: () => import('antd/locale/nl_NL'),
@@ -0,0 +1,407 @@
/**
* 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.
*/
// Throwaway debug UI for sc-107283 activity-view endpoints. Verify the
// JSON responses look right by eye. Delete this directory + the route
// entry in views/routes.tsx when the activity-view feature ships.
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import {
Card,
Empty,
Input,
Loading,
Radio,
Space,
Tag,
Typography,
} from '@superset-ui/core/components';
type ResourceKind = 'dashboard' | 'chart' | 'dataset';
type IncludeMode = 'self' | 'related' | 'all';
type EntityKind = 'dashboard' | 'chart' | 'dataset';
interface ChangedBy {
id: number;
first_name: string;
last_name: string;
}
interface ActivityRecord {
version_uuid: string;
entity_kind: EntityKind;
entity_uuid: string | null;
entity_name: string;
entity_deleted: boolean;
entity_deletion_state: string | null;
source: 'self' | 'related';
transaction_id: number;
issued_at: string;
changed_by: ChangedBy | null;
kind: string;
path: string[];
from_value: unknown;
to_value: unknown;
summary: string;
impact: { charts?: number; datasets?: number } | null;
}
interface ActivityResponse {
result: ActivityRecord[];
count: number;
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
const KIND_COLOR: Record<string, string> = {
filter: 'blue',
metric: 'green',
dimension: 'cyan',
column: 'geekblue',
chart: 'purple',
time_range: 'gold',
color_palette: 'magenta',
restore: 'orange',
field: 'default',
};
const ENTITY_KIND_COLOR: Record<EntityKind, string> = {
dashboard: 'blue',
chart: 'purple',
dataset: 'green',
};
function formatValue(value: unknown): string {
if (value === null || value === undefined) return 'null';
if (typeof value === 'string') return JSON.stringify(value);
return JSON.stringify(value);
}
function ChangedByDisplay({ changedBy }: { changedBy: ChangedBy | null }) {
if (changedBy === null) {
return (
<Typography.Text type="secondary" italic>
{t('system / unknown')}
</Typography.Text>
);
}
return (
<Typography.Text>
{changedBy.first_name} {changedBy.last_name} (id={changedBy.id})
</Typography.Text>
);
}
function RecordCard({ record }: { record: ActivityRecord }) {
const headerExtra = (
<Space size="small">
<Tag color={ENTITY_KIND_COLOR[record.entity_kind] || 'default'}>
{record.entity_kind}
</Tag>
<Tag color={record.source === 'self' ? 'default' : 'volcano'}>
{record.source}
</Tag>
<Tag color={KIND_COLOR[record.kind] || 'default'}>{record.kind}</Tag>
{record.entity_deleted && <Tag color="red">deleted</Tag>}
{record.entity_deletion_state === 'soft_deleted' && (
<Tag color="orange">soft-deleted</Tag>
)}
</Space>
);
const headline = record.summary
? record.summary
: `${record.entity_kind}: ${record.entity_name || '(unnamed)'}`;
return (
<Card
size="small"
title={
<Space direction="vertical" size={0}>
<Typography.Text strong>{headline}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{record.issued_at} · tx={record.transaction_id} ·{' '}
<ChangedByDisplay changedBy={record.changed_by} />
</Typography.Text>
</Space>
}
extra={headerExtra}
style={{ marginBottom: 8 }}
>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">{t('entity_name:')}</Typography.Text>{' '}
<Typography.Text
delete={record.entity_deleted}
italic={!record.entity_name}
>
{record.entity_name || t('(no name)')}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">{t('entity_uuid:')}</Typography.Text>{' '}
<Typography.Text code>
{record.entity_uuid || t('null (tombstoned)')}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">
{t('version_uuid:')}
</Typography.Text>{' '}
<Typography.Text code>
{record.version_uuid || t('null')}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">{t('path:')}</Typography.Text>{' '}
<Typography.Text code>{JSON.stringify(record.path)}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">{t('from →')}</Typography.Text>{' '}
<Typography.Text code>{formatValue(record.from_value)}</Typography.Text>{' '}
<Typography.Text type="secondary">{t('→ to')}</Typography.Text>{' '}
<Typography.Text code>{formatValue(record.to_value)}</Typography.Text>
</div>
{record.impact !== null && (
<div>
<Typography.Text type="secondary">{t('impact:')}</Typography.Text>{' '}
<Typography.Text code>
{JSON.stringify(record.impact)}
</Typography.Text>
</div>
)}
</Space>
</Card>
);
}
function ActivityDebug() {
const { resource: resourceParam, uuid } = useParams<{
resource: string;
uuid: string;
}>();
const resource = useMemo<ResourceKind | null>(() => {
if (
resourceParam === 'dashboard' ||
resourceParam === 'chart' ||
resourceParam === 'dataset'
) {
return resourceParam;
}
return null;
}, [resourceParam]);
const [include, setInclude] = useState<IncludeMode>('all');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(25);
const [since, setSince] = useState('');
const [until, setUntil] = useState('');
const [data, setData] = useState<ActivityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reloadCounter, setReloadCounter] = useState(0);
useEffect(() => {
if (!resource || !uuid) return;
const params = new URLSearchParams();
params.set('include', include);
params.set('page', String(page));
params.set('page_size', String(pageSize));
if (since) params.set('since', since);
if (until) params.set('until', until);
setLoading(true);
setError(null);
SupersetClient.get({
endpoint: `/api/v1/${resource}/${uuid}/activity/?${params.toString()}`,
})
.then(({ json }) => {
setData(json as ActivityResponse);
})
.catch(err => {
const msg = err?.message || String(err);
setError(msg);
setData(null);
})
.finally(() => setLoading(false));
}, [resource, uuid, include, page, pageSize, since, until, reloadCounter]);
if (!resource) {
return (
<div style={{ padding: 24 }}>
<Typography.Title level={3}>
{t('Activity Debug — invalid URL')}
</Typography.Title>
<Typography.Paragraph>
{t(
'Use /activity-debug/{dashboard|chart|dataset}/{uuid} — e.g. /activity-debug/dashboard/4a8f3c2e-...',
)}
</Typography.Paragraph>
</div>
);
}
const records = data?.result ?? [];
const totalCount = data?.count ?? 0;
const lastPage = Math.max(0, Math.ceil(totalCount / pageSize) - 1);
return (
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
{t('Activity Debug')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('Throwaway tool for verifying sc-107283 activity-view responses.')}
</Typography.Paragraph>
<Card size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space wrap>
<Typography.Text strong>{t('endpoint')}</Typography.Text>
<Typography.Text code>
{`GET /api/v1/${resource}/${uuid}/activity/`}
</Typography.Text>
</Space>
<Space wrap size="middle">
<Space>
<Typography.Text>include</Typography.Text>
<Radio.Group
value={include}
onChange={e => {
setInclude(e.target.value as IncludeMode);
setPage(0);
}}
size="small"
>
<Radio.Button value="all">all</Radio.Button>
<Radio.Button value="self">self</Radio.Button>
<Radio.Button value="related">related</Radio.Button>
</Radio.Group>
</Space>
<Space>
<Typography.Text>page_size</Typography.Text>
<select
aria-label={t('page_size')}
value={pageSize}
onChange={e => {
setPageSize(Number(e.target.value));
setPage(0);
}}
>
{PAGE_SIZE_OPTIONS.map(n => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</Space>
<Space>
<Typography.Text>since</Typography.Text>
<Input
placeholder={t('ISO 8601')}
size="small"
value={since}
onChange={e => {
setSince(e.target.value);
setPage(0);
}}
style={{ width: 220 }}
/>
</Space>
<Space>
<Typography.Text>until</Typography.Text>
<Input
placeholder={t('ISO 8601')}
size="small"
value={until}
onChange={e => {
setUntil(e.target.value);
setPage(0);
}}
style={{ width: 220 }}
/>
</Space>
<button
type="button"
onClick={() => setReloadCounter(c => c + 1)}
style={{ padding: '4px 12px' }}
>
{t('reload')}
</button>
</Space>
</Space>
</Card>
<Space style={{ marginBottom: 12 }} wrap>
<Typography.Text>
{t('count')}: <Typography.Text strong>{totalCount}</Typography.Text>
</Typography.Text>
<Typography.Text>
{t('page')}: {page} / {lastPage}
</Typography.Text>
<button
type="button"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
{t('← prev')}
</button>
<button
type="button"
onClick={() => setPage(p => p + 1)}
disabled={page >= lastPage}
>
{t('next →')}
</button>
</Space>
{loading && <Loading />}
{error && (
<Card size="small" style={{ marginBottom: 12 }}>
<Typography.Text type="danger" strong>
{t('error')}:
</Typography.Text>{' '}
<Typography.Text type="danger">{error}</Typography.Text>
</Card>
)}
{!loading && !error && records.length === 0 && (
<Empty description={t('No activity records for this page.')} />
)}
{records.map(record => (
<RecordCard
key={`${record.transaction_id}-${record.entity_kind}-${record.entity_uuid}-${record.kind}-${record.path.join('.')}`}
record={record}
/>
))}
</div>
);
}
export default ActivityDebug;
@@ -57,12 +57,9 @@ const mockUser = {
const findFilterByLabel = (labelText: string) => {
const containers = screen.getAllByTestId('select-filter-container');
for (const container of containers) {
// Compact pill filters show the label as button text
const pill = container.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement | null;
if (pill && pill.textContent?.includes(labelText)) {
return pill;
const label = container.querySelector('label');
if (label?.textContent === labelText) {
return container.querySelector('[role="combobox"], .ant-select');
}
}
return null;
@@ -0,0 +1,347 @@
/**
* 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.
*/
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
// dropdown of recent versions on a chart and restore one. Not part
// of the merged feature scope (ADR-005 limits v1 to backend); revert
// before pushing the versioning branch.
import { useState, useCallback } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
interface Change {
kind: string;
path: string[];
from_value: unknown;
to_value: unknown;
}
interface ChangedBy {
id: number;
username: string;
first_name: string;
last_name: string;
}
interface Version {
version_uuid: string;
version_number: number;
transaction_id: number;
operation_type: string;
issued_at: string;
changed_by: ChangedBy | null;
changes: Change[];
}
interface Props {
chartUuid: string;
onRestored?: () => void;
}
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
// backend): path = [verb, kind, id]. Same shape across the three
// debug widgets so chart/dataset dropdowns also recognise them — even
// though they don't normally produce layout records, the formatter
// stays uniform.
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
// Localized labels for the kinds emitted by the backend (layout walker
// + dataset child diff). Defined statically so xgettext can extract them.
const KIND_LABELS: Record<string, string> = {
chart: t('chart'),
row: t('row'),
column: t('column'),
tab: t('tab'),
tabs: t('tabs'),
header: t('header'),
markdown: t('markdown'),
divider: t('divider'),
metric: t('metric'),
};
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
function summarizeChange(c: Change): string {
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
const verb = String(c.path[0]);
const kind = localizedKind(String(c.path[1]));
const payload =
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
const name = payload?.name;
if (verb === 'add') {
return name
? t('Added %(kind)s "%(name)s"', { kind, name })
: t('Added %(kind)s', { kind });
}
if (verb === 'remove') {
return name
? t('Removed %(kind)s "%(name)s"', { kind, name })
: t('Removed %(kind)s', { kind });
}
if (verb === 'move') {
return name
? t('Moved %(kind)s "%(name)s"', { kind, name })
: t('Moved %(kind)s', { kind });
}
return name
? t('Edited %(kind)s "%(name)s"', { kind, name })
: t('Edited %(kind)s', { kind });
}
const isAdd = c.from_value == null && c.to_value != null;
const isRemove = c.from_value != null && c.to_value == null;
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
const kind = localizedKind(c.kind);
const name = String(c.path[1]);
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
return t('Changed %(kind)s "%(name)s"', { kind, name });
}
if (c.path[0] === 'slices') {
const id = String(c.path[1] ?? '');
if (isAdd) return t('Added chart %(id)s', { id }).trim();
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
return t('Changed chart %(id)s', { id }).trim();
}
if (c.kind === 'field') {
const fieldName = String(c.path[c.path.length - 1]);
const fieldLabel: string =
fieldName === 'dashboard_title'
? t('title')
: fieldName === 'slice_name'
? t('chart name')
: fieldName === 'table_name'
? t('table name')
: fieldName;
const isShortScalar =
c.to_value !== null &&
c.to_value !== undefined &&
(typeof c.to_value === 'string' ||
typeof c.to_value === 'number' ||
typeof c.to_value === 'boolean') &&
String(c.to_value).length <= 80;
if (!isAdd && !isRemove && isShortScalar) {
return t('Changed %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isRemove) {
return t('Cleared %(field)s', { field: fieldLabel });
}
if (isAdd && isShortScalar) {
return t('Set %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
return t('Changed %(field)s', { field: fieldLabel });
}
const kind = localizedKind(c.kind);
if (c.path.length) {
const detail = String(c.path[c.path.length - 1]);
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
return t('Changed %(kind)s %(detail)s', { kind, detail });
}
if (isAdd) return t('Added %(kind)s', { kind });
if (isRemove) return t('Removed %(kind)s', { kind });
return t('Changed %(kind)s', { kind });
}
function formatChangeTitle(changes: Change[]): string {
if (!changes.length) return t('Baseline');
const first = summarizeChange(changes[0]);
if (changes.length === 1) return first;
return t('%(first)s (+%(more)s more)', {
first,
more: changes.length - 1,
});
}
function formatUser(by: ChangedBy | null): string {
if (!by) return t('system');
if (by.first_name || by.last_name) {
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
}
return by.username;
}
function formatDate(iso: string): string {
try {
// Match the Superset locale set in src/views/App.tsx on
// ``document.documentElement.lang`` rather than the browser default.
const lang = document.documentElement.lang || undefined;
return new Date(iso).toLocaleString(lang);
} catch {
return iso;
}
}
export default function VersionHistoryDropdown({
chartUuid,
onRestored,
}: Props) {
const [versions, setVersions] = useState<Version[] | null>(null);
const [loading, setLoading] = useState(false);
const loadVersions = useCallback(async () => {
setLoading(true);
try {
const { json } = await SupersetClient.get({
endpoint: `/api/v1/chart/${chartUuid}/versions/`,
});
const result = (json as { result: Version[] }).result || [];
// Newest first (API returns oldest-first)
setVersions([...result].reverse().slice(0, 20));
} catch (e) {
console.error('Failed to load versions', e);
setVersions([]);
} finally {
setLoading(false);
}
}, [chartUuid]);
const handleRestore = useCallback(
async (version: Version) => {
const summary = formatChangeTitle(version.changes);
if (
// eslint-disable-next-line no-alert
!window.confirm(
t(
'Restore this chart to version %(num)s (%(summary)s)? This will overwrite the current state.',
{ num: version.version_number, summary },
),
)
) {
return;
}
try {
await SupersetClient.post({
endpoint: `/api/v1/chart/${chartUuid}/versions/${version.version_uuid}/restore`,
});
// eslint-disable-next-line no-alert
window.alert(t('Restored. Reload the page to see the change.'));
if (onRestored) onRestored();
} catch (e) {
console.error('Restore failed', e);
// eslint-disable-next-line no-alert
window.alert(t('Restore failed — see browser console for details.'));
}
},
[chartUuid, onRestored],
);
const items = (() => {
if (loading) {
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
}
if (!versions) {
return [
{ key: 'empty', label: t('Click to load versions'), disabled: true },
];
}
if (versions.length === 0) {
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
}
// versions is already newest-first, so [0] is the live/current version.
return versions.map((v, idx) => {
const isCurrent = idx === 0;
return {
key: String(v.transaction_id),
// antd's `disabled: true` greys the item and blocks default
// click handling; combined with the inner div NOT having an
// onClick when current, the row becomes informational only.
disabled: isCurrent,
label: (
<div
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
onClick={isCurrent ? undefined : () => handleRestore(v)}
>
<div style={{ fontWeight: 600 }}>
#{v.version_number} {formatChangeTitle(v.changes)}
{isCurrent && (
<span
style={{
marginLeft: 8,
fontWeight: 400,
fontSize: 12,
opacity: 0.7,
}}
>
{t('(current)')}
</span>
)}
</div>
<div style={{ fontSize: 12, opacity: 0.75 }}>
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
</div>
{v.changes.length > 1 && (
<ul
style={{
margin: '4px 0 0 18px',
padding: 0,
fontSize: 12,
opacity: 0.85,
listStyle: 'disc',
}}
>
{v.changes.slice(0, 5).map((c, i) => (
<li key={i}>{summarizeChange(c)}</li>
))}
{v.changes.length > 5 && (
<li style={{ opacity: 0.6 }}>
{t('+%(n)s more', { n: v.changes.length - 5 })}
</li>
)}
</ul>
)}
</div>
),
};
});
})();
return (
<Dropdown
trigger={['click']}
menu={{ items }}
onOpenChange={open => {
if (open && versions === null && !loading) loadVersions();
}}
>
<Tooltip
id="version-history-tooltip"
title={t('Version history (demo)')}
placement="bottom"
>
<span role="button" tabIndex={0} className="action-button">
<Icons.HistoryOutlined iconSize="l" />
</span>
</Tooltip>
</Dropdown>
);
}
@@ -84,6 +84,8 @@ import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import { Tag } from 'src/components/Tag';
import { datasetLabel } from 'src/features/semanticLayers/label';
// TEMP: sc-103156 versioning demo. Revert before any commit.
import VersionHistoryDropdown from './VersionHistoryDropdown';
const FlexRowContainer = styled.div`
align-items: center;
@@ -576,6 +578,13 @@ function ChartList(props: ChartListProps) {
)}
</ConfirmStatusChange>
)}
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
{original.uuid && canEdit && (
<VersionHistoryDropdown
chartUuid={original.uuid}
onRestored={() => refreshData()}
/>
)}
</StyledActions>
);
},
@@ -156,16 +156,18 @@ describe('DashboardList Card View Tests', () => {
).toBeInTheDocument();
});
// Find the sort select by its testId, then the pill button within it
// Find the sort select by its testId, then the combobox within it
const sortContainer = screen.getByTestId('card-sort-select');
// eslint-disable-next-line testing-library/no-node-access
const sortPill = sortContainer.querySelector(
'[data-test="compact-filter-pill"]',
) as HTMLElement;
await userEvent.click(sortPill);
const sortCombobox = within(sortContainer).getByRole('combobox');
await userEvent.click(sortCombobox);
// Select "Alphabetical" from the dropdown
const alphabeticalOption = await screen.findByText('Alphabetical');
const alphabeticalOption = await waitFor(() =>
within(
// eslint-disable-next-line testing-library/no-node-access
document.querySelector('.rc-virtual-list')!,
).getByText('Alphabetical'),
);
await userEvent.click(alphabeticalOption);
await waitFor(() => {
@@ -20,7 +20,7 @@ import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import {
screen,
selectPillOption,
selectOption,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
@@ -88,7 +88,7 @@ test('fetches data', async () => {
const calls = fetchMock.callHistory.calls(/dashboard\/\?q/);
expect(calls[0].url).toMatchInlineSnapshot(
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`,
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,uuid,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`,
);
});
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API call', async () => {
).toBeInTheDocument();
});
await selectPillOption('Published', 'Status');
await selectOption('Published', 'Status');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -242,7 +242,7 @@ test('selecting Owner filter encodes rel_m_m owner in API call', async () => {
).toBeInTheDocument();
});
await selectPillOption('Admin User', 'Owner');
await selectOption('Admin User', 'Owner');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -287,7 +287,7 @@ test('selecting Modified by filter encodes rel_o_m changed_by in API call', asyn
).toBeInTheDocument();
});
await selectPillOption('Admin User', 'Modified by');
await selectOption('Admin User', 'Modified by');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -0,0 +1,363 @@
/**
* 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.
*/
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
// dropdown of recent versions on a dashboard and restore one. Not part
// of the merged feature scope (ADR-005 limits v1 to backend); revert
// before pushing the versioning branch.
import { useState, useCallback } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
interface Change {
kind: string;
path: string[];
from_value: unknown;
to_value: unknown;
}
interface ChangedBy {
id: number;
username: string;
first_name: string;
last_name: string;
}
interface Version {
version_uuid: string;
version_number: number;
transaction_id: number;
operation_type: string;
issued_at: string;
changed_by: ChangedBy | null;
changes: Change[];
}
interface Props {
dashboardUuid: string;
onRestored?: () => void;
}
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
// backend): path = [verb, kind, id].
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
// Localized labels for the kinds emitted by the backend (layout walker
// + dataset child diff). Defined statically so xgettext can extract them.
const KIND_LABELS: Record<string, string> = {
chart: t('chart'),
row: t('row'),
column: t('column'),
tab: t('tab'),
tabs: t('tabs'),
header: t('header'),
markdown: t('markdown'),
divider: t('divider'),
metric: t('metric'),
};
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
function summarizeChange(c: Change): string {
// Layout record (dashboard): path = [verb, kind, id], with payload
// carrying ``name`` / ``chartId`` etc.
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
const verb = String(c.path[0]);
const kind = localizedKind(String(c.path[1]));
const payload =
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
const name = payload?.name;
if (verb === 'add') {
return name
? t('Added %(kind)s "%(name)s"', { kind, name })
: t('Added %(kind)s', { kind });
}
if (verb === 'remove') {
return name
? t('Removed %(kind)s "%(name)s"', { kind, name })
: t('Removed %(kind)s', { kind });
}
if (verb === 'move') {
return name
? t('Moved %(kind)s "%(name)s"', { kind, name })
: t('Moved %(kind)s', { kind });
}
return name
? t('Edited %(kind)s "%(name)s"', { kind, name })
: t('Edited %(kind)s', { kind });
}
const isAdd = c.from_value == null && c.to_value != null;
const isRemove = c.from_value != null && c.to_value == null;
// Dataset child: path = [columns | metrics, <name>]. ``kind`` is
// ``column`` / ``metric`` so we can rebuild a readable summary.
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
const kind = localizedKind(c.kind);
const name = String(c.path[1]);
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
return t('Changed %(kind)s "%(name)s"', { kind, name });
}
// Slice membership (mostly folded into layout records server-side,
// but may still appear if the layout walk didn't catch a chart).
if (c.path[0] === 'slices') {
const id = String(c.path[1] ?? '');
if (isAdd) return t('Added chart %(id)s', { id }).trim();
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
return t('Changed chart %(id)s', { id }).trim();
}
// Scalar field record: path = [field_name] or [json_field, sub_key].
if (c.kind === 'field') {
const fieldName = String(c.path[c.path.length - 1]);
// Friendly labels for the most user-visible fields.
const fieldLabel: string =
fieldName === 'dashboard_title'
? t('title')
: fieldName === 'slice_name'
? t('chart name')
: fieldName === 'table_name'
? t('table name')
: fieldName;
// If the new value is a short primitive (string/number/bool), show
// "Changed <field> to <value>" — much more useful than just naming
// the field. Long strings, dicts and arrays fall through to the
// generic verb-only summary.
const isShortScalar =
c.to_value !== null &&
c.to_value !== undefined &&
(typeof c.to_value === 'string' ||
typeof c.to_value === 'number' ||
typeof c.to_value === 'boolean') &&
String(c.to_value).length <= 80;
if (!isAdd && !isRemove && isShortScalar) {
return t('Changed %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isRemove) {
return t('Cleared %(field)s', { field: fieldLabel });
}
if (isAdd && isShortScalar) {
return t('Set %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
return t('Changed %(field)s', { field: fieldLabel });
}
// Fallback: kind plus the trailing path segment (if any).
const kind = localizedKind(c.kind);
if (c.path.length) {
const detail = String(c.path[c.path.length - 1]);
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
return t('Changed %(kind)s %(detail)s', { kind, detail });
}
if (isAdd) return t('Added %(kind)s', { kind });
if (isRemove) return t('Removed %(kind)s', { kind });
return t('Changed %(kind)s', { kind });
}
function formatChangeTitle(changes: Change[]): string {
if (!changes.length) return t('Baseline');
const first = summarizeChange(changes[0]);
if (changes.length === 1) return first;
return t('%(first)s (+%(more)s more)', {
first,
more: changes.length - 1,
});
}
function formatUser(by: ChangedBy | null): string {
if (!by) return t('system');
if (by.first_name || by.last_name) {
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
}
return by.username;
}
function formatDate(iso: string): string {
try {
// Match the Superset locale set in src/views/App.tsx on
// ``document.documentElement.lang`` rather than the browser default.
const lang = document.documentElement.lang || undefined;
return new Date(iso).toLocaleString(lang);
} catch {
return iso;
}
}
export default function VersionHistoryDropdown({
dashboardUuid,
onRestored,
}: Props) {
const [versions, setVersions] = useState<Version[] | null>(null);
const [loading, setLoading] = useState(false);
const loadVersions = useCallback(async () => {
setLoading(true);
try {
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/`,
});
const result = (json as { result: Version[] }).result || [];
// Newest first (API returns oldest-first)
setVersions([...result].reverse().slice(0, 20));
} catch (e) {
console.error('Failed to load versions', e);
setVersions([]);
} finally {
setLoading(false);
}
}, [dashboardUuid]);
const handleRestore = useCallback(
async (version: Version) => {
const summary = formatChangeTitle(version.changes);
if (
// eslint-disable-next-line no-alert
!window.confirm(
t(
'Restore this dashboard to version %(num)s (%(summary)s)? This will overwrite the current state.',
{ num: version.version_number, summary },
),
)
) {
return;
}
try {
await SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/${version.version_uuid}/restore`,
});
onRestored?.();
// Navigate to the dashboard with no URL params. A previous
// ``?native_filters_key=…`` (or ``permalink_key`` / ``form_data_key``)
// points at a server-cached snapshot from before the restore;
// the next page hydration would merge it on top of the freshly
// restored ``json_metadata`` and effectively mask the rollback
// (e.g. dashboard-level colour scheme changes don't appear).
// A clean URL forces hydration from the restored DB state.
window.location.href = `/superset/dashboard/${dashboardUuid}/`;
} catch (e) {
console.error('Restore failed', e);
// eslint-disable-next-line no-alert
window.alert(t('Restore failed — see browser console for details.'));
}
},
[dashboardUuid, onRestored],
);
const items = (() => {
if (loading) {
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
}
if (!versions) {
return [
{ key: 'empty', label: t('Click to load versions'), disabled: true },
];
}
if (versions.length === 0) {
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
}
// versions is already newest-first, so [0] is the live/current version.
return versions.map((v, idx) => {
const isCurrent = idx === 0;
return {
key: String(v.transaction_id),
// antd's `disabled: true` greys the item and blocks default
// click handling; combined with the inner div NOT having an
// onClick when current, the row becomes informational only.
disabled: isCurrent,
label: (
<div
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
onClick={isCurrent ? undefined : () => handleRestore(v)}
>
<div style={{ fontWeight: 600 }}>
#{v.version_number} {formatChangeTitle(v.changes)}
{isCurrent && (
<span
style={{
marginLeft: 8,
fontWeight: 400,
fontSize: 12,
opacity: 0.7,
}}
>
{t('(current)')}
</span>
)}
</div>
<div style={{ fontSize: 12, opacity: 0.75 }}>
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
</div>
{v.changes.length > 1 && (
<ul
style={{
margin: '4px 0 0 18px',
padding: 0,
fontSize: 12,
opacity: 0.85,
listStyle: 'disc',
}}
>
{v.changes.slice(0, 5).map((c, i) => (
<li key={i}>{summarizeChange(c)}</li>
))}
{v.changes.length > 5 && (
<li style={{ opacity: 0.6 }}>
{t('+%(n)s more', { n: v.changes.length - 5 })}
</li>
)}
</ul>
)}
</div>
),
};
});
})();
return (
<Dropdown
trigger={['click']}
menu={{ items }}
onOpenChange={open => {
if (open && versions === null && !loading) loadVersions();
}}
>
<Tooltip
id="version-history-tooltip"
title={t('Version history (demo)')}
placement="bottom"
>
<span role="button" tabIndex={0} className="action-button">
<Icons.HistoryOutlined iconSize="l" />
</span>
</Tooltip>
</Dropdown>
);
}
@@ -77,6 +77,8 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { findPermission } from 'src/utils/findPermission';
import { navigateTo } from 'src/utils/navigationUtils';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
// TEMP: sc-103156 versioning demo. Revert before any commit.
import VersionHistoryDropdown from './VersionHistoryDropdown';
const PAGE_SIZE = 25;
const PASSWORDS_NEEDED_MESSAGE = t(
@@ -122,6 +124,10 @@ const Actions = styled.div`
const DASHBOARD_COLUMNS_TO_FETCH = [
'id',
// TEMP: sc-103156 versioning demo. The version-history dropdown
// calls /api/v1/dashboard/<uuid>/versions/, so the row needs `uuid`.
// Revert this entry along with the dropdown component.
'uuid',
'dashboard_title',
'published',
'url',
@@ -504,6 +510,13 @@ function DashboardList(props: DashboardListProps) {
)}
</ConfirmStatusChange>
)}
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
{original.uuid && canEdit && (
<VersionHistoryDropdown
dashboardUuid={original.uuid}
onRestored={() => refreshData()}
/>
)}
</Actions>
);
},
@@ -20,7 +20,7 @@ import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { selectPillOption } from 'spec/helpers/testing-library';
import { selectOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -102,11 +102,11 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
).toBeGreaterThan(callsBeforeSort);
});
// 2. Apply a filter using selectPillOption helper (compact pill UI)
// 2. Apply a filter using selectOption helper
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await selectPillOption('Virtual', 'Type');
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { SupersetClient } from '@superset-ui/core';
import { selectPillOption } from 'spec/helpers/testing-library';
import { selectOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -1510,8 +1510,11 @@ test('bulk selection clears when filter changes', async () => {
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply a filter using selectPillOption helper (compact pill UI)
await selectPillOption('Virtual', 'Type');
// Wait for filter combobox to be ready before applying filter
await screen.findByRole('combobox', { name: 'Type' });
// Apply a filter using selectOption helper
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1553,13 +1556,16 @@ test('type filter API call includes correct filter parameter', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
// Wait for Type filter combobox
await screen.findByRole('combobox', { name: 'Type' });
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter using compact pill UI
await selectPillOption('Virtual', 'Type');
// Apply Type filter
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1600,13 +1606,16 @@ test('type filter persists after duplicating a dataset', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
// Wait for Type filter combobox
await screen.findByRole('combobox', { name: 'Type' });
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter using compact pill UI
await selectPillOption('Virtual', 'Type');
// Apply Type filter
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -200,8 +200,8 @@ test('renders Name search filter', async () => {
test('renders Type filter (Virtual/Physical dropdown)', async () => {
renderDatasetList(mockAdminUser);
// Filter pills should be present (compact pill UI)
const filters = await screen.findAllByTestId('compact-filter-pill');
// Filter dropdowns should be present
const filters = await screen.findAllByRole('combobox');
expect(filters.length).toBeGreaterThan(0);
});
@@ -445,8 +445,7 @@ test('selecting Database filter triggers API call with database relation filter'
await waitForDatasetsPageReady();
// Filter pills should be present (compact pill UI replaces comboboxes)
const filtersContainers = screen.getAllByTestId('compact-filter-pill');
const filtersContainers = screen.getAllByRole('combobox');
expect(filtersContainers.length).toBeGreaterThan(0);
});
@@ -0,0 +1,343 @@
/**
* 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.
*/
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
// dropdown of recent versions on a dataset and restore one. Not part
// of the merged feature scope (ADR-005 limits v1 to backend); revert
// before pushing the versioning branch.
import { useState, useCallback } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
interface Change {
kind: string;
path: string[];
from_value: unknown;
to_value: unknown;
}
interface ChangedBy {
id: number;
username: string;
first_name: string;
last_name: string;
}
interface Version {
version_uuid: string;
version_number: number;
transaction_id: number;
operation_type: string;
issued_at: string;
changed_by: ChangedBy | null;
changes: Change[];
}
interface Props {
datasetUuid: string;
onRestored?: () => void;
}
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
// backend): path = [verb, kind, id]. Same shape across the three
// debug widgets so chart/dataset dropdowns also recognise them — even
// though they don't normally produce layout records, the formatter
// stays uniform.
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
// Localized labels for the kinds emitted by the backend (layout walker
// + dataset child diff). Defined statically so xgettext can extract them.
const KIND_LABELS: Record<string, string> = {
chart: t('chart'),
row: t('row'),
column: t('column'),
tab: t('tab'),
tabs: t('tabs'),
header: t('header'),
markdown: t('markdown'),
divider: t('divider'),
metric: t('metric'),
};
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
function summarizeChange(c: Change): string {
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
const verb = String(c.path[0]);
const kind = localizedKind(String(c.path[1]));
const payload =
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
const name = payload?.name;
if (verb === 'add') {
return name
? t('Added %(kind)s "%(name)s"', { kind, name })
: t('Added %(kind)s', { kind });
}
if (verb === 'remove') {
return name
? t('Removed %(kind)s "%(name)s"', { kind, name })
: t('Removed %(kind)s', { kind });
}
if (verb === 'move') {
return name
? t('Moved %(kind)s "%(name)s"', { kind, name })
: t('Moved %(kind)s', { kind });
}
return name
? t('Edited %(kind)s "%(name)s"', { kind, name })
: t('Edited %(kind)s', { kind });
}
const isAdd = c.from_value == null && c.to_value != null;
const isRemove = c.from_value != null && c.to_value == null;
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
const kind = localizedKind(c.kind);
const name = String(c.path[1]);
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
return t('Changed %(kind)s "%(name)s"', { kind, name });
}
if (c.path[0] === 'slices') {
const id = String(c.path[1] ?? '');
if (isAdd) return t('Added chart %(id)s', { id }).trim();
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
return t('Changed chart %(id)s', { id }).trim();
}
if (c.kind === 'field') {
const fieldName = String(c.path[c.path.length - 1]);
const fieldLabel: string =
fieldName === 'dashboard_title'
? t('title')
: fieldName === 'slice_name'
? t('chart name')
: fieldName === 'table_name'
? t('table name')
: fieldName;
const isShortScalar =
c.to_value !== null &&
c.to_value !== undefined &&
(typeof c.to_value === 'string' ||
typeof c.to_value === 'number' ||
typeof c.to_value === 'boolean') &&
String(c.to_value).length <= 80;
if (!isAdd && !isRemove && isShortScalar) {
return t('Changed %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isRemove) {
return t('Cleared %(field)s', { field: fieldLabel });
}
if (isAdd && isShortScalar) {
return t('Set %(field)s to "%(value)s"', {
field: fieldLabel,
value: String(c.to_value),
});
}
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
return t('Changed %(field)s', { field: fieldLabel });
}
const kind = localizedKind(c.kind);
if (c.path.length) {
const detail = String(c.path[c.path.length - 1]);
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
return t('Changed %(kind)s %(detail)s', { kind, detail });
}
if (isAdd) return t('Added %(kind)s', { kind });
if (isRemove) return t('Removed %(kind)s', { kind });
return t('Changed %(kind)s', { kind });
}
function formatChangeTitle(changes: Change[]): string {
if (!changes.length) return t('Baseline');
const first = summarizeChange(changes[0]);
if (changes.length === 1) return first;
return t('%(first)s (+%(more)s more)', {
first,
more: changes.length - 1,
});
}
function formatUser(by: ChangedBy | null): string {
if (!by) return t('system');
if (by.first_name || by.last_name) {
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
}
return by.username;
}
function formatDate(iso: string): string {
try {
// Match the Superset locale set in src/views/App.tsx on
// ``document.documentElement.lang`` rather than the browser default.
const lang = document.documentElement.lang || undefined;
return new Date(iso).toLocaleString(lang);
} catch {
return iso;
}
}
export default function VersionHistoryDropdown({
datasetUuid,
onRestored,
}: Props) {
const [versions, setVersions] = useState<Version[] | null>(null);
const [loading, setLoading] = useState(false);
const loadVersions = useCallback(async () => {
setLoading(true);
try {
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${datasetUuid}/versions/`,
});
const result = (json as { result: Version[] }).result || [];
// Newest first (API returns oldest-first)
setVersions([...result].reverse().slice(0, 20));
} catch (e) {
console.error('Failed to load versions', e);
setVersions([]);
} finally {
setLoading(false);
}
}, [datasetUuid]);
const handleRestore = useCallback(
async (version: Version) => {
const summary = formatChangeTitle(version.changes);
if (
// eslint-disable-next-line no-alert
!window.confirm(
t(
'Restore this dataset to version %(num)s (%(summary)s)? This will overwrite the current state.',
{ num: version.version_number, summary },
),
)
) {
return;
}
try {
await SupersetClient.post({
endpoint: `/api/v1/dataset/${datasetUuid}/versions/${version.version_uuid}/restore`,
});
// eslint-disable-next-line no-alert
window.alert(t('Restored. Reload the page to see the change.'));
if (onRestored) onRestored();
} catch (e) {
console.error('Restore failed', e);
// eslint-disable-next-line no-alert
window.alert(t('Restore failed — see browser console for details.'));
}
},
[datasetUuid, onRestored],
);
const items = (() => {
if (loading) {
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
}
if (!versions) {
return [
{ key: 'empty', label: t('Click to load versions'), disabled: true },
];
}
if (versions.length === 0) {
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
}
return versions.map((v, idx) => {
const isCurrent = idx === 0;
return {
key: String(v.transaction_id),
disabled: isCurrent,
label: (
<div
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
onClick={isCurrent ? undefined : () => handleRestore(v)}
>
<div style={{ fontWeight: 600 }}>
#{v.version_number} {formatChangeTitle(v.changes)}
{isCurrent && (
<span
style={{
marginLeft: 8,
fontWeight: 400,
fontSize: 12,
opacity: 0.7,
}}
>
{t('(current)')}
</span>
)}
</div>
<div style={{ fontSize: 12, opacity: 0.75 }}>
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
</div>
{v.changes.length > 1 && (
<ul
style={{
margin: '4px 0 0 18px',
padding: 0,
fontSize: 12,
opacity: 0.85,
listStyle: 'disc',
}}
>
{v.changes.slice(0, 5).map((c, i) => (
<li key={i}>{summarizeChange(c)}</li>
))}
{v.changes.length > 5 && (
<li style={{ opacity: 0.6 }}>
{t('+%(n)s more', { n: v.changes.length - 5 })}
</li>
)}
</ul>
)}
</div>
),
};
});
})();
return (
<Dropdown
trigger={['click']}
menu={{ items }}
onOpenChange={open => {
if (open && versions === null && !loading) loadVersions();
}}
>
<Tooltip
id="version-history-tooltip"
title={t('Version history (demo)')}
placement="bottom"
>
<span role="button" tabIndex={0} className="action-button">
<Icons.HistoryOutlined iconSize="l" />
</span>
</Tooltip>
</Dropdown>
);
}
@@ -99,6 +99,8 @@ import { useSelector } from 'react-redux';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import type { BootstrapData } from 'src/types/bootstrapTypes';
// TEMP: sc-103156 versioning demo. Revert before any commit.
import VersionHistoryDropdown from './VersionHistoryDropdown';
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
type DatasetExtra = {
@@ -165,6 +167,7 @@ type Dataset = {
source_type?: 'database' | 'semantic_layer';
explore_url: string;
id: number;
uuid?: string;
owners: Array<Owner>;
schema: string | null;
table_name: string;
@@ -936,6 +939,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</span>
</Tooltip>
)}
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
{original.uuid && canEdit && (
<VersionHistoryDropdown
datasetUuid={original.uuid}
onRestored={() => refreshData()}
/>
)}
</Actions>
);
},
@@ -121,17 +121,13 @@ describe('GroupsList', () => {
test('renders the filters correctly', async () => {
await renderComponent();
const filtersSelect = screen.getAllByTestId('filters-select')[0];
// The compact filter UI renders the first search filter as an input,
// and select filters as pill buttons. Only "Name" search renders inline;
// "Label" and "Description" searches are hidden (one search box per page).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
// Select filters render as compact pill buttons
const pills = screen.getAllByTestId('compact-filter-pill');
const pillLabels = pills.map(p => p.textContent ?? '');
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
expect(pillLabels.some(l => /users/i.test(l))).toBe(true);
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
});
test('renders correct columns in the table', async () => {
@@ -151,11 +151,8 @@ describe('RolesList', () => {
test('renders filters options', async () => {
await renderAndWait();
// Compact filter UI: one search input for "Name" and 3 select pills
// (Users, Permissions, Groups).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
const selectContainers = screen.getAllByTestId('select-filter-container');
expect(selectContainers).toHaveLength(3);
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(4);
});
test('renders correct list columns', async () => {
@@ -166,14 +166,11 @@ describe('RuleList RTL', () => {
test('renders filter options', async () => {
await renderAndWait();
// Compact filter UI: only the first search filter renders (Name),
// subsequent search filters (Group Key) are hidden — one search box per page.
const searchFilters = screen.queryAllByTestId('filters-search');
expect(searchFilters).toHaveLength(1);
expect(searchFilters).toHaveLength(2);
// Select filters render as compact pill buttons (Filter Type, Modified by)
const selectContainers = screen.queryAllByTestId('select-filter-container');
expect(selectContainers).toHaveLength(2);
const typeFilter = screen.queryAllByTestId('filters-select');
expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
});
test('renders correct list columns', async () => {
@@ -138,16 +138,16 @@ describe('UsersList', () => {
test('renders filters options', async () => {
await renderAndWait();
// The compact filter UI shows: only the first search filter as an input,
// and select/datetime filters as pill buttons. Only "First name" search
// renders (subsequent search filters are hidden — one search box per page).
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
// Select and datetime filters render as compact pill buttons
const pills = screen.getAllByTestId('compact-filter-pill');
const pillLabels = pills.map(p => p.textContent ?? '');
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
expect(pillLabels.some(l => /is active\?/i.test(l))).toBe(true);
const submenu = screen.queryAllByTestId('filters-select')[0];
expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
});
test('renders correct list columns', async () => {
+8 -13
View File
@@ -96,21 +96,16 @@ export const fetchPaginatedData = async ({
}
const totalPages = Math.ceil(totalItems / pageSize);
const concurrencyLimit = 5;
const allResults = [...firstPageResults];
for (let batch = 1; batch < totalPages; batch += concurrencyLimit) {
const batchEnd = Math.min(batch + concurrencyLimit, totalPages);
// eslint-disable-next-line no-await-in-loop
const batchResults = await Promise.all(
Array.from({ length: batchEnd - batch }, (_, i) =>
fetchPage(batch + i),
),
);
allResults.push(...batchResults.flatMap(res => res.results));
}
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
fetchPage(i + 1),
);
const remainingResults = await Promise.all(requests);
setData(allResults);
setData([
...firstPageResults,
...remainingResults.flatMap(res => res.results),
]);
} catch (err) {
addDangerToast(t(errorMessage));
} finally {
+13
View File
@@ -74,6 +74,14 @@ const DashboardList = lazy(
import(/* webpackChunkName: "DashboardList" */ 'src/pages/DashboardList'),
);
// Throwaway: sc-107283 activity-view debug UI. Delete this lazy import +
// the route below + the src/pages/ActivityDebug directory when the
// feature ships.
const ActivityDebug = lazy(
() =>
import(/* webpackChunkName: "ActivityDebug" */ 'src/pages/ActivityDebug'),
);
const Dashboard = lazy(
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
);
@@ -227,6 +235,11 @@ export const routes: Routes = [
path: '/dashboard/list/',
Component: DashboardList,
},
// Throwaway: sc-107283 activity-view debug UI.
{
path: '/activity-debug/:resource/:uuid',
Component: ActivityDebug,
},
{
path: '/superset/dashboard/:idOrSlug/',
Component: Dashboard,
+7 -7
View File
@@ -15,7 +15,7 @@
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.0"
"ws": "^8.20.1"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
@@ -6428,9 +6428,9 @@
"dev": true
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -11207,9 +11207,9 @@
"dev": true
},
"ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"requires": {}
},
"y18n": {
+1 -1
View File
@@ -23,7 +23,7 @@
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
"winston": "^3.19.0",
"ws": "^8.21.0"
"ws": "^8.20.1"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
+397 -2
View File
@@ -130,6 +130,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
"screenshot",
"cache_screenshot",
"warm_up_cache",
"list_versions",
"get_version",
"restore_version",
"activity",
}
class_permission_name = "Chart"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -308,7 +312,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
try:
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
result = self.chart_get_response_schema.dump(dash)
return self.response(200, result=result)
from superset.daos.version import VersionDAO
from superset.versioning.etag import set_version_etag
return set_version_etag(
self.response(200, result=result),
VersionDAO.current_live_version_uuid(Slice, dash.id, dash.uuid),
)
except ChartNotFoundError:
return self.response_404()
@@ -415,6 +425,34 @@ class ChartRestApi(BaseSupersetModelRestApi):
type: number
result:
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
old_version:
type: integer
nullable: true
description: >-
0-based version_number of the live row before this
update. Unstable under retention pruning see
old_transaction_id for a stable identifier.
new_version:
type: integer
nullable: true
description: >-
0-based version_number of the newly-live row after
this update. Can equal old_version when no
versioned column changed, or when retention
pruning dropped an older closed row in the same
commit.
old_transaction_id:
type: integer
nullable: true
description: Continuum transaction_id of the live
row before this update. Stable across pruning.
new_transaction_id:
type: integer
nullable: true
description: Continuum transaction_id of the live
row after this update. Differs from
old_transaction_id when the update produced a new
version row.
400:
$ref: '#/components/responses/400'
401:
@@ -433,9 +471,43 @@ class ChartRestApi(BaseSupersetModelRestApi):
# This validates custom Schema with custom validations
except ValidationError as error:
return self.response_400(message=error.messages)
# pylint: disable=import-outside-toplevel
from superset.daos.version import VersionDAO
from superset.extensions import db as _db
pre_chart = _db.session.query(Slice).filter(Slice.id == pk).one_or_none()
old_version = VersionDAO.current_version_number(Slice, pk)
old_transaction_id = VersionDAO.current_live_transaction_id(Slice, pk)
old_version_uuid = (
VersionDAO.current_live_version_uuid(Slice, pk, pre_chart.uuid)
if pre_chart is not None
else None
)
try:
changed_model = UpdateChartCommand(pk, item).run()
response = self.response(200, id=changed_model.id, result=item)
new_version = VersionDAO.current_version_number(Slice, changed_model.id)
new_transaction_id = VersionDAO.current_live_transaction_id(
Slice, changed_model.id
)
new_version_uuid = VersionDAO.current_live_version_uuid(
Slice, changed_model.id, changed_model.uuid
)
response = self.response(
200,
id=changed_model.id,
result=item,
old_version=old_version,
new_version=new_version,
old_transaction_id=old_transaction_id,
new_transaction_id=new_transaction_id,
old_version_uuid=str(old_version_uuid) if old_version_uuid else None,
new_version_uuid=str(new_version_uuid) if new_version_uuid else None,
)
from superset.versioning.etag import set_version_etag
set_version_etag(response, new_version_uuid)
except ChartNotFoundError:
response = self.response_404()
except ChartForbiddenError:
@@ -1199,3 +1271,326 @@ class ChartRestApi(BaseSupersetModelRestApi):
)
command.run()
return self.response(200, message="OK")
@expose("/<uuid_str>/versions/", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.list_versions",
log_to_statsd=False,
)
def list_versions(self, uuid_str: str) -> Response:
"""List version history for a chart.
---
get:
summary: Return the version history for a chart
parameters:
- in: path
schema:
type: string
format: uuid
name: uuid_str
description: Chart UUID
responses:
200:
description: Version history ordered by oldest first
content:
application/json:
schema:
type: object
properties:
result:
type: array
items:
type: object
count:
type: integer
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
# pylint: disable=import-outside-toplevel
from uuid import UUID
from superset.daos.version import VersionDAO
from superset.exceptions import SupersetSecurityException
try:
entity_uuid = UUID(uuid_str)
except ValueError:
return self.response_400(message="Invalid UUID")
entity = VersionDAO.find_active_by_uuid(Slice, entity_uuid)
if entity is None:
return self.response_404()
try:
security_manager.raise_for_ownership(entity)
except SupersetSecurityException:
return self.response_403()
versions = VersionDAO.list_versions(Slice, entity_uuid, entity=entity)
if versions is None:
return self.response_404()
from superset.versioning.etag import set_version_etag_by_uuid
return set_version_etag_by_uuid(
self.response(200, result=versions, count=len(versions)),
Slice,
entity_uuid,
)
@expose(
"/<uuid_str>/versions/<version_uuid_str>/",
methods=("GET",),
)
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_version", # noqa: E501
log_to_statsd=False,
)
def get_version(self, uuid_str: str, version_uuid_str: str) -> Response:
"""Return the chart's state at a specific version.
---
get:
summary: Read-only snapshot of the chart at a given version
parameters:
- in: path
schema:
type: string
format: uuid
name: uuid_str
description: Chart UUID
- in: path
schema:
type: string
format: uuid
name: version_uuid_str
description: Version UUID as returned by the list endpoint
responses:
200:
description: Snapshot of the chart at the target version
content:
application/json:
schema:
type: object
properties:
result:
type: object
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
# pylint: disable=import-outside-toplevel
from uuid import UUID
from superset.daos.version import VersionDAO
from superset.exceptions import SupersetSecurityException
try:
entity_uuid = UUID(uuid_str)
except ValueError:
return self.response_400(message="Invalid UUID")
try:
version_uuid = UUID(version_uuid_str)
except ValueError:
return self.response_400(message="Invalid version UUID")
entity = VersionDAO.find_active_by_uuid(Slice, entity_uuid)
if entity is None:
return self.response_404()
try:
security_manager.raise_for_ownership(entity)
except SupersetSecurityException:
return self.response_403()
snapshot = VersionDAO.get_version(
Slice, entity_uuid, version_uuid, entity=entity
)
if snapshot is None:
return self.response_404()
from superset.versioning.etag import set_version_etag_by_uuid
return set_version_etag_by_uuid(
self.response(200, result=snapshot), Slice, entity_uuid
)
@expose("/<uuid_str>/activity/", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.activity",
log_to_statsd=False,
)
def activity(self, uuid_str: str) -> Response:
"""Return the cross-entity activity stream for a chart.
---
get:
summary: Activity stream chart own edits + datasets the
chart pointed at during association (sc-107283 US2)
parameters:
- in: path
schema:
type: string
format: uuid
name: uuid_str
description: Chart UUID
- in: query
schema:
type: string
format: date-time
name: since
- in: query
schema:
type: string
format: date-time
name: until
- in: query
schema:
type: string
enum: [self, related, all]
default: all
name: include
- in: query
schema:
type: integer
minimum: 0
default: 0
name: page
- in: query
schema:
type: integer
minimum: 1
maximum: 200
default: 25
name: page_size
responses:
200:
description: Activity stream ordered newest-first
content:
application/json:
schema: ActivityResponseSchema
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
# pylint: disable=import-outside-toplevel
from superset.versioning import activity as activity_module
from superset.versioning.schemas import ActivityResponseSchema
try:
entity = activity_module.resolve_endpoint_path_entity(self, Slice, uuid_str)
except activity_module.PathEntityResponseError as exc:
return exc.response
try:
params = activity_module.parse_activity_query_params(request.args)
except activity_module.ActivityParamsError as exc:
return self.response_400(message=str(exc))
records, count = activity_module.get_activity(Slice, entity.uuid, **params)
payload = ActivityResponseSchema().dump({"result": records, "count": count})
return self.response(200, **payload)
@expose(
"/<uuid_str>/versions/<version_uuid_str>/restore",
methods=("POST",),
)
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.restore_version"
), # noqa: E501
log_to_statsd=False,
)
def restore_version(self, uuid_str: str, version_uuid_str: str) -> Response:
"""Restore a chart to a previous version.
---
post:
summary: Revert a chart to an earlier version (non-destructive)
parameters:
- in: path
schema:
type: string
format: uuid
name: uuid_str
description: Chart UUID
- in: path
schema:
type: string
format: uuid
name: version_uuid_str
description: >-
Version UUID as returned by the list-versions endpoint.
Stable across retention pruning.
responses:
200:
description: Chart was restored
content:
application/json:
schema:
type: object
properties:
message:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
"""
# pylint: disable=import-outside-toplevel
from uuid import UUID
from superset.commands.chart.restore_version import (
RestoreChartVersionCommand,
)
try:
entity_uuid = UUID(uuid_str)
except ValueError:
return self.response_400(message="Invalid UUID")
try:
version_uuid = UUID(version_uuid_str)
except ValueError:
return self.response_400(message="Invalid version UUID")
try:
RestoreChartVersionCommand(entity_uuid, version_uuid).run()
except ChartNotFoundError:
return self.response_404()
except ChartForbiddenError:
return self.response_403()
except ChartUpdateFailedError as ex:
logger.error("Error restoring chart version: %s", ex)
return self.response_422(message=str(ex))
from superset.versioning.etag import set_version_etag_by_uuid
return set_version_etag_by_uuid(
self.response(200, message="OK"), Slice, entity_uuid
)

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