Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions e2e/preferences.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Page } from "@playwright/test";

import { expect, test } from "./fixtures";

const dropClientCachesKeepingSession = (page: Page) =>
page.evaluate(() => {
const all = Object.keys(localStorage);
if (!all.some((key) => key.startsWith("RaStore"))) {
throw new Error(
`Expected ra-core store keys in localStorage, found: ${all.join(", ")}`,
);
}
all
.filter((key) => !key.startsWith("sb-"))
.forEach((key) => localStorage.removeItem(key));
});

const savedPreferences = (page: Page) =>
page.waitForResponse(
(response) =>
response.url().includes("/rest/v1/sales") &&
response.request().method() === "PATCH" &&
response.ok(),
);

const openLanguageSetting = async (page: Page, isMobile: boolean) => {
if (isMobile) {
await page.getByRole("link", { name: /^(Settings|Paramètres)$/ }).click();
} else {
await page.getByRole("button", { name: /^Profil/ }).click();
await page.getByRole("menuitem", { name: /^Profil/ }).click();
}
};

test.describe("user preferences", () => {
test.beforeEach(async ({ createSales }) => {
await createSales({
first_name: "John",
last_name: "Doe",
email: "john@doe.com",
password: "password",
});
});

test("theme and language survive a reload", async ({ page, isMobile }) => {
await page.goto("/");
await page.getByLabel("Email").fill("john@doe.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveTitle(/Atomic CRM/);

const themeSaved = savedPreferences(page);
if (isMobile) {
await openLanguageSetting(page, isMobile);
await page.getByRole("radio", { name: "Dark" }).click();
} else {
await page.getByRole("button", { name: "Toggle theme" }).click();
await page.getByRole("menuitem", { name: "Dark" }).click();
}
await expect(page.locator("html")).toHaveClass(/dark/);
await themeSaved;

if (!isMobile) {
await openLanguageSetting(page, isMobile);
}
const localeSaved = savedPreferences(page);
await page.getByRole("combobox").filter({ hasText: "English" }).click();
await page.getByRole("option", { name: "Français" }).click();
await expect(page.getByText("Langue")).toBeVisible();
await localeSaved;

await dropClientCachesKeepingSession(page);
await page.goto("/");

await expect(page.locator("html")).toHaveClass(/dark/);
await openLanguageSetting(page, isMobile);
await expect(page.getByText("Langue")).toBeVisible();
});
});
16 changes: 16 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,22 @@
"path": "src/components/atomic-crm/sales/SalesCreate.tsx",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/root/usePreferencesLoader.ts",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/root/usePersistPreference.ts",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/root/useConfigurationLoader.ts",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/root/preferences.ts",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/root/defaultConfiguration.ts",
"type": "registry:component"
Expand Down Expand Up @@ -481,6 +493,10 @@
"path": "src/components/atomic-crm/misc/ActiveFilterButton.tsx",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/login/authConfig.ts",
"type": "registry:component"
},
{
"path": "src/components/atomic-crm/login/StartPage.tsx",
"type": "registry:component"
Expand Down
3 changes: 3 additions & 0 deletions src/components/admin/locales-menu-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { usePersistPreference } from "@/components/atomic-crm/root/usePersistPreference";
import { useLocales, useLocaleState } from "ra-core";

/**
Expand All @@ -22,6 +23,7 @@ import { useLocales, useLocaleState } from "ra-core";
export function LocalesMenuButton() {
const languages = useLocales();
const [locale, setLocale] = useLocaleState();
const persist = usePersistPreference();

const getNameForLocale = (locale: string): string => {
const language = languages.find((language) => language.locale === locale);
Expand All @@ -30,6 +32,7 @@ export function LocalesMenuButton() {

const changeLocale = (locale: string) => (): void => {
setLocale(locale);
persist({ locale });
};

if (languages.length <= 1) {
Expand Down
14 changes: 11 additions & 3 deletions src/components/admin/theme-mode-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { Theme } from "@/components/admin/theme-context";
import { useTheme } from "@/components/admin/use-theme";
import { usePersistPreference } from "@/components/atomic-crm/root/usePersistPreference";

/**
* Toggle button that lets users switch between light, dark, and system UI themes.
Expand All @@ -19,6 +21,12 @@ import { useTheme } from "@/components/admin/use-theme";
*/
export function ThemeModeToggle() {
const { theme, setTheme } = useTheme();
const persist = usePersistPreference();

const handleSetTheme = (value: Theme) => {
setTheme(value);
persist({ theme: value });
};

return (
<DropdownMenu modal={false}>
Expand All @@ -30,15 +38,15 @@ export function ThemeModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<DropdownMenuItem onClick={() => handleSetTheme("light")}>
Light
<Check className={cn("ml-auto", theme !== "light" && "hidden")} />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<DropdownMenuItem onClick={() => handleSetTheme("dark")}>
Dark
<Check className={cn("ml-auto", theme !== "dark" && "hidden")} />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<DropdownMenuItem onClick={() => handleSetTheme("system")}>
System
<Check className={cn("ml-auto", theme !== "system" && "hidden")} />
</DropdownMenuItem>
Expand Down
3 changes: 3 additions & 0 deletions src/components/admin/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useGetIdentity,
useLogout,
UserMenuContext,
useTranslate,
} from "ra-core";
import { LogOut } from "lucide-react";
import {
Expand Down Expand Up @@ -34,6 +35,7 @@ export type UserMenuProps = {
export function UserMenu({ children }: UserMenuProps) {
const authProvider = useAuthProvider();
const { data: identity } = useGetIdentity();
const translate = useTranslate();
const logout = useLogout();

const [open, setOpen] = useState(false);
Expand All @@ -55,6 +57,7 @@ export function UserMenu({ children }: UserMenuProps) {
<Button
variant="ghost"
className="relative h-8 w-8 ml-2 rounded-full"
aria-label={translate("ra.auth.user_menu")}
>
<Avatar className="h-8 w-8">
<AvatarImage src={identity?.avatar} role="presentation" />
Expand Down
2 changes: 2 additions & 0 deletions src/components/atomic-crm/layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { Error } from "@/components/admin/error";
import { Skeleton } from "@/components/ui/skeleton";

import { useConfigurationLoader } from "../root/useConfigurationLoader";
import { usePreferencesLoader } from "../root/usePreferencesLoader";
import Header from "./Header";

export const Layout = ({ children }: { children: ReactNode }) => {
useConfigurationLoader();
usePreferencesLoader();
return (
<>
<Header />
Expand Down
2 changes: 2 additions & 0 deletions src/components/atomic-crm/layout/MobileLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { Suspense, type ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";

import { useConfigurationLoader } from "../root/useConfigurationLoader";
import { usePreferencesLoader } from "../root/usePreferencesLoader";
import { MobileNavigation } from "./MobileNavigation";

export const MobileLayout = ({ children }: { children: ReactNode }) => {
useConfigurationLoader();
usePreferencesLoader();
return (
<>
<ErrorBoundary FallbackComponent={Error}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ export const englishCrmMessages = {
},
},
},
preferences: {
update_error: "Could not save your preferences. Please try again",
},
theme: {
dark: "Dark",
label: "Theme",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,9 @@ export const frenchCrmMessages = {
},
},
},
preferences: {
update_error: "Vos préférences n'ont pas pu être enregistrées. Réessayez",
},
theme: {
dark: "Sombre",
label: "Thème",
Expand Down
75 changes: 75 additions & 0 deletions src/components/atomic-crm/providers/fakerest/dataProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createCrmDb } from "@/test/StoryWrapper";
import { DEFAULT_USER, USER_STORAGE_KEY } from "./authProvider";
import { createDataProvider } from "./dataProvider";

const createProvider = () =>
createDataProvider({ db: createCrmDb(), latency: 0, silent: true });

describe("fakerest preferences", () => {
it("round trips the preferences of the logged sale, whose id is 0", async () => {
expect(DEFAULT_USER.id).toBe(0);
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(DEFAULT_USER));
const dataProvider = createProvider();

expect(await dataProvider.getPreferences()).toEqual({});

await dataProvider.updatePreferences({ theme: "dark" });
expect(await dataProvider.getPreferences()).toEqual({ theme: "dark" });

await dataProvider.updatePreferences({ locale: "fr" });
expect(await dataProvider.getPreferences()).toEqual({
theme: "dark",
locale: "fr",
});
});

it("keeps keys it does not know about when writing", async () => {
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(DEFAULT_USER));
const db = structuredClone(createCrmDb());
db.sales[0].preferences = {
locale: "fr",
writtenByAnotherVersion: "keep me",
} as never;
const dataProvider = createDataProvider({ db, latency: 0, silent: true });

await dataProvider.updatePreferences({ theme: "dark" });

const { data } = await dataProvider.getOne("sales", {
id: DEFAULT_USER.id,
});
expect(data.preferences).toEqual({
theme: "dark",
locale: "fr",
writtenByAnotherVersion: "keep me",
});
expect(await dataProvider.getPreferences()).toEqual({
theme: "dark",
locale: "fr",
});
});

it("refuses to write, and touches no sale, when nobody is logged in", async () => {
localStorage.removeItem(USER_STORAGE_KEY);
const dataProvider = createProvider();

expect(await dataProvider.getPreferences()).toEqual({});

await expect(
dataProvider.updatePreferences({ theme: "dark" }),
).rejects.toThrow();

localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(DEFAULT_USER));
expect(await dataProvider.getPreferences()).toEqual({});
});

it("returns a validated value, so a corrupt stored theme cannot reach the UI", async () => {
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(DEFAULT_USER));
const db = structuredClone(createCrmDb());
db.sales[0].preferences = { theme: "a b" } as never;
const dataProvider = createDataProvider({ db, latency: 0, silent: true });

expect(await dataProvider.updatePreferences({ locale: "fr" })).toEqual({
locale: "fr",
});
});
});
45 changes: 45 additions & 0 deletions src/components/atomic-crm/providers/fakerest/dataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import type {
SalesFormData,
SignUpData,
Task,
UserPreferences,
} from "../../types";
import type { ConfigurationContextValue } from "../../root/ConfigurationContext";
import { parseUserPreferences } from "../../root/preferences";
import { getActivityLog } from "../commons/activity";
import { getCompanyAvatar } from "../commons/getCompanyAvatar";
import { getContactAvatar } from "../commons/getContactAvatar";
Expand All @@ -33,6 +35,16 @@ import generateData from "./dataGenerator";
import type { Db } from "./dataGenerator/types";
import { withSupabaseFilterAdapter } from "./internal/supabaseAdapter";

const getLoggedSaleId = (): Identifier | undefined => {
const item = localStorage.getItem(USER_STORAGE_KEY);
if (!item) return undefined;
try {
return (JSON.parse(item) as Sale).id;
} catch {
return undefined;
}
};

const TASK_MARKED_AS_DONE = "TASK_MARKED_AS_DONE";
const TASK_MARKED_AS_UNDONE = "TASK_MARKED_AS_UNDONE";
const TASK_DONE_NOT_CHANGED = "TASK_DONE_NOT_CHANGED";
Expand Down Expand Up @@ -316,6 +328,39 @@ export const createDataProvider = ({
});
return config;
},
getPreferences: async (): Promise<UserPreferences> => {
const saleId = getLoggedSaleId();
if (saleId === undefined) return {};
const { data } = await dataProvider.getOne<Sale>("sales", {
id: saleId,
});
return parseUserPreferences(data?.preferences);
},
updatePreferences: async (
patch: Partial<UserPreferences>,
): Promise<UserPreferences> => {
const saleId = getLoggedSaleId();
if (saleId === undefined) {
throw new Error("Cannot save preferences without a logged in user");
}
const { data: sale } = await dataProvider.getOne<Sale>("sales", {
id: saleId,
});
if (!sale) {
throw new Error("Failed to update preferences");
}
const stored =
typeof sale.preferences === "object" && sale.preferences !== null
? sale.preferences
: {};
const preferences = { ...stored, ...patch };
await dataProvider.update("sales", {
id: saleId,
data: { preferences },
previousData: sale,
});
return parseUserPreferences(preferences);
},
};

const dataProvider = withLifecycleCallbacks(
Expand Down
Loading
Loading