diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx index bb18a1a74..78aa058b5 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_general-settings-form.tsx @@ -5,12 +5,12 @@ import { Button, Card, Divider, Group, Radio, Select, SimpleGrid, Stack, Switch, import type { DayOfWeek } from "@mantine/dates"; import dayjs from "dayjs"; import localeData from "dayjs/plugin/localeData"; -import { useForm } from "@tanstack/react-form"; 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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { @@ -47,17 +47,6 @@ const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [ { value: 0, labelKey: 0 }, ]; -const getErrorMessage = (errors: unknown[]): string | undefined => { - const messages = errors - .map((err) => { - if (typeof err === "string") return err; - if (err && typeof err === "object" && "message" in err) return String((err as { message: unknown }).message); - return null; - }) - .filter(Boolean); - return messages.length > 0 ? messages.join(", ") : undefined; -}; - interface UserGeneralSettingsFormProps { user: RouterOutputs["user"]["getById"]; boardsData: Pick[]; @@ -92,118 +81,90 @@ export const UserGeneralSettingsForm = ({ const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation(); const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation(); - const hasChangesRef = useRef(false); + const initialValues = buildInitialValues(user); + const initialValuesRef = useRef(initialValues); - const form = useForm({ - defaultValues: buildInitialValues(user), - validators: { - onChange: userGeneralSettingsSchema, - }, - onSubmit: async ({ value, formApi }) => { - const parsed = userGeneralSettingsSchema.safeParse(value); - if (!parsed.success) return; - - const defaults = formApi.options.defaultValues as FormValues | undefined; - if (!defaults) return; - - const hasChanged = (field: K) => - field === "email" - ? (value[field] || null) !== (defaults[field] || null) - : value[field] !== defaults[field]; - - const promises: Promise[] = []; - - const profileChanged = hasChanged("name") || hasChanged("email"); - if (isCredentialsUser && profileChanged) { - promises.push( - editProfileMutation.mutateAsync({ - id: user.id, - name: parsed.data.name, - email: parsed.data.email ?? "", - }), - ); - } - - const boardsChanged = hasChanged("homeBoardId") || hasChanged("mobileHomeBoardId"); - if (boardsChanged) { - promises.push( - changeHomeBoardsMutation.mutateAsync({ - userId: user.id, - homeBoardId: parsed.data.homeBoardId, - mobileHomeBoardId: parsed.data.mobileHomeBoardId, - }), - ); - } - - const searchChanged = hasChanged("defaultSearchEngineId") || hasChanged("openInNewTab"); - if (searchChanged) { - promises.push( - changeSearchPreferencesMutation.mutateAsync({ - userId: user.id, - defaultSearchEngineId: parsed.data.defaultSearchEngineId, - openInNewTab: parsed.data.openInNewTab, - }), - ); - } - - if (hasChanged("firstDayOfWeek")) { - promises.push( - changeFirstDayOfWeekMutation.mutateAsync({ - id: user.id, - firstDayOfWeek: parsed.data.firstDayOfWeek, - }), - ); - } - - if (hasChanged("pingIconsEnabled")) { - promises.push( - changePingIconsEnabledMutation.mutateAsync({ - id: user.id, - pingIconsEnabled: parsed.data.pingIconsEnabled, - }), - ); - } - - if (promises.length === 0) return; - - try { - await Promise.all(promises); - formApi.reset({ ...parsed.data, email: parsed.data.email ?? "" }); - await revalidatePathActionAsync(`/manage/users/${user.id}`); - showSuccessNotification({ - title: t("common.notification.update.success"), - message: t("common.notification.update.success"), - }); - } catch { - showErrorNotification({ - title: t("common.notification.update.error"), - message: t("common.notification.update.error"), - }); - } - }, + const form = useZodForm(userGeneralSettingsSchema, { + initialValues, }); - hasChangesRef.current = !form.state.isDefaultValue; + const isDirtyRef = useRef(false); + isDirtyRef.current = form.isDirty(); const weekDays = useMemo(() => dayjs.weekdays(false), []); + const mutations = [editProfileMutation, changeHomeBoardsMutation, changeSearchPreferencesMutation, changeFirstDayOfWeekMutation, changePingIconsEnabledMutation]; + const isPending = mutations.some((m) => m.isPending); + useEffect(() => { const handler = (e: BeforeUnloadEvent) => { if (env.NODE_ENV === "development") return; - if (hasChangesRef.current) e.preventDefault(); + if (isDirtyRef.current) e.preventDefault(); }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, []); + const handleSubmit = async (values: FormValues) => { + const parsed = userGeneralSettingsSchema.safeParse(values); + if (!parsed.success) return; + + const defaults = initialValuesRef.current; + const changed = (...fields: K[]) => + fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f])); + + const saveActions: { when: boolean; action: () => Promise }[] = [ + { + when: isCredentialsUser && changed("name", "email"), + action: () => editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }), + }, + { + when: changed("homeBoardId", "mobileHomeBoardId"), + action: () => changeHomeBoardsMutation.mutateAsync({ userId: user.id, homeBoardId: parsed.data.homeBoardId, mobileHomeBoardId: parsed.data.mobileHomeBoardId }), + }, + { + when: changed("defaultSearchEngineId", "openInNewTab"), + action: () => changeSearchPreferencesMutation.mutateAsync({ userId: user.id, defaultSearchEngineId: parsed.data.defaultSearchEngineId, openInNewTab: parsed.data.openInNewTab }), + }, + { + when: changed("firstDayOfWeek"), + action: () => changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }), + }, + { + when: changed("pingIconsEnabled"), + action: () => changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }), + }, + ]; + + const promises = saveActions.filter((s) => s.when).map((s) => s.action()); + if (promises.length === 0) return; + + try { + await Promise.all(promises); + const newValues = { ...parsed.data, email: parsed.data.email ?? "" }; + initialValuesRef.current = newValues; + form.setInitialValues(newValues); + form.resetDirty(); + await revalidatePathActionAsync(`/manage/users/${user.id}`); + showSuccessNotification({ + title: t("common.notification.update.success"), + message: t("common.notification.update.success"), + }); + } catch { + showErrorNotification({ + title: t("common.notification.update.error"), + message: t("common.notification.update.error"), + }); + } + }; + + const handleDiscard = () => { + form.setValues(initialValuesRef.current); + form.resetDirty(); + }; + return ( -
{ - e.preventDefault(); - e.stopPropagation(); - void form.handleSubmit(); - }} - > + @@ -217,31 +178,17 @@ export const UserGeneralSettingsForm = ({ )} - - {({ state, handleChange, handleBlur }) => ( - handleChange(e.target.value)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - /> - )} - - - {({ state, handleChange, handleBlur }) => ( - handleChange(e.target.value)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - /> - )} - + + @@ -251,34 +198,20 @@ export const UserGeneralSettingsForm = ({ {tGeneral("item.board.title")} - - {({ state, handleChange, handleBlur }) => ( - - )} - - - {({ state, handleChange, handleBlur }) => ( - - )} - + + @@ -288,30 +221,16 @@ export const UserGeneralSettingsForm = ({ {tGeneral("item.search")} - - {({ state, handleChange, handleBlur }) => ( - + @@ -323,62 +242,40 @@ export const UserGeneralSettingsForm = ({ {showLanguageSelector && } {tGeneral("item.firstDayOfWeek")} - - {({ state, handleChange, handleBlur }) => ( - handleChange(parseInt(value) as DayOfWeek)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - > - - {FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => ( - - ))} - - - )} - + + + {FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => ( + + ))} + + {tGeneral("item.accessibility")} - - {({ state, handleChange, handleBlur }) => ( - handleChange(e.currentTarget.checked)} - onBlur={handleBlur} - error={getErrorMessage(state.meta.errors)} - /> - )} - + - ({ hasChanges: !state.isDefaultValue, isSubmitting: state.isSubmitting, canSubmit: state.canSubmit })} - > - {({ hasChanges, isSubmitting, canSubmit }) => ( -
- {hasChanges && ( - - - {t("common.unsavedChanges")} - - - - - - - )} -
+
+ {form.isDirty() && ( + + + {t("common.unsavedChanges")} + + + + + + )} - +
);