fix(deps): upgrade zod to v4 and fix breaking changes (#3461)
* fix(deps): update dependency drizzle-zod to ^0.8.2 * chore: update zod to v4 import * fix: path is no longer available in transform context * fix: AnyZodObject does no longer exist * fix: auth env.ts using wrong createEnv and remove unused file env-validation.ts * fix: required_error no longer exists on z.string * fix: zod error map is deprecated and replaced with config * fix: default requires callback now * fix: migrate zod resolver for mantine * fix: remove unused form translation file * fix: wrong enum type * fix: record now requires two arguments * fix: add-confirm-password-refinement type issues * fix: add missing first record argument for entityStateSchema * fix: migrate superrefine to check * fix(deps): upgrade zod-form-data to v3 * fix: migrate superRefine to check for mediaUploadSchema * fix: authProvidersSchema default is array * fix: use stringbool instead of custom implementation * fix: record requires first argument * fix: migrate superRefine to check for certificate router * fix: confirm pasword refinement is overwriting types * fix: email optional not working * fix: migrate intersection to object converter * fix: safe parse return value rename * fix: easier access for min and max number value * fix: migrate superRefine to check for oldmarr import file * fix: inference of enum shape for old-import board-size wrong * fix: errors renamed to issues * chore: address pull request feedback * fix: zod form requires object * fix: inference for use-zod-form not working * fix: remove unnecessary convertion * fix(deps): upgrade trpc-to-openapi to v3 * fix: build error * fix: migrate missing zod imports to v4 * fix: migrate zod records to v4 * fix: missing core package dependency in api module * fix: unable to convert custom zod schema to openapi schema * fix(deps): upgrade zod to v4 * chore(renovate): enable zod dependency updates * test: add simple unit test for convertIntersectionToZodObject --------- Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
This commit is contained in:
114
packages/validation/src/form/i18n.spec.ts
Normal file
114
packages/validation/src/form/i18n.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
|
||||
import { createCustomErrorParams, zodErrorMap } from "./i18n";
|
||||
|
||||
const expectError = (error: z.core.$ZodIssue, key: string) => {
|
||||
expect(error.message).toContain(key);
|
||||
};
|
||||
|
||||
describe("i18n", () => {
|
||||
const t = ((key: string) => {
|
||||
return `${key}`;
|
||||
}) as TranslationFunction;
|
||||
z.config({
|
||||
customError: zodErrorMap(t),
|
||||
});
|
||||
|
||||
test("should return required error for string when passing null", () => {
|
||||
const schema = z.string();
|
||||
const result = schema.safeParse(null);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "required");
|
||||
});
|
||||
test("should return required error for empty string", () => {
|
||||
const schema = z.string().nonempty();
|
||||
const result = schema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "required");
|
||||
});
|
||||
test("should return invalid email error", () => {
|
||||
const schema = z.string().email();
|
||||
const result = schema.safeParse("invalid-email");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "invalidEmail");
|
||||
});
|
||||
test("should return startsWith error", () => {
|
||||
const schema = z.string().startsWith("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "startsWith");
|
||||
});
|
||||
test("should return endsWith error", () => {
|
||||
const schema = z.string().endsWith("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "endsWith");
|
||||
});
|
||||
test("should return includes error", () => {
|
||||
const schema = z.string().includes("test");
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "includes");
|
||||
});
|
||||
test("should return tooSmall error for string", () => {
|
||||
const schema = z.string().min(5);
|
||||
const result = schema.safeParse("test");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooSmall.string");
|
||||
});
|
||||
test("should return tooSmall error for number", () => {
|
||||
const schema = z.number().min(5);
|
||||
const result = schema.safeParse(3);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooSmall.number");
|
||||
});
|
||||
test("should return tooBig error for string", () => {
|
||||
const schema = z.string().max(5);
|
||||
const result = schema.safeParse("too long");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooBig.string");
|
||||
});
|
||||
test("should return tooBig error for number", () => {
|
||||
const schema = z.number().max(5);
|
||||
const result = schema.safeParse(10);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "tooBig.number");
|
||||
});
|
||||
test("should return custom error", () => {
|
||||
const schema = z.string().refine((val) => val === "valid", {
|
||||
params: createCustomErrorParams({
|
||||
key: "boardAlreadyExists",
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = schema.safeParse("invalid");
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectError(result.error.issues[0]!, "boardAlreadyExists");
|
||||
});
|
||||
});
|
||||
@@ -1,132 +1,139 @@
|
||||
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
|
||||
import { ZodIssueCode } from "zod";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||
import type { ScopedTranslationFunction, TranslationFunction, TranslationObject } from "@homarr/translation";
|
||||
|
||||
export const zodErrorMap = <TFunction extends TranslationFunction>(t: TFunction) => {
|
||||
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
const error = handleZodError(issue, ctx);
|
||||
if ("message" in error && error.message) {
|
||||
return {
|
||||
message: error.message,
|
||||
};
|
||||
export const zodErrorMap = (t: TranslationFunction): z.core.$ZodErrorMap<z.core.$ZodIssue> => {
|
||||
return (issue) => {
|
||||
const error = handleError(issue);
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return {
|
||||
// use never to make ts happy
|
||||
message: t(error.key ? `common.zod.${error.key}` : "common.zod.errors.default", (error.params ?? {}) as never),
|
||||
};
|
||||
return t(`common.zod.errors.${error.key}`, (error.params ?? {}) as never);
|
||||
};
|
||||
};
|
||||
|
||||
const handleStringError = (issue: z.ZodInvalidStringIssue) => {
|
||||
if (issue.validation === "email") {
|
||||
type ValidTranslationKeys = Parameters<ScopedTranslationFunction<"common.zod.errors">>[0];
|
||||
|
||||
type HandlerReturnValue =
|
||||
| string
|
||||
| {
|
||||
key: ValidTranslationKeys;
|
||||
params?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
const handleError = (issue: z.core.$ZodRawIssue): HandlerReturnValue => {
|
||||
if (issue.code === "too_big") return handleTooBigError(issue);
|
||||
if (issue.code === "too_small") return handleTooSmallError(issue);
|
||||
if (issue.code === "invalid_format") return handleInvalidFormatError(issue);
|
||||
if (issue.code === "invalid_type" && issue.expected === "string" && issue.input === null) {
|
||||
return {
|
||||
key: "errors.string.invalidEmail",
|
||||
} as const;
|
||||
key: "required",
|
||||
};
|
||||
}
|
||||
if (issue.code === "custom" && issue.params?.i18n) {
|
||||
const i18n = issue.params.i18n as { key: CustomErrorKey; params?: Record<string, string | number> };
|
||||
return {
|
||||
key: `custom.${i18n.key}`,
|
||||
params: i18n.params,
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTooBigError = (
|
||||
issue: Pick<z.core.$ZodIssueTooBig, "origin" | "maximum"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.origin !== "string" && issue.origin !== "number") {
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const origin = issue.origin as "string" | "number";
|
||||
|
||||
return {
|
||||
key: `tooBig.${origin}`,
|
||||
params: {
|
||||
maximum: issue.maximum.toString(),
|
||||
count: issue.maximum.toString(),
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleTooSmallError = (
|
||||
issue: Pick<z.core.$ZodIssueTooSmall, "origin" | "minimum"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.origin !== "string" && issue.origin !== "number") {
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const origin = issue.origin as "string" | "number";
|
||||
if (origin === "string" && issue.minimum === 1) {
|
||||
return {
|
||||
key: "errors.string.includes",
|
||||
key: "required",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
key: `tooSmall.${origin}`,
|
||||
params: {
|
||||
minimum: issue.minimum.toString(),
|
||||
count: issue.minimum.toString(),
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const handleInvalidFormatError = (
|
||||
issue: Pick<z.core.$ZodIssueInvalidStringFormat, "format"> & { message?: string },
|
||||
): HandlerReturnValue => {
|
||||
if (issue.format === "includes" && "includes" in issue && typeof issue.includes === "string") {
|
||||
return {
|
||||
key: "string.includes",
|
||||
params: {
|
||||
includes: issue.validation.includes,
|
||||
includes: issue.includes,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTooSmallError = (issue: ZodTooSmallIssue) => {
|
||||
if (issue.type !== "string" && issue.type !== "number") {
|
||||
if (issue.format === "ends_with" && "suffix" in issue && typeof issue.suffix === "string") {
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.type === "string" && issue.minimum === 1) {
|
||||
return {
|
||||
key: "errors.required",
|
||||
key: "string.endsWith",
|
||||
params: {
|
||||
endsWith: issue.suffix,
|
||||
},
|
||||
} 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") {
|
||||
if (issue.format === "starts_with" && "prefix" in issue && typeof issue.prefix === "string") {
|
||||
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: {},
|
||||
key: "string.startsWith",
|
||||
params: {
|
||||
startsWith: issue.prefix,
|
||||
},
|
||||
} 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.invalid_type && (ctx.data === "" || issue.received === "null")) {
|
||||
if (issue.format === "email") {
|
||||
return {
|
||||
key: "errors.required",
|
||||
params: {},
|
||||
} as const;
|
||||
}
|
||||
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
||||
const { i18n } = issue.params as CustomErrorParams<CustomErrorKey>;
|
||||
return {
|
||||
key: `errors.custom.${i18n.key}`,
|
||||
params: i18n.params,
|
||||
key: "string.invalidEmail",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
message: issue.message,
|
||||
};
|
||||
return (
|
||||
issue.message ?? {
|
||||
key: "default",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom"];
|
||||
|
||||
Reference in New Issue
Block a user