Compare commits

...

13 Commits

Author SHA1 Message Date
Claude
a5dc0f27ea chore: untrack session lock (again) 2026-05-14 17:54:00 -07:00
Claude
ca40472153 fix(theming): make ComponentThemeProvider tolerate isolated component tests
Two CI follow-ups:

1. \`TabsRenderer.test.tsx\` rendered the component without a Redux
   Provider, which broke after Phase 4b added \`<ComponentThemeProvider>\`
   wrapping the body — the provider's \`useEffectiveThemeId\` calls
   \`useSelector\`. Pass \`useRedux: true\` on every \`render(...)\` call
   in the test (rerender shares the same wrapper, so it doesn't need
   the option).

2. After fixing #1, the test then failed on \`useThemeContext\` throwing
   because there's no \`<ThemeProvider>\` in the test tree either. Make
   \`ComponentThemeProvider\` resilient: read the underlying
   \`ThemeContext\` directly via \`useContext\` (instead of going through
   \`useThemeContext\`'s mandatory throw) and pass through children
   when no theme context is mounted. Required exporting \`ThemeContext\`
   from \`ThemeProvider\`.

Same defensive posture also helps storybook stories and embedded
contexts that don't always set up a dashboard ThemeProvider.

11/11 TabsRenderer tests passing locally; the 14 ComponentThemeProvider
tests + 4 ComponentHeaderControls tests + 3 setComponentThemeId tests
all still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:53:44 -07:00
Claude
0cc17017fc chore: untrack accidentally-committed session lock 2026-05-14 17:47:33 -07:00
Claude
9683eefb6f style(theming): apply pre-commit auto-formats (prettier + oxlint)
Pure formatting / oxlint auto-fixes from running pre-commit:

- Inline a single-element array literal in
  `ComponentHeaderControls.test.tsx` (prettier).
- Drop a redundant `?? {}` on a spread in
  `useEffectiveThemeId.test.ts` (oxlint).
- Spread `?? {}` cleanup in
  `setComponentThemeId.test.ts` (prettier).

No behavior change. Was caught by CI's pre-commit job because the
local pre-commit hook hadn't run on these files at commit time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:47:22 -07:00
Claude
4be803cb6e fix(sip): add Apache license header to SIP.md (RAT)
License Check (Apache RAT) flagged `SIP.md` at the repo root for
missing the license header. Add it as an HTML comment so the
rendered Markdown is unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:37:08 -07:00
Claude
635853047b fix(dashboard): TS errors on theme provider + setComponentThemeId
CI lint-frontend caught two type errors:

1. \`setComponentThemeId.ts\` imported \`AppDispatch\` from
   \`src/dashboard/types\` — that name isn't exported there. The
   project's pattern (used by \`dashboardLayout.ts\`,
   \`hydrate.ts\`, \`sqlLab.ts\`) is to declare a local
   \`type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>\`.
   Match that.

2. \`useEffectiveThemeId.ts\` had a \`const node = layout[cursorId]\`
   inside a loop where TS couldn't infer \`node\`'s type cleanly
   (TS7022 — implicit any in self-referencing initializer, likely from
   the inline \`as { themeId?: number | null } | undefined\` cast on
   \`node.meta\`). Drop the cast (LayoutItemMeta already has
   \`themeId?: number | null\` from Phase 1) and add an explicit
   \`LayoutItem | undefined\` annotation on \`node\`.

All 17 unit tests still pass. Project \`tsc --noEmit\` is clean for
the touched files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:34:07 -07:00
Claude
ad37366e93 feat(dashboard): live theme preview in ThemeSelectorModal
When the modal is open, the targeted component re-renders with the
candidate theme as soon as the user picks an option — no dashboard-
dirty round-trip required. Cancel reverts; Apply commits to Redux.

Implementation:

- New \`previewThemeStore\` (module-level subscribable map keyed by
  layoutId, distinguishing "no preview" / "preview value=null" /
  "preview value=number"). Tiny surface: \`set\`/\`clear\`/\`get\`/
  \`subscribe\`. No-op \`set\` / \`clear\` calls don't fire listeners.
- \`useEffectiveThemeId\` now subscribes via \`useSyncExternalStore\`
  and prefers the preview value over the Redux-resolved id when
  present.
- \`ThemeSelectorModal\` writes the in-flight selection through the
  store as the user picks options; cleanup on close (Cancel, X
  button, escape) clears it. Apply dispatches the Redux action
  *before* hiding, so the post-cleanup re-resolution lands on the
  saved value (no flicker).

Snapshot of the resolved id at open-time goes through a \`useRef\`
because \`currentThemeId\` itself becomes reactive (it would already
reflect the in-flight preview), so we can't read it for "what should
we revert to?".

6 new tests for the preview store: get-undefined-for-unknown,
set-stores-numeric, set-stores-explicit-null, clear-removes,
subscriber-fires-on-real-change-and-not-on-no-op, multi-layoutId-
independence. Total dashboard ComponentThemeProvider suite is now
14 passing tests.

Drops "live preview" from the deferred-items list in SIP.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:56:26 -07:00
Claude
1be84f1769 feat(dashboard): component theming for Tabs, Row, Column, Markdown — Phase 4b-4e
Same three-step recipe applied to each grid-component type:

  (a) wrap body in <ComponentThemeProvider layoutId={id}>
  (b) add "Apply theme" item to the component's menu via
      ComponentHeaderControls
  (c) mount <ThemeSelectorModal> gated on editMode

- TabsRenderer (4b): wraps StyledTabsContainer; dots menu lands in the
  existing left HoverMenu next to drag/delete.
- Row (4c): wraps WithPopoverMenu body; dots menu in the left HoverMenu
  next to drag/delete/setting-icon. The existing gear icon (opens the
  BackgroundStyleDropdown focus popover) is preserved as-is.
- Column (4d): same recipe as Row, top-positioned HoverMenu.
- Markdown (4e): class component, so themeModalOpen lives on
  this.state. Dots menu lands inside the existing WithPopoverMenu
  menuItems array next to MarkdownModeDropdown; the Edit/Preview
  toggle is intentionally preserved unchanged.

Note on scope: the SIP originally imagined Phase 4 would also converge
MarkdownModeDropdown and the Row/Column gear icon onto the shared dots
menu. Those user-visible UX displacements are intentionally deferred
so this phase adds the theming affordance *additively* — every existing
menu control is untouched. The menu-pattern unification can be picked
up later without coupling it to theming.

Functional outcome: every grid-component type (Chart, Markdown, Row,
Column, Tabs) now supports the full inheritance chain end-to-end:
Instance -> Dashboard -> Tab -> Row/Col -> Chart/Markdown. Setting a
themeId at any level applies to that subtree; clearing it falls
through to the parent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:43 -07:00
Claude
1f3d2cc305 feat(dashboard): end-to-end component theming on Chart — Phase 4a
Closes the loop on per-component theming for Chart: adds an "Apply
theme" item to \`SliceHeaderControls\` (gated on dashboard edit mode)
that opens the Phase-3 \`ThemeSelectorModal\`. On save, the Phase-3
\`setComponentThemeId\` action updates \`meta.themeId\`; the Phase-1
\`ComponentThemeProvider\` (already wrapping ChartHolder since Phase 1)
re-resolves and re-renders the chart with the new theme tokens.

The full inheritance chain (Instance → Dashboard → Tab → Row/Col →
Chart) is functionally complete for Chart with this commit. Subsequent
per-component PRs (Markdown, Row, Column, Tabs) will repeat the same
three-step recipe — menu item, modal mount, body wrapper — in
isolation so each user-visible menu/UX change can be reviewed without
dragging in the theming framework changes (already merged via Phases
1-3).

- Adds \`MenuKeys.ApplyTheme\` to the dashboard menu-key enum.
- \`SliceHeaderControls\` gets local \`themeModalOpen\` state, a
  Redux selector for \`dashboardState.editMode\`, a handler that opens
  the modal on menu click, the menu item itself (push gated on
  editMode), and a \`<ThemeSelectorModal>\` mounted with the
  component's \`layoutId\`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:06 -07:00
Claude
96880a5e8a feat(dashboard): ThemeSelectorModal + setComponentThemeId action — Phase 3
End-to-end mechanism for applying a CRUD theme to a single dashboard
grid component. Two pieces:

1. `setComponentThemeId(componentId, themeId | null)` — thin Redux
   action that merges \`themeId\` into the target component's \`meta\`
   via the existing \`updateComponents\` thunk, preserving every other
   meta field. Explicit \`null\` clears the override and falls back to
   the inherited theme; the resolver in Phase 1 treats null and
   undefined identically. No-ops when the component id isn't in the
   layout.

2. \`ThemeSelectorModal\` — parent-owned modal that fetches non-system
   themes (same query as the dashboard Properties modal:
   \`is_system:false\` filter on \`/api/v1/theme/\`), preselects the
   currently-resolved override via the Phase-1 \`useEffectiveThemeId\`
   hook, and exposes Apply / Cancel / Clear-override-(inherit) actions.
   Each call site provides \`layoutId\` + the \`show\`/\`onHide\` toggle.

No call site for the modal yet — Phase 4 wires the "Apply theme" menu
item into each component's \`ComponentHeaderControls\` to open it.

3 passing tests on the action: merge preserves other meta keys, clear
stores explicit null (not undefined), no-op for missing component.

SIP.md updated with the Phase 3 implementation notes and the deferred-
to-Phase-4 wiring detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:06 -07:00
Claude
5d9f0780fc feat(dashboard): ComponentHeaderControls — Phase 2
Shared vertical-dots menu component for dashboard grid components.
Generic `items: ComponentMenuItem[]` API — each component (Chart,
Markdown, Row, Column, Tabs) plugs in its own list; the visual chrome
(dots icon trigger, dropdown surface, accessible label, divider
handling, danger/disabled styling) lives in this one component.

Built on `MenuDotsDropdown` from `@superset-ui/core/components` so the
trigger styling matches Chart's existing `SliceHeaderControls` — Phase
4's per-component PRs will converge `SliceHeaderControls` and the
other menu patterns (Markdown's `MarkdownModeDropdown`, Row/Col's
gear-icon + `WithPopoverMenu`) onto this same component.

Phase 2 lands the component + tests only. The actual per-component
menu conversions are user-visible UX changes (e.g. Markdown loses its
toggle-style Edit/Preview switcher and gains a dots menu) and ship in
Phase 4 alongside theme wiring per component, so each can be reviewed
in isolation rather than as a sweeping refactor.

4 passing tests: empty items renders nothing, trigger renders, onClick
fires from menu selection, disabled items don't fire onClick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:06 -07:00
Claude
96e8ddc95c feat(dashboard): per-component theme provider — Phase 1 (Chart PoC)
Adds the skeleton for granular (per-component) theming on dashboard grid
components, with the inheritance chain:
  Instance theme -> Dashboard theme -> Tab theme
                 -> Row/Col theme   -> Chart/Markdown theme

This commit lands Phase 1 from the SIP (`SIP.md` at repo root): the
storage shape and the resolver, wired into `ChartHolder` as the
proof-of-concept call site. No UI yet — `themeId` must be set via Redux
devtools / position_json hand-edit to verify visually. Phase 2 will
introduce the `ComponentHeaderControls` menu and Phase 3 the
`ThemeSelectorModal` that drives this from a real UI.

Surface:

- `LayoutItemMeta.themeId?: number | null` — optional CRUD theme id
  stored per-component in `position_json` meta (no schema migration; the
  meta map is already open-ended). `null` and `undefined` both mean
  "inherit from parent".

- `pickEffectiveThemeId(layoutId, layout)` — pure resolver. Walks
  `parents` up the layout map from the given node until it finds a
  numeric `themeId` or hits `DASHBOARD_ROOT_ID`. Hop-capped at 32 to
  defend against malformed parents chains.

- `useEffectiveThemeId(layoutId)` — Redux hook variant.

- `<ComponentThemeProvider layoutId={...}>` — wraps children in the
  resolved theme's `SupersetThemeProvider`. Lazy-fetches via the
  existing `ThemeController.createDashboardThemeProvider`, which already
  caches by id so N components sharing one theme = 1 fetch. Pass-through
  when no ancestor sets a `themeId`.

- `ChartHolder.tsx` — wraps the existing `<AntdThemeProvider>` (which is
  a popup-container shim for fullscreen mode, not a token provider) so
  per-component tokens are set before antd's ConfigProvider for popup
  targeting.

Tests: 8 unit cases for `pickEffectiveThemeId` covering own / inherited /
null-skip / no-ancestor / root-stop / malformed-parents / other-meta /
missing-id.

Closes the spirit of the closed PR #36749 (which became unrebasable
after .jsx -> .tsx + React 18 + theme controller churn).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:06 -07:00
Claude
9959465017 docs(sip): draft granular component theming SIP
Draft Superset Improvement Proposal for component-level theming on
dashboard grid components (Charts, Markdown, Row, Column, Tabs) with
inheritance Instance -> Dashboard -> Tab -> Row/Col -> Chart.

Supersedes the closed PR #36749 (became unrebasable after .jsx -> .tsx
conversion, React 18 upgrade, and theme-controller churn since 2025-12).

This is a living document — kept in lockstep with the work on
`feat/granular-theming-v2`. Each phase updates the status / shortcomings
/ test-plan sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:52:06 -07:00
20 changed files with 1780 additions and 410 deletions

298
SIP.md Normal file
View File

@@ -0,0 +1,298 @@
<!--
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.
-->
# SIP: Granular (Component-Level) Theming for Dashboard Components
**Status:** Draft — living doc, kept in lockstep with the work on `feat/granular-theming-v2`.
**Champion:** @rusackas
**Supersedes:** Closed PR [#36749](https://github.com/apache/superset/pull/36749) (became unrebasable after the .jsx → .tsx conversion, React 18 upgrade, and theme-controller churn since 2025-12).
## Motivation
Superset already supports themes at two levels:
- **Instance** — the global default theme configured by the deployment.
- **Dashboard** — a per-dashboard override (managed via `ThemeController.dashboardCrudTheme` + `createDashboardThemeProvider`).
Users have repeatedly asked to override theme tokens at a finer granularity — for example to make a single chart match a brand color in a sales dashboard, to highlight a tab with a different palette, or to give a Markdown callout a distinct background. Today the only options are to (a) override the entire dashboard or (b) inject custom CSS.
This SIP proposes a **third level**: per-component theme overrides on dashboard grid components (Charts, Markdown, Row, Column, Tabs), with an inheritance chain:
```
Instance Theme (deployment default)
└── Dashboard Theme (existing per-dashboard override)
└── Tab Theme ┐
└── Row/Col Theme │ (new, per-component)
└── Chart/Markdown Theme
```
Each level can override any subset of theme tokens; unspecified tokens are inherited from the parent.
## Non-goals
- **Custom CSS replacement.** This isn't trying to subsume CSS injection — only theme-token-level overrides (colors, font sizes, spacing, etc.).
- **New theme authoring UI.** Users pick from existing themes (the CRUD `theme` resource); creating themes still happens in the existing Themes section.
- **Backend schema changes.** All persistence lives on existing fields (`position_json` per-component `meta`, see Storage below).
- **Cross-dashboard reuse of component theme assignments.** A theme can be reused, but an *assignment* of a theme to a specific component lives with that component.
## Storage
Dashboard layout items are stored in `position_json` and surfaced in Redux as `LayoutItem`s with a `meta: LayoutItemMeta` field already typed as open-ended:
```ts
export type LayoutItemMeta = {
// ...known fields...
[key: string]: unknown;
};
```
We add an optional `themeId?: number | null` to `LayoutItemMeta`. No new tables, no migrations, no dashboard `json_metadata` changes.
A `themeId === null` means "explicitly no override — inherit from parent." A missing key means the same thing semantically; we treat them identically when reading.
Round-trip:
- **Read**: `LayoutItem.meta.themeId` is parsed straight from `position_json` like any other meta property.
- **Write**: dashboard save serializes the entire `position_json` already; storing `themeId` is free.
- **Backwards compatibility**: pre-feature dashboards have no `themeId` keys, so they fall through to the dashboard/instance theme as today.
## Architecture
### `ComponentThemeProvider`
A wrapper component placed inside each grid component, between the dashboard's existing `ThemeProvider` and the component's body:
```tsx
<ComponentThemeProvider layoutId={id}>
{/* component body */}
</ComponentThemeProvider>
```
Responsibilities:
1. Read `themeId` from the layout item via Redux selector.
2. Walk up the layout tree (`parents`) to compute the effective theme — first non-null `themeId` wins; fall back to the dashboard/instance theme.
3. Call `ThemeController.createDashboardThemeProvider(themeId)` (same code path used for dashboard CRUD themes — themes are themes, regardless of which scope picked them).
4. Wrap children in `AntdThemeProvider` with the resolved theme.
Caching: `ThemeController` already memoizes themes by id (`dashboardThemes: Map<string, Theme>`). We reuse that — same theme assigned to 100 charts costs one fetch.
### `ComponentHeaderControls` (Phase 2, lands first)
A shared vertical-dots menu for grid components. Each grid component type
plugs in its own list of menu items via a `useComponentMenuItems` hook;
the visual chrome (the dots icon button, the dropdown surface, the
edit-mode visibility gating) lives in `ComponentHeaderControls` itself.
Per-component menu surfaces (informational — the actual conversions of
the existing patterns happen as part of Phase 4 for each component, so
we don't change user-visible UX in Phase 2):
| Component | Current pattern | Converges to |
|---|---|---|
| Markdown | `MarkdownModeDropdown` (Edit/Preview popover) | dots menu w/ Edit + Preview items |
| Row / Col | Gear icon → `WithPopoverMenu` with `BackgroundStyleDropdown` | dots menu w/ Background item |
| Chart | `SliceHeaderControls` (already a dots menu — wraps `MenuDotsDropdown`) | reuses the same shared component |
| Tabs | none | dots menu (new affordance) |
Phase 2 itself only **builds the component** and converts **Markdown** as
the PoC. The other components remain on their existing patterns until
the per-component Phase-4 PRs wire them up together with their theme
provider — that lets reviewers evaluate the menu unification + theming
together rather than as separate churn passes.
### `ThemeSelectorModal` (Phase 3)
Modal triggered from "Apply theme" in `ComponentHeaderControls`. Shows:
- A theme picker populated from the CRUD `/api/v1/theme/` endpoint (same source the dashboard-level picker uses).
- A "Clear override (inherit)" button when `themeId` is already set.
- **Live preview**: as the user picks options the targeted component
re-renders with the candidate theme tokens immediately, *without*
marking the dashboard dirty. Cancel reverts; Apply commits to Redux.
Implemented via a tiny module-level subscribable `previewThemeStore`
+ `useSyncExternalStore` in `useEffectiveThemeId` (preview wins over
the Redux-resolved id when present).
On save it dispatches a Redux action that updates the component's `meta.themeId` and marks the dashboard dirty.
## Phases
| Phase | Scope | PR target |
|---|---|---|
| **1** | Storage shape (`LayoutItemMeta.themeId`) + `ComponentThemeProvider` skeleton wired to one component (Chart) for proof of concept. No UI. | One PR |
| **2** | Build `ComponentHeaderControls` (shared dots menu) + tests. **Component creation only** — per-component conversions of the existing menu patterns happen in Phase 4 alongside theme wiring, so reviewers can evaluate the menu unification + theming together rather than as separate churn passes. | One PR |
| **3** | `ThemeSelectorModal` + persistence + "Apply theme" menu item. End-to-end demo on Chart. | One PR |
| **4** | Per-component PRs (Markdown / Row / Column / Tabs): swap their existing menu pattern for `ComponentHeaderControls`, wire `ComponentThemeProvider` around the body, add the "Apply theme" item. One PR per component so each menu/UX change can be reviewed in isolation. | ~4 small PRs |
Each phase is independently revertable. Phase 2 has standalone value.
## Open questions / shortcomings
These get refined as the work progresses; do not merge any phase without revisiting this section.
- [ ] **Theme resolution caching at the component level.** `ThemeController` caches themes by id, but `ComponentThemeProvider` walks the parents tree every render to find the effective `themeId`. Need to confirm the walk is cheap enough at typical dashboard sizes (~50 components), or memoize via Redux reselect.
- [ ] **Export / screenshot behavior.** The screenshot service (Playwright / WebDriver) reads the same DOM, so theme overrides should "just work" — but we need a screenshot regression test.
- [ ] **Embedded SDK.** Embedded dashboards default to light mode (#38644). Need to confirm component-level themes still apply in embedded context, since embedded skips `ThemeController.setCrudTheme`.
- [ ] **Theme deletion** — what happens if a `themeId` references a theme that's been deleted from the CRUD store? Likely fall back silently to parent; need a `useEffect` cleanup path.
- [ ] **Permission model.** Should `theme_write` be required to assign a theme to a component? Currently any dashboard editor can do it. Probably fine, but worth confirming with @michael-s-molina.
- [ ] **i18n / a11y of the modal.** Standard checklist — needs labels, focus management, keyboard.
- [ ] **Mobile**`ComponentHeaderControls` hover-to-reveal pattern needs a tap-equivalent.
## Test plan
Each phase brings its own tests; the cumulative bar:
### Unit
- `ComponentThemeProvider`: resolves theme from own `themeId`; resolves from parent when own is null; falls back to dashboard/instance when no ancestor sets one; reacts to Redux meta changes.
- `useComponentThemeId` (selector / hook the provider uses): correctness on the parents-walk.
- `ComponentHeaderControls`: shows correct menu items per component type; routes `onClick` for each.
- `ThemeSelectorModal`: opens populated with available themes; "save" dispatches the right action; "clear override" sets `themeId: null`.
### Integration (RTL)
- Dashboard with one chart → assign a theme → chart re-renders with new tokens.
- Same dashboard → assign theme to the Row containing the chart → chart inherits Row theme (no own override).
- Set chart override + row override → chart wins (most-specific).
- Clear chart override → chart inherits row.
- Reload dashboard (re-render from `position_json`) → state preserved.
### E2E (Playwright)
- One scenario per component type: open dashboard → menu → apply theme → save → reload → verify.
- Permission scenario: editor can apply, viewer cannot see the menu.
### Manual / screenshot
- Light theme dashboard with one dark-themed chart and one default-themed chart — visual diff in CI.
- Embedded dashboard with a component theme — verify no host-CSS bleed.
## Out-of-scope (potential follow-ups)
- **Theme presets** — "apply this theme to all charts of viz type X" via a dashboard-level rule.
- **Theme inheritance debugger** — devtools view that shows which level set each token for a hovered component.
- **Bulk operations** — multi-select components and apply a theme to all.
## Implementation log
- _(Phase 1)_ — ✅ landed locally. `LayoutItemMeta.themeId`,
`ComponentThemeProvider` + `useEffectiveThemeId` hook, wired into
`ChartHolder`. 8 passing unit tests. No UI yet — `themeId` has to be
set via Redux devtools or position_json hand-edit to verify visually.
- _(Phase 2)_ — ✅ landed locally. `ComponentHeaderControls` shared dots
menu + 4 passing unit tests. Generic `items: ComponentMenuItem[]` API
so each grid component can plug in its own list (Edit/Preview for
Markdown, Background for Row/Col, Apply Theme/Delete for Chart, etc.).
Built on the existing `MenuDotsDropdown` so the trigger styling
matches Chart's `SliceHeaderControls` today (Phase 4 will converge
`SliceHeaderControls` onto this).
**Deferred to Phase 4**: actually swapping the existing per-component
menu UI (Markdown's `MarkdownModeDropdown` PopoverDropdown, Row/Col's
gear-icon-into-`WithPopoverMenu`, Tabs' nothing) for this component.
Those conversions are user-visible UX changes (e.g. Markdown loses
its toggle-style Edit/Preview switcher and gains a dots menu), so we
do them per-component alongside the theme wiring so each can be
reviewed in isolation.
- _(Phase 3)_ — ✅ landed locally. `ThemeSelectorModal` (fetches non-system
themes via the same `/api/v1/theme/?q=...` query that the dashboard
Properties modal uses; preselects the currently-resolved override;
"Apply" / "Cancel" / "Clear override (inherit)" buttons) and the
thin `setComponentThemeId(componentId, themeId | null)` action that
merges into `meta.themeId` via the existing `updateComponents` thunk.
No call site for the modal yet — Phase 4's per-component PRs add the
"Apply theme" item to each component's menu that opens this modal.
The modal is parent-controlled (`show`/`onHide`), parent-owned, so
there's no wiring needed beyond `<ThemeSelectorModal layoutId={id}
show={open} onHide={...} />` in each call site.
3 passing tests on `setComponentThemeId`: preserves other meta keys
+ sets numeric `themeId`; stores explicit `null` for the clear path;
no-op when the component id isn't in the layout.
- _(Phase 4)_ — ✅ landed locally for all five grid-component types.
Same three-step recipe applied to each:
(a) wrap body in `<ComponentThemeProvider layoutId={id}>`,
(b) add "Apply theme" item to the component's menu via
`ComponentHeaderControls`,
(c) mount `<ThemeSelectorModal>` gated on `editMode`.
- **Chart (4a)**: `SliceHeaderControls` gets the menu item; the
provider was already wrapping `ChartHolder` from Phase 1.
- **Tabs (4b)**: `TabsRenderer` wraps `<StyledTabsContainer>` in the
provider; adds the dots-menu trigger inside the existing left
`HoverMenu` next to the drag handle and delete button.
- **Row (4c)**: wraps the `<WithPopoverMenu>` body; adds the
dots-menu trigger to the left `HoverMenu` next to drag/delete/
setting-icon. The existing gear icon (which opens the
BackgroundStyleDropdown focus popover) is preserved as-is.
- **Column (4d)**: same recipe as Row, wrapping its
`<WithPopoverMenu>` body and adding the dots menu to the top
`HoverMenu` next to drag/delete/setting-icon.
- **Markdown (4e)**: class component, so theme-modal state goes
through `this.state.themeModalOpen`. Adds a second
`ComponentHeaderControls` to the existing `<WithPopoverMenu
menuItems>` array next to the `MarkdownModeDropdown`
(Edit/Preview toggle is preserved as-is — the full menu-pattern
convergence onto a single dots menu is intentionally deferred so
Markdown's Edit/Preview UX is not changed in this phase).
Functional outcome: every grid-component type now supports the full
Instance → Dashboard → Tab → Row/Col → Chart/Markdown inheritance
chain end-to-end. Setting a `themeId` at any level applies to that
subtree; clearing it falls through to the parent.
Note on the broader menu-pattern unification: the SIP originally
imagined Phase 4 PRs would also converge `MarkdownModeDropdown`
(Edit/Preview popover) and the Row/Column gear icon into the shared
dots menu. We deferred those user-visible UX displacements so each
Phase-4 PR adds the theming affordance *additively* — i.e. the
existing menu controls are untouched, the dots menu sits alongside.
A follow-up SIP (or single sweep PR) can take the menu unification
later without coupling it to the theming work.
### Phase 1 status
- [x] Add optional `themeId` field to `LayoutItemMeta`. (`src/dashboard/types.ts`)
- [x] Build `ComponentThemeProvider``pickEffectiveThemeId` resolver (pure
function, walks `parents` up the layout map until it finds a non-null
`themeId` or hits `DASHBOARD_ROOT_ID`) + `useEffectiveThemeId` Redux hook
+ `ComponentThemeProvider` that lazy-fetches the resolved theme via the
existing `ThemeController.createDashboardThemeProvider` (which caches by
id, so N components referencing the same theme = 1 fetch). Renders as a
pass-through when no ancestor sets a `themeId`.
- [x] Wire into `ChartHolder` — wrapped around the existing
`<AntdThemeProvider>` so per-component theme tokens apply to the chart
body while the existing popup-container `ConfigProvider` continues to
work in fullscreen mode.
- [x] Add unit tests — 8 cases for `pickEffectiveThemeId` covering own-id /
inheritance / null-skip / no-ancestor / root-stop / malformed-parents /
other-meta-keys / missing-id.
- [x] Update SIP with surprises uncovered during wiring (none significant —
the existing `createDashboardThemeProvider` did exactly what we needed,
including caching by id; the only structural decision was treating the
ChartHolder's `<AntdThemeProvider>` as a popup-container shim rather than
a token provider, and nesting our provider outside it).
#### Phase 1 surprises / notes
- `ThemeController.createDashboardThemeProvider` already does Theme.fromConfig
with the right dark/light base + font loading + caching. We did not need
to duplicate any of that logic in the component-level provider.
- The provider is `useState` + `useEffect` rather than `useMemo` because the
fetch is async. That means there's a one-frame flash of the parent theme
before the component theme kicks in. Probably acceptable; if not, we can
Suspense-ify in Phase 4.
- `useEffectiveThemeId` re-runs on every Redux state change because the
selector returns a primitive `number | null` — that's fine for now, but
if dashboards get bigger we may want a memoized selector via reselect
keyed on `(layoutId, layout)` — file in the open questions section.

View File

@@ -0,0 +1,97 @@
/**
* 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 { setComponentThemeId } from './setComponentThemeId';
import { UPDATE_COMPONENTS } from './dashboardLayout';
const componentFixture = {
id: 'CHART-abc',
type: 'CHART',
children: [],
parents: ['ROOT_ID', 'ROW-1'],
meta: {
chartId: 42,
sliceName: 'My Chart',
width: 4,
height: 30,
},
};
const buildState = () =>
({
dashboardLayout: {
present: {
'CHART-abc': componentFixture,
},
},
// The thunk wrapper (`setUnsavedChangesAfterAction`) reads this.
dashboardState: { hasUnsavedChanges: false },
}) as unknown as ReturnType<
Parameters<ReturnType<typeof setComponentThemeId>>[1]
>;
// `updateComponents` is wrapped by `setUnsavedChangesAfterAction`, which
// returns a thunk. The outer dispatch receives the thunk; we recursively
// execute it to capture the actual UPDATE_COMPONENTS action object.
const dispatchedActions = (
outer: (dispatch: any, getState: any) => void,
getState: any,
): any[] => {
const actions: any[] = [];
const dispatch = (action: any) => {
if (typeof action === 'function') {
action(dispatch, getState);
} else {
actions.push(action);
}
};
outer(dispatch, getState);
return actions;
};
test('dispatches an UPDATE_COMPONENTS that preserves existing meta and sets themeId', () => {
const actions = dispatchedActions(setComponentThemeId('CHART-abc', 7), () =>
buildState(),
);
const action = actions.find(a => a.type === UPDATE_COMPONENTS);
expect(action).toBeDefined();
expect(action.payload.nextComponents['CHART-abc'].meta).toEqual({
chartId: 42,
sliceName: 'My Chart',
width: 4,
height: 30,
themeId: 7,
});
});
test('clearing the override stores explicit null (not undefined)', () => {
const actions = dispatchedActions(
setComponentThemeId('CHART-abc', null),
() => buildState(),
);
const action = actions.find(a => a.type === UPDATE_COMPONENTS);
expect(action.payload.nextComponents['CHART-abc'].meta.themeId).toBeNull();
});
test('no-op when the component is missing from layout', () => {
const actions = dispatchedActions(
setComponentThemeId('CHART-missing', 7),
() => buildState(),
);
expect(actions).toEqual([]);
});

View File

@@ -0,0 +1,61 @@
/**
* 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 { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { GetState, RootState } from 'src/dashboard/types';
import { updateComponents } from './dashboardLayout';
// Match the local pattern used by `dashboardLayout.ts` and `hydrate.ts` —
// the project doesn't export a shared `AppDispatch` from
// `src/dashboard/types`; the closest exported one is
// `src/views/store`'s `typeof store.dispatch`, which we don't want to
// import here just to type a thunk.
type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
/**
* Sets (or clears) the per-component theme override on a dashboard
* grid component. `themeId === null` clears the override and falls back
* to the inherited theme.
*
* Thin wrapper around `updateComponents` that touches only the `themeId`
* key on the component's `meta`, preserving every other meta field. Used
* by `ThemeSelectorModal` (and any future call site) so the meta-merge
* logic lives in one place.
*/
export function setComponentThemeId(
componentId: string,
themeId: number | null,
) {
return (dispatch: AppDispatch, getState: GetState) => {
const { dashboardLayout } = getState();
const component = dashboardLayout.present[componentId];
if (!component) return;
dispatch(
updateComponents({
[componentId]: {
...component,
meta: {
...component.meta,
themeId,
},
},
}),
);
};
}

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ReactNode, useContext, useEffect, useState } from 'react';
import type { Theme } from '@apache-superset/core/theme';
import { ThemeContext } from 'src/theme/ThemeProvider';
import { useEffectiveThemeId } from './useEffectiveThemeId';
interface ComponentThemeProviderProps {
/**
* Layout item id (the key into `dashboardLayout.present`). The provider
* walks up the parents tree from this node to compute the effective
* theme override.
*/
layoutId: string | undefined;
children: ReactNode;
}
/**
* Per-component theme override wrapper. When the component (or any
* ancestor up to but not including the dashboard root) sets a `themeId`
* in its `LayoutItemMeta`, this provider loads that CRUD theme and
* applies it as a `SupersetThemeProvider` around the children, overriding
* the dashboard-level (and, transitively, the instance-level) theme for
* this subtree.
*
* When no ancestor sets a `themeId` — the default — the component renders
* as a pass-through. The outer `CrudThemeProvider` (mounted by
* `DashboardPage`) continues to provide the dashboard-level theme.
*/
export default function ComponentThemeProvider({
layoutId,
children,
}: ComponentThemeProviderProps) {
const effectiveThemeId = useEffectiveThemeId(layoutId);
// Read ThemeContext directly (not via `useThemeContext`, which throws
// when no ThemeProvider is mounted). When rendered outside a dashboard
// — e.g. isolated component tests, storybook, embedded contexts that
// skip the dashboard ThemeProvider — we behave as a pass-through.
const themeContext = useContext(ThemeContext);
const [componentTheme, setComponentTheme] = useState<Theme | null>(null);
useEffect(() => {
if (effectiveThemeId == null || !themeContext) {
setComponentTheme(null);
return undefined;
}
let cancelled = false;
// `createDashboardThemeProvider` caches by id internally, so per-component
// calls for the same theme are deduplicated to a single fetch.
themeContext
.createDashboardThemeProvider(String(effectiveThemeId))
.then(theme => {
if (!cancelled) setComponentTheme(theme);
});
return () => {
cancelled = true;
};
}, [effectiveThemeId, themeContext]);
if (!componentTheme) {
return <>{children}</>;
}
return (
<componentTheme.SupersetThemeProvider>
{children}
</componentTheme.SupersetThemeProvider>
);
}
export {
useEffectiveThemeId,
pickEffectiveThemeId,
} from './useEffectiveThemeId';

View File

@@ -0,0 +1,70 @@
/**
* 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 { previewThemeStore } from './previewThemeStore';
afterEach(() => {
// Defensive — module-level state would leak between tests otherwise.
previewThemeStore.clear('CHART-a');
previewThemeStore.clear('CHART-b');
});
test('get returns undefined for unknown layoutId', () => {
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
});
test('set stores a numeric preview readable by get', () => {
previewThemeStore.set('CHART-a', 7);
expect(previewThemeStore.get('CHART-a')).toBe(7);
});
test('set stores explicit null (distinct from "no preview")', () => {
previewThemeStore.set('CHART-a', null);
expect(previewThemeStore.get('CHART-a')).toBeNull();
// Distinct from the unknown-key case
expect(previewThemeStore.get('CHART-b')).toBeUndefined();
});
test('clear removes the entry; subsequent get returns undefined', () => {
previewThemeStore.set('CHART-a', 7);
previewThemeStore.clear('CHART-a');
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
});
test('subscribers fire on set and clear, do not fire on no-op set', () => {
const listener = jest.fn();
const unsubscribe = previewThemeStore.subscribe(listener);
previewThemeStore.set('CHART-a', 7);
previewThemeStore.set('CHART-a', 7); // no-op (same value)
previewThemeStore.set('CHART-a', 9);
previewThemeStore.clear('CHART-a');
previewThemeStore.clear('CHART-a'); // no-op
unsubscribe();
previewThemeStore.set('CHART-a', 1); // not observed (unsubscribed)
expect(listener).toHaveBeenCalledTimes(3);
});
test('multiple layoutIds are tracked independently', () => {
previewThemeStore.set('CHART-a', 1);
previewThemeStore.set('CHART-b', 2);
expect(previewThemeStore.get('CHART-a')).toBe(1);
expect(previewThemeStore.get('CHART-b')).toBe(2);
previewThemeStore.clear('CHART-a');
expect(previewThemeStore.get('CHART-a')).toBeUndefined();
expect(previewThemeStore.get('CHART-b')).toBe(2);
});

View File

@@ -0,0 +1,74 @@
/**
* 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-level subscribable store for transient per-component theme
* previews. Used by `ThemeSelectorModal` to make a draft selection
* visually applied without committing to Redux (which would mark the
* dashboard dirty). `ComponentThemeProvider` subscribes via
* `useSyncExternalStore` and prefers a present preview over the
* resolved-from-Redux `themeId`.
*
* `null` means "explicitly clear the override during preview" — the
* provider treats it the same way it treats a Redux `null`. Absence
* (key not in the map) means "no preview active; use Redux".
*/
type PreviewValue = number | null;
type Listener = () => void;
const previewMap = new Map<string, PreviewValue>();
const listeners = new Set<Listener>();
const emit = (): void => {
listeners.forEach(l => l());
};
export const previewThemeStore = {
/** Sets a transient preview for `layoutId`. Replaces any prior preview. */
set(layoutId: string, themeId: PreviewValue): void {
if (previewMap.get(layoutId) === themeId) return;
previewMap.set(layoutId, themeId);
emit();
},
/** Clears any preview for `layoutId`. No-op when none is active. */
clear(layoutId: string): void {
if (!previewMap.has(layoutId)) return;
previewMap.delete(layoutId);
emit();
},
/**
* Returns the previewed value for `layoutId`, or `undefined` when no
* preview is active. Used by `ComponentThemeProvider` via
* `useSyncExternalStore`. Returning `undefined` (vs `null`) lets
* callers distinguish "no preview" from "preview the cleared state".
*/
get(layoutId: string): PreviewValue | undefined {
return previewMap.has(layoutId) ? previewMap.get(layoutId) : undefined;
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};

View File

@@ -0,0 +1,121 @@
/**
* 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 { DashboardLayout } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { pickEffectiveThemeId } from './useEffectiveThemeId';
/**
* Helper to build a minimally-shaped `DashboardLayout` for these tests.
* The real reducer carries many more fields per node; only `parents` and
* `meta` are read by the resolver.
*/
const buildLayout = (
nodes: Record<
string,
{
parents?: string[];
themeId?: number | null;
extraMeta?: Record<string, unknown>;
}
>,
): DashboardLayout =>
Object.fromEntries(
Object.entries(nodes).map(([id, { parents, themeId, extraMeta }]) => [
id,
{
id,
type: 'CHART',
children: [],
parents,
meta: {
...extraMeta,
...(themeId !== undefined ? { themeId } : {}),
},
},
]),
) as unknown as DashboardLayout;
test('returns null for missing layoutId', () => {
expect(pickEffectiveThemeId(undefined, buildLayout({}))).toBeNull();
expect(pickEffectiveThemeId('CHART-1', buildLayout({}))).toBeNull();
});
test("returns the node's own themeId when set", () => {
const layout = buildLayout({
'CHART-1': {
parents: [DASHBOARD_ROOT_ID, 'ROW-1'],
themeId: 42,
},
'ROW-1': { parents: [DASHBOARD_ROOT_ID], themeId: 7 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(42);
});
test("inherits the closest ancestor's themeId when own is unset", () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1', 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1'], themeId: 7 },
'TAB-1': { parents: [DASHBOARD_ROOT_ID], themeId: 99 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(7);
});
test('skips ancestors whose themeId is null and continues walking', () => {
// A literal `null` on a node means "I don't override" — keep walking.
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1', 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID, 'TAB-1'], themeId: null },
'TAB-1': { parents: [DASHBOARD_ROOT_ID], themeId: 99 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBe(99);
});
test('returns null when no ancestor sets a themeId', () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID, 'ROW-1'] },
'ROW-1': { parents: [DASHBOARD_ROOT_ID] },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('stops at DASHBOARD_ROOT_ID — root-level theme is the dashboard CRUD theme, handled separately', () => {
const layout = buildLayout({
'CHART-1': { parents: [DASHBOARD_ROOT_ID] },
[DASHBOARD_ROOT_ID]: { parents: [], themeId: 999 },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('does not loop on a malformed parents chain', () => {
// Self-referential parent shouldn't hang the resolver.
const layout = buildLayout({
'CHART-1': { parents: ['CHART-1'] },
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});
test('ignores other meta keys', () => {
const layout = buildLayout({
'CHART-1': {
parents: [DASHBOARD_ROOT_ID],
extraMeta: { sliceName: 'Foo', width: 4, background: 'gray' },
},
});
expect(pickEffectiveThemeId('CHART-1', layout)).toBeNull();
});

View File

@@ -0,0 +1,85 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useSyncExternalStore } from 'react';
import { useSelector } from 'react-redux';
import type {
DashboardLayout,
LayoutItem,
RootState,
} from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { previewThemeStore } from './previewThemeStore';
/**
* Walks up the dashboard layout tree from `layoutId` and returns the first
* `themeId` it finds, or `null` if no ancestor sets one.
*
* Inheritance order (closest wins):
* Chart/Markdown -> Row/Column -> Tab -> Dashboard root -> (null = inherit
* from dashboard CRUD theme or instance theme, applied by CrudThemeProvider
* higher in the tree).
*
* `themeId: null` on a node means "explicitly clear my override" — we treat
* the property as absent (and continue walking) iff it is undefined; a literal
* `null` is also treated as "no override" since the dashboard-level theme is
* applied by a different provider.
*/
export function pickEffectiveThemeId(
layoutId: string | undefined,
layout: DashboardLayout,
): number | null {
if (!layoutId || !layout) return null;
let cursorId: string | undefined = layoutId;
// Defensive cap — dashboards shouldn't nest deeper than this, and the cap
// protects against malformed `parents` arrays causing infinite loops.
let hops = 0;
while (cursorId && cursorId !== DASHBOARD_ROOT_ID && hops < 32) {
const node: LayoutItem | undefined = layout[cursorId];
if (!node) return null;
const themeId = node.meta?.themeId;
if (typeof themeId === 'number') return themeId;
cursorId = node.parents?.[node.parents.length - 1];
hops += 1;
}
return null;
}
/**
* Redux hook variant of `pickEffectiveThemeId`. Memoizes on the layout
* reference; consumers that only care about the resolved id (not the layout
* map itself) won't re-render when sibling components change their meta.
*
* If `ThemeSelectorModal` has registered a preview override for this
* `layoutId` via `previewThemeStore`, the preview wins — that's how the
* modal applies a draft selection visually without committing to Redux.
*/
export function useEffectiveThemeId(
layoutId: string | undefined,
): number | null {
const reduxResolved = useSelector<RootState, number | null>(state =>
pickEffectiveThemeId(layoutId, state.dashboardLayout?.present),
);
const preview = useSyncExternalStore(
previewThemeStore.subscribe,
() =>
layoutId === undefined ? undefined : previewThemeStore.get(layoutId),
() => undefined,
);
return preview === undefined ? reduxResolved : preview;
}

View File

@@ -57,6 +57,7 @@ import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenu
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import { usePermissions } from 'src/hooks/usePermissions';
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
@@ -166,6 +167,13 @@ const SliceHeaderControls = (
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
// setting openKeys undefined falls back to uncontrolled behaviour
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Per-component theming is an edit-mode affordance only — viewers see the
// applied theme but can't change it.
const editMode = useSelector<RootState, boolean>(
state => !!state.dashboardState.editMode,
);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id,
);
@@ -258,6 +266,9 @@ const SliceHeaderControls = (
// eslint-disable-next-line no-unused-expressions
props.toggleExpandSlice?.(props.slice.slice_id);
break;
case MenuKeys.ApplyTheme:
setThemeModalOpen(true);
break;
case MenuKeys.ExploreChart:
// eslint-disable-next-line no-unused-expressions
props.logExploreChart?.(props.slice.slice_id);
@@ -450,6 +461,13 @@ const SliceHeaderControls = (
});
}
if (editMode) {
newMenuItems.push({
key: MenuKeys.ApplyTheme,
label: t('Apply theme'),
});
}
if (canExplore) {
newMenuItems.push({
key: MenuKeys.ExploreChart,
@@ -681,6 +699,13 @@ const SliceHeaderControls = (
dataset={datasetWithVerboseMap}
/>
{canEditCrossFilters && scopingModal}
{editMode && (
<ThemeSelectorModal
layoutId={componentId}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
{isFullSize && <Global styles={fullscreenStyles(theme)} />}
</>
);

View File

@@ -0,0 +1,176 @@
/**
* 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, useMemo, useRef, useState } from 'react';
import rison from 'rison';
import { useDispatch } from 'react-redux';
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Button, Modal, Select } from '@superset-ui/core/components';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { useEffectiveThemeId } from 'src/dashboard/components/ComponentThemeProvider';
import { previewThemeStore } from 'src/dashboard/components/ComponentThemeProvider/previewThemeStore';
import { setComponentThemeId } from 'src/dashboard/actions/setComponentThemeId';
interface ThemeOption {
id: number;
theme_name: string;
}
interface ThemeSelectorModalProps {
/** The layout component receiving the theme override. */
layoutId: string;
/** Controls visibility. Parent owns this — toggled via menu click. */
show: boolean;
onHide: () => void;
}
/**
* Modal for picking a CRUD theme to apply to a single dashboard component
* (or clearing the existing override). On save, dispatches
* `setComponentThemeId`, which updates `component.meta.themeId` and marks
* the dashboard dirty. The actual visual application is handled by
* `ComponentThemeProvider`, which reads the meta change via its Redux
* selector and re-renders the component with the new theme tokens.
*/
export default function ThemeSelectorModal({
layoutId,
show,
onHide,
}: ThemeSelectorModalProps) {
const dispatch = useDispatch();
const { addDangerToast } = useToasts();
const currentThemeId = useEffectiveThemeId(layoutId);
// Modal-local draft of the selection. Synced from the resolved id when
// the modal opens; live-previewed via the previewThemeStore as the user
// picks options; only committed to Redux on Apply.
const [selectedId, setSelectedId] = useState<number | null>(currentThemeId);
const [themes, setThemes] = useState<ThemeOption[]>([]);
const [loading, setLoading] = useState(false);
// Snapshot the resolved id at open-time so we can revert correctly when
// the user cancels — `currentThemeId` itself is reactive (and would
// already reflect the in-flight preview), so we can't use it directly.
const initialIdRef = useRef<number | null>(currentThemeId);
useEffect(() => {
if (show) {
initialIdRef.current = currentThemeId;
setSelectedId(currentThemeId);
}
// No `show` cleanup here — the close handlers below clear the preview
// explicitly so we don't fight with the Apply path (which keeps the
// theme applied).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show]);
// Push the user's draft selection through the preview store. The
// ComponentThemeProvider prefers preview > Redux, so the targeted
// component re-renders with the candidate theme as soon as this updates.
useEffect(() => {
if (!show) return undefined;
previewThemeStore.set(layoutId, selectedId);
return () => {
// Cleanup runs on close + on every selectedId change; the next
// effect call re-sets it. On unmount/close we want the preview
// gone so the provider re-resolves from Redux. Safe because Apply
// commits to Redux *before* hiding the modal, so the post-clear
// resolution lands on the saved value, not the original.
previewThemeStore.clear(layoutId);
};
}, [show, layoutId, selectedId]);
useEffect(() => {
if (!show) return;
setLoading(true);
// Same query the dashboard-properties modal uses — non-system themes only.
const q = rison.encode({
columns: ['id', 'theme_name'],
filters: [{ col: 'is_system', opr: 'eq', value: false }],
});
SupersetClient.get({ endpoint: `/api/v1/theme/?q=${q}` })
.then(({ json }) => {
setThemes((json.result as ThemeOption[]) ?? []);
})
.catch(() => {
addDangerToast(t('An error occurred while fetching available themes'));
})
.finally(() => setLoading(false));
}, [show, addDangerToast]);
const options = useMemo(
() => themes.map(t => ({ value: t.id, label: t.theme_name })),
[themes],
);
const handleSave = () => {
dispatch(setComponentThemeId(layoutId, selectedId));
onHide();
};
const handleClear = () => {
// Clearing the override means "inherit from parent" — store explicit
// null so the resolver knows it was intentional (vs absent / never set).
dispatch(setComponentThemeId(layoutId, null));
onHide();
};
return (
<Modal
show={show}
onHide={onHide}
title={t('Apply theme')}
footer={
<>
{currentThemeId !== null && (
<Button
data-test="component-theme-clear"
buttonStyle="secondary"
onClick={handleClear}
>
{t('Clear override (inherit)')}
</Button>
)}
<Button data-test="component-theme-cancel" onClick={onHide}>
{t('Cancel')}
</Button>
<Button
data-test="component-theme-apply"
buttonStyle="primary"
onClick={handleSave}
disabled={selectedId === null}
>
{t('Apply')}
</Button>
</>
}
>
<Select
ariaLabel={t('Theme')}
loading={loading}
options={options}
value={selectedId ?? undefined}
onChange={value => setSelectedId(value as number)}
placeholder={t('Select a theme')}
allowClear
onClear={() => setSelectedId(null)}
/>
</Modal>
);
}

View File

@@ -33,6 +33,7 @@ import ResizableContainer from 'src/dashboard/components/resizable/ResizableCont
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
import useFilterFocusHighlightStyles from 'src/dashboard/util/useFilterFocusHighlightStyles';
import { AntdThemeProvider } from '@superset-ui/core/components';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
GRID_BASE_UNIT,
@@ -284,59 +285,61 @@ const ChartHolder = ({
outlinedComponentId ? 'fade-in' : 'fade-out',
)}
>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
>
{!editMode && (
<AnchorLink
id={component.id}
scrollIntoView={outlinedComponentId === component.id}
/>
)}
{!!outlinedComponentId && (
<style>
{`label[for=${outlinedColumnName}] + .Select .Select__control {
<ComponentThemeProvider layoutId={component.id}>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
>
{!editMode && (
<AnchorLink
id={component.id}
scrollIntoView={outlinedComponentId === component.id}
/>
)}
{!!outlinedComponentId && (
<style>
{`label[for=${outlinedColumnName}] + .Select .Select__control {
border-color: ${theme.colorPrimary};
transition: border-color 1s ease-in-out;
}`}
</style>
)}
<Chart
componentId={component.id}
id={component.meta.chartId ?? 0}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride ||
component.meta.sliceName ||
''
}
updateSliceName={(_sliceId: number, name: string) =>
handleUpdateSliceName(name)
}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
chartHolderRef={chartHolderRef}
/>
{editMode && (
<HoverMenu position="top">
<div data-test="dashboard-delete-component-button">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</div>
</HoverMenu>
)}
</AntdThemeProvider>
</style>
)}
<Chart
componentId={component.id}
id={component.meta.chartId ?? 0}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride ||
component.meta.sliceName ||
''
}
updateSliceName={(_sliceId: number, name: string) =>
handleUpdateSliceName(name)
}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
chartHolderRef={chartHolderRef}
/>
{editMode && (
<HoverMenu position="top">
<div data-test="dashboard-delete-component-button">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</div>
</HoverMenu>
)}
</AntdThemeProvider>
</ComponentThemeProvider>
</div>
</ResizableContainer>
),

