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 `` 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>[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(currentThemeId); + const [themes, setThemes] = useState([]); + 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 ( + + {currentThemeId !== null && ( + + )} + + + + } + > +