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
14 changes: 14 additions & 0 deletions .changeset/gantt-count-interpolation-4157.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-gantt': patch
'@object-ui/i18n': patch
---

The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`

`gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.

The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.

`gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.

Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
13 changes: 10 additions & 3 deletions packages/i18n/src/__tests__/all-locales-key-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,16 @@ describe('all locale packs are at full key parity with en (objectui#2872)', () =

it('placeholders match en in every pack', () => {
// A translation that drops `{{count}}` renders a sentence with a hole in it
// and no error. Two gantt keys use SINGLE braces on purpose — their call
// site does a literal `.replace('{count}', …)` instead of i18next
// interpolation — so both forms are compared.
// and no error. `gantt.quickFilter.resultSummary` uses SINGLE braces on
// purpose — its call site does a literal `.replace('{shown}', …)` instead
// of i18next interpolation — so both forms are compared.
//
// NOTE this comparison is RELATIVE (en vs pack) and cannot see the defect
// in objectui#4157: every pack agreed with `en` on `{{count}}` while the
// render call site still did `.replace('{count}', …)`, so the shapes
// matched and this stayed green while the dialog showed a literal `{2}`.
// The absolute pack-vs-call-site form is pinned in
// `gantt-count-interpolation-4157.test.ts`.
const DOUBLE = /\{\{\w+\}\}/g;
const SINGLE = /(?<!\{)\{\w+\}(?!\})/g;
const shape = (v: unknown) =>
Expand Down
92 changes: 92 additions & 0 deletions packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* The three `{count}` gantt dialog strings use i18next `{{count}}`
* interpolation in every pack (objectui#4157).
*
* ## The defect this pins
*
* `gantt.conflict.body` was authored with SINGLE braces and resolved by a
* literal `t(key).replace('{count}', n)` at the call site. All ten packs were
* later written (correctly, by i18next convention) with `{{count}}` — and
* `"…{{count}}…".replace("{count}", "2")` consumes the INNER seven characters,
* leaving `{2}` on screen. The user-visible symptom was a literal `{2}` in the
* conflict dialog under every loaded locale.
*
* Nothing caught it, and that is the interesting part:
*
* - `all-locales-key-parity`'s placeholder check compares placeholder *shape*
* between packs. All ten packs agreed with each other, so it stayed green.
* - `check:i18n-en-drift` compares an `en` value against its own history — the
* packs never drifted from `en`, they were born matching it.
* - `check:i18n-call-site-keys` reads KEYS, never interpolation syntax.
*
* The invariant no existing gate can express is the **absolute** form: pack
* spelling versus the syntax the render call site actually resolves. This file
* asserts it directly, the same way `gantt-quickfilter-locale-parity.test.ts`
* pins the opposite (deliberately single-brace) convention for
* `gantt.quickFilter.resultSummary`.
*
* The two sibling keys (`autoScheduleDlg.body` / `.skipped`) were NOT broken —
* they were single-brace on both sides and rendered correctly. They are
* converted with the defect so the gantt's two write-confirmation dialogs stop
* carrying two different interpolation idioms three lines apart in
* `GanttView.tsx`, which is how the conflict key drifted in the first place.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

/** Dotted paths under `gantt.` whose call site passes `{ count }` to `t()`. */
const COUNT_KEYS = [
'conflict.body',
'autoScheduleDlg.body',
'autoScheduleDlg.skipped',
] as const;

const LANGS = Object.keys(builtInLocales);

/** A `{word}` NOT wrapped in a second pair of braces. */
const SINGLE_BRACE = /(?<!\{)\{\w+\}(?!\})/;

const at = (lang: string, dotted: string): string | undefined =>
dotted
.split('.')
.reduce<unknown>((n, p) => (n as Record<string, unknown> | undefined)?.[p], (builtInLocales as Record<string, unknown>)[lang]) as
| string
| undefined;

describe('gantt count-interpolation spelling (objectui#4157)', () => {
it('covers all ten built-in packs', () => {
expect(LANGS).toHaveLength(10);
});

it.each(LANGS)('%s spells every count placeholder as i18next {{count}}', (lang) => {
for (const key of COUNT_KEYS) {
const value = at(lang, `gantt.${key}`);
expect(typeof value, `${lang}.gantt.${key} is missing`).toBe('string');
expect(value, `${lang}.gantt.${key} lost its {{count}} placeholder`).toContain('{{count}}');
// The absolute form is the point: a pack respelled to `{count}` renders
// the raw placeholder now that the call site passes `{ count }` to
// i18next instead of doing a literal string replace.
expect(
SINGLE_BRACE.test(value!),
`${lang}.gantt.${key} still carries a single-brace placeholder: ${value}`,
).toBe(false);
}
});

it('the English pack still reads as the source of the bundled defaults', () => {
// Byte-exact: `plugin-gantt`'s standalone fallback map (used when the gantt
// is embedded without an I18nProvider) must agree with the `en` pack, or a
// provider-less embed disagrees with an `en` session. Asserted as literals
// rather than by importing the plugin — `@object-ui/plugin-gantt` depends
// on this package, so reading it back here would invert the dependency.
expect(at('en', 'gantt.conflict.body')).toBe(
'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?',
);
expect(at('en', 'gantt.autoScheduleDlg.body')).toBe(
'Shift {{count}} task(s) later to satisfy dependency links?',
);
expect(at('en', 'gantt.autoScheduleDlg.skipped')).toBe(
'{{count}} locked task(s) also violate links and were skipped.',
);
});
});
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,8 +684,8 @@ const ar = {
},
autoScheduleDlg: {
title: "الجدولة التلقائية",
body: "هل تريد تأخير {count} من المهام لتلبية روابط التبعية؟",
skipped: "{count} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.",
body: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟",
skipped: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.",
confirm: "تطبيق",
cancel: "إلغاء",
none: "جميع التبعيات مستوفاة — لا شيء لإعادة جدولته.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,8 +680,8 @@ const de = {
},
autoScheduleDlg: {
title: "Automatisch planen",
body: "{count} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?",
skipped: "{count} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.",
body: "{{count}} Vorgänge nach hinten verschieben, um die Abhängigkeiten einzuhalten?",
skipped: "{{count}} gesperrte Vorgänge verletzen die Verknüpfungen ebenfalls und wurden übersprungen.",
confirm: "Anwenden",
cancel: "Abbrechen",
none: "Alle Abhängigkeiten sind erfüllt — nichts neu zu planen.",
Expand Down
11 changes: 7 additions & 4 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,8 +757,8 @@ const en = {
},
autoScheduleDlg: {
title: 'Auto-schedule',
body: 'Shift {count} task(s) later to satisfy dependency links?',
skipped: '{count} locked task(s) also violate links and were skipped.',
body: 'Shift {{count}} task(s) later to satisfy dependency links?',
skipped: '{{count}} locked task(s) also violate links and were skipped.',
confirm: 'Apply',
cancel: 'Cancel',
none: 'All dependencies satisfied — nothing to reschedule.',
Expand All @@ -774,8 +774,11 @@ const en = {
clear: 'Clear filters',
empty: 'No options',
// SINGLE braces on purpose: the ObjectGantt call site resolves these
// with a literal `.replace('{shown}', …)`, not i18next interpolation
// (same convention as `autoScheduleDlg.body` above).
// with a literal `.replace('{shown}', …)`, not i18next interpolation.
// The last key in the gantt namespace on that idiom — `conflict.body`
// and the two `autoScheduleDlg` counts moved to `{{count}}` + i18next
// interpolation in objectui#4157, where the single-brace call site met
// a `{{count}}` pack and rendered a literal `{2}`.
resultSummary: 'Showing {shown} / {total} tasks',
},
readOnly: 'Read-only',
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,8 +684,8 @@ const es = {
},
autoScheduleDlg: {
title: "Programación automática",
body: "¿Retrasar {count} tarea(s) para respetar los vínculos de dependencia?",
skipped: "{count} tarea(s) bloqueada(s) también incumplen los vínculos y se han omitido.",
body: "¿Retrasar {{count}} tarea(s) para respetar los vínculos de dependencia?",
skipped: "{{count}} tarea(s) bloqueada(s) también incumplen los vínculos y se han omitido.",
confirm: "Aplicar",
cancel: "Cancelar",
none: "Todas las dependencias se cumplen: no hay nada que reprogramar.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,8 +680,8 @@ const fr = {
},
autoScheduleDlg: {
title: "Planification automatique",
body: "Décaler {count} tâche(s) plus tard pour respecter les liens de dépendance ?",
skipped: "{count} tâche(s) verrouillée(s) violent aussi les liens et ont été ignorées.",
body: "Décaler {{count}} tâche(s) plus tard pour respecter les liens de dépendance ?",
skipped: "{{count}} tâche(s) verrouillée(s) violent aussi les liens et ont été ignorées.",
confirm: "Appliquer",
cancel: "Annuler",
none: "Toutes les dépendances sont respectées — rien à replanifier.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,8 +680,8 @@ const ja = {
},
autoScheduleDlg: {
title: "自動スケジュール",
body: "依存リンクを満たすため {count} 件のタスクを後ろにずらしますか?",
skipped: "ロックされた {count} 件のタスクもリンクに違反していますが、スキップされました。",
body: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?",
skipped: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。",
confirm: "適用",
cancel: "キャンセル",
none: "依存関係はすべて満たされています — 再スケジュールの必要はありません。",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,8 +680,8 @@ const ko = {
},
autoScheduleDlg: {
title: "자동 일정 조정",
body: "종속성 연결을 충족하도록 작업 {count}건을 뒤로 미룰까요?",
skipped: "잠긴 작업 {count}건도 연결을 위반하지만 건너뛰었습니다.",
body: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?",
skipped: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.",
confirm: "적용",
cancel: "취소",
none: "모든 종속성이 충족되었습니다 — 조정할 일정이 없습니다.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,8 +679,8 @@ const pt = {
},
autoScheduleDlg: {
title: "Agendamento automático",
body: "Adiar {count} tarefa(s) para atender aos vínculos de dependência?",
skipped: "{count} tarefa(s) bloqueada(s) também violam os vínculos e foram ignoradas.",
body: "Adiar {{count}} tarefa(s) para atender aos vínculos de dependência?",
skipped: "{{count}} tarefa(s) bloqueada(s) também violam os vínculos e foram ignoradas.",
confirm: "Aplicar",
cancel: "Cancelar",
none: "Todas as dependências foram atendidas — nada a reagendar.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,8 +686,8 @@ const ru = {
},
autoScheduleDlg: {
title: "Автопланирование",
body: "Сдвинуть {count} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?",
skipped: "{count} заблокированных задач(и) также нарушают связи и были пропущены.",
body: "Сдвинуть {{count}} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?",
skipped: "{{count}} заблокированных задач(и) также нарушают связи и были пропущены.",
confirm: "Применить",
cancel: "Отмена",
none: "Все зависимости соблюдены — перепланировать нечего.",
Expand Down
4 changes: 2 additions & 2 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,8 +729,8 @@ const zh = {
},
autoScheduleDlg: {
title: '自动排程',
body: '将顺延 {count} 个任务以满足依赖约束,是否执行?',
skipped: '另有 {count} 项因锁定/无权限跳过。',
body: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?',
skipped: '另有 {{count}} 项因锁定/无权限跳过。',
confirm: '执行',
cancel: '取消',
none: '依赖均满足,无需排程',
Expand Down
Loading
Loading