View File

@@ -35,6 +35,9 @@ import IconButton from 'src/dashboard/components/IconButton';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
@@ -161,6 +164,7 @@ const Column = (props: ColumnProps) => {
} = props;
const [isFocused, setIsFocused] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -216,135 +220,154 @@ const Column = (props: ColumnProps) => {
onResizeStop={onResizeStop}
editMode={editMode}
>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
key={`${columnComponent.id}-background`}
id={`${columnComponent.id}-background`}
value={
(columnComponent.meta.background as string) ||
BACKGROUND_TRANSPARENT
}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu
innerRef={
dragSourceRef as unknown as React.RefObject<HTMLDivElement>
}
position="top"
>
<DragHandle position="top" />
<DeleteComponentButton
iconSize="m"
onDelete={handleDeleteComponent}
/>
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle?.className)}
<ComponentThemeProvider layoutId={columnComponent.id}>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
key={`${columnComponent.id}-background`}
id={`${columnComponent.id}-background`}
value={
(columnComponent.meta.background as string) ||
BACKGROUND_TRANSPARENT
}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
<HoverMenu
innerRef={
dragSourceRef as unknown as React.RefObject<HTMLDivElement>
}
</Droppable>
position="top"
>
<DragHandle position="top" />
<DeleteComponentButton
iconSize="m"
onDelete={handleDeleteComponent}
/>
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="m" />}
/>
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Column options')}
/>
</HoverMenu>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId: string, itemIndex: number) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width ?? 0}
columnWidth={columnWidth}
onResizeStart={
onResizeStart as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
) => void
}
onResize={
onResize as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
) => void
}
onResizeStop={
onResizeStop as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
) => void
}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
{editMode && (
<ThemeSelectorModal
layoutId={columnComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle?.className)}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
</Droppable>
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId: string, itemIndex: number) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width ?? 0}
columnWidth={columnWidth}
onResizeStart={
onResizeStart as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
) => void
}
onResize={
onResize as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
) => void
}
onResizeStop={
onResizeStop as unknown as (
event: MouseEvent | TouchEvent,
direction: string,
elementRef: HTMLElement,
delta: { width: number; height: number },
id: string,
) => void
}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }: DropIndicatorChildProps) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ComponentThemeProvider>
</ResizableContainer>
),
[

View File

@@ -34,6 +34,9 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import MarkdownModeDropdown from 'src/dashboard/components/menu/MarkdownModeDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import type { LayoutItem } from 'src/dashboard/types';
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { ROW_TYPE, COLUMN_TYPE } from 'src/dashboard/util/componentTypes';
@@ -90,6 +93,7 @@ export interface MarkdownState {
undoLength: number;
redoLength: number;
hasError?: boolean;
themeModalOpen: boolean;
}
// TODO: localize
@@ -152,6 +156,7 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
themeModalOpen: false,
};
this.renderStartTime = Logger.getTimestamp();
@@ -396,62 +401,82 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
editMode={editMode}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
<ComponentThemeProvider layoutId={component.id}>
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
<ComponentHeaderControls
key={`${component.id}-options`}
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => this.setState({ themeModalOpen: true }),
},
]}
ariaLabel={t('Markdown options')}
/>,
]}
editMode={editMode}
>
<ResizableContainer
{editMode && (
<ThemeSelectorModal
layoutId={component.id}
show={this.state.themeModalOpen}
onHide={() => this.setState({ themeModalOpen: false })}
/>
)}
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
</ComponentThemeProvider>
)}
</Draggable>
);

