feat(manage): consolidate user general settings form

This commit is contained in:
Thomas Camlong committed 2026-01-25 16:00:21 +01:00
1 parent d8eaef859a
commit d99b20d3ef
2 files changed
+470 -55

No files matched your search

@@ -0,0 +1,451 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Card, Divider, Group, Radio, Select, SimpleGrid, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
import { z } from "zod/v4";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
dayjs.extend(localeData);
const anchorSelector = "a[href]:not([target='_blank'])";
const userGeneralSettingsSchema = z.object({
name: userEditProfileSchema.shape.name,
email: userEditProfileSchema.shape.email,
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean;
}
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
name: user.name ?? "",
email: user.email ?? "",
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
});
const valuesEqual = (a: unknown, b: unknown) => a === b;
export const UserGeneralSettingsForm = ({
user,
boardsData,
searchEnginesData,
showLanguageSelector = false,
}: UserGeneralSettingsFormProps) => {
const t = useI18n();
const tGeneral = useScopedI18n("management.page.user.setting.general");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
const isCredentialsUser = user.provider === "credentials";
const [savedValues, setSavedValues] = useState<FormValues>(() => buildInitialValues(user));
const form = useZodForm(userGeneralSettingsSchema, { initialValues: savedValues });
const allowLeaveRef = useRef(false);
const confirmOpenRef = useRef(false);
const editProfileMutation = clientApi.user.editProfile.useMutation();
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const isPending =
editProfileMutation.isPending ||
changeHomeBoardsMutation.isPending ||
changeSearchPreferencesMutation.isPending ||
changeFirstDayOfWeekMutation.isPending ||
changePingIconsEnabledMutation.isPending;
const weekDays = useMemo(() => dayjs.weekdays(false), []);
useEffect(() => {
if (!form.isDirty() || allowLeaveRef.current) return;
const handleClick = (event: Event) => {
if (allowLeaveRef.current) return;
const target = (event.target as HTMLElement).closest("a");
if (!target) return;
event.preventDefault();
if (confirmOpenRef.current) return;
confirmOpenRef.current = true;
openConfirmModal({
title: t("common.unsavedChanges"),
children: t("common.unsavedChanges"),
confirmProps: {
children: t("common.action.discard"),
},
onCancel() {
confirmOpenRef.current = false;
},
onConfirm() {
allowLeaveRef.current = true;
confirmOpenRef.current = false;
router.push(target.href);
},
});
};
const handlePopState = (event: Event) => {
if (allowLeaveRef.current) return;
// Keep the user on this page and ask for confirmation.
window.history.pushState(null, document.title, window.location.href);
event.preventDefault();
if (confirmOpenRef.current) return;
confirmOpenRef.current = true;
openConfirmModal({
title: t("common.unsavedChanges"),
children: t("common.unsavedChanges"),
confirmProps: {
children: t("common.action.discard"),
},
onCancel() {
confirmOpenRef.current = false;
},
onConfirm() {
allowLeaveRef.current = true;
confirmOpenRef.current = false;
window.history.back();
},
});
};
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault();
event.returnValue = true;
};
// Add a history entry so back triggers popstate for this page first.
window.history.pushState(null, document.title, window.location.href);
const anchors = document.querySelectorAll(anchorSelector);
anchors.forEach((link) => link.addEventListener("click", handleClick));
window.addEventListener("popstate", handlePopState);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
anchors.forEach((link) => link.removeEventListener("click", handleClick));
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [form, openConfirmModal, router, t]);
const handleSave = useCallback(async () => {
const values = form.getValues() as FormValues;
const tasks: Array<{ key: keyof FormValues | "profile"; run: () => Promise<unknown> }> = [];
const profileChanged =
!valuesEqual(values.name, savedValues.name) || !valuesEqual(values.email ?? "", savedValues.email ?? "");
if (isCredentialsUser && profileChanged) {
tasks.push({
key: "profile",
run: () =>
editProfileMutation.mutateAsync({
id: user.id,
name: values.name,
email: values.email ?? "",
}),
});
}
const boardsChanged =
!valuesEqual(values.homeBoardId, savedValues.homeBoardId) ||
!valuesEqual(values.mobileHomeBoardId, savedValues.mobileHomeBoardId);
if (boardsChanged) {
tasks.push({
key: "homeBoardId",
run: () =>
changeHomeBoardsMutation.mutateAsync({
userId: user.id,
homeBoardId: values.homeBoardId,
mobileHomeBoardId: values.mobileHomeBoardId,
}),
});
}
const searchChanged =
!valuesEqual(values.defaultSearchEngineId, savedValues.defaultSearchEngineId) ||
!valuesEqual(values.openInNewTab, savedValues.openInNewTab);
if (searchChanged) {
tasks.push({
key: "defaultSearchEngineId",
run: () =>
changeSearchPreferencesMutation.mutateAsync({
userId: user.id,
defaultSearchEngineId: values.defaultSearchEngineId,
openInNewTab: values.openInNewTab,
}),
});
}
const firstDayChanged = !valuesEqual(values.firstDayOfWeek, savedValues.firstDayOfWeek);
if (firstDayChanged) {
tasks.push({
key: "firstDayOfWeek",
run: () =>
changeFirstDayOfWeekMutation.mutateAsync({
id: user.id,
firstDayOfWeek: values.firstDayOfWeek,
}),
});
}
const pingChanged = !valuesEqual(values.pingIconsEnabled, savedValues.pingIconsEnabled);
if (pingChanged) {
tasks.push({
key: "pingIconsEnabled",
run: () =>
changePingIconsEnabledMutation.mutateAsync({
id: user.id,
pingIconsEnabled: values.pingIconsEnabled,
}),
});
}
if (tasks.length === 0) return;
const results = await Promise.allSettled(tasks.map((t) => t.run()));
const hasError = results.some((r) => r.status === "rejected");
const nextSaved: FormValues = {
...savedValues,
};
if (!hasError) {
nextSaved.name = values.name;
nextSaved.email = values.email ?? "";
nextSaved.homeBoardId = values.homeBoardId;
nextSaved.mobileHomeBoardId = values.mobileHomeBoardId;
nextSaved.defaultSearchEngineId = values.defaultSearchEngineId;
nextSaved.openInNewTab = values.openInNewTab;
nextSaved.firstDayOfWeek = values.firstDayOfWeek;
nextSaved.pingIconsEnabled = values.pingIconsEnabled;
} else {
results.forEach((r, index) => {
if (r.status === "rejected") return;
const task = tasks[index];
if (!task) return;
if (task.key === "profile") {
nextSaved.name = values.name;
nextSaved.email = values.email ?? "";
}
if (task.key === "homeBoardId") {
nextSaved.homeBoardId = values.homeBoardId;
nextSaved.mobileHomeBoardId = values.mobileHomeBoardId;
}
if (task.key === "defaultSearchEngineId") {
nextSaved.defaultSearchEngineId = values.defaultSearchEngineId;
nextSaved.openInNewTab = values.openInNewTab;
}
if (task.key === "firstDayOfWeek") {
nextSaved.firstDayOfWeek = values.firstDayOfWeek;
}
if (task.key === "pingIconsEnabled") {
nextSaved.pingIconsEnabled = values.pingIconsEnabled;
}
});
}
setSavedValues(nextSaved);
form.setInitialValues(nextSaved);
await revalidatePathActionAsync(`/manage/users/${user.id}`);
if (hasError) {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
return;
}
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
}, [
changeFirstDayOfWeekMutation,
changeHomeBoardsMutation,
changePingIconsEnabledMutation,
changeSearchPreferencesMutation,
editProfileMutation,
form,
isCredentialsUser,
savedValues,
t,
user.id,
]);
const handleDiscard = useCallback(() => {
form.reset();
}, [form]);
const firstDayInputProps = form.getInputProps("firstDayOfWeek");
const onFirstDayChange = firstDayInputProps.onChange as (value: number) => void;
const firstDayValue = (firstDayInputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(() => handleSave())}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder>
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{t("user.name")}</Title>
<Text c="dimmed" size="sm">
{t("user.field.username.label")} · {t("user.field.email.label")}
</Text>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{t("management.page.user.fieldsDisabledExternalProvider")}
</Text>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput disabled={!isCredentialsUser} label={t("user.field.email.label")} {...form.getInputProps("email")} />
</Stack>
</Card>
<Card withBorder>
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder>
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch label={t("user.field.openSearchInNewTab.label")} {...form.getInputProps("openInNewTab", { type: "checkbox" })} />
</Stack>
</Card>
<Card withBorder>
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group {...firstDayInputProps} value={firstDayValue} onChange={(value) => onFirstDayChange(parseInt(value))}>
<Group mt="xs" wrap="wrap">
<Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} />
<Radio value="0" label={weekDays[0]} />
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit">
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</div>
</Stack>
</form>
);
};
@@ -1,23 +1,17 @@
import { notFound } from "next/navigation";
import { Alert, Box, Group, Stack, Title } from "@mantine/core";
import { IconExclamationCircle } from "@tabler/icons-react";
import { Box, Card, Group, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
interface Props {
params: Promise<{
@@ -63,61 +57,31 @@ export default async function EditUserPage(props: Props) {
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isCredentialsUser = user.provider === "credentials";
const isSelf = session?.user.id === user.id;
return (
<Stack>
{!isCredentialsUser && (
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
{t("management.page.user.fieldsDisabledExternalProvider")}
</Alert>
)}
<Title>{tGeneral("title")}</Title>
<Group gap="xl">
<Box flex={1}>
<UserProfileForm user={user} />
<Group gap="xl" align="flex-start" wrap="wrap">
<Box flex={1} miw={{ base: "100%", md: 540 }}>
<UserGeneralSettingsForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
searchEnginesData={searchEngines}
showLanguageSelector={isSelf}
/>
</Box>
<Box w={{ base: "100%", lg: 200 }}>
<UserProfileAvatarForm user={user} />
<Box w={{ base: "100%", lg: 260 }}>
<Card withBorder>
<UserProfileAvatarForm user={user} />
</Card>
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
<ChangeHomeBoardForm
user={user}
boardsData={boards.map((board) => ({
id: board.id,
name: board.name,
logoImageUrl: board.logoImageUrl,
}))}
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
</Stack>
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}