diff --git a/site/e2e/tests/users/userSettings.spec.ts b/site/e2e/tests/users/userSettings.spec.ts index 39dd398765..ff419f89ea 100644 --- a/site/e2e/tests/users/userSettings.spec.ts +++ b/site/e2e/tests/users/userSettings.spec.ts @@ -12,15 +12,19 @@ const rootClassNames = async (page: Page) => { return page.locator("html").evaluate((it) => Array.from(it.classList)); }; -// Assert the light theme without rejecting unrelated root classes. -const expectLightThemeClasses = (classes: string[]) => { - const className = "light"; - expect(classes).toContain(className); - for (const themeClassName of CONCRETE_THEMES.filter( - (it) => it !== className, - )) { - expect(classes).not.toContain(themeClassName); - } +const expectLightThemeClasses = async (page: Page) => { + await expect(async () => { + const classes = await rootClassNames(page); + const className = "light"; + + // Assert the light theme without rejecting unrelated root classes. + expect(classes).toContain(className); + for (const themeClassName of CONCRETE_THEMES.filter( + (it) => it !== className, + )) { + expect(classes).not.toContain(themeClassName); + } + }).toPass({ timeout: 10_000 }); }; test("adjust user theme preference", async ({ page }) => { @@ -28,14 +32,18 @@ test("adjust user theme preference", async ({ page }) => { await page.goto("/settings/appearance", { waitUntil: "domcontentloaded" }); - await page.getByText("Light", { exact: true }).click(); - await expect(page.getByLabel("Light")).toBeChecked(); + await page.getByRole("combobox", { name: /theme mode/i }).click(); + await page.getByRole("option", { name: /single theme/i }).click(); - expectLightThemeClasses(await rootClassNames(page)); + const singleThemeGroup = page.getByRole("group", { name: "Theme" }); + await expect(singleThemeGroup).toBeVisible(); + await singleThemeGroup.getByText("Light default", { exact: true }).click(); + + await expectLightThemeClasses(page); await page.goto("/", { waitUntil: "domcontentloaded" }); // Make sure the page is still using the light theme after reloading and // navigating away from the settings page. - expectLightThemeClasses(await rootClassNames(page)); + await expectLightThemeClasses(page); }); diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx index aa732c8a45..d37f48de1f 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.stories.tsx @@ -1,25 +1,510 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type FC, useState } from "react"; import { action } from "storybook/actions"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import type { + UpdateUserAppearanceSettingsRequest, + UserAppearanceSettings, +} from "#/api/typesGenerated"; +import { CONCRETE_THEMES } from "#/theme"; import { AppearanceForm } from "./AppearanceForm"; const onUpdateTheme = action("update"); +const baseSettings: UserAppearanceSettings = { + theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", + terminal_font: "", +}; + +const resolvedSubmit = () => + fn((update: UpdateUserAppearanceSettingsRequest) => { + onUpdateTheme(update); + return Promise.resolve({ ...baseSettings, ...update }); + }); + +interface ResyncHarnessProps { + initialValues: UserAppearanceSettings; + onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void; +} + +const ResyncHarness: FC = ({ initialValues, onSubmit }) => { + const [settings, setSettings] = useState(initialValues); + + return ( +
+ + +
+ ); +}; + +interface PendingUpdateHarnessProps { + initialValues: UserAppearanceSettings; + onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void; +} + +const PendingUpdateHarness: FC = ({ + initialValues, + onSubmit, +}) => { + const [isUpdating, setIsUpdating] = useState(true); + const [renderCount, setRenderCount] = useState(0); + + return ( +
+
+ + +
+ + +
+ ); +}; + const meta: Meta = { title: "pages/UserSettingsPage/AppearanceForm", component: AppearanceForm, args: { - onSubmit: (update) => - Promise.resolve(onUpdateTheme(update.theme_preference)), + activeScheme: "dark", + onSubmit: (update) => Promise.resolve(onUpdateTheme(update)), }, }; export default meta; type Story = StoryObj; -export const Example: Story = { +export const SingleDarkDefault: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + }, +}; + +export const SingleLightDefault: Story = { + args: { + activeScheme: "light", + initialValues: { ...baseSettings, theme_preference: "light" }, + }, +}; + +export const SelectSingleLightDefault: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + onSubmit: resolvedSubmit(), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await user.click( + await canvas.findByRole("radio", { name: /light default/i }), + ); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith({ + theme_preference: "light", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", + terminal_font: "geist-mono", + }); + }); + expect( + await canvas.findByRole("radio", { name: /light default/i }), + ).toBeChecked(); + }, +}; + +export const SingleDarkProtanDeuter: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark-protan-deuter" }, + }, +}; + +export const SingleLightTritan: Story = { + args: { + activeScheme: "light", + initialValues: { ...baseSettings, theme_preference: "light-tritan" }, + }, +}; + +export const SyncDefault: Story = { args: { initialValues: { - theme_preference: "", + ...baseSettings, + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const lightOptions = await canvas.findByRole("group", { + name: "Light theme options", + }); + const darkOptions = await canvas.findByRole("group", { + name: "Dark theme options", + }); + + expect(within(lightOptions).getAllByRole("radio")).toHaveLength( + CONCRETE_THEMES.length, + ); + expect(within(darkOptions).getAllByRole("radio")).toHaveLength( + CONCRETE_THEMES.length, + ); + }, +}; + +export const SyncActiveLight: Story = { + args: { + activeScheme: "light", + initialValues: { + ...baseSettings, + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark", + }, + }, +}; + +export const SelectSyncMode: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + onSubmit: resolvedSubmit(), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const dropdown = await canvas.findByRole("combobox", { + name: "Theme mode", + }); + + await user.click(dropdown); + await user.click( + await within(document.body).findByRole("option", { + name: "Sync with system", + }), + ); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith({ + theme_preference: "dark", + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark", + terminal_font: "geist-mono", + }); + }); + expect(dropdown).toHaveTextContent("Sync with system"); + }, +}; + +export const SelectSingleFromSync: Story = { + args: { + activeScheme: "light", + initialValues: { + ...baseSettings, + theme_preference: "light-protan-deuter", + theme_mode: "sync", + theme_light: "light-protan-deuter", + theme_dark: "dark-tritan", + }, + onSubmit: resolvedSubmit(), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const dropdown = await canvas.findByRole("combobox", { + name: "Theme mode", + }); + + await user.click(dropdown); + await user.click( + await within(document.body).findByRole("option", { + name: "Single theme", + }), + ); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith({ + theme_preference: "light-protan-deuter", + theme_mode: "single", + theme_light: "light-protan-deuter", + theme_dark: "dark-tritan", + terminal_font: "geist-mono", + }); + }); + expect(dropdown).toHaveTextContent("Single theme"); + expect( + await canvas.findByRole("radio", { + name: /light protanopia and deuteranopia/i, + }), + ).toBeChecked(); + }, +}; + +export const SyncProtanDeuter: Story = { + args: { + initialValues: { + ...baseSettings, + theme_mode: "sync", + theme_light: "light-protan-deuter", + theme_dark: "dark-protan-deuter", + }, + }, +}; + +export const SyncTritan: Story = { + args: { + initialValues: { + ...baseSettings, + theme_mode: "sync", + theme_light: "light-tritan", + theme_dark: "dark-tritan", + }, + }, +}; + +export const SyncMixed: Story = { + args: { + initialValues: { + ...baseSettings, + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark-tritan", + }, + }, +}; + +export const SelectDarkThemeInLightSyncSlot: Story = { + args: { + activeScheme: "light", + initialValues: { + ...baseSettings, + theme_preference: "light", + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark", + }, + onSubmit: resolvedSubmit(), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const lightOptions = await canvas.findByRole("group", { + name: "Light theme options", + }); + const darkTritanopia = await within(lightOptions).findByRole("radio", { + name: /dark tritanopia/i, + }); + + await user.click(darkTritanopia); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith({ + theme_preference: "dark-tritan", + theme_mode: "sync", + theme_light: "dark-tritan", + theme_dark: "dark", + terminal_font: "geist-mono", + }); + }); + expect(darkTritanopia).toBeChecked(); + }, +}; + +export const SyncHoverPreview: Story = { + args: { + initialValues: { + ...baseSettings, + theme_mode: "sync", + theme_light: "light", + theme_dark: "dark", + }, + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + const lightOptions = await canvas.findByRole("group", { + name: "Light theme options", + }); + const radio = await within(lightOptions).findByRole("radio", { + name: "Light tritanopia", + }); + const swatch = radio.closest("label"); + if (swatch === null) { + throw new Error("Expected the theme radio to be inside a swatch label."); + } + await user.hover(swatch); + }, +}; + +export const SelectTerminalFont: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + onSubmit: resolvedSubmit(), + }, + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + await user.click(await canvas.findByText("Fira Code")); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledWith({ + theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", + terminal_font: "fira-code", + }); + }); + + await user.click( + await canvas.findByRole("radio", { name: /light default/i }), + ); + + await waitFor(() => { + expect(args.onSubmit).toHaveBeenCalledTimes(2); + }); + expect(args.onSubmit).toHaveBeenLastCalledWith({ + theme_preference: "light", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", + terminal_font: "fira-code", + }); + expect( + await canvas.findByRole("radio", { name: /light default/i }), + ).toBeChecked(); + }, +}; + +export const ResyncsWhenInitialValuesChange: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + onSubmit: fn(), + }, + render: (args) => ( + + ), + play: async ({ canvasElement, args }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + expect( + await canvas.findByRole("radio", { name: /dark default/i }), + ).toBeChecked(); + + await user.click( + await canvas.findByRole("button", { name: "Load light default" }), + ); + + await waitFor(() => { + expect( + canvas.getByRole("radio", { name: /light default/i }), + ).toBeChecked(); + }); + expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const PreservesDraftWhileUpdating: Story = { + args: { + initialValues: { ...baseSettings, theme_preference: "dark" }, + onSubmit: resolvedSubmit(), + }, + render: (args) => ( + + ), + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const canvas = within(canvasElement); + + await user.click( + await canvas.findByRole("radio", { name: /light default/i }), + ); + expect( + await canvas.findByRole("radio", { name: /light default/i }), + ).toBeChecked(); + + await user.click( + await canvas.findByRole("button", { + name: "Rerender pending update", + }), + ); + await waitFor(() => { + expect( + canvas.getByRole("radio", { name: /light default/i }), + ).toBeChecked(); + }); + + await user.click( + await canvas.findByRole("button", { name: "Complete update" }), + ); + await waitFor(() => { + expect( + canvas.getByRole("radio", { name: /dark default/i }), + ).toBeChecked(); + }); + }, +}; + +// Migration paths: settings without the new fields but with a legacy +// `auto-*` theme_preference should render in sync mode on mount. +export const LegacyAutoTritan: Story = { + args: { + initialValues: { + theme_preference: "auto-tritan", + // Legacy rows predate the theme_mode field. + theme_mode: "", + theme_light: "", + theme_dark: "", + terminal_font: "", + }, + }, +}; + +export const LegacyAuto: Story = { + args: { + initialValues: { + theme_preference: "auto", theme_mode: "", theme_light: "", theme_dark: "", diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index d8d107e773..06752bf4c1 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -1,25 +1,37 @@ -import type { FC } from "react"; +import { type FC, useEffect, useId, useState } from "react"; import { type TerminalFontName, TerminalFontNames, type UpdateUserAppearanceSettingsRequest, + type UserAppearanceSettings, } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { PreviewBadge } from "#/components/Badges/Badges"; import { Label } from "#/components/Label/Label"; import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup"; import { - SettingsHeader, - SettingsHeaderTitle, -} from "#/components/SettingsHeader/SettingsHeader"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; import { Spinner } from "#/components/Spinner/Spinner"; -import { DEFAULT_THEME } from "#/theme"; +import { Section } from "#/pages/UserSettingsPage/Section"; +import type { ConcreteThemeName } from "#/theme"; import { DEFAULT_TERMINAL_FONT, terminalFontLabels, terminalFonts, } from "#/theme/constants"; -import { cn } from "#/utils/cn"; +import { + draftFromState, + draftToUpdate, + migrateLegacyPreference, + switchToSingle, + type ThemeModeDraft, +} from "#/theme/themeMode"; +import { SingleModeSection } from "./SingleModeSection"; +import { SyncModeSection } from "./SyncModeSection"; // Display Geist Mono (the default monospace font) first, then the rest // alphabetically. TerminalFontNames is auto-generated in alphabetical @@ -29,11 +41,19 @@ const sortedTerminalFontNames = [ ...TerminalFontNames.filter((name) => name !== "" && name !== "geist-mono"), ]; +interface AppearanceFormValues { + draft: ThemeModeDraft; + terminalFont: TerminalFontName; +} + +type AppearanceThemeMode = ThemeModeDraft["mode"]; + interface AppearanceFormProps { isUpdating?: boolean; error?: unknown; - initialValues: UpdateUserAppearanceSettingsRequest; - onSubmit: (values: UpdateUserAppearanceSettingsRequest) => Promise; + initialValues: UserAppearanceSettings; + activeScheme: "dark" | "light"; // The OS color scheme currently in effect + onSubmit: (values: UpdateUserAppearanceSettingsRequest) => void; } export const AppearanceForm: FC = ({ @@ -41,92 +61,180 @@ export const AppearanceForm: FC = ({ error, onSubmit, initialValues, + activeScheme, }) => { - const currentTheme = initialValues.theme_preference || DEFAULT_THEME; - const currentTerminalFont = - initialValues.terminal_font || DEFAULT_TERMINAL_FONT; + const [values, setValues] = useState(() => toFormValues(initialValues)); + const themeModeId = useId(); + const fontGroupId = useId(); + const fontGroupLabelId = `${fontGroupId}-label`; + const fontGroupName = `${fontGroupId}-fonts`; + const singleThemeGroupName = `${themeModeId}-single`; + const syncThemeGroupNamePrefix = `${themeModeId}-sync`; - const onChangeTheme = async (theme: string) => { + const { + theme_preference, + theme_mode, + theme_light, + theme_dark, + terminal_font, + } = initialValues; + + useEffect(() => { if (isUpdating) { return; } - await onSubmit({ - theme_preference: theme, - theme_mode: "", - theme_light: "", - theme_dark: "", - terminal_font: currentTerminalFont, + const next = toFormValues({ + theme_preference, + theme_mode, + theme_light, + theme_dark, + terminal_font, + }); + setValues(next); + }, [ + isUpdating, + theme_preference, + theme_mode, + theme_light, + theme_dark, + terminal_font, + ]); + + const { draft, terminalFont } = values; + + const submit = (next: AppearanceFormValues) => { + setValues(next); + onSubmit(toUpdateRequest(next, activeScheme)); + }; + + const onChangeMode = (mode: AppearanceThemeMode) => { + if (mode === draft.mode) { + return; + } + const next: ThemeModeDraft = + mode === "single" + ? { + mode: "single", + single: switchToSingle( + { mode: "sync", light: draft.light, dark: draft.dark }, + activeScheme, + ).theme, + light: draft.light, + dark: draft.dark, + } + : { + mode: "sync", + single: draft.single, + light: draft.light, + dark: draft.dark, + }; + submit({ draft: next, terminalFont }); + }; + + const onSelectSyncSlot = ( + scheme: "light" | "dark", + theme: ConcreteThemeName, + ) => { + const next: ThemeModeDraft = + scheme === "light" + ? { ...draft, light: theme } + : { ...draft, dark: theme }; + submit({ draft: next, terminalFont }); + }; + + const onSelectSingle = (theme: ConcreteThemeName) => { + submit({ + draft: { ...draft, single: theme, mode: "single" }, + terminalFont, }); }; - const onChangeTerminalFont = async (terminalFont: TerminalFontName) => { - if (isUpdating) { - return; - } - await onSubmit({ - theme_preference: currentTheme, - theme_mode: "", - theme_light: "", - theme_dark: "", - terminal_font: terminalFont, - }); + const onChangeTerminalFont = (nextTerminalFont: TerminalFontName) => { + submit({ draft, terminalFont: nextTerminalFont }); }; return ( -
+ event.preventDefault()}> {Boolean(error) && } -
- - +
Theme - - +
+ } + layout="fluid" + className="mb-12" + > +
+
+ +
+ +
+
-
- onChangeTheme("auto")} - /> - onChangeTheme("dark")} - /> - onChangeTheme("light")} - /> + {draft.mode === "sync" ? ( + + ) : ( + + )}
-
+ -
- - - Terminal Font +
+ Terminal Font - - - +
+ } + layout="fluid" + > onChangeTerminalFont(toTerminalFontName(value)) } > {sortedTerminalFontNames.map((name) => (
- +
))}
- + ); }; +function toFormValues(settings: UserAppearanceSettings): AppearanceFormValues { + return { + draft: draftFromState(migrateLegacyPreference(settings), { + light: settings.theme_light, + dark: settings.theme_dark, + }), + terminalFont: settings.terminal_font || DEFAULT_TERMINAL_FONT, + }; +} + +function toUpdateRequest( + values: AppearanceFormValues, + activeScheme: "dark" | "light", +): UpdateUserAppearanceSettingsRequest { + return draftToUpdate(values.draft, values.terminalFont, activeScheme); +} + +function isThemeMode(value: string): value is AppearanceThemeMode { + return value === "sync" || value === "single"; +} + function toTerminalFontName(value: string): TerminalFontName { return TerminalFontNames.includes(value as TerminalFontName) ? (value as TerminalFontName) : ""; } - -type ThemeMode = "dark" | "light"; - -interface AutoThemePreviewButtonProps extends Omit { - themes: [ThemeMode, ThemeMode]; - onSelect?: () => void; -} - -const AutoThemePreviewButton: FC = ({ - active, - preview, - className, - displayName, - themes, - onSelect, -}) => { - const [leftTheme, rightTheme] = themes; - - return ( - <> - - - - ); -}; - -interface ThemePreviewButtonProps extends ThemePreviewProps { - onSelect?: () => void; -} - -const ThemePreviewButton: FC = ({ - active, - preview, - className, - displayName, - theme, - onSelect, -}) => { - return ( - <> - - - - ); -}; - -interface ThemePreviewProps { - active?: boolean; - preview?: boolean; - className?: string; - style?: React.CSSProperties; - displayName: string; - theme: ThemeMode; -} - -const ThemePreview: FC = ({ - active, - preview, - className, - style, - displayName, - theme, -}) => { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {displayName} - {preview && } -
-
-
- ); -}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx index 92114ed306..f987d0e67e 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx @@ -1,127 +1,60 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { API } from "#/api/api"; -import { MockUserOwner } from "#/testHelpers/entities"; -import { renderWithAuth } from "#/testHelpers/renderHelpers"; -import AppearancePage from "./AppearancePage"; +import { act, renderHook } from "@testing-library/react"; +import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated"; +import { useQueuedAppearanceSubmit } from "./AppearancePage"; -describe("appearance page", () => { - it("does nothing when selecting current theme", async () => { - renderWithAuth(); +const updateRequest = ( + overrides: Partial = {}, +): UpdateUserAppearanceSettingsRequest => ({ + theme_preference: "dark", + theme_mode: "single", + theme_light: "light", + theme_dark: "dark", + terminal_font: "geist-mono", + ...overrides, +}); - vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({ - ...MockUserOwner, - theme_preference: "dark", - theme_mode: "single", - theme_light: "light", - theme_dark: "dark", - terminal_font: "fira-code", +describe("useQueuedAppearanceSubmit", () => { + it("submits one request at a time and keeps only the latest pending update", () => { + const mutations: Array<{ + values: UpdateUserAppearanceSettingsRequest; + onSettled: () => void; + }> = []; + const mutate = vi.fn( + ( + values: UpdateUserAppearanceSettingsRequest, + options: { onSettled: () => void }, + ) => { + mutations.push({ values, onSettled: options.onSettled }); + }, + ); + const { result } = renderHook(() => useQueuedAppearanceSubmit(mutate)); + + const first = updateRequest({ terminal_font: "fira-code" }); + const overwritten = updateRequest({ theme_preference: "dark-tritan" }); + const latest = updateRequest({ theme_preference: "light" }); + + act(() => { + result.current(first); + result.current(overwritten); + result.current(latest); }); - const dark = await screen.findByText("Dark"); - await userEvent.click(dark); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutations[0]?.values).toEqual(first); - // Check if the API was called correctly - expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(0); - }); - - it("changes theme to light", async () => { - renderWithAuth(); - - vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({ - ...MockUserOwner, - terminal_font: "geist-mono", - theme_preference: "light", - theme_mode: "single", - theme_light: "light", - theme_dark: "dark", + act(() => { + mutations[0]?.onSettled(); }); - const light = await screen.findByText("Light"); - await userEvent.click(light); + expect(mutate).toHaveBeenCalledTimes(2); + expect(mutations[1]?.values).toEqual(latest); - // Check if the API was called correctly - expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith( - expect.objectContaining({ - terminal_font: "geist-mono", - theme_preference: "light", - }), - ); - }); - - it("changes font to fira code", async () => { - renderWithAuth(); - - vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({ - ...MockUserOwner, - terminal_font: "fira-code", - theme_preference: "dark", - theme_mode: "single", - theme_light: "light", - theme_dark: "dark", + act(() => { + mutations[1]?.onSettled(); + result.current(overwritten); }); - const firaCode = await screen.findByText("Fira Code"); - await userEvent.click(firaCode); - - // Check if the API was called correctly - expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith( - expect.objectContaining({ - terminal_font: "fira-code", - theme_preference: "dark", - }), - ); - }); - - it("changes font to fira code, then back to geist mono", async () => { - renderWithAuth(); - - // given - vi.spyOn(API, "updateAppearanceSettings") - .mockResolvedValueOnce({ - ...MockUserOwner, - terminal_font: "fira-code", - theme_preference: "dark", - theme_mode: "single", - theme_light: "light", - theme_dark: "dark", - }) - .mockResolvedValueOnce({ - ...MockUserOwner, - terminal_font: "geist-mono", - theme_preference: "dark", - theme_mode: "single", - theme_light: "light", - theme_dark: "dark", - }); - - // when - const firaCode = await screen.findByText("Fira Code"); - await userEvent.click(firaCode); - - // then - expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1); - expect(API.updateAppearanceSettings).toHaveBeenCalledWith( - expect.objectContaining({ - terminal_font: "fira-code", - theme_preference: "dark", - }), - ); - - // when - const geistMono = await screen.findByText("Geist Mono"); - await userEvent.click(geistMono); - - // then - expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(2); - expect(API.updateAppearanceSettings).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - terminal_font: "geist-mono", - theme_preference: "dark", - }), - ); + expect(mutate).toHaveBeenCalledTimes(3); + expect(mutations[2]?.values).toEqual(overwritten); }); }); diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx index d84cc8ff91..1c0b48f68b 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearancePage.tsx @@ -1,14 +1,52 @@ -import type { FC } from "react"; +import { type FC, useRef } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { appearanceSettings, updateAppearanceSettings, } from "#/api/queries/users"; +import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Loader } from "#/components/Loader/Loader"; import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata"; +import { usePreferredColorScheme } from "#/theme/usePreferredColorScheme"; import { AppearanceForm } from "./AppearanceForm"; +type MutateAppearanceSettings = ( + values: UpdateUserAppearanceSettingsRequest, + options: { onSettled: () => void }, +) => void; + +export const useQueuedAppearanceSubmit = (mutate: MutateAppearanceSettings) => { + const submitInFlightRef = useRef(false); + const pendingSubmitRef = useRef( + null, + ); + + const fireSubmit = (values: UpdateUserAppearanceSettingsRequest) => { + submitInFlightRef.current = true; + mutate(values, { + onSettled: () => { + const queued = pendingSubmitRef.current; + pendingSubmitRef.current = null; + if (queued !== null) { + fireSubmit(queued); + return; + } + submitInFlightRef.current = false; + }, + }); + }; + + return (values: UpdateUserAppearanceSettingsRequest) => { + if (submitInFlightRef.current) { + pendingSubmitRef.current = values; + return; + } + + fireSubmit(values); + }; +}; + const AppearancePage: FC = () => { const queryClient = useQueryClient(); const updateAppearanceSettingsMutation = useMutation( @@ -19,6 +57,14 @@ const AppearancePage: FC = () => { const appearanceSettingsQuery = useQuery( appearanceSettings(metadata.userAppearance), ); + const osColorScheme = usePreferredColorScheme(); + const submitAppearanceSettings = useQueuedAppearanceSubmit( + (values, options) => { + updateAppearanceSettingsMutation.mutate(values, { + onSettled: options.onSettled, + }); + }, + ); if (appearanceSettingsQuery.isLoading) { return ; @@ -32,14 +78,9 @@ const AppearancePage: FC = () => { ); }; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx b/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx index 8595584b92..d31271278f 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx @@ -6,6 +6,7 @@ import { DARK_THEMES, LIGHT_THEMES, THEME_COPY } from "./themeCopy"; interface SingleModeSectionProps { selected: ConcreteThemeName; + name?: string; onSelect: (theme: ConcreteThemeName) => void; } @@ -16,6 +17,7 @@ const SINGLE_MODE_ORDER: ConcreteThemeName[] = [ export const SingleModeSection: FC = ({ selected, + name = "theme-single", onSelect, }) => { return ( @@ -25,6 +27,7 @@ export const SingleModeSection: FC = ({ {SINGLE_MODE_ORDER.map((theme) => ( onSelect(theme)} @@ -36,12 +39,18 @@ export const SingleModeSection: FC = ({ }; interface SingleTileProps { + name: string; theme: ConcreteThemeName; selected: boolean; onSelect: () => void; } -const SingleTile: FC = ({ theme, selected, onSelect }) => { +const SingleTile: FC = ({ + name, + theme, + selected, + onSelect, +}) => { const copy = THEME_COPY[theme]; return (
@@ -44,6 +48,7 @@ interface SyncCardProps { scheme: "light" | "dark"; selected: ConcreteThemeName; active: boolean; + name: string; onSelect: (theme: ConcreteThemeName) => void; } @@ -51,6 +56,7 @@ const SyncCard: FC = ({ scheme, selected, active, + name, onSelect, }) => { const [previewTheme, setPreviewTheme] = useState< @@ -94,7 +100,7 @@ const SyncCard: FC = ({ {SYNC_MODE_THEMES.map((theme) => ( onSelect(theme)}