View File

@@ -43,6 +43,9 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import IconButton from 'src/dashboard/components/IconButton';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
@@ -158,6 +161,7 @@ const Row = memo((props: RowProps) => {
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const isComponentVisibleRef = useRef(isComponentVisible);
@@ -270,130 +274,151 @@ const Row = memo((props: RowProps) => {
const remainColumnCount = availableColumnCount - occupiedColumnCount;
const renderChild = useCallback(
({ dragSourceRef }: { dragSourceRef: RefObject<HTMLDivElement> }) => (
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
<ComponentThemeProvider layoutId={rowComponent.id}>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="l" />}
/>
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Row options')}
/>
</HoverMenu>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
{editMode && (
<ThemeSelectorModal
layoutId={rowComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
</Droppable>
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
</ComponentThemeProvider>
),
[
backgroundStyle.className,

View File

@@ -55,7 +55,7 @@ describe('TabsRenderer', () => {
});
test('renders tabs container with correct test attributes', () => {
render(<TabsRenderer {...mockProps} />);
render(<TabsRenderer {...mockProps} />, { useRedux: true });
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
@@ -64,14 +64,14 @@ describe('TabsRenderer', () => {
});
test('renders LineEditableTabs with correct props', () => {
render(<TabsRenderer {...mockProps} />);
render(<TabsRenderer {...mockProps} />, { useRedux: true });
const editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
});
test('applies correct tab bar padding', () => {
const { rerender } = render(<TabsRenderer {...mockProps} />);
const { rerender } = render(<TabsRenderer {...mockProps} />, { useRedux: true });
let editableTabs = screen.getByTestId('nav-list');
expect(editableTabs).toBeInTheDocument();
@@ -89,7 +89,7 @@ describe('TabsRenderer', () => {
activeKey: 'tab-2',
handleClickTab: handleClickTabMock,
};
render(<TabsRenderer {...propsWithTab2Active} />);
render(<TabsRenderer {...propsWithTab2Active} />, { useRedux: true });
const tabElement = screen.getByText('Tab 1').closest('[role="tab"]');
expect(tabElement).not.toBeNull();
@@ -109,7 +109,7 @@ describe('TabsRenderer', () => {
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...editModeProps} />);
render(<TabsRenderer {...editModeProps} />, { useRedux: true });
const hoverMenu = document.querySelector('.hover-menu');
@@ -123,7 +123,7 @@ describe('TabsRenderer', () => {
renderHoverMenu: true,
};
render(<TabsRenderer {...viewModeProps} />);
render(<TabsRenderer {...viewModeProps} />, { useRedux: true });
const hoverMenu = document.querySelector('.hover-menu');
@@ -139,7 +139,7 @@ describe('TabsRenderer', () => {
tabsDragSourceRef: mockRef,
};
render(<TabsRenderer {...noHoverMenuProps} />);
render(<TabsRenderer {...noHoverMenuProps} />, { useRedux: true });
const hoverMenu = document.querySelector('.hover-menu');
@@ -149,6 +149,7 @@ describe('TabsRenderer', () => {
test('renders with correct tab type based on edit mode', () => {
const { rerender } = render(
<TabsRenderer {...mockProps} editMode={false} />,
{ useRedux: true },
);
let editableTabs = screen.getByTestId('nav-list');
@@ -173,7 +174,7 @@ describe('TabsRenderer', () => {
handleEdit: mockProps.handleEdit,
};
render(<TabsRenderer {...minimalProps} />);
render(<TabsRenderer {...minimalProps} />, { useRedux: true });
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
@@ -188,13 +189,13 @@ describe('TabsRenderer', () => {
handleEdit: handleEditMock,
};
render(<TabsRenderer {...editableProps} />);
render(<TabsRenderer {...editableProps} />, { useRedux: true });
expect(screen.getByTestId('nav-list')).toBeInTheDocument();
});
test('renders tab content correctly', () => {
render(<TabsRenderer {...mockProps} />);
render(<TabsRenderer {...mockProps} />, { useRedux: true });
expect(screen.getByText('Tab 1 Content')).toBeInTheDocument();
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active

View File

@@ -45,6 +45,10 @@ import {
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
import ComponentThemeProvider from '../../ComponentThemeProvider';
import ComponentHeaderControls from '../../menu/ComponentHeaderControls';
import ThemeSelectorModal from '../../ThemeSelectorModal';
import { t } from '@apache-superset/core/translation';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
width: 100%;
@@ -173,6 +177,7 @@ const TabsRenderer = memo<TabsRendererProps>(
onTabTitleEditingChange,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Use ref to always have access to the current tabIds in callbacks
const tabIdsRef = useRef(tabIds);
@@ -209,66 +214,86 @@ const TabsRenderer = memo<TabsRendererProps>(
const isDragging = activeId !== null;
return (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<ComponentThemeProvider layoutId={tabsComponent.id}>
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<ComponentHeaderControls
items={[
{
key: 'apply-theme',
label: t('Apply theme'),
onClick: () => setThemeModalOpen(true),
},
]}
ariaLabel={t('Tabs options')}
/>
</HoverMenu>
)}
{editMode && (
<ThemeSelectorModal
layoutId={tabsComponent.id}
show={themeModalOpen}
onHide={() => setThemeModalOpen(false)}
/>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
if (typeof key === 'string') {
const tabIndex = tabIds.indexOf(key);
if (tabIndex !== -1) handleClickTab(tabIndex);
}
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
fullHeight
{...(editMode && {
renderTabBar: (tabBarProps, DefaultTabBar) => (
<DndContext
key={tabIds.join('-')}
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
<SortableContext
items={tabIds}
strategy={horizontalListSortingStrategy}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
if (typeof key === 'string') {
const tabIndex = tabIds.indexOf(key);
if (tabIndex !== -1) handleClickTab(tabIndex);
}
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
items={tabItems}
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
fullHeight
{...(editMode && {
renderTabBar: (tabBarProps, DefaultTabBar) => (
<DndContext
key={tabIds.join('-')}
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
<DefaultTabBar {...tabBarProps}>
{(node: React.ReactElement) => (
<DraggableTabNode
{...(node as React.ReactElement<DraggableTabNodeProps>)
.props}
key={node.key}
data-node-key={node.key as string}
disabled={isEditingTabTitle}
>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
),
})}
/>
</StyledTabsContainer>
<SortableContext
items={tabIds}
strategy={horizontalListSortingStrategy}
>
<DefaultTabBar {...tabBarProps}>
{(node: React.ReactElement) => (
<DraggableTabNode
{...(
node as React.ReactElement<DraggableTabNodeProps>
).props}
key={node.key}
data-node-key={node.key as string}
disabled={isEditingTabTitle}
>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
),
})}
/>
</StyledTabsContainer>
</ComponentThemeProvider>
);
},
);

View File

@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import ComponentHeaderControls from '.';
test('renders nothing when items is empty', () => {
const { container } = render(<ComponentHeaderControls items={[]} />);
expect(container).toBeEmptyDOMElement();
});
test('renders the trigger when items are provided', () => {
render(
<ComponentHeaderControls
items={[{ key: 'edit', label: 'Edit', onClick: () => {} }]}
/>,
);
expect(screen.getByTestId('dropdown-trigger')).toBeInTheDocument();
});
test('fires onClick when a menu item is selected', async () => {
const onEdit = jest.fn();
const onPreview = jest.fn();
render(
<ComponentHeaderControls
items={[
{ key: 'edit', label: 'Edit', onClick: onEdit },
{ key: 'preview', label: 'Preview', onClick: onPreview },
]}
/>,
);
await userEvent.click(screen.getByTestId('dropdown-trigger'));
await userEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onPreview).not.toHaveBeenCalled();
});
test('disabled items are still rendered but do not fire onClick', async () => {
const onClick = jest.fn();
render(
<ComponentHeaderControls
items={[{ key: 'gone', label: 'Gone', onClick, disabled: true }]}
/>,
);
await userEvent.click(screen.getByTestId('dropdown-trigger'));
const item = await screen.findByRole('menuitem', { name: 'Gone' });
await userEvent.click(item);
expect(onClick).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
import { MenuDotsDropdown, Menu } from '@superset-ui/core/components';
export interface ComponentMenuItem {
/** Stable key for React + telemetry. */
key: string;
/** Label rendered in the menu row. */
label: ReactNode;
/** Optional icon rendered to the left of the label. */
icon?: ReactNode;
/** Click handler. Provider closes the menu after firing. */
onClick?: () => void;
/** When true, dims and disables the row. */
disabled?: boolean;
/** Renders a horizontal rule above this item. */
divider?: boolean;
/** Marks the row as destructive (red tone). */
danger?: boolean;
}
interface ComponentHeaderControlsProps {
items: ComponentMenuItem[];
/** Data-test attribute hook for the trigger button. */
dataTest?: string;
/**
* Optional `aria-label` override for the trigger button. Default is the
* generic "Component options".
*/
ariaLabel?: string;
}
/**
* Shared vertical-dots menu for dashboard grid components. Each component
* (Chart, Markdown, Row, Column, Tabs) plugs in its own `items` and the
* visual chrome — the dots icon, dropdown surface, accessible labelling —
* lives here.
*
* Built on `MenuDotsDropdown` from `@superset-ui/core/components` so we get
* the same trigger styling as Chart's `SliceHeaderControls` does today;
* Phase 4 will converge `SliceHeaderControls` onto this same component.
*
* The component is intentionally render-only: it does not read Redux, does
* not gate on `editMode`, and does not know about theming. Callers decide
* when to render it. This keeps it reusable across edit vs view, hover
* menus, embedded contexts, etc.
*/
export default function ComponentHeaderControls({
items,
dataTest = 'component-header-controls',
ariaLabel,
}: ComponentHeaderControlsProps) {
if (items.length === 0) return null;
// antd Menu items: split divider markers into their own item entries.
const menuItems = items.flatMap(item => {
const row = {
key: item.key,
label: item.label,
icon: item.icon,
onClick: item.onClick,
disabled: item.disabled,
danger: item.danger,
};
return item.divider
? [{ type: 'divider' as const, key: `${item.key}-divider` }, row]
: [row];
});
return (
<MenuDotsDropdown
data-test={dataTest}
aria-label={ariaLabel}
overlay={<Menu items={menuItems} />}
/>
);
}

View File

@@ -278,6 +278,12 @@ export type LayoutItemMeta = {
code?: string;
/** Background style value for columns and rows */
background?: string;
/**
* Optional per-component theme override (CRUD `theme` resource id).
* `null` means "explicitly inherit"; a missing key means the same thing
* semantically. See ComponentThemeProvider for the inheritance walk.
*/
themeId?: number | null;
/** Allow additional meta properties used by different component types */
[key: string]: unknown;
};
@@ -373,6 +379,7 @@ export enum MenuKeys {
ExportFullXlsx = 'export_full_xlsx',
ForceRefresh = 'force_refresh',
Fullscreen = 'fullscreen',
ApplyTheme = 'apply_theme',
ToggleChartDescription = 'toggle_chart_description',
ViewQuery = 'view_query',
ViewResults = 'view_results',

View File

@@ -33,7 +33,13 @@ import {
} from '@apache-superset/core/theme';
import { ThemeController } from './ThemeController';
const ThemeContext = createContext<ThemeContextType | null>(null);
// Exported so callers that want to opt out of `useThemeContext`'s
// "must be used within a ThemeProvider" throw can read the context
// directly and handle the null case themselves (e.g. components like
// `ComponentThemeProvider` that need to behave as a pass-through when
// rendered outside the dashboard's ThemeProvider tree, such as in
// isolated component tests).
export const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: React.ReactNode;