mirror of
https://github.com/apache/superset.git
synced 2026-05-16 21:35:08 +00:00
Compare commits
13 Commits
chat-proto
...
feat/granu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5dc0f27ea | ||
|
|
ca40472153 | ||
|
|
0cc17017fc | ||
|
|
9683eefb6f | ||
|
|
4be803cb6e | ||
|
|
635853047b | ||
|
|
ad37366e93 | ||
|
|
1be84f1769 | ||
|
|
1f3d2cc305 | ||
|
|
96880a5e8a | ||
|
|
5d9f0780fc | ||
|
|
96e8ddc95c | ||
|
|
9959465017 |
298
SIP.md
Normal file
298
SIP.md
Normal 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.
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
[
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user