mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-03 13:19:17 +01:00
feat: add i18n translated form errors (#509)
This commit is contained in:
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -24,18 +24,17 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const router = useRouter();
|
||||
const { mutate, isPending } = clientApi.user.register.useMutation();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.registration),
|
||||
const form = useZodForm(validation.user.registration, {
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
const handleSubmit = (
|
||||
values: z.infer<typeof validation.user.registration>,
|
||||
) => {
|
||||
mutate(
|
||||
{
|
||||
...values,
|
||||
@@ -88,5 +87,3 @@ export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.registration>;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -27,15 +27,16 @@ export const LoginForm = () => {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.signIn),
|
||||
const form = useZodForm(validation.user.signIn, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
const handleSubmitAsync = async (
|
||||
values: z.infer<typeof validation.user.signIn>,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
await signIn("credentials", {
|
||||
@@ -92,5 +93,3 @@ export const LoginForm = () => {
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.signIn>;
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
@@ -23,7 +24,7 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
const form = useZodForm(validation.board.savePartialSettings, {
|
||||
initialValues: {
|
||||
backgroundImageUrl: board.backgroundImageUrl ?? "",
|
||||
backgroundImageAttachment: board.backgroundImageAttachment,
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { generateColors } from "../../(content)/_theme";
|
||||
@@ -33,7 +34,7 @@ const hexRegex = /^#[0-9a-fA-F]{6}$/;
|
||||
const progressPercentageLabel = (value: number) => `${value}%`;
|
||||
|
||||
export const ColorSettingsContent = ({ board }: Props) => {
|
||||
const form = useForm({
|
||||
const form = useZodForm(validation.board.savePartialSettings, {
|
||||
initialValues: {
|
||||
primaryColor: board.primaryColor,
|
||||
secondaryColor: board.secondaryColor,
|
||||
@@ -114,15 +115,16 @@ export const ColorSettingsContent = ({ board }: Props) => {
|
||||
};
|
||||
|
||||
interface ColorsPreviewProps {
|
||||
previewColor: string;
|
||||
previewColor: string | undefined;
|
||||
}
|
||||
|
||||
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colors = hexRegex.test(previewColor)
|
||||
? generateColors(previewColor)
|
||||
: generateColors("#000000");
|
||||
const colors =
|
||||
previewColor && hexRegex.test(previewColor)
|
||||
? generateColors(previewColor)
|
||||
: generateColors("#000000");
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
} from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useUpdateBoard } from "../../(content)/_client";
|
||||
@@ -38,20 +39,30 @@ export const GeneralSettingsContent = ({ board }: Props) => {
|
||||
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
const form = useZodForm(
|
||||
validation.board.savePartialSettings
|
||||
.pick({
|
||||
pageTitle: true,
|
||||
logoImageUrl: true,
|
||||
metaTitle: true,
|
||||
faviconImageUrl: true,
|
||||
})
|
||||
.required(),
|
||||
{
|
||||
initialValues: {
|
||||
pageTitle: board.pageTitle ?? "",
|
||||
logoImageUrl: board.logoImageUrl ?? "",
|
||||
metaTitle: board.metaTitle ?? "",
|
||||
faviconImageUrl: board.faviconImageUrl ?? "",
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
},
|
||||
onValuesChange({ pageTitle }) {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
pageTitle,
|
||||
}));
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
|
||||
const faviconStatus = useFaviconPreview(form.values.faviconImageUrl);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { Button, Grid, Group, Input, Slider, Stack } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
@@ -15,11 +16,14 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
||||
const t = useI18n();
|
||||
const { mutate: savePartialSettings, isPending } =
|
||||
useSavePartialSettingsMutation(board);
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
columnCount: board.columnCount,
|
||||
const form = useZodForm(
|
||||
validation.board.savePartialSettings.pick({ columnCount: true }).required(),
|
||||
{
|
||||
initialValues: {
|
||||
columnCount: board.columnCount,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -18,10 +18,7 @@ export const InitUserForm = () => {
|
||||
const t = useScopedI18n("user");
|
||||
const { mutateAsync, error, isPending } =
|
||||
clientApi.user.initUser.useMutation();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.init),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
const form = useZodForm(validation.user.init, {
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
@@ -25,14 +25,13 @@ export const AppForm = (props: AppFormProps) => {
|
||||
props;
|
||||
const t = useI18n();
|
||||
|
||||
const form = useForm({
|
||||
const form = useZodForm(validation.app.manage, {
|
||||
initialValues: initialValues ?? {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
href: "",
|
||||
},
|
||||
validate: zodResolver(validation.app.manage),
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -41,9 +40,7 @@ export const AppForm = (props: AppFormProps) => {
|
||||
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
|
||||
<IconPicker
|
||||
initialValue={initialValues?.iconUrl}
|
||||
onChange={(iconUrl) => {
|
||||
form.setFieldValue("iconUrl", iconUrl);
|
||||
}}
|
||||
{...form.getInputProps("iconUrl")}
|
||||
/>
|
||||
<Textarea {...form.getInputProps("description")} label="Description" />
|
||||
<TextInput {...form.getInputProps("href")} label="URL" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { integrationSecretIcons } from "./_integration-secret-icons";
|
||||
|
||||
interface IntegrationSecretInputProps {
|
||||
withAsterisk?: boolean;
|
||||
label?: string;
|
||||
kind: IntegrationSecretKind;
|
||||
value?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getAllSecretKindOptions,
|
||||
getDefaultSecretKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
@@ -55,9 +55,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
const form = useZodForm(validation.integration.update.omit({ id: true }), {
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(validation.integration.update.omit({ id: true })),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
||||
@@ -103,11 +102,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
} from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -60,9 +60,8 @@ export const NewIntegrationForm = ({
|
||||
initialFormValue: initialFormValues,
|
||||
});
|
||||
const router = useRouter();
|
||||
const form = useForm<FormType>({
|
||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||
initialValues: initialFormValues,
|
||||
validate: zodResolver(validation.integration.create.omit({ kind: true })),
|
||||
onValuesChange,
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
|
||||
@@ -100,11 +99,13 @@ export const NewIntegrationForm = ({
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("integration.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("integration.field.url.label")}
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
@@ -119,6 +120,7 @@ export const NewIntegrationForm = ({
|
||||
)}
|
||||
{form.values.secrets.map(({ kind }, index) => (
|
||||
<IntegrationSecretInput
|
||||
withAsterisk
|
||||
key={kind}
|
||||
kind={kind}
|
||||
{...form.getInputProps(`secrets.${index}.value`)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -38,14 +38,11 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
const form = useZodForm(validation.user.editProfile.omit({ id: true }), {
|
||||
initialValues: {
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
},
|
||||
validate: zodResolver(validation.user.editProfile.omit({ id: true })),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
|
||||
import type { RouterInputs, RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -37,15 +37,13 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm<FormType>({
|
||||
const form = useZodForm(validation.user.changePassword, {
|
||||
initialValues: {
|
||||
previousPassword: "",
|
||||
/* Require previous password if the current user want's to change his password */
|
||||
previousPassword: session?.user.id === user.id ? "" : "_",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validate: zodResolver(validation.user.changePassword),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { IconUserCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||
|
||||
import { StepperNavigationComponent } from "./stepper-navigation.component";
|
||||
|
||||
@@ -40,40 +41,36 @@ export const UserCreateStepperComponent = () => {
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.user.create.useMutation();
|
||||
|
||||
const generalForm = useForm({
|
||||
initialValues: {
|
||||
username: "",
|
||||
email: undefined,
|
||||
const generalForm = useZodForm(
|
||||
z.object({
|
||||
username: z.string().min(1),
|
||||
email: z.string().email().or(z.string().length(0).optional()),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
},
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
username: z.string().min(1),
|
||||
email: z.string().email().or(z.string().length(0).optional()),
|
||||
}),
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
);
|
||||
|
||||
const securityForm = useForm({
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
const securityForm = useZodForm(
|
||||
z
|
||||
.object({
|
||||
password: validation.user.password,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
},
|
||||
validate: zodResolver(
|
||||
z
|
||||
.object({
|
||||
password: validation.user.password,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
}),
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
);
|
||||
|
||||
const allForms = useMemo(
|
||||
() => [generalForm, securityForm],
|
||||
|
||||
@@ -4,12 +4,13 @@ import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
@@ -23,7 +24,7 @@ interface RenameGroupFormProps {
|
||||
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
|
||||
const form = useForm<FormType>({
|
||||
const form = useZodForm(validation.group.update.pick({ name: true }), {
|
||||
initialValues: {
|
||||
name: group.name,
|
||||
},
|
||||
|
||||
@@ -4,13 +4,14 @@ import { useCallback } from "react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
@@ -32,7 +33,7 @@ export const AddGroup = () => {
|
||||
const AddGroupModal = createModal<void>(({ actions }) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
|
||||
const form = useForm({
|
||||
const form = useZodForm(validation.group.create, {
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
interface InnerProps {
|
||||
id: string;
|
||||
@@ -26,7 +27,7 @@ export const BoardRenameModal = createModal<InnerProps>(
|
||||
void utils.board.getDefaultBoard.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useForm<FormType>({
|
||||
const form = useZodForm(validation.board.rename.omit({ id: true }), {
|
||||
initialValues: {
|
||||
name: innerProps.previousName,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -18,7 +19,7 @@ interface InnerProps {
|
||||
export const CategoryEditModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
const form = useZodForm(z.object({ name: z.string().min(1) }), {
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
@@ -15,9 +16,18 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
interface IconPickerProps {
|
||||
initialValue?: string;
|
||||
onChange: (iconUrl: string) => void;
|
||||
error?: string | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
}
|
||||
|
||||
export const IconPicker = ({ initialValue, onChange }: IconPickerProps) => {
|
||||
export const IconPicker = ({
|
||||
initialValue,
|
||||
onChange,
|
||||
error,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useState<string>(initialValue ?? "");
|
||||
const [search, setSearch] = useState(initialValue ?? "");
|
||||
|
||||
@@ -76,13 +86,18 @@ export const IconPicker = ({ initialValue, onChange }: IconPickerProps) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
onFocus={(event) => {
|
||||
onFocus?.(event);
|
||||
combobox.openDropdown();
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setSearch(value || "");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk
|
||||
error={error}
|
||||
label="Icon URL"
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { boardSchemas } from "node_modules/@homarr/validation/src/board";
|
||||
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||
|
||||
interface InnerProps {
|
||||
boardNames: string[];
|
||||
@@ -14,20 +15,21 @@ interface InnerProps {
|
||||
export const AddBoardModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
name: boardSchemas.byName.shape.name.refine(
|
||||
(value) => !innerProps.boardNames.includes(value),
|
||||
{
|
||||
params: createCustomErrorParams("boardAlreadyExists"),
|
||||
},
|
||||
),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
name: boardSchemas.byName.shape.name.refine(
|
||||
(value) => !innerProps.boardNames.includes(value),
|
||||
),
|
||||
}),
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -183,7 +183,9 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
savePartialBoardSettings: protectedProcedure
|
||||
.input(validation.board.savePartialSettings)
|
||||
.input(
|
||||
validation.board.savePartialSettings.and(z.object({ id: z.string() })),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(
|
||||
ctx,
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("initUser should initialize the first user", () => {
|
||||
confirmPassword: "12345679",
|
||||
});
|
||||
|
||||
await expect(actAsync()).rejects.toThrow("Passwords do not match");
|
||||
await expect(actAsync()).rejects.toThrow("passwordsDoNotMatch");
|
||||
});
|
||||
|
||||
it("should not create a user if the password is too short", async () => {
|
||||
|
||||
@@ -255,6 +255,7 @@ const createUserAsync = async (
|
||||
await db.insert(schema.users).values({
|
||||
id: userId,
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@mantine/form": "^7.9.2"
|
||||
"@mantine/form": "^7.9.2",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
export const name = "form";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
import type {
|
||||
AnyZodObject,
|
||||
ZodEffects,
|
||||
ZodIntersection,
|
||||
} from "@homarr/validation";
|
||||
import { zodErrorMap } from "@homarr/validation/form";
|
||||
|
||||
export const useZodForm = <
|
||||
TSchema extends
|
||||
| AnyZodObject
|
||||
| ZodEffects<AnyZodObject>
|
||||
| ZodIntersection<AnyZodObject, AnyZodObject>,
|
||||
>(
|
||||
schema: TSchema,
|
||||
options: Omit<
|
||||
Exclude<Parameters<typeof useForm<z.infer<TSchema>>>[0], undefined>,
|
||||
"validate" | "validateInputOnBlur" | "validateInputOnChange"
|
||||
>,
|
||||
) => {
|
||||
const t = useI18n();
|
||||
|
||||
z.setErrorMap(zodErrorMap(t));
|
||||
return useForm<z.infer<TSchema>>({
|
||||
...options,
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
validate: zodResolver(schema),
|
||||
});
|
||||
};
|
||||
|
||||
110
packages/form/src/messages.ts
Normal file
110
packages/form/src/messages.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import type {
|
||||
ErrorMapCtx,
|
||||
z,
|
||||
ZodTooBigIssue,
|
||||
ZodTooSmallIssue,
|
||||
} from "@homarr/validation";
|
||||
import { ZodIssueCode } from "@homarr/validation";
|
||||
|
||||
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
if (typeof issue.validation === "object") {
|
||||
// Check if object contains startsWith / endsWith key to determine the error type. If not, it's an includes error. (see type of issue.validation)
|
||||
if ("startsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.startsWith",
|
||||
params: {
|
||||
startsWith: issue.validation.startsWith,
|
||||
},
|
||||
} as const;
|
||||
} else if ("endsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.endsWith",
|
||||
params: {
|
||||
endsWith: issue.validation.endsWith,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
key: "errors.invalid_string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTooSmallError = (issue: ZodTooSmallIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooSmall.${issue.type}`,
|
||||
params: {
|
||||
minimum: issue.minimum,
|
||||
count: issue.minimum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleTooBigError = (issue: ZodTooBigIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooBig.${issue.type}`,
|
||||
params: {
|
||||
maximum: issue.maximum,
|
||||
count: issue.maximum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const handleZodError = (
|
||||
issue: z.ZodIssueOptionalMessage,
|
||||
ctx: ErrorMapCtx,
|
||||
) => {
|
||||
if (ctx.defaultError === "Required") {
|
||||
return {
|
||||
key: "errors.required",
|
||||
params: {},
|
||||
} as const;
|
||||
}
|
||||
if (issue.code === ZodIssueCode.invalid_string) {
|
||||
return handleStringError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_small) {
|
||||
return handleTooSmallError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_big) {
|
||||
return handleTooBigError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
||||
const { i18n } = issue.params as CustomErrorParams;
|
||||
return {
|
||||
key: `errors.custom.${i18n.key}`,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
export interface CustomErrorParams {
|
||||
i18n: {
|
||||
key: keyof TranslationObject["common"]["zod"]["errors"]["custom"];
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
@@ -512,6 +512,31 @@ export default {
|
||||
show: "Show preview",
|
||||
hide: "Hide preview",
|
||||
},
|
||||
zod: {
|
||||
errors: {
|
||||
default: "This field is invalid",
|
||||
required: "This field is required",
|
||||
string: {
|
||||
startsWith: "This field must start with {startsWith}",
|
||||
endsWith: "This field must end with {endsWith}",
|
||||
includes: "This field must include {includes}",
|
||||
invalidEmail: "This field must be a valid email",
|
||||
},
|
||||
tooSmall: {
|
||||
string: "This field must be at least {minimum} characters long",
|
||||
number: "This field must be greater than or equal to {minimum}",
|
||||
},
|
||||
tooBig: {
|
||||
string: "This field must be at most {maximum} characters long",
|
||||
number: "This field must be less than or equal to {maximum}",
|
||||
},
|
||||
custom: {
|
||||
passwordsDoNotMatch: "Passwords do not match",
|
||||
boardAlreadyExists: "A board with this name already exists",
|
||||
// TODO: Add custom error messages
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
section: {
|
||||
category: {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
".": "./index.ts",
|
||||
"./form": "./src/form/i18n.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
@@ -35,6 +36,7 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8",
|
||||
"@homarr/definitions": "workspace:^0.1.0"
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const boardNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[A-Za-z0-9-\\._]+$/);
|
||||
.regex(/^[A-Za-z0-9-\\._]*$/);
|
||||
|
||||
const byNameSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
@@ -53,12 +53,7 @@ const savePartialSettingsSchema = z
|
||||
customCss: z.string().max(16384),
|
||||
columnCount: z.number().min(1).max(24),
|
||||
})
|
||||
.partial()
|
||||
.and(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
);
|
||||
.partial();
|
||||
|
||||
const saveSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
139
packages/validation/src/form/i18n.ts
Normal file
139
packages/validation/src/form/i18n.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
|
||||
import { ZodIssueCode } from "zod";
|
||||
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
export const zodErrorMap = <
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
TFunction extends (key: string, ...params: any[]) => string,
|
||||
>(
|
||||
t: TFunction,
|
||||
) => {
|
||||
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
const error = handleZodError(issue, ctx);
|
||||
if ("message" in error && error.message)
|
||||
return {
|
||||
message: error.message ?? ctx.defaultError,
|
||||
};
|
||||
return {
|
||||
message: t(
|
||||
error.key ? `common.zod.${error.key}` : "common.zod.errors.default",
|
||||
error.params ?? {},
|
||||
),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
if (issue.validation === "email") {
|
||||
return {
|
||||
key: "errors.string.invalidEmail",
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (typeof issue.validation === "object") {
|
||||
if ("startsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.startsWith",
|
||||
params: {
|
||||
startsWith: issue.validation.startsWith,
|
||||
},
|
||||
} as const;
|
||||
} else if ("endsWith" in issue.validation) {
|
||||
return {
|
||||
key: "errors.string.endsWith",
|
||||
params: {
|
||||
endsWith: issue.validation.endsWith,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
key: "errors.invalid_string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTooSmallError = (issue: ZodTooSmallIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.type === "string" && issue.minimum === 1) {
|
||||
return {
|
||||
key: "errors.required",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooSmall.${issue.type}`,
|
||||
params: {
|
||||
minimum: issue.minimum,
|
||||
count: issue.minimum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleTooBigError = (issue: ZodTooBigIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: `errors.tooBig.${issue.type}`,
|
||||
params: {
|
||||
maximum: issue.maximum,
|
||||
count: issue.maximum,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
if (ctx.defaultError === "Required") {
|
||||
return {
|
||||
key: "errors.required",
|
||||
params: {},
|
||||
} as const;
|
||||
}
|
||||
if (issue.code === ZodIssueCode.invalid_string) {
|
||||
return handleStringError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_small) {
|
||||
return handleTooSmallError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.too_big) {
|
||||
return handleTooBigError(issue);
|
||||
}
|
||||
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
||||
const { i18n } = issue.params as CustomErrorParams;
|
||||
return {
|
||||
key: `errors.custom.${i18n.key}`,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
export interface CustomErrorParams {
|
||||
i18n: {
|
||||
key: keyof TranslationObject["common"]["zod"]["errors"]["custom"];
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export const createCustomErrorParams = (
|
||||
i18n: CustomErrorParams["i18n"] | CustomErrorParams["i18n"]["key"],
|
||||
) => (typeof i18n === "string" ? { i18n: { key: i18n } } : { i18n });
|
||||
@@ -1,25 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
const usernameSchema = z.string().min(3).max(255);
|
||||
const passwordSchema = z.string().min(8).max(255);
|
||||
|
||||
const confirmPasswordRefine = [
|
||||
(data: { password: string; confirmPassword: string }) =>
|
||||
data.password === data.confirmPassword,
|
||||
{
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] satisfies [(args: any) => boolean, unknown];
|
||||
|
||||
const createUserSchema = z
|
||||
.object({
|
||||
username: usernameSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().email().or(z.string().length(0).optional()),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
.refine(confirmPasswordRefine[0], confirmPasswordRefine[1]);
|
||||
|
||||
const initUserSchema = createUserSchema;
|
||||
|
||||
const signInSchema = z.object({
|
||||
name: z.string(),
|
||||
password: z.string(),
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const registrationSchema = z
|
||||
@@ -28,10 +37,7 @@ const registrationSchema = z
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
.refine(confirmPasswordRefine[0], confirmPasswordRefine[1]);
|
||||
|
||||
const registrationSchemaApi = registrationSchema.and(
|
||||
z.object({
|
||||
@@ -54,14 +60,11 @@ const editProfileSchema = z.object({
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
previousPassword: z.string(),
|
||||
previousPassword: z.string().min(1),
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
.refine(confirmPasswordRefine[0], confirmPasswordRefine[1]);
|
||||
|
||||
const changePasswordApiSchema = changePasswordSchema.and(
|
||||
z.object({ userId: z.string() }),
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -556,6 +556,12 @@ importers:
|
||||
|
||||
packages/form:
|
||||
dependencies:
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
'@mantine/form':
|
||||
specifier: ^7.9.2
|
||||
version: 7.9.2(react@18.3.1)
|
||||
@@ -787,6 +793,9 @@ importers:
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
'@homarr/translation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../translation
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
|
||||
Reference in New Issue
Block a user