feat: add internationalization message platform
This commit is contained in:
@@ -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]) => (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user