Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/6888-location-residue-refusal-keyed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@object-ui/fields': patch
'@object-ui/i18n': patch
---

Key `LocationField`'s THIRD refusal sentence — the residue arm — into the locale
packs (objectui#6888).

Typing a half that is only PARTLY a number (`12abc, 34`) is refused by
`LocationField` with its own sentence, added by objectui#6715. objectui#6755 had
ruled two weeks earlier that a widget's own refusal sentence goes through
`useFieldTranslation` + `FIELD_DEFAULTS`, and named three sentences — but it was
written on 2026-08-29 14:53 and this arm landed after, so it stayed a hard-coded
English literal while its two siblings were keyed.

**The consequence was worse than one more English string.** All three refusal arms
render through the SAME `<p>` and the same `refusalError` state, so after #6755
landed that one line spoke the reader's language when the format or range arm fired
and English when the residue arm did — objectui#4028's shape ("four Chinese labels
around one English one") compressed into a single sentence position, which reads to
a user as a bug rather than as a missing translation.

**Arity is answered explicitly, not defaulted.** Unlike the other two sentences, this
one had grammatical number: `verb` was `is not a number` / `are not numbers`, chosen
in TypeScript. Handing a pack an English verb form through a `{{hole}}` gives it a
fragment it cannot inflect around — Arabic has a DUAL, and two halves is exactly that
case. So the verb is not a hole. It lives inside two SIBLING keys picked at the call
site, `fields.location.refusedResidue` and `fields.location.refusedResidueOne`,
following this repo's own plural convention rather than i18next's `_one`/`_other`
suffixes — the same shape `RecordPickerDialog` already uses in this very defaults map
(`lookup.recordCount` / `lookup.recordCountOne`), and for the reason `ReactionPicker`
states in source: zh/ja/ko have no separate singular form, would legitimately omit a
`_one` half, and `all-locales-key-parity` reads that as a missing key. The `ar` pack
now uses its dual (`ليسا رقمين`), which the old implementation could not have produced.

The English conjunction `' and '` and the coordinate NOUNS go the same way: each pack
writes its own conjunction inside the two-half value, and `latitude` / `longitude`
become `fields.location.latitude` / `fields.location.longitude`, keyed once each and
interpolated into both arities so no locale holds two spellings of the same word. The
only holes carrying untranslated data are `{{text}}` / `{{otherText}}` — the
characters the person actually typed.

**No behaviour moves.** The English values are byte-identical to the literal they
replace in both arities, verified by `check:i18n-drift` (0 en values changed, 4 added)
and by objectui#6715's own `LocationField.strictNumeric.test.tsx` and `plugin-form`'s
`ObjectForm.locationResidue.test.tsx` passing untouched. Provider-less rendering is
unchanged, the refusal itself is unchanged, and the four new keys are bound from here
on by `check:i18n-keys` and `all-locales-key-parity` like their three siblings.
314 changes: 314 additions & 0 deletions packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx

Large diffs are not rendered by default.

79 changes: 67 additions & 12 deletions packages/fields/src/widgets/LocationField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,17 @@ const WHOLE_NUMBER_TEXT = /^[+-]?(?:Infinity|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?
/** The two coordinates, in the order they are typed, named for the diagnostic. */
const COORDINATE_LABELS = ['latitude', 'longitude'] as const;

/** One half of the typed pair that carried non-numeric residue. */
type ResidueHalf = { label: string; text: string };
/** Which coordinate a half is — the key half of `COORDINATE_LABELS`. */
type CoordinateLabel = (typeof COORDINATE_LABELS)[number];

/**
* One half of the typed pair that carried non-numeric residue.
*
* `label` is the COORDINATE_LABELS member, not free text: since objectui#6888
* it selects a locale key, so widening it to `string` would make
* {@link coordinateName}'s branch fall through to `longitude` silently.
*/
type ResidueHalf = { label: CoordinateLabel; text: string };

/**
* What the typed text means to this widget, as ONE reading (objectui#6716).
Expand Down Expand Up @@ -314,16 +323,62 @@ function refusedRangeMessage(t: TranslateFn, candidate: LocationValue): string {
* ruling declines that parse, so pointing at it would advertise a route this
* widget refuses.
*
* ⚠️ Still a LITERAL, alone among the three arms, and deliberately so:
* objectui#6755's ruling locks its scope to the three sentences that existed
* when it was written, and this arm landed after. objectui#6888 carries the
* gap — including the one question the other two did not have to answer, which
* is how `verb` (English grammar, not data) should be keyed.
* objectui#6888 — keyed, closing the gap objectui#6755's scope lock left open:
* that ruling was written before this arm existed, so it named three sentences
* and this is the fourth. All three arms share ONE `<p>` and one
* `refusalError`, so leaving this one a literal made a single line speak the
* reader's language on two arms and English on the third.
*
* ⭐ ARITY — the one question the other two arms did not have to answer.
* `verb` was English GRAMMAR (`is not a number` / `are not numbers`), and a
* pack whose plural rules differ cannot inflect around an English verb form
* handed to it through a hole. So the verb is not a hole: it lives inside two
* SIBLING KEYS picked here, at the call site.
*
* ⛔ Deliberately NOT i18next's `_one`/`_other` suffixes. This repo's own
* plural convention is a `X` / `XOne` pair branched at the call site —
* `lookup.recordCount`/`recordCountOne` in this very defaults map
* (`RecordPickerDialog`), `list.recordCount`/`recordCountOne`,
* `detail.reactionCount`/`reactionCountOne` — because zh/ja/ko have no separate
* singular form and would legitimately omit a `_one` half, which
* `all-locales-key-parity` reads as a missing key (objectstack#5430). The
* suffix form also needs a base key to cover the categories no pack enumerates
* (`ru` few/many, `ar` two/zero — objectui#3863); the sibling pair needs none.
*
* The arity here is exactly binary and always will be: `residue` is
* `COORDINATE_LABELS` filtered, and a draft that is not two comma-separated
* parts never reaches this arm. So `ar` can use its DUAL in the two-half value,
* which is precisely what an English `verb` hole made impossible.
*
* `named`'s two components are split the same way rather than pre-joined:
* `' and '` was an English conjunction and `latitude`/`longitude` are
* translatable nouns (every pack already spells them inside its own
* `refusedFormat`). The nouns are keyed ONCE each and interpolated into both
* values, so no locale has to keep two spellings of the same word in step. The
* only holes carrying untranslated data are `{{text}}`/`{{otherText}}` — the
* characters the person actually typed, which no pack should touch.
*/
function refusedResidueMessage(t: TranslateFn, residue: readonly ResidueHalf[]): string {
const [first, second] = residue.map(half => ({ name: coordinateName(t, half.label), text: half.text }));
if (!second) return t('fields.location.refusedResidueOne', { name: first.name, text: first.text });
return t('fields.location.refusedResidue', {
name: first.name,
text: first.text,
otherName: second.name,
otherText: second.text,
});
}

/**
* The reader's word for one coordinate.
*
* ⛔ Written as a branch over STRING LITERALS rather than a computed
* `t(`fields.location.${label}`)`: `check:i18n-keys` and `check:i18n-dead-keys`
* both read `t()` literals, so a template key would be invisible to the gate
* that proves it resolves AND to the one that proves it is still used.
*/
function refusedResidueMessage(residue: readonly ResidueHalf[]): string {
const named = residue.map(half => `${half.label} "${half.text}"`).join(' and ');
const verb = residue.length > 1 ? 'are not numbers' : 'is not a number';
return `Not saved: ${named} ${verb}. Enter plain decimals (example: 30.2741, 120.1551).`;
function coordinateName(t: TranslateFn, label: CoordinateLabel): string {
return label === 'latitude' ? t('fields.location.latitude') : t('fields.location.longitude');
}

/**
Expand Down Expand Up @@ -452,7 +507,7 @@ export function LocationField({ value, onChange, field, readonly, error, ...prop
// `setRefusalError` the other two arms use — a third SILENT refusal is
// precisely the defect objectui#6716 had just finished removing, which is
// why this card was held until #6716 landed.
setRefusalError(refusedResidueMessage(parsed.residue));
setRefusalError(refusedResidueMessage(t as TranslateFn, parsed.residue));
return;
}

Expand Down
22 changes: 22 additions & 0 deletions packages/fields/src/widgets/useFieldTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ const FIELD_DEFAULTS: Record<string, string> = {
'fields.location.refusedFormat':
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).',
'fields.location.refusedRange': 'Not saved: {{detail}}',
// objectui#6888 — the THIRD refusal arm (`12abc, 34`), added by objectui#6715
// after #6755's ruling was written and therefore outside the three sentences
// it named. All three arms share one `<p>`, so a literal here made ONE line
// bilingual rather than one screen.
//
// Two SIBLING keys, not an i18next `_one`/`_other` family: the verb is
// English grammar and cannot be a hole, and this repo's plural convention is
// the `X`/`XOne` pair branched at the call site (`lookup.recordCount` /
// `recordCountOne` above is the same map's own instance). The coordinate
// NOUNS are keyed once each and interpolated into both, so a locale spells
// `latitude` in exactly one place. `{{text}}`/`{{otherText}}` are what the
// person typed and stay untouched by every pack.
//
// Values are byte-identical to the literal they replace, in both arities:
// `Not saved: latitude "12abc" is not a number. …` and
// `… latitude "12abc" and longitude "34xyz" are not numbers. …`.
'fields.location.latitude': 'latitude',
'fields.location.longitude': 'longitude',
'fields.location.refusedResidueOne':
'Not saved: {{name}} "{{text}}" is not a number. Enter plain decimals (example: 30.2741, 120.1551).',
'fields.location.refusedResidue':
'Not saved: {{name}} "{{text}}" and {{otherName}} "{{otherText}}" are not numbers. Enter plain decimals (example: 30.2741, 120.1551).',
// objectui#3342 — the tags widget's input hint. Used only when the field
// author declared no `placeholder` of their own (author declaration wins).
'fields.tags.placeholder': 'Type and press Enter to add…',
Expand Down
12 changes: 10 additions & 2 deletions packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,13 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// placeholder suggests („Falsche Datensatz-ID — …“) — one LITERAL span, like
// `timeline.unsupported.objectBoundGantt` above rather than the interpolated
// ones, because the quoted thing is sample prose this pack authored.
expect(okSpans, 'correctly paired spans').toBe(59);
// 62 once objectui#6888 keyed `LocationField`'s residue refusal: the
// one-half sentence quotes the offending text („{{text}}“) and the two-half
// one quotes both („{{text}}“ / „{{otherText}}“) — three interpolated
// spans, all runtime data, like the `ActivityTimeline` pair above. The two
// new coordinate NOUN keys (`fields.location.latitude`/`longitude`) carry
// no quotes at all: they are interpolated INTO those spans, not around them.
expect(okSpans, 'correctly paired spans').toBe(62);
});

it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
Expand All @@ -305,7 +311,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// each new value added a MATCHED „…“ pair, not a stray closer that would
// have made `close === open` true for the wrong reason. 59 / 59 / 0 after
// objectui#7173 added `aiApprovals.rejectPlaceholder`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 59, close: 59, rdq: 0 });
// 62 / 62 / 0 after objectui#6888 keyed `LocationField`'s residue refusal —
// three more matched pairs across its two arity siblings, and `rdq` still 0.
expect({ open, close, rdq }).toEqual({ open: 62, close: 62, rdq: 0 });
// The durable shape: every „ closed by a “, every surplus “ an English
// opener answered by a ”. Survived translating the two English values.
expect(close).toBe(open + rdq);
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ const ar = {
refusedFormat:
"لم يتم الحفظ: أدخل زوجًا من خط العرض وخط الطول (مثال: 30.2741, 120.1551).",
refusedRange: "لم يتم الحفظ: {{detail}}",
latitude: "خط العرض",
longitude: "خط الطول",
refusedResidueOne:
"لم يتم الحفظ: {{name}} «{{text}}» ليس رقمًا. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).",
refusedResidue:
"لم يتم الحفظ: {{name}} «{{text}}» و{{otherName}} «{{otherText}}» ليسا رقمين. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).",
},
tags: {
placeholder: "اكتب واضغط Enter للإضافة…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ const de = {
refusedFormat:
"Nicht gespeichert: Geben Sie ein Paar aus Breitengrad, Längengrad ein (Beispiel: 30.2741, 120.1551).",
refusedRange: "Nicht gespeichert: {{detail}}",
latitude: "Breitengrad",
longitude: "Längengrad",
refusedResidueOne:
"Nicht gespeichert: {{name}} „{{text}}“ ist keine Zahl. Geben Sie einfache Dezimalzahlen ein (Beispiel: 30.2741, 120.1551).",
refusedResidue:
"Nicht gespeichert: {{name}} „{{text}}“ und {{otherName}} „{{otherText}}“ sind keine Zahlen. Geben Sie einfache Dezimalzahlen ein (Beispiel: 30.2741, 120.1551).",
},
tags: {
placeholder: "Tippen und mit der Eingabetaste hinzufügen…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ const en = {
refusedFormat:
'Not saved: enter a latitude, longitude pair (example: 30.2741, 120.1551).',
refusedRange: 'Not saved: {{detail}}',
latitude: 'latitude',
longitude: 'longitude',
refusedResidueOne:
'Not saved: {{name}} "{{text}}" is not a number. Enter plain decimals (example: 30.2741, 120.1551).',
refusedResidue:
'Not saved: {{name}} "{{text}}" and {{otherName}} "{{otherText}}" are not numbers. Enter plain decimals (example: 30.2741, 120.1551).',
},
// objectui#3342 — the tags widget's input hint, shown while the tag list
// is empty. The author-declared `field.placeholder` always wins over this.
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ const es = {
refusedFormat:
"No guardado: introduce un par latitud, longitud (ejemplo: 30.2741, 120.1551).",
refusedRange: "No guardado: {{detail}}",
latitude: "latitud",
longitude: "longitud",
refusedResidueOne:
"No guardado: {{name}} «{{text}}» no es un número. Introduce decimales simples (ejemplo: 30.2741, 120.1551).",
refusedResidue:
"No guardado: {{name}} «{{text}}» y {{otherName}} «{{otherText}}» no son números. Introduce decimales simples (ejemplo: 30.2741, 120.1551).",
},
tags: {
placeholder: "Escriba y pulse Intro para añadir…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ const fr = {
refusedFormat:
"Non enregistré : saisissez une paire latitude, longitude (exemple : 30.2741, 120.1551).",
refusedRange: "Non enregistré : {{detail}}",
latitude: "latitude",
longitude: "longitude",
refusedResidueOne:
"Non enregistré : {{name}} « {{text}} » n'est pas un nombre. Saisissez des décimales simples (exemple : 30.2741, 120.1551).",
refusedResidue:
"Non enregistré : {{name}} « {{text}} » et {{otherName}} « {{otherText}} » ne sont pas des nombres. Saisissez des décimales simples (exemple : 30.2741, 120.1551).",
},
tags: {
placeholder: "Saisissez puis appuyez sur Entrée pour ajouter…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ const ja = {
refusedFormat:
"保存されていません: 緯度, 経度 の組で入力してください(例: 30.2741, 120.1551)。",
refusedRange: "保存されていません: {{detail}}",
latitude: "緯度",
longitude: "経度",
refusedResidueOne:
"保存されていません: {{name}}「{{text}}」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。",
refusedResidue:
"保存されていません: {{name}}「{{text}}」と{{otherName}}「{{otherText}}」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。",
},
tags: {
placeholder: "入力してEnterキーで追加…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ const ko = {
refusedFormat:
"저장되지 않았습니다: 위도, 경도 쌍으로 입력하세요(예: 30.2741, 120.1551).",
refusedRange: "저장되지 않았습니다: {{detail}}",
latitude: "위도",
longitude: "경도",
refusedResidueOne:
"저장되지 않았습니다: {{name}} “{{text}}”은(는) 숫자가 아닙니다. 일반 소수로 입력하세요(예: 30.2741, 120.1551).",
refusedResidue:
"저장되지 않았습니다: {{name}} “{{text}}”과(와) {{otherName}} “{{otherText}}”은(는) 숫자가 아닙니다. 일반 소수로 입력하세요(예: 30.2741, 120.1551).",
},
tags: {
placeholder: "입력 후 Enter 키로 추가…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ const pt = {
refusedFormat:
"Não salvo: informe um par latitude, longitude (exemplo: 30.2741, 120.1551).",
refusedRange: "Não salvo: {{detail}}",
latitude: "latitude",
longitude: "longitude",
refusedResidueOne:
"Não salvo: {{name}} “{{text}}” não é um número. Informe decimais simples (exemplo: 30.2741, 120.1551).",
refusedResidue:
"Não salvo: {{name}} “{{text}}” e {{otherName}} “{{otherText}}” não são números. Informe decimais simples (exemplo: 30.2741, 120.1551).",
},
tags: {
placeholder: "Digite e pressione Enter para adicionar…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ const ru = {
refusedFormat:
"Не сохранено: введите пару широта, долгота (например: 30.2741, 120.1551).",
refusedRange: "Не сохранено: {{detail}}",
latitude: "широта",
longitude: "долгота",
refusedResidueOne:
"Не сохранено: {{name}} «{{text}}» — не число. Введите обычные десятичные дроби (например: 30.2741, 120.1551).",
refusedResidue:
"Не сохранено: {{name}} «{{text}}» и {{otherName}} «{{otherText}}» — не числа. Введите обычные десятичные дроби (например: 30.2741, 120.1551).",
},
tags: {
placeholder: "Введите и нажмите Enter, чтобы добавить…",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ const zh = {
refusedFormat:
'未保存:请输入纬度, 经度坐标对(例如 30.2741, 120.1551)。',
refusedRange: '未保存:{{detail}}',
latitude: '纬度',
longitude: '经度',
refusedResidueOne:
'未保存:{{name}}“{{text}}”不是数字。请输入普通小数(例如 30.2741, 120.1551)。',
refusedResidue:
'未保存:{{name}}“{{text}}”和{{otherName}}“{{otherText}}”不是数字。请输入普通小数(例如 30.2741, 120.1551)。',
},
tags: {
placeholder: '输入后回车添加…',
Expand Down
Loading