mirror of
https://github.com/apache/superset.git
synced 2026-05-29 20:29:34 +00:00
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:
17
SIP.md
17
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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user