feat: add i18n translated form errors (#509)
This commit is contained in:
@@ -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() }),
|
||||
|
||||
Reference in New Issue
Block a user