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

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