diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 386955c60..5811bc4ae 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -57,6 +57,17 @@ vi.mock("sonner", () => ({ }, })); +// v1.18.0 — mutable module map so the widget-toggle gating tests can flip a +// module off without re-mocking. Defaults to `undefined` (no map → fail-open, +// every web-renderable row shown), preserving the pre-existing row counts. +const authState: { + modules: Partial> | undefined; +} = { modules: undefined }; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { modules: authState.modules } }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { DashboardLayoutSection, @@ -71,6 +82,7 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") { beforeEach(() => { queryState.layout = DEFAULT_DASHBOARD_LAYOUT; + authState.modules = undefined; }); describe(" — tile + chart split", () => { @@ -382,3 +394,47 @@ describe(" — iOS-pin-only ids hidden from web (v1.11.2 } }); }); + +/** + * v1.18.0 — a dashboard widget toggle whose owning module is disabled is a + * dead control: the snapshot gates the tile/chart out server-side no matter + * what the switch says. The Settings list must hide those rows. Core widgets + * (no module entry in `WIDGET_MODULE_BY_ID`) always show; the gate fails open + * when the module map is absent or the key is unset. + */ +describe(" — disabled-module widget toggles", () => { + // The achievements widget label drives its switch aria-label; it is the + // canonical disabled-module probe (label resolves to "Achievements"). + const ACHIEVEMENTS_ARIA = "Achievements — "; + // Mood is a module-owned widget too; weight is a core widget with NO module + // entry, so it must survive any module-off state. + const MOOD_ARIA = "Mood — "; + const WEIGHT_ARIA = "Weight — "; + + it("hides a widget toggle whose owning module is disabled", () => { + authState.modules = { achievements: false }; + const html = render(); + // The achievements row (and its switches) are gone… + expect(html).not.toContain(ACHIEVEMENTS_ARIA); + // …and exactly one fewer web-renderable row renders. + const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? []; + expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1); + }); + + it("keeps an enabled-module widget and core (no-module) widgets shown", () => { + authState.modules = { achievements: false }; + const html = render(); + // Enabled module (mood) still renders… + expect(html).toContain(MOOD_ARIA); + // …and a core widget with no module entry (weight) always shows. + expect(html).toContain(WEIGHT_ARIA); + }); + + it("shows every row when the module map is absent (fail-open)", () => { + authState.modules = undefined; + const html = render(); + const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? []; + expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT); + expect(html).toContain(ACHIEVEMENTS_ARIA); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index d93498f9d..3c60ef775 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -49,6 +49,8 @@ import { SelectValue, } from "@/components/ui/select"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/snapshot"; /** * v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the @@ -149,6 +151,14 @@ const WIDGET_LABEL_KEYS: Record = { export function DashboardLayoutSection({ id }: { id: string }) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // v1.18.0 — a widget toggle whose owning module is disabled is a dead + // control (the snapshot gates the tile/chart out server-side regardless + // of the switch). Read the resolved module map and hide those rows. + // Fail-open: a missing map / absent key (stale /me payload, core widget) + // reads as enabled, so the row always shows unless the module is + // explicitly `false`. + const { user } = useAuth(); + const modules = user?.modules; // v1.4.47 W4 — stable id namespace for the drag-handle `aria-describedby` // tooltip. One hint paragraph is rendered once at the bottom of the list // and referenced by every drag handle in this section. @@ -471,6 +481,14 @@ export function DashboardLayoutSection({ id }: { id: string }) { .filter((w): w is typeof w & { id: DashboardWidgetId } => webWidgetIds.has(w.id), ) + // v1.18.0 — hide a widget toggle whose owning module is + // disabled. Map the widget id → ModuleKey FIRST (undefined = + // core widget = always shown), THEN check the module map. + // Fail-open: only an explicit `false` hides the row. + .filter((w) => { + const moduleKey = WIDGET_MODULE_BY_ID[w.id]; + return !moduleKey || modules?.[moduleKey] !== false; + }) .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); return (