Repository navigation
Expand file tree
/
Copy pathvalidation-message.ts
More file actions
402 lines (388 loc) · 27.7 KB
/
Copy pathvalidation-message.ts
File metadata and controls
402 lines (388 loc) · 27.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Validation Message Catalog
*
* The localized message templates for the write path's BUILT-IN field
* constraints (`Field.required`, `min`/`max`, `maxLength`, format, options,
* value shape). Rendered by the record validator into
* `FieldValidationError.message`, which REST ships verbatim in a
* `400 VALIDATION_FAILED` envelope and every generic surface — Console toast,
* CSV-import row report, CLI, custom client — displays as-is.
*
* ## Why the catalog lives here
*
* Before #3957 each message was a hardcoded English template with the API field
* name concatenated in, so a Chinese-locale user importing a bad row read
* `penalty_amount must be ≥ 0` — an English sentence naming a column they have
* never seen, for a field declared `label: '处罚金额'` with a full `zh-CN`
* bundle. The form layer localized the SAME constraint correctly (the browser's
* native `min`), so the language flipped depending on which layer caught it.
*
* These strings are platform text, not authored metadata: they exist for every
* deployment whether or not anyone wrote a `translation`, so they ship as
* constants (same shape as `SYSTEM_FIELD_LABELS` in `i18n-resolver.ts`) rather
* than through the extract-and-gate bundle pipeline, which tracks *declared
* metadata labels* and would read added keys as drift.
*
* A deployment can still override any of them: {@link renderValidationMessage}
* consults the i18n service first under
* `validation.field.<messageKey>` (see
* {@link validationMessageTranslationKey}), so a `translation` metadata item
* that defines that key wins over the built-in.
*
* ## Message keys are finer-grained than wire codes
*
* `FieldErrorCode` (ADR-0114) is the stable machine vocabulary and must not
* split (clients match on it). But one code can need several sentences — an
* `invalid_option` on a single select reads "must be one of …" while the same
* code on a multiselect must name the offending element. So the catalog is
* keyed by MESSAGE key, of which a code is the default; extra variants
* (`invalid_option_value`, `invalid_datetime`, `invalid_type_array`, …) are
* rendering detail and never appear on the wire.
*
* `invalid_date_range` / `invalid_datetime_range` (#20846) are two more
* `invalid_date` sentences, for a value the kind's rule reads but whose year
* falls outside the kind's supported years. "Must be a valid date (ISO-8601)"
* is false for such a value — `0500-07-15T10:00:00Z` is valid ISO 8601 — and
* sends its author to rewrite a spelling when the year is what is refused. The
* years come from `{{firstYear}}` / `{{lastYear}}`, which the refusing door
* fills from `@objectstack/core`'s `SUPPORTED_TEMPORAL_YEARS`, the range it
* judges by; ⛔ no template spells the numbers, so the sentence cannot drift
* from the range it names.
*
* The `import_*` keys are the CSV/XLSX importer's cell-coercion failures
* (`rest/import-coerce.ts`) — "this cell is not a number" rather than "this
* value violates a constraint". They live here because they land in the same
* row report a user reads: leaving them behind would have localized half of it.
* Their wording deliberately omits the referenced object's API name (the old
* `no sys_user matches "…"`), since naming internal identifiers is the defect
* this issue is about; the column and the offending value are what the importer
* can act on.
*
* ## Interpolation
*
* `{{name}}` placeholders, matching `II18nService.t()`'s convention, filled from
* the error's `constraint` values (`min`, `maxLength`, `actual`, `allowed`, …)
* plus `{{value}}` (the offending value), `{{label}}` (the field's display name
* in the caller's locale) and `{{field}}` (its API name). A few are filled from
* the refusing door's message-only parameters, which name a fact of the
* platform rather than a constraint of the field and are never shipped on the
* error (`{{firstYear}}` / `{{lastYear}}` above). An unknown placeholder is
* left verbatim so a broken override is visible rather than silently blank.
*/
import { resolveBundleLocale } from './i18n-resolver';
/** Prefix under which a deployment can override a built-in message. */
export const VALIDATION_MESSAGE_KEY_PREFIX = 'validation.field';
/**
* The i18n key a `messageKey` resolves under, e.g.
* `validation.field.min_value`. A `translation` metadata item that defines this
* key overrides the built-in catalog for its locale.
*/
export function validationMessageTranslationKey(messageKey: string): string {
return `${VALIDATION_MESSAGE_KEY_PREFIX}.${messageKey}`;
}
/**
* Built-in templates, `locale → messageKey → template`.
*
* Locale keys match the platform bundles (`en`, `zh-CN`, `ja-JP`, `es-ES`);
* `en` is the last-resort fallback and is therefore the one locale that MUST
* define every key. The English wording is byte-identical to the pre-#3957
* hardcoded strings, so an English deployment's messages do not change.
*/
export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>> = {
en: {
required: '{{label}} is required',
required_cleared: '{{label}} is required and cannot be cleared',
min_length: '{{label}} must be ≥ {{minLength}} characters (got {{actual}})',
max_length: '{{label}} must be ≤ {{maxLength}} characters (got {{actual}})',
min_value: '{{label}} must be ≥ {{min}}',
max_value: '{{label}} must be ≤ {{max}}',
max_scale: '{{label}} must have at most {{scale}} decimal places (got {{actual}})',
// `max_precision` (#19992): two sentences, one wire code. The plain one when
// the count is the value's own digits; `_scaled` when the field's scale
// padded it, so `1234.5` at `scale: 2` reads as the 6 digits of `1234.50`.
max_precision: '{{label}} must have at most {{precision}} digits in total (got {{actual}})',
max_precision_scaled: '{{label}} must have at most {{precision}} digits in total, counting {{scale}} decimal places (got {{actual}})',
invalid_email: '{{label}} must be a valid email address',
invalid_url: '{{label}} must be a valid URL (scheme://...)',
invalid_phone: '{{label}} must be a valid phone number',
invalid_number: '{{label}} must be a number',
// `invalid_number`'s second sentence (#27236): a `currency` field written
// as an object, the retired `{ value, currency }` shape (ADR-0104 D1). The
// sentence names no currency code: the field's `currencyConfig` or the
// tenant default decides it (#27119 Q2 = X).
invalid_number_currency_object: '{{label}} is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode\'s defaultCurrency, or under the dynamic mode (the default) the tenant default currency',
invalid_boolean: '{{label}} must be true or false',
invalid_date: '{{label}} must be a valid date (ISO-8601)',
invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)',
// `invalid_date`'s range sentences (#20846): a value the kind's rule reads,
// in a year outside the kind's supported years. The years are parameters
// the refusing door fills from the range it judges by, never literals here.
invalid_date_range: '{{label}} must be a date in the years {{firstYear}} to {{lastYear}}',
invalid_datetime_range: '{{label}} must be a datetime whose UTC year falls in the years {{firstYear}} to {{lastYear}}',
invalid_time: '{{label}} must be a valid time (HH:MM or HH:MM:SS)',
// `invalid_time`'s second sentence: a time of day written with a `Z` or an
// offset. A `time` field is a zone-less wall clock, so the sentence says
// what to do instead.
invalid_time_zoned: '{{label}} is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant',
invalid_option: '{{label}} must be one of: {{allowed}}',
reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"',
invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}',
// `invalid_option`'s picklist sentences: the field takes its options from a
// shared picklist, so the refusal names the list (`{{picklist}}`, a
// message-only parameter). `_unresolved` is the field whose list no loaded
// package declares, which accepts no value at all.
invalid_option_picklist: '{{label}} must be one of the values of picklist "{{picklist}}": {{allowed}}',
invalid_option_value_picklist: '{{label}}: "{{value}}" is not a value of picklist "{{picklist}}": {{allowed}}',
invalid_option_picklist_unresolved: '{{label}} takes its values from picklist "{{picklist}}", which no loaded package declares, so no value can be accepted',
// `value_domain` (ADR-0114 member; the field-level `valueDomain` card's
// spec half) — the code-named default names the domain by its machine
// word; the three finer variants (one per vocabulary member, rendering
// detail that never reaches the wire) spell the standard out for a human.
value_domain: '{{label}} must be a member of the {{valueDomain}} value domain (got "{{value}}")',
value_domain_iana_time_zone: '{{label}} must be a valid IANA time zone identifier, e.g. Europe/Zurich (got "{{value}}")',
value_domain_iso_4217_currency: '{{label}} must be a valid ISO 4217 currency code, e.g. CHF (got "{{value}}")',
value_domain_iso_3166_alpha2: '{{label}} must be a valid ISO 3166-1 alpha-2 country code, e.g. CH (got "{{value}}")',
option_unavailable: "{{label}}: option '{{value}}' is not available",
invalid_type_array: '{{label}} must be an array of values',
invalid_value_shape: '{{label}} has an invalid {{type}} value: {{detail}}',
invalid_initial_state: 'Invalid initial state for {{label}}: {{value}} (allowed: {{allowed}})',
invalid_transition: 'Invalid transition for {{label}}: {{from}} → {{to}}',
import_invalid_boolean: '{{label}}: "{{value}}" is not a boolean',
import_invalid_number: '{{label}}: "{{value}}" is not a number',
import_invalid_date: '{{label}}: "{{value}}" is not a valid date',
import_invalid_datetime: '{{label}}: "{{value}}" is not a valid datetime',
import_invalid_time: '{{label}}: "{{value}}" is not a valid time',
import_unknown_option: '{{label}}: "{{value}}" is not a known option',
import_reference_ambiguous: '{{label}}: "{{value}}" matches more than one record — use a unique value or the record id',
import_reference_not_found: '{{label}}: no record matches "{{value}}"',
},
'zh-CN': {
required: '{{label}}不能为空',
required_cleared: '{{label}}是必填项,不能清空',
min_length: '{{label}}长度不能少于 {{minLength}} 个字符(当前 {{actual}} 个)',
max_length: '{{label}}长度不能超过 {{maxLength}} 个字符(当前 {{actual}} 个)',
// Wording mirrors the browser's native range message ("值必须大于或等于 0。")
// so the same constraint reads the same whether the form or the server
// catches it — the inconsistency #3957 reported.
min_value: '{{label}}必须大于或等于 {{min}}',
max_value: '{{label}}必须小于或等于 {{max}}',
max_scale: '{{label}}的小数位数不能超过 {{scale}} 位(当前 {{actual}} 位)',
max_precision: '{{label}}的总位数不能超过 {{precision}} 位(当前 {{actual}} 位)',
max_precision_scaled: '{{label}}的总位数不能超过 {{precision}} 位(按 {{scale}} 位小数计,当前 {{actual}} 位)',
invalid_email: '{{label}}必须是有效的电子邮件地址',
invalid_url: '{{label}}必须是有效的 URL(scheme://...)',
invalid_phone: '{{label}}必须是有效的电话号码',
invalid_number: '{{label}}必须是数字',
invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(ADR-0104),如 10,不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种',
invalid_boolean: '{{label}}必须是 true 或 false',
invalid_date: '{{label}}必须是有效的日期(ISO-8601)',
invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)',
invalid_date_range: '{{label}}必须是 {{firstYear}} 年至 {{lastYear}} 年之间的日期',
invalid_datetime_range: '{{label}}必须是 UTC 年份在 {{firstYear}} 年至 {{lastYear}} 年之间的日期时间',
invalid_time: '{{label}}必须是有效的时间(HH:MM 或 HH:MM:SS)',
invalid_time_zoned: '{{label}}是不带时区的时刻:请去掉 Z 或时区偏移(HH:MM 或 HH:MM:SS),表示时间点请改用日期时间字段',
invalid_option: '{{label}}必须是以下值之一:{{allowed}}',
reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录',
invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}',
invalid_option_picklist: '{{label}}必须是选项列表“{{picklist}}”中的值之一:{{allowed}}',
invalid_option_value_picklist: '{{label}}:“{{value}}”不是选项列表“{{picklist}}”中的值:{{allowed}}',
invalid_option_picklist_unresolved: '{{label}}的取值来自选项列表“{{picklist}}”,但没有已加载的包声明该列表,因此无法接受任何值',
value_domain: '{{label}}必须是 {{valueDomain}} 值域的成员(当前 “{{value}}”)',
value_domain_iana_time_zone: '{{label}}必须是有效的 IANA 时区标识符,例如 Europe/Zurich(当前 “{{value}}”)',
value_domain_iso_4217_currency: '{{label}}必须是有效的 ISO 4217 货币代码,例如 CHF(当前 “{{value}}”)',
value_domain_iso_3166_alpha2: '{{label}}必须是有效的 ISO 3166-1 alpha-2 国家代码,例如 CH(当前 “{{value}}”)',
option_unavailable: '{{label}}:选项“{{value}}”当前不可用',
invalid_type_array: '{{label}}必须是数组',
invalid_value_shape: '{{label}}的 {{type}} 值格式无效:{{detail}}',
invalid_initial_state: '{{label}}的初始状态无效:{{value}}(允许:{{allowed}})',
invalid_transition: '{{label}}不允许从 {{from}} 变更为 {{to}}',
import_invalid_boolean: '{{label}}:“{{value}}”不是有效的布尔值',
import_invalid_number: '{{label}}:“{{value}}”不是有效的数字',
import_invalid_date: '{{label}}:“{{value}}”不是有效的日期',
import_invalid_datetime: '{{label}}:“{{value}}”不是有效的日期时间',
import_invalid_time: '{{label}}:“{{value}}”不是有效的时间',
import_unknown_option: '{{label}}:“{{value}}”不是可选值之一',
import_reference_ambiguous: '{{label}}:“{{value}}”匹配到多条记录,请改用唯一值或记录 ID',
import_reference_not_found: '{{label}}:未找到与“{{value}}”匹配的记录',
},
'ja-JP': {
required: '{{label}}は必須です',
required_cleared: '{{label}}は必須項目のため、空にできません',
min_length: '{{label}}は {{minLength}} 文字以上で入力してください(現在 {{actual}} 文字)',
max_length: '{{label}}は {{maxLength}} 文字以内で入力してください(現在 {{actual}} 文字)',
min_value: '{{label}}は {{min}} 以上で入力してください',
max_value: '{{label}}は {{max}} 以下で入力してください',
max_scale: '{{label}}の小数点以下は {{scale}} 桁以内で入力してください(現在 {{actual}} 桁)',
max_precision: '{{label}}は合計 {{precision}} 桁以内で入力してください(現在 {{actual}} 桁)',
max_precision_scaled: '{{label}}は小数点以下 {{scale}} 桁を含めて合計 {{precision}} 桁以内で入力してください(現在 {{actual}} 桁)',
invalid_email: '{{label}}は有効なメールアドレスを入力してください',
invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください',
invalid_phone: '{{label}}は有効な電話番号を入力してください',
invalid_number: '{{label}}は数値で入力してください',
invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(ADR-0104、例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です',
invalid_boolean: '{{label}}は true または false で入力してください',
invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください',
invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください',
invalid_date_range: '{{label}}は {{firstYear}} 年から {{lastYear}} 年までの日付を入力してください',
invalid_datetime_range: '{{label}}は UTC の年が {{firstYear}} 年から {{lastYear}} 年までの日時を入力してください',
invalid_time: '{{label}}は有効な時刻(HH:MM または HH:MM:SS)を入力してください',
invalid_time_zoned: '{{label}}はタイムゾーンを持たない時刻です。Z やオフセットを外す(HH:MM または HH:MM:SS)か、時点を表すには日時フィールドを使ってください',
invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}',
reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません',
invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})',
invalid_option_picklist: '{{label}}は選択リスト「{{picklist}}」の値のいずれかを指定してください:{{allowed}}',
invalid_option_value_picklist: '{{label}}:「{{value}}」は選択リスト「{{picklist}}」の値ではありません(指定可能:{{allowed}})',
invalid_option_picklist_unresolved: '{{label}}の値は選択リスト「{{picklist}}」から取りますが、読み込まれたパッケージにこのリストがないため、値を受け付けられません',
value_domain: '{{label}}は {{valueDomain}} 値ドメインのメンバーでなければなりません(現在「{{value}}」)',
value_domain_iana_time_zone: '{{label}}は有効な IANA タイムゾーン識別子でなければなりません(例: Europe/Zurich、現在「{{value}}」)',
value_domain_iso_4217_currency: '{{label}}は有効な ISO 4217 通貨コードでなければなりません(例: CHF、現在「{{value}}」)',
value_domain_iso_3166_alpha2: '{{label}}は有効な ISO 3166-1 alpha-2 国コードでなければなりません(例: CH、現在「{{value}}」)',
option_unavailable: '{{label}}:選択肢「{{value}}」は現在利用できません',
invalid_type_array: '{{label}}は配列で指定してください',
invalid_value_shape: '{{label}}の {{type}} 値が不正です:{{detail}}',
invalid_initial_state: '{{label}}の初期ステータスが不正です:{{value}}(許可:{{allowed}})',
invalid_transition: '{{label}}は {{from}} から {{to}} へ変更できません',
import_invalid_boolean: '{{label}}:「{{value}}」は真偽値として解釈できません',
import_invalid_number: '{{label}}:「{{value}}」は数値として解釈できません',
import_invalid_date: '{{label}}:「{{value}}」は日付として解釈できません',
import_invalid_datetime: '{{label}}:「{{value}}」は日時として解釈できません',
import_invalid_time: '{{label}}:「{{value}}」は時刻として解釈できません',
import_unknown_option: '{{label}}:「{{value}}」は選択肢にありません',
import_reference_ambiguous: '{{label}}:「{{value}}」に複数のレコードが一致します。一意の値かレコード ID を指定してください',
import_reference_not_found: '{{label}}:「{{value}}」に一致するレコードが見つかりません',
},
'es-ES': {
required: '{{label}} es obligatorio',
required_cleared: '{{label}} es obligatorio y no puede vaciarse',
min_length: '{{label}} debe tener al menos {{minLength}} caracteres (actual: {{actual}})',
max_length: '{{label}} no debe superar {{maxLength}} caracteres (actual: {{actual}})',
min_value: '{{label}} debe ser mayor o igual que {{min}}',
max_value: '{{label}} debe ser menor o igual que {{max}}',
max_scale: '{{label}} no debe superar {{scale}} decimales (actual: {{actual}})',
max_precision: '{{label}} no debe superar {{precision}} dígitos en total (actual: {{actual}})',
max_precision_scaled: '{{label}} no debe superar {{precision}} dígitos en total, contando {{scale}} decimales (actual: {{actual}})',
invalid_email: '{{label}} debe ser una dirección de correo electrónico válida',
invalid_url: '{{label}} debe ser una URL válida (scheme://...)',
invalid_phone: '{{label}} debe ser un número de teléfono válido',
invalid_number: '{{label}} debe ser un número',
invalid_number_currency_object: '{{label}} es un importe de moneda: escríbalo como un número sin más (ADR-0104), por ejemplo 10, no como un objeto del tipo { value, currency }. El campo declara su moneda en currencyConfig: el defaultCurrency del modo fixed o, en el modo dynamic (el predeterminado), la moneda predeterminada del inquilino',
invalid_boolean: '{{label}} debe ser true o false',
invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)',
invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)',
invalid_date_range: '{{label}} debe ser una fecha entre los años {{firstYear}} y {{lastYear}}',
invalid_datetime_range: '{{label}} debe ser una fecha y hora cuyo año UTC esté entre los años {{firstYear}} y {{lastYear}}',
invalid_time: '{{label}} debe ser una hora válida (HH:MM o HH:MM:SS)',
invalid_time_zoned: '{{label}} es una hora del día sin zona horaria: quite la Z o el desfase (HH:MM o HH:MM:SS), o use un campo de fecha y hora para un instante',
invalid_option: '{{label}} debe ser uno de: {{allowed}}',
reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»',
invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}',
invalid_option_picklist: '{{label}} debe ser uno de los valores de la lista de selección «{{picklist}}»: {{allowed}}',
invalid_option_value_picklist: '{{label}}: «{{value}}» no es un valor de la lista de selección «{{picklist}}»: {{allowed}}',
invalid_option_picklist_unresolved: '{{label}} toma sus valores de la lista de selección «{{picklist}}», que ningún paquete cargado declara, así que no se puede aceptar ningún valor',
value_domain: '{{label}} debe pertenecer al dominio de valores {{valueDomain}} (actual: «{{value}}»)',
value_domain_iana_time_zone: '{{label}} debe ser un identificador de zona horaria IANA válido, p. ej. Europe/Zurich (actual: «{{value}}»)',
value_domain_iso_4217_currency: '{{label}} debe ser un código de moneda ISO 4217 válido, p. ej. CHF (actual: «{{value}}»)',
value_domain_iso_3166_alpha2: '{{label}} debe ser un código de país ISO 3166-1 alfa-2 válido, p. ej. CH (actual: «{{value}}»)',
option_unavailable: '{{label}}: la opción «{{value}}» no está disponible',
invalid_type_array: '{{label}} debe ser una lista de valores',
invalid_value_shape: '{{label}} tiene un valor {{type}} no válido: {{detail}}',
invalid_initial_state: 'Estado inicial no válido para {{label}}: {{value}} (permitidos: {{allowed}})',
invalid_transition: 'Transición no válida para {{label}}: {{from}} → {{to}}',
import_invalid_boolean: '{{label}}: «{{value}}» no es un booleano',
import_invalid_number: '{{label}}: «{{value}}» no es un número',
import_invalid_date: '{{label}}: «{{value}}» no es una fecha válida',
import_invalid_datetime: '{{label}}: «{{value}}» no es una fecha y hora válidas',
import_invalid_time: '{{label}}: «{{value}}» no es una hora válida',
import_unknown_option: '{{label}}: «{{value}}» no es una opción conocida',
import_reference_ambiguous: '{{label}}: «{{value}}» coincide con más de un registro; use un valor único o el id del registro',
import_reference_not_found: '{{label}}: ningún registro coincide con «{{value}}»',
},
};
/** Locale whose catalog is guaranteed complete and used as the last resort. */
export const VALIDATION_MESSAGE_FALLBACK_LOCALE = 'en';
/**
* Fill `{{name}}` placeholders from `params`. An unresolved placeholder is left
* verbatim (`{{min}}`) rather than blanked, so a malformed override or a missing
* param is visible in the message instead of producing "must be ≥ .".
*/
export function interpolateValidationMessage(
template: string,
params: Record<string, unknown> | undefined,
): string {
if (!params) return template;
return template.replace(/\{\{(\w+)\}\}/g, (whole, name: string) => {
const value = params[name];
return value === undefined || value === null ? whole : String(value);
});
}
/** Translation lookup, shaped after `II18nService.t` (key returned on a miss). */
export type ValidationMessageTranslator = (
key: string,
locale: string,
params?: Record<string, unknown>,
) => string;
export interface RenderValidationMessageInput {
/** Catalog key — a `FieldValidationCode` or one of the finer variants. */
messageKey: string;
/** Field display label in the caller's locale; falls back to the API name. */
label: string;
/** API field name, exposed to templates as `{{field}}`. */
field?: string;
/** Discrete constraint values (`FieldValidationParams`). */
params?: Record<string, unknown>;
}
export interface RenderValidationMessageOptions {
/** BCP-47 locale; defaults to `en`. */
locale?: string;
/** Deployment override hook — an `II18nService.t`-compatible lookup. */
translate?: ValidationMessageTranslator;
}
/**
* Render one built-in validation message in the caller's locale.
*
* Resolution order:
* 1. `translate('validation.field.<messageKey>', locale)` — a deployment's
* `translation` override. A miss is detected by the II18nService contract
* of echoing the key back.
* 2. the built-in catalog for the locale (BCP-47 matched: exact →
* case-insensitive → base language → variant).
* 3. the built-in catalog for `en`.
* 4. `<label> (<messageKey>)` — only reachable for a messageKey absent from
* even the English catalog, i.e. a coding error; still returns something a
* human can act on rather than an empty string.
*/
export function renderValidationMessage(
input: RenderValidationMessageInput,
opts: RenderValidationMessageOptions = {},
): string {
const locale = opts.locale ?? VALIDATION_MESSAGE_FALLBACK_LOCALE;
const params: Record<string, unknown> = {
...(input.params ?? {}),
label: input.label,
...(input.field !== undefined ? { field: input.field } : {}),
};
if (opts.translate) {
const key = validationMessageTranslationKey(input.messageKey);
let override: string | undefined;
try {
override = opts.translate(key, locale, params);
} catch {
// A misbehaving i18n service must never turn a 400 into a 500.
override = undefined;
}
// The contract is "returns the key itself if not found" — anything else is
// a real translation. Interpolate defensively: an implementation that does
// not substitute (or ignores `params`) still yields a filled message.
if (typeof override === 'string' && override.length > 0 && override !== key) {
return interpolateValidationMessage(override, params);
}
}
const matched = resolveBundleLocale(BUILTIN_VALIDATION_MESSAGES, locale);
const template = (matched !== undefined
? BUILTIN_VALIDATION_MESSAGES[matched][input.messageKey]
: undefined)
?? BUILTIN_VALIDATION_MESSAGES[VALIDATION_MESSAGE_FALLBACK_LOCALE][input.messageKey];
if (template === undefined) return `${input.label} (${input.messageKey})`;
return interpolateValidationMessage(template, params);
}