feat(dashboard): ThemeSelectorModal + setComponentThemeId action — Phase 3

End-to-end mechanism for applying a CRUD theme to a single dashboard
grid component. Two pieces:

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-13 11:03:44 -07:00
parent 5d9f0780fc
commit 96880a5e8a
4 changed files with 315 additions and 1 deletions

17
SIP.md
View File

@@ -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

View File

@@ -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([]);
});

View File

@@ -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,
},
},
}),
);
};
}

View File

@@ -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>
);
}