From 45793bf76cf7ffc916640779ef2d8f8c54df18e2 Mon Sep 17 00:00:00 2001 From: Bhargav Date: Fri, 26 Jun 2026 17:42:32 +0530 Subject: [PATCH] fix: persist theme selection by name with redesigned theme drawer (#12890) Theme selection across light and dark modes is now persisted by name instead of numeric ID. A shared registry (constants/themes.ts) owns the single source of truth for all predefined theme definitions, and utils/themeManager.ts provides a central applyCurrentTheme() / applyThemeForMode() API that replaces scattered inline resolution logic in App.vue and PredefinedThemes.vue. Custom themes continue to store their hex color as the source of truth, marked with the CUSTOM_THEME_NAME sentinel. The theme drawer is redesigned: a segmented Light/Dark toggle replaces the tabs, each theme is a single clickable row (no per-row Apply button), the applied theme is highlighted with a primary ring plus an Applied badge, long names truncate instead of wrapping, and the custom color row opens the picker with an Apply/Cancel dialog. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../pages/generalPages/themePage.js | 57 +- .../GeneralTests/theme-management.spec.js | 22 +- web/src/App.vue | 122 +- web/src/components/PredefinedThemes.spec.ts | 1149 +++++++++++++++-- web/src/components/PredefinedThemes.vue | 971 +++++--------- web/src/components/settings/General.vue | 16 +- web/src/constants/themes.spec.ts | 212 +++ web/src/constants/themes.ts | 309 +++++ web/src/main.ts | 5 + web/src/plugins/logs/SearchResult.vue | 13 +- web/src/stores/index.ts | 12 +- web/src/utils/themeManager.spec.ts | 187 +++ web/src/utils/themeManager.ts | 119 ++ 13 files changed, 2279 insertions(+), 915 deletions(-) create mode 100644 web/src/constants/themes.spec.ts create mode 100644 web/src/constants/themes.ts create mode 100644 web/src/utils/themeManager.spec.ts create mode 100644 web/src/utils/themeManager.ts diff --git a/tests/ui-testing/pages/generalPages/themePage.js b/tests/ui-testing/pages/generalPages/themePage.js index 04b04d92cd..ba28bda405 100644 --- a/tests/ui-testing/pages/generalPages/themePage.js +++ b/tests/ui-testing/pages/generalPages/themePage.js @@ -27,9 +27,12 @@ export class ThemePage { this.customColorPreviewLight = page.locator('[data-test="predefined-themes-custom-color-preview-light"]'); // ODialog hosting OColor picker this.colorPickerDialog = page.locator('[data-test="predefined-themes-color-picker-dialog"]'); - // Custom-color picker is an ODialog whose only footer button is the - // primary "Close" button (see PredefinedThemes.vue:215). - this.colorPickerClose = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-primary-btn"]'); + // PredefinedThemes.vue's "Pick Custom Color" ODialog now has two footer + // buttons: the primary "Apply" button (confirms + applies the color) and + // the neutral "Cancel" button (closes without applying). ODialog forwards + // them as o-dialog-primary-btn / o-dialog-neutral-btn under the parent slug. + this.colorPickerApply = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-primary-btn"]'); + this.colorPickerClose = page.locator('[data-test="predefined-themes-color-picker-dialog"] [data-test="o-dialog-neutral-btn"]'); // General Settings - Theme Management this.settingsMenuItem = page.locator('[data-test="menu-link-\\/settings-item"]'); @@ -56,12 +59,16 @@ export class ThemePage { /** * Returns the theme card locator for a given theme name and mode. - * Cards expose `data-test="predefined-themes-card-{mode}-{slug}"` (added in - * PredefinedThemes.vue) so we no longer depend on text-based filters. + * The PredefinedThemes.vue redesign merged each predefined theme's card and + * Apply control into a single clickable `', - props: ['open', 'size', 'title', 'primaryButtonLabel'], - emits: ['update:open', 'click:primary', 'click:secondary', 'click:neutral'], + name: "ODialog", + template: + '
{{ title }}
', + props: [ + "open", + "size", + "title", + "primaryButtonLabel", + "neutralButtonLabel", + ], + emits: ["update:open", "click:primary", "click:secondary", "click:neutral"], }, + // OToggleGroup — replaces the old OTabs segmented control + OToggleGroup: { + name: "OToggleGroup", + template: + '
', + props: ["modelValue", "type"], + emits: ["update:modelValue"], + }, + // OToggleGroupItem — individual toggle items; forward data-test and value + OToggleGroupItem: { + name: "OToggleGroupItem", + template: + '', + props: ["value", "iconLeft"], + emits: ["click"], + inheritAttrs: false, + }, + // OBadge — inline badge for "Applied" indicator; forward data-test + OBadge: { + name: "OBadge", + template: + '', + props: ["variant", "size", "icon"], + inheritAttrs: false, + }, + OButton: { + template: + '', + props: ["variant", "size", "disabled", "loading"], + emits: ["click"], + inheritAttrs: false, + }, + OCardSection: { + template: '
', + }, + OSeparator: { + template: '
', + }, + OIcon: { + template: '', + props: ["name", "size"], + }, + OColor: { + template: '
', + props: ["modelValue"], + emits: ["update:modelValue"], + }, + // Legacy Quasar stubs kept for safety (no longer rendered, harmless) QCard: { template: '
', }, QCardSection: { template: '
', }, - QTabs: { - template: '
', - props: ['modelValue'], - }, - QTab: { - template: '', - }, - QTabPanels: { - template: '
', - props: ['modelValue'], - }, - QTabPanel: { - template: '
', - props: ['name'], - }, QBtn: { - template: '', - }, - // O2 component stubs (migrated from Quasar) - OTabs: { - template: '
', - props: ['modelValue', 'dense', 'align', 'orientation'], - emits: ['update:modelValue'], - }, - OTab: { - template: '', - props: ['name', 'label'], - }, - OTabPanels: { - template: '
', - props: ['modelValue', 'animated'], - emits: ['update:modelValue'], - }, - OTabPanel: { - template: '
', - props: ['name'], - }, - OButton: { - template: '', - props: ['variant', 'size', 'disabled', 'loading'], - emits: ['click'], + template: + '', }, QIcon: { template: '', @@ -191,8 +224,8 @@ const createWrapper = (props = {}, options = {}) => { }, QColor: { template: '
', - props: ['modelValue'], - emits: ['update:modelValue'], + props: ["modelValue"], + emits: ["update:modelValue"], }, }, ...options, @@ -205,38 +238,66 @@ describe("PredefinedThemes", () => { vi.clearAllMocks(); mockToast.mockReturnValue(vi.fn()); mockLocalStorage.getItem.mockImplementation((key: string) => { - if (key === 'appliedLightTheme') return null; - if (key === 'appliedDarkTheme') return null; - if (key === 'customLightColor') return null; - if (key === 'customDarkColor') return null; + if (key === "appliedLightTheme") return null; + if (key === "appliedDarkTheme") return null; + if (key === "customLightColor") return null; + if (key === "customDarkColor") return null; return null; }); + // Reset store state mutations between tests + mockStore.state.tempThemeColors = { light: null, dark: null }; + mockStore.state.theme = "light"; + mockStore.state.organizationData = { + organizationSettings: { + light_mode_theme_color: "#3F7994", + dark_mode_theme_color: "#5B9FBE", + }, + }; }); describe("Component Mounting", () => { it("should mount successfully", () => { const wrapper = createWrapper(); + expect(wrapper.exists()).toBe(true); }); it("should render drawer (ODrawer)", () => { const wrapper = createWrapper(); + expect(wrapper.find('[data-test-stub="o-drawer"]').exists()).toBe(true); }); it("should render inner color picker ODialog", () => { const wrapper = createWrapper(); + expect(wrapper.find('[data-test-stub="o-dialog"]').exists()).toBe(true); }); - it("should render light and dark mode tabs", () => { + it("should render light and dark mode toggle items", () => { const wrapper = createWrapper(); - expect(wrapper.find('[data-test-stub="q-tabs"]').exists()).toBe(true); + + // New segmented control uses OToggleGroupItem with data-test attrs + expect( + wrapper.find('[data-test="predefined-themes-tab-light"]').exists(), + ).toBe(true); + expect( + wrapper.find('[data-test="predefined-themes-tab-dark"]').exists(), + ).toBe(true); + }); + + it("should render the OToggleGroup container", () => { + const wrapper = createWrapper(); + + expect(wrapper.find('[data-test-stub="o-toggle-group"]').exists()).toBe( + true, + ); }); it("should initialize with light mode tab active", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(vm.activeTab).toBe("light"); }); }); @@ -245,34 +306,42 @@ describe("PredefinedThemes", () => { it("should display predefined themes", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(vm.predefinedThemes.length).toBeGreaterThan(0); }); it("includes O2 Crimson Ink as a predefined theme with semanticColors", () => { const wrapper = createWrapper(); const { vm } = wrapper; - const o2Theme = (vm as any).predefinedThemes.find((t: any) => t.name === 'O2 Crimson Ink'); + const o2Theme = (vm as any).predefinedThemes.find( + (t: any) => t.name === "O2 Crimson Ink", + ); + expect(o2Theme).toBeDefined(); - expect(o2Theme.light.themeColor).toBe('#E11D48'); + expect(o2Theme.light.themeColor).toBe("#E11D48"); expect(o2Theme.light.semanticColors).toBeDefined(); - expect(o2Theme.light.semanticColors.error).toBe('#F97316'); - expect(o2Theme.light.semanticColors.success).toBe('#6366F1'); + expect(o2Theme.light.semanticColors.error).toBe("#F97316"); + expect(o2Theme.light.semanticColors.success).toBe("#6366F1"); expect(o2Theme.dark.semanticColors).toBeDefined(); }); it("passes semanticColors to applyThemeColors when applying O2 Crimson Ink", async () => { const wrapper = createWrapper(); const { vm } = wrapper; - const { applyThemeColors } = await import('@/utils/theme'); - const o2Theme = (vm as any).predefinedThemes.find((t: any) => t.name === 'O2 Crimson Ink'); - (vm as any).applyTheme(o2Theme, 'light'); + const { applyThemeColors } = await import("@/utils/theme"); + const o2Theme = (vm as any).predefinedThemes.find( + (t: any) => t.name === "O2 Crimson Ink", + ); + + (vm as any).applyTheme(o2Theme, "light"); + expect(applyThemeColors).toHaveBeenCalledWith( - '#E11D48', - 'light', + "#E11D48", + "light", false, expect.objectContaining({ - error: '#F97316', - success: '#6366F1', + error: "#F97316", + success: "#6366F1", }), ); }); @@ -280,12 +349,16 @@ describe("PredefinedThemes", () => { it("does not pass semanticColors when applying a non-signature theme", async () => { const wrapper = createWrapper(); const { vm } = wrapper; - const { applyThemeColors } = await import('@/utils/theme'); - const oceanTheme = (vm as any).predefinedThemes.find((t: any) => t.name === 'Ocean Breeze'); - (vm as any).applyTheme(oceanTheme, 'light'); + const { applyThemeColors } = await import("@/utils/theme"); + const oceanTheme = (vm as any).predefinedThemes.find( + (t: any) => t.name === "O2 Pulse", + ); + + (vm as any).applyTheme(oceanTheme, "light"); + expect(applyThemeColors).toHaveBeenCalledWith( oceanTheme.light.themeColor, - 'light', + "light", false, undefined, ); @@ -293,14 +366,83 @@ describe("PredefinedThemes", () => { it("should display custom color option", () => { const wrapper = createWrapper(); + expect(wrapper.text()).toContain("Custom Color"); }); - it("should show default colors from store", () => { + it("should use the default theme (O2 Signature) colors as defaults", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; - expect(vm.DEFAULT_LIGHT_COLOR).toBe("#3F7994"); - expect(vm.DEFAULT_DARK_COLOR).toBe("#5B9FBE"); + + expect(vm.DEFAULT_LIGHT_COLOR).toBe(DEFAULT_THEME.light.themeColor); + expect(vm.DEFAULT_DARK_COLOR).toBe(DEFAULT_THEME.dark.themeColor); + }); + + it("renders theme row buttons for each predefined theme in light mode", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // Each predefined theme should have a row button in the active (light) mode + const pulseSlug = themeNameSlug("O2 Pulse"); + const pulseRow = wrapper.find( + `[data-test="predefined-themes-apply-btn-light-${pulseSlug}"]`, + ); + + expect(pulseRow.exists()).toBe(true); + }); + + it("renders theme row for O2 Crimson Ink in light mode", () => { + const wrapper = createWrapper(); + + const slug = themeNameSlug("O2 Crimson Ink"); + const row = wrapper.find( + `[data-test="predefined-themes-apply-btn-light-${slug}"]`, + ); + + expect(row.exists()).toBe(true); + }); + + it("renders custom color row for active mode", () => { + const wrapper = createWrapper(); + + expect( + wrapper + .find('[data-test="predefined-themes-card-light-custom-color"]') + .exists(), + ).toBe(true); + }); + + it("shows hint text 'Pick any brand hex' when custom theme is not applied", () => { + const wrapper = createWrapper(); + + expect(wrapper.text()).toContain("Pick any brand hex"); + }); + + it("shows bottom note about device-only storage", () => { + const wrapper = createWrapper(); + + expect(wrapper.text()).toContain("Saved to this device only"); + }); + }); + + describe("Mode (computed)", () => { + it("mode is 'light' when activeTab is 'light'", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.activeTab = "light"; + + expect(vm.mode).toBe("light"); + }); + + it("mode is 'dark' when activeTab is 'dark'", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.activeTab = "dark"; + await nextTick(); + + expect(vm.mode).toBe("dark"); }); }); @@ -326,25 +468,160 @@ describe("PredefinedThemes", () => { expect(vm.activeTab).toBe("light"); }); - }); - describe("Theme Application", () => { - it("should have apply buttons for each theme", () => { - const wrapper = createWrapper(); - const applyButtons = wrapper.findAll('[data-test-stub="q-btn"]'); - expect(applyButtons.length).toBeGreaterThan(0); - }); - - it("should store theme in localStorage when applied", async () => { + it("renders dark-mode theme rows when activeTab switches to dark", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; - // Apply a predefined theme - const theme = vm.predefinedThemes[0]; - vm.applyTheme(theme); + vm.activeTab = "dark"; await nextTick(); - expect(mockLocalStorage.setItem).toHaveBeenCalled(); + const slug = themeNameSlug("O2 Pulse"); + expect( + wrapper + .find(`[data-test="predefined-themes-apply-btn-dark-${slug}"]`) + .exists(), + ).toBe(true); + }); + + it("renders dark-mode custom-color row when activeTab switches to dark", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.activeTab = "dark"; + await nextTick(); + + expect( + wrapper + .find('[data-test="predefined-themes-card-dark-custom-color"]') + .exists(), + ).toBe(true); + }); + }); + + describe("Theme Application", () => { + it("should have apply buttons (theme rows) for each theme", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // Every predefined theme should have a clickable row + for (const theme of vm.predefinedThemes) { + const slug = themeNameSlug(theme.name); + const row = wrapper.find( + `[data-test="predefined-themes-apply-btn-light-${slug}"]`, + ); + expect(row.exists()).toBe(true); + } + }); + + it("should store theme in localStorage when applied via applyTheme()", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes[0]; + vm.applyTheme(theme, "light"); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "appliedLightThemeName", + theme.name, + ); + }); + + it("clicking the O2 Pulse theme row calls localStorage.setItem with its name", async () => { + const wrapper = createWrapper(); + + const slug = themeNameSlug("O2 Pulse"); + const row = wrapper.find( + `[data-test="predefined-themes-apply-btn-light-${slug}"]`, + ); + expect(row.exists()).toBe(true); + + await row.trigger("click"); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "appliedLightThemeName", + "O2 Pulse", + ); + }); + + it("clicking a theme row calls applyThemeColors with the correct color", async () => { + const wrapper = createWrapper(); + const { applyThemeColors } = await import("@/utils/theme"); + const vm = wrapper.vm as any; + const pulseTheme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Pulse", + ); + + const slug = themeNameSlug("O2 Pulse"); + const row = wrapper.find( + `[data-test="predefined-themes-apply-btn-light-${slug}"]`, + ); + await row.trigger("click"); + await nextTick(); + + expect(applyThemeColors).toHaveBeenCalledWith( + pulseTheme.light.themeColor, + "light", + false, + undefined, + ); + }); + + it("shows applied badge for the theme that is currently applied", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // Mark O2 Pulse as applied + const slug = themeNameSlug("O2 Pulse"); + vm.appliedLightThemeName = "O2 Pulse"; + await nextTick(); + + const badge = wrapper.find( + `[data-test="predefined-themes-applied-badge-light-${slug}"]`, + ); + expect(badge.exists()).toBe(true); + expect(badge.text()).toContain("Applied"); + }); + + it("does NOT show applied badge for a theme that is NOT applied", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // Only O2 Pulse is applied; O2 Horizon badge should not exist + vm.appliedLightThemeName = "O2 Pulse"; + await nextTick(); + + const horizonSlug = themeNameSlug("O2 Horizon"); + const badge = wrapper.find( + `[data-test="predefined-themes-applied-badge-light-${horizonSlug}"]`, + ); + expect(badge.exists()).toBe(false); + }); + + it("isThemeApplied returns true when the theme name matches the applied name", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedLightThemeName = "O2 Signature"; + + const theme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Signature", + ); + expect(vm.isThemeApplied(theme, "light")).toBe(true); + }); + + it("isThemeApplied returns false when a different theme is applied", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedLightThemeName = "O2 Pulse"; + + const theme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Signature", + ); + expect(vm.isThemeApplied(theme, "light")).toBe(false); }); }); @@ -352,16 +629,18 @@ describe("PredefinedThemes", () => { it("should have custom light color initialized", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(vm.customLightColor).toBeDefined(); }); it("should have custom dark color initialized", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(vm.customDarkColor).toBeDefined(); }); - it("should open color picker for light mode", async () => { + it("should open color picker for light mode via openColorPicker()", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; @@ -372,7 +651,7 @@ describe("PredefinedThemes", () => { expect(vm.currentPickerMode).toBe("light"); }); - it("should open color picker for dark mode", async () => { + it("should open color picker for dark mode via openColorPicker()", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; @@ -383,6 +662,41 @@ describe("PredefinedThemes", () => { expect(vm.currentPickerMode).toBe("dark"); }); + it("clicking the custom-color row opens the picker for the active mode", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const row = wrapper.find( + '[data-test="predefined-themes-card-light-custom-color"]', + ); + expect(row.exists()).toBe(true); + + await row.trigger("click"); + await nextTick(); + + expect(vm.showColorPicker).toBe(true); + expect(vm.currentPickerMode).toBe("light"); + }); + + it("clicking the custom-color row in dark mode opens picker with dark mode", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.activeTab = "dark"; + await nextTick(); + + const row = wrapper.find( + '[data-test="predefined-themes-card-dark-custom-color"]', + ); + expect(row.exists()).toBe(true); + + await row.trigger("click"); + await nextTick(); + + expect(vm.showColorPicker).toBe(true); + expect(vm.currentPickerMode).toBe("dark"); + }); + it("should update custom color when color picker changes", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; @@ -395,7 +709,19 @@ describe("PredefinedThemes", () => { expect(vm.customLightColor).toBe("#FF0000"); }); - it("should apply custom theme and save to localStorage", async () => { + it("updateCustomColor updates dark color when currentPickerMode is dark", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.tempColor = "#0000FF"; + vm.currentPickerMode = "dark"; + vm.updateCustomColor(); + await nextTick(); + + expect(vm.customDarkColor).toBe("#0000FF"); + }); + + it("should apply custom theme and save to localStorage via applyCustomTheme()", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; @@ -404,25 +730,130 @@ describe("PredefinedThemes", () => { await nextTick(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'customLightColor', - '#FF0000' + "customLightColor", + "#FF0000", ); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'appliedLightTheme', - '-1' + "appliedLightThemeName", + CUSTOM_THEME_NAME, + ); + }); + + it("applyCustomTheme stores CUSTOM_THEME_NAME for dark mode", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.customDarkColor = "#AABBCC"; + vm.applyCustomTheme("dark"); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "appliedDarkThemeName", + CUSTOM_THEME_NAME, ); }); }); - describe("Reset Functionality", () => { - it("should have reset to default theme function", () => { + describe("confirmCustomColor (Dialog Apply button)", () => { + it("confirmCustomColor closes the color picker", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; - // The component should have resetToDefaultTheme function - expect(typeof vm.resetToDefaultTheme).toBe('function'); + + vm.showColorPicker = true; + vm.currentPickerMode = "light"; + await nextTick(); + + vm.confirmCustomColor(); + await nextTick(); + + expect(vm.showColorPicker).toBe(false); }); - it("should reset to organization settings when available", async () => { + it("confirmCustomColor applies the custom theme for the current picker mode", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.customLightColor = "#123456"; + vm.showColorPicker = true; + vm.currentPickerMode = "light"; + await nextTick(); + + vm.confirmCustomColor(); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "appliedLightThemeName", + CUSTOM_THEME_NAME, + ); + }); + + it("clicking the ODialog primary button closes the color picker via confirmCustomColor", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.showColorPicker = true; + vm.currentPickerMode = "light"; + await nextTick(); + + const primaryBtn = wrapper.find('[data-test-stub="o-dialog-primary"]'); + expect(primaryBtn.exists()).toBe(true); + await primaryBtn.trigger("click"); + await nextTick(); + + expect(vm.showColorPicker).toBe(false); + }); + + it("clicking the ODialog neutral button closes the picker without applying", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vi.clearAllMocks(); + vm.showColorPicker = true; + vm.currentPickerMode = "light"; + await nextTick(); + + const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]'); + expect(neutralBtn.exists()).toBe(true); + await neutralBtn.trigger("click"); + await nextTick(); + + expect(vm.showColorPicker).toBe(false); + // neutral must NOT call applyCustomTheme — no appliedLightThemeName set + expect(mockLocalStorage.setItem).not.toHaveBeenCalledWith( + "appliedLightThemeName", + CUSTOM_THEME_NAME, + ); + }); + + it("shows applied badge for custom color row after confirmCustomColor", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.showColorPicker = true; + vm.currentPickerMode = "light"; + await nextTick(); + + vm.confirmCustomColor(); + await nextTick(); + + // The badge appears when isCustomThemeApplied returns true + const badge = wrapper.find( + '[data-test="predefined-themes-applied-badge-light-custom-color"]', + ); + expect(badge.exists()).toBe(true); + expect(badge.text()).toContain("Applied"); + }); + }); + + describe("Reset Functionality", () => { + it("should have resetToDefaultTheme function", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + expect(typeof vm.resetToDefaultTheme).toBe("function"); + }); + + it("should call localStorage.removeItem when resetting", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; @@ -432,16 +863,70 @@ describe("PredefinedThemes", () => { expect(mockLocalStorage.removeItem).toHaveBeenCalled(); }); - it("should show notification after reset", async () => { + it("should show toast notification after reset", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; vm.resetToDefaultTheme(); await nextTick(); - // resetToDefaultTheme calls toast() from @/lib/feedback/Toast/useToast, not useQuasar().notify + // resetToDefaultTheme calls toast() from @/lib/feedback/Toast/useToast expect(mockToast).toHaveBeenCalled(); }); + + it("reset button is rendered in the drawer header-right slot", () => { + const wrapper = createWrapper(); + + const resetBtn = wrapper.find( + '[data-test="predefined-themes-reset-btn"]', + ); + expect(resetBtn.exists()).toBe(true); + }); + + it("clicking the reset button triggers the reset (localStorage.removeItem called)", async () => { + const wrapper = createWrapper(); + + // Arrange — pre-set an applied theme so removeItem is definitely called by reset + (wrapper.vm as any).appliedLightThemeName = "O2 Pulse"; + await nextTick(); + + const resetBtn = wrapper.find( + '[data-test="predefined-themes-reset-btn"]', + ); + expect(resetBtn.exists()).toBe(true); + + await resetBtn.trigger("click"); + await nextTick(); + + // If resetToDefaultTheme ran, it removes the localStorage entries + expect(mockLocalStorage.removeItem).toHaveBeenCalled(); + }); + + it("after reset, appliedLightThemeName is null", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedLightThemeName = "O2 Pulse"; + await nextTick(); + + vm.resetToDefaultTheme(); + await nextTick(); + + expect(vm.appliedLightThemeName).toBeNull(); + }); + + it("after reset, appliedDarkThemeName is null", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedDarkThemeName = "O2 Horizon"; + await nextTick(); + + vm.resetToDefaultTheme(); + await nextTick(); + + expect(vm.appliedDarkThemeName).toBeNull(); + }); }); describe("Temp Theme Colors (Preview)", () => { @@ -465,13 +950,13 @@ describe("PredefinedThemes", () => { it("should show 'Applied' badge when theme is permanently applied", () => { mockStore.state.tempThemeColors.light = null; mockLocalStorage.getItem.mockImplementation((key: string) => { - if (key === 'appliedLightTheme') return '-1'; + if (key === "appliedLightThemeName") return CUSTOM_THEME_NAME; return null; }); const wrapper = createWrapper(); const vm = wrapper.vm as any; - vm.appliedLightTheme = -1; + vm.appliedLightThemeName = CUSTOM_THEME_NAME; const isApplied = vm.isCustomThemeApplied("light"); expect(isApplied).toBe(true); @@ -482,7 +967,7 @@ describe("PredefinedThemes", () => { it("should prioritize temp colors over localStorage", () => { mockStore.state.tempThemeColors.light = "#FF0000"; mockLocalStorage.getItem.mockImplementation((key: string) => { - if (key === 'customLightColor') return '#00FF00'; + if (key === "customLightColor") return "#00FF00"; return null; }); @@ -495,7 +980,7 @@ describe("PredefinedThemes", () => { it("should use localStorage when no temp colors", () => { mockStore.state.tempThemeColors.light = null; mockLocalStorage.getItem.mockImplementation((key: string) => { - if (key === 'customLightColor') return '#00FF00'; + if (key === "customLightColor") return "#00FF00"; return null; }); @@ -520,6 +1005,7 @@ describe("PredefinedThemes", () => { it("should handle missing organization settings gracefully", () => { mockStore.state.organizationData = {}; const wrapper = createWrapper(); + expect(wrapper.exists()).toBe(true); }); @@ -527,26 +1013,423 @@ describe("PredefinedThemes", () => { mockStore.state.tempThemeColors = { light: null, dark: null }; const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(vm.customLightColor).toBeDefined(); }); it("should handle empty predefined themes", () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; + expect(Array.isArray(vm.predefinedThemes)).toBe(true); }); + + it("isCustomThemeApplied returns false when no theme is applied", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedLightThemeName = null; + + expect(vm.isCustomThemeApplied("light")).toBe(false); + }); + + it("isCustomThemeApplied returns false when a predefined (not custom) theme is applied", () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.appliedLightThemeName = "O2 Pulse"; + + expect(vm.isCustomThemeApplied("light")).toBe(false); + }); + }); + + describe("applyTheme — dark mode branch", () => { + it("stores appliedDarkThemeName in localStorage when applying a dark theme", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes.find((t: any) => t.name === "O2 Pulse"); + vm.applyTheme(theme, "dark"); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "appliedDarkThemeName", + "O2 Pulse", + ); + }); + + it("sets appliedDarkThemeName ref when applying a dark theme", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Horizon", + ); + vm.applyTheme(theme, "dark"); + await nextTick(); + + expect(vm.appliedDarkThemeName).toBe("O2 Horizon"); + }); + + it("updates customDarkColor ref to the theme color when applying dark", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Beacon", + ); + vm.applyTheme(theme, "dark"); + await nextTick(); + + expect(vm.customDarkColor).toBe(theme.dark.themeColor); + }); + + it("stores semanticColors JSON in localStorage when applying O2 Crimson Ink dark", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes.find( + (t: any) => t.name === "O2 Crimson Ink", + ); + vm.applyTheme(theme, "dark"); + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + "darkSemanticColors", + JSON.stringify(theme.dark.semanticColors), + ); + }); + }); + + describe("applyTheme — semantic-less theme removes stale semantic localStorage key", () => { + it("calls localStorage.removeItem for semantic key when theme has no semanticColors", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + const theme = vm.predefinedThemes.find((t: any) => t.name === "O2 Pulse"); + // O2 Pulse has no semanticColors — removeItem should be called for lightSemanticColors + vm.applyTheme(theme, "light"); + await nextTick(); + + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + "lightSemanticColors", + ); + }); + }); + + describe("Watch handlers — isOpen composable", () => { + it("syncs dialogOpen when isOpen ref changes", async () => { + const { usePredefinedThemes } = await import( + "@/composables/usePredefinedThemes" + ); + + // Start with isOpen=false so dialogOpen starts false, then flip to true + const reactiveIsOpen = ref(false); + vi.mocked(usePredefinedThemes).mockReturnValueOnce({ + isOpen: reactiveIsOpen, + toggleThemes: vi.fn(), + } as any); + + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // Initially closed (isOpen=false → dialogOpen=false, watch hasn't fired yet) + expect(vm.dialogOpen).toBe(false); + + // Simulate composable opening the dialog + reactiveIsOpen.value = true; + await nextTick(); + + expect(vm.dialogOpen).toBe(true); + + // Simulate composable closing the dialog (covers the else/false branch) + reactiveIsOpen.value = false; + await nextTick(); + + expect(vm.dialogOpen).toBe(false); + }); + + it("syncs activeTab from store theme when isOpen becomes true", async () => { + const { usePredefinedThemes } = await import( + "@/composables/usePredefinedThemes" + ); + + mockStore.state.theme = "dark"; + const reactiveIsOpen = ref(false); + vi.mocked(usePredefinedThemes).mockReturnValueOnce({ + isOpen: reactiveIsOpen, + toggleThemes: vi.fn(), + } as any); + + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + // Initially closed — activeTab is "light" (default) + expect(vm.activeTab).toBe("light"); + + // Open the dialog — watcher should sync activeTab to store theme (dark) + reactiveIsOpen.value = true; + await nextTick(); + + expect(vm.activeTab).toBe("dark"); + }); + }); + + describe("Watch handlers — reactive Vuex store", () => { + // These tests use the real Vuex store so watchers on store.state actually fire. + const createWrapperWithRealStore = async () => { + const { default: realStore } = await import( + "@/test/unit/helpers/store" + ); + return { + store: realStore, + wrapper: mount(PredefinedThemes, { + global: { + plugins: [i18n, realStore], + stubs: { + ODrawer: { + name: "ODrawer", + template: + '
', + props: ["open", "size", "seamless", "title"], + emits: ["update:open"], + }, + ODialog: { + name: "ODialog", + template: + '
', + props: ["open", "size", "title", "primaryButtonLabel", "neutralButtonLabel"], + emits: ["update:open", "click:primary", "click:neutral"], + }, + OToggleGroup: { + name: "OToggleGroup", + template: '
', + props: ["modelValue", "type"], + emits: ["update:modelValue"], + }, + OToggleGroupItem: { + name: "OToggleGroupItem", + template: + '', + props: ["value", "iconLeft"], + emits: ["click"], + inheritAttrs: false, + }, + OBadge: { + name: "OBadge", + template: '', + props: ["variant", "size", "icon"], + inheritAttrs: false, + }, + OButton: { + template: '', + props: ["variant", "size"], + emits: ["click"], + inheritAttrs: false, + }, + OCardSection: { template: '
' }, + OSeparator: { template: '
' }, + OIcon: { template: '', props: ["name", "size"] }, + OColor: { + template: '
', + props: ["modelValue"], + emits: ["update:modelValue"], + }, + }, + }, + }), + }; + }; + + it("updates activeTab when store.state.theme changes", async () => { + // Real test store starts with theme:"dark". + // First mount — activeTab starts at "light" (component default). + // Switch the mock store to "light" first so the watcher transition goes light→dark. + const { store, wrapper } = await createWrapperWithRealStore(); + const vm = wrapper.vm as any; + + // activeTab starts at "light" (the component's initial ref value) + expect(vm.activeTab).toBe("light"); + + // Commit "dark" — store.state.theme changes from "dark"→"dark" (no change) … + // so first reset to "light" so the next commit is a real change + store.commit("appTheme", "light"); + await nextTick(); + // No change: activeTab was already "light" + expect(vm.activeTab).toBe("light"); + + // Now change to "dark" — watcher should fire and update activeTab + store.commit("appTheme", "dark"); + await nextTick(); + + expect(vm.activeTab).toBe("dark"); + wrapper.unmount(); + }); + + it("does not change activeTab when store.state.theme matches current activeTab", async () => { + const { store, wrapper } = await createWrapperWithRealStore(); + const vm = wrapper.vm as any; + + // activeTab starts at "light" — set store to "light" then commit "light" again + store.commit("appTheme", "light"); + await nextTick(); + expect(vm.activeTab).toBe("light"); + + // Commit same value — guard should prevent activeTab from re-assignment + store.commit("appTheme", "light"); + await nextTick(); + expect(vm.activeTab).toBe("light"); + wrapper.unmount(); + }); + + it("updates customLightColor when organizationSettings changes and no localStorage color", async () => { + const { store, wrapper } = await createWrapperWithRealStore(); + const vm = wrapper.vm as any; + + mockLocalStorage.getItem.mockReturnValue(null); + // Commit new org settings — fires the deep watcher + store.commit("setOrganizationSettings", { + light_mode_theme_color: "#AABBCC", + dark_mode_theme_color: "#112233", + }); + await nextTick(); + await nextTick(); // deep watcher may need an extra tick + + expect(vm.customLightColor).toBe("#AABBCC"); + wrapper.unmount(); + }); + + it("does not update customLightColor when hasTempColors is set in orgSettings watcher", async () => { + const { store, wrapper } = await createWrapperWithRealStore(); + const vm = wrapper.vm as any; + + // Simulate temp colors being active in the store + (store.state as any).tempThemeColors = { light: "#FF0000", dark: null }; + mockLocalStorage.getItem.mockReturnValue(null); + + const colorBefore = vm.customLightColor; + + store.commit("setOrganizationSettings", { + light_mode_theme_color: "#DEADBE", + dark_mode_theme_color: "#EFFACE", + }); + await nextTick(); + await nextTick(); + + // hasTempColors guard should have returned early + expect(vm.customLightColor).toBe(colorBefore); + wrapper.unmount(); + }); + }); + + describe("Watch handlers — activeTab", () => { + it("dispatches appTheme when activeTab changes to a different theme mode", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // store.state.theme starts as "light"; changing to dark should dispatch + vm.activeTab = "dark"; + await nextTick(); + + expect(mockStore.dispatch).toHaveBeenCalledWith("appTheme", "dark"); + }); + + it("does not dispatch appTheme when activeTab matches current store.state.theme", async () => { + // Set store theme to dark before mounting so they start in sync + mockStore.state.theme = "dark"; + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + // activeTab defaults to "light" (store override not used by default during mount) + // but the component syncs from store.state.theme — let's directly set activeTab to dark + // first to match the store + vm.activeTab = "dark"; + await nextTick(); + vi.clearAllMocks(); + + // Now set it to the same value — watcher should not dispatch again + vm.activeTab = "dark"; + await nextTick(); + + expect(mockStore.dispatch).not.toHaveBeenCalled(); + }); + + it("sets localStorage theme key when activeTab changes", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.activeTab = "dark"; + await nextTick(); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith("theme", "dark"); + }); + }); + + describe("Lifecycle — MutationObserver and onUnmounted", () => { + it("disconnects the MutationObserver on unmount without throwing", async () => { + const wrapper = createWrapper(); + + // Unmounting must not throw even if observer is null + await wrapper.unmount(); + // If we got here without an error, the guard `if (observer)` worked + expect(true).toBe(true); + }); + + it("triggers applyThemeForMode when body class changes to body--dark", async () => { + const { applyThemeForMode } = await import("@/utils/themeManager"); + const wrapper = createWrapper(); + // Wait for onMounted to set up the observer + await nextTick(); + + // Reset so we can check for new calls + vi.clearAllMocks(); + + // Simulate the body class toggle (the MutationObserver watches this) + document.body.classList.add("body--dark"); + // MutationObserver fires asynchronously in a microtask + await new Promise((resolve) => setTimeout(resolve, 0)); + + // applyThemeForMode should have been called by the observer callback + expect(applyThemeForMode).toHaveBeenCalledWith("dark", expect.anything()); + + // Cleanup + document.body.classList.remove("body--dark"); + wrapper.unmount(); + }); + + it("does NOT call applyThemeForMode when temp colors exist (observer hasTempColors guard)", async () => { + const { applyThemeForMode } = await import("@/utils/themeManager"); + + // Mount with temp colors active + mockStore.state.tempThemeColors = { light: "#FF0000", dark: null }; + const wrapper = createWrapper(); + await nextTick(); + vi.clearAllMocks(); + + // Trigger the MutationObserver + document.body.classList.add("body--dark"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // With hasTempColors=true the observer returns early — no call + expect(applyThemeForMode).not.toHaveBeenCalled(); + + // Cleanup + document.body.classList.remove("body--dark"); + wrapper.unmount(); + }); }); describe("Dialog Controls", () => { - it("should pass title 'Predefined Themes' to ODrawer", () => { + it("should pass title 'Theme' to ODrawer", () => { const wrapper = createWrapper(); + const drawer = wrapper.find('[data-test-stub="o-drawer"]'); expect(drawer.exists()).toBe(true); - expect(drawer.attributes("data-title")).toBe("Predefined Themes"); + expect(drawer.attributes("data-title")).toBe("Theme"); }); it("should pass title 'Pick Custom Color' to inner ODialog", () => { const wrapper = createWrapper(); + const dialog = wrapper.find('[data-test-stub="o-dialog"]'); expect(dialog.exists()).toBe(true); expect(dialog.attributes("data-title")).toBe("Pick Custom Color"); @@ -567,6 +1450,34 @@ describe("PredefinedThemes", () => { expect(vm.showColorPicker).toBe(false); }); + it("ODialog primary button label is 'Apply'", () => { + const wrapper = createWrapper(); + + const primaryBtn = wrapper.find('[data-test-stub="o-dialog-primary"]'); + expect(primaryBtn.text()).toBe("Apply"); + }); + + it("ODialog neutral button label is 'Cancel'", () => { + const wrapper = createWrapper(); + + const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]'); + expect(neutralBtn.text()).toBe("Cancel"); + }); + + it("should close color picker when ODialog emits click:neutral", async () => { + const wrapper = createWrapper(); + const vm = wrapper.vm as any; + + vm.showColorPicker = true; + await nextTick(); + + const neutralBtn = wrapper.find('[data-test-stub="o-dialog-neutral"]'); + await neutralBtn.trigger("click"); + await nextTick(); + + expect(vm.showColorPicker).toBe(false); + }); + it("should close drawer when dialogOpen is set to false", async () => { const wrapper = createWrapper(); const vm = wrapper.vm as any; diff --git a/web/src/components/PredefinedThemes.vue b/web/src/components/PredefinedThemes.vue index fe2c59f301..c4cf4c4e1a 100644 --- a/web/src/components/PredefinedThemes.vue +++ b/web/src/components/PredefinedThemes.vue @@ -1,4 +1,4 @@ - + + + Light + Dark + + - - - - -
-
-
-
-
{{ theme.name }}
-
- {{ theme.light.themeColor }} -
-
-
- Applied - Apply -
-
- - -
-
-
- -
-
-
Custom Color
-
- {{ customLightColor }} -
-
-
- Applied - Apply -
-
- - - - -
-
-
-
-
{{ theme.name }}
-
- {{ theme.dark.themeColor }} -
-
-
- Applied - Apply -
-
- - -
-
-
- -
-
-
Custom Color
-
- {{ customDarkColor }} -
-
-
- Applied - Apply -
-
- - - - - - - -
- - Theme preferences are stored locally on this device and will not - sync across different browsers or devices. + +
    +
  • +
-
+ + + {{ theme.name }} + {{ theme[mode].themeColor }} + + Applied + + + + +
  • + +
  • + + + + + + +
    + + Saved to this device only — themes don't sync across different + browsers or devices. +
    +
    .