feat: add i18n translated form errors (#509)

This commit is contained in:
Meier Lukas
2024-05-18 16:55:08 +02:00
committed by GitHub
parent b312032f02
commit dfed804f65
32 changed files with 501 additions and 156 deletions

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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">

View File

@@ -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);

View File

@@ -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

View File

@@ -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: "",

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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")}
/>

View File

@@ -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`)}

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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],

View File

@@ -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,
},

View File

@@ -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: "",
},

View File

@@ -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,
},

View File

@@ -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,
},

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -255,6 +255,7 @@ const createUserAsync = async (
await db.insert(schema.users).values({
id: userId,
name: input.username,
email: input.email,
password: hashedPassword,
salt,
});

View File

@@ -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"
}
}

View File

@@ -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),
});
};

View 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>;
};
}

View File

@@ -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: {

View File

@@ -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"
}
}

View File

@@ -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(),

View 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 });

View File

@@ -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
View File

@@ -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