feat: add internationalization message platform

This commit is contained in:
donghyeon-ka
2026-07-26 16:17:39 +09:00
parent b8c0444217
commit 668bf05b48
57 changed files with 1711 additions and 201 deletions
+3 -1
View File
@@ -1,6 +1,7 @@
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
import { TextField } from "../components/ui/text-field.jsx";
import { useLocale } from "../i18n/index.js";
import type {
FieldErrors,
FieldName,
@@ -31,6 +32,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
fieldId(name: FieldName<Values>): string;
onFocusField?(name: FieldName<Values>): void;
}>) {
const { message } = useLocale();
const {
fieldErrors,
formErrors = [],
@@ -50,7 +52,7 @@ export function ErrorSummary<Values extends FormValues>(props: Readonly<{
role="alert"
aria-labelledby={headingId}
>
<h2 id={headingId}> .</h2>
<h2 id={headingId}>{message("form.errorSummary")}</h2>
{entries.length > 0 ? (
<ul>
{entries.map(([name, message]) => (
+24 -12
View File
@@ -1,4 +1,8 @@
import type { ApiFailure } from "../../contracts/errors.js";
import {
formatMessage,
type ParameterlessMessageKey,
} from "../i18n/index.js";
export type FormValues = Readonly<Record<string, unknown>>;
export type FieldName<Values extends FormValues> = Extract<keyof Values, string>;
@@ -23,24 +27,32 @@ export type MappedValidationFailure<Values extends FormValues> = Readonly<{
}>;
const VALIDATION_COPY = Object.freeze({
REQUIRED: "필수 입력값입니다.",
too_small: "입력값이 너무 짧습니다.",
too_big: "입력값이 너무 깁니다.",
invalid_type: "입력 형식을 확인해 주세요.",
invalid_format: "입력 형식을 확인해 주세요.",
invalid_value: "허용된 값을 선택해 주세요.",
REQUIRED: "form.validation.required",
too_small: "form.validation.tooSmall",
too_big: "form.validation.tooBig",
invalid_type: "form.validation.invalidType",
invalid_format: "form.validation.invalidFormat",
invalid_value: "form.validation.invalidValue",
});
export function validationMessage(code: string): string {
return (
type MessageResolver = (key: ParameterlessMessageKey) => string;
const defaultMessage: MessageResolver = (key) => formatMessage("ko-KR", key);
export function validationMessage(
code: string,
message: MessageResolver = defaultMessage,
): string {
return message(
VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
"입력값을 확인해 주세요."
"form.validation.unknown",
);
}
export function mapValidationFailureToFields<Values extends FormValues>(
failure: ApiFailure,
allowedFields: readonly FieldName<Values>[],
message: MessageResolver = defaultMessage,
): MappedValidationFailure<Values> {
if (failure.kind !== "VALIDATION_REJECTED") {
return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
@@ -51,15 +63,15 @@ export function mapValidationFailureToFields<Values extends FormValues>(
const issues = failure.validationIssues ?? [];
if (issues.length === 0) {
formErrors.push("입력값을 다시 확인해 주세요.");
formErrors.push(message("form.validation.retry"));
}
for (const issue of issues) {
const field = issue.path.split(".").at(0) ?? "";
if (allowed.has(field)) {
const name = field as FieldName<Values>;
fieldErrors[name] ??= validationMessage(issue.code);
fieldErrors[name] ??= validationMessage(issue.code, message);
} else {
formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
formErrors.push(message("form.validation.unknownField"));
}
}
return Object.freeze({
+13 -4
View File
@@ -8,6 +8,10 @@ import {
type FormEvent,
} from "react";
import type { ZodType, ZodIssue } from "zod";
import {
useLocale,
type ParameterlessMessageKey,
} from "../i18n/index.js";
import {
mapValidationFailureToFields,
@@ -37,6 +41,7 @@ export function useAppForm<
Command,
Output,
>(options: AppFormOptions<Values, Command, Output>) {
const { message } = useLocale();
const {
schema,
defaultValues,
@@ -142,13 +147,14 @@ export function useAppForm<
const errors = issuesToFieldErrors<Values>(
parsed.error.issues,
allowedServerFields,
message,
);
setFieldErrors(errors);
setFormErrors(
parsed.error.issues.some(
(issue) => !allowedServerFields.includes(issue.path[0] as FieldName<Values>),
)
? ["입력 구성을 다시 확인해 주세요."]
? [message("form.validation.configuration")]
: [],
);
setTouched(new Set(allowedServerFields));
@@ -178,6 +184,7 @@ export function useAppForm<
const mapped = mapValidationFailureToFields<Values>(
outcome.error,
allowedServerFields,
message,
);
setFieldErrors(mapped.fieldErrors);
setFormErrors(mapped.formErrors);
@@ -185,11 +192,11 @@ export function useAppForm<
focusFirstError(mapped.fieldErrors);
} else if (outcome.error.kind === "CONFLICT") {
setFormErrors([
"다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
message("form.conflict"),
]);
setResult("conflict");
} else {
setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
setFormErrors([message("form.unavailable")]);
setResult("unavailable");
}
return outcome;
@@ -203,6 +210,7 @@ export function useAppForm<
defaultValues,
focusFirstError,
mapToCommand,
message,
resetOnSuccess,
schema,
submit,
@@ -231,6 +239,7 @@ export function useAppForm<
function issuesToFieldErrors<Values extends FormValues>(
issues: readonly ZodIssue[],
allowedFields: readonly FieldName<Values>[],
message: (key: ParameterlessMessageKey) => string,
): FieldErrors<Values> {
const allowed = new Set<PropertyKey>(allowedFields);
const errors: Partial<Record<FieldName<Values>, string>> = {};
@@ -238,7 +247,7 @@ function issuesToFieldErrors<Values extends FormValues>(
const field = issue.path[0];
if (!allowed.has(field)) continue;
const name = field as FieldName<Values>;
errors[name] ??= validationMessage(issue.code);
errors[name] ??= validationMessage(issue.code, message);
}
return Object.freeze(errors);
}
@@ -3,6 +3,7 @@ import { useBeforeUnload, useBlocker } from "react-router-dom";
import { Button } from "../components/ui/button.jsx";
import { Dialog } from "../components/ui/dialog.jsx";
import { useLocale } from "../i18n/index.js";
export function useDirtyNavigationGuard(when: boolean) {
const blocker = useBlocker(when);
@@ -33,19 +34,20 @@ export function useDirtyNavigationGuard(when: boolean) {
export function DirtyNavigationDialog(props: Readonly<{
guard: ReturnType<typeof useDirtyNavigationGuard>;
}>) {
const { message } = useLocale();
return (
<Dialog
open={props.guard.blocked}
onClose={props.guard.stay}
title="저장하지 않은 변경이 있습니다."
description="이 화면을 떠나면 입력한 내용이 사라집니다."
title={message("form.unsaved.title")}
description={message("form.unsaved.description")}
actions={
<>
<Button variant="secondary" onClick={props.guard.stay}>
{message("action.continueEditing")}
</Button>
<Button variant="danger" onClick={props.guard.leave}>
{message("action.discardAndLeave")}
</Button>
</>
}