You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/05/13 18:08:11 UTC
(superset) 02/03: feat(dashboard): ThemeSelectorModal + setComponentThemeId action — Phase 3
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch feat/granular-theming-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
commit ae5068c81c0b6547f50ad40b14cc36f761101e9d
Author: Claude <cl...@anthropic.com>
AuthorDate: Wed May 13 11:03:44 2026 -0700
feat(dashboard): ThemeSelectorModal + setComponentThemeId action — Phase 3
End-to-end mechanism for applying a CRUD theme to a single dashboard
grid component. Two pieces:
1. `setComponentThemeId(componentId, themeId | null)` — thin Redux
action that merges \`themeId\` into the target component's \`meta\`
via the existing \`updateComponents\` thunk, preserving every other
meta field. Explicit \`null\` clears the override and falls back to
the inherited theme; the resolver in Phase 1 treats null and
undefined identically. No-ops when the component id isn't in the
layout.
2. \`ThemeSelectorModal\` — parent-owned modal that fetches non-system
themes (same query as the dashboard Properties modal:
\`is_system:false\` filter on \`/api/v1/theme/\`), preselects the
currently-resolved override via the Phase-1 \`useEffectiveThemeId\`
hook, and exposes Apply / Cancel / Clear-override-(inherit) actions.
Each call site provides \`layoutId\` + the \`show\`/\`onHide\` toggle.
No call site for the modal yet — Phase 4 wires the "Apply theme" menu
item into each component's \`ComponentHeaderControls\` to open it.
3 passing tests on the action: merge preserves other meta keys, clear
stores explicit null (not undefined), no-op for missing component.
SIP.md updated with the Phase 3 implementation notes and the deferred-
to-Phase-4 wiring detail.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
SIP.md | 17 ++-
.../dashboard/actions/setComponentThemeId.test.ts | 98 ++++++++++++++
.../src/dashboard/actions/setComponentThemeId.ts | 52 +++++++
.../components/ThemeSelectorModal/index.tsx | 149 +++++++++++++++++++++
4 files changed, 315 insertions(+), 1 deletion(-)
diff --git a/SIP.md b/SIP.md
index bdb0a82cd93..8be321d732c 100644
--- a/SIP.md
+++ b/SIP.md
@@ -180,7 +180,22 @@ Each phase brings its own tests; the cumulative bar:
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)_ — pending.
+- _(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)_ — pending.
### Phase 1 status
diff --git a/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts b/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts
new file mode 100644
index 00000000000..26ac622d1b6
--- /dev/null
+++ b/superset-frontend/src/dashboard/actions/setComponentThemeId.test.ts
@@ -0,0 +1,98 @@
+/**
+ * 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([]);
+});
diff --git a/superset-frontend/src/dashboard/actions/setComponentThemeId.ts b/superset-frontend/src/dashboard/actions/setComponentThemeId.ts
new file mode 100644
index 00000000000..41770c1b0b8
--- /dev/null
+++ b/superset-frontend/src/dashboard/actions/setComponentThemeId.ts
@@ -0,0 +1,52 @@
+/**
+ * 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 { AppDispatch, GetState } from 'src/dashboard/types';
+import { updateComponents } from './dashboardLayout';
+
+/**
+ * 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,
+ },
+ },
+ }),
+ );
+ };
+}
diff --git a/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx b/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx
new file mode 100644
index 00000000000..19ec62b2411
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/ThemeSelectorModal/index.tsx
@@ -0,0 +1,149 @@
+/**
+ * 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, 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 { 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; only committed to Redux on save.
+ const [selectedId, setSelectedId] = useState<number | null>(currentThemeId);
+ const [themes, setThemes] = useState<ThemeOption[]>([]);
+ const [loading, setLoading] = useState(false);
+
+ // Keep the draft in sync if the resolved id changes while the modal is
+ // open (e.g. another tab updated the dashboard). Cheap because the
+ // selector returns a primitive.
+ useEffect(() => {
+ if (show) setSelectedId(currentThemeId);
+ }, [show, currentThemeId]);
+
+ 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>
+ );
+}