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.
16 changes: 16 additions & 0 deletions .changeset/gantt-link-rejection-feedback-4158.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/plugin-gantt': patch
'@object-ui/i18n': patch
---

An illegal gantt dependency link now says why it was refused, instead of doing nothing

Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target *had* been registered, so the drop itself was a no-op.

Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.

During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.

Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.

One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
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.',
);
});
});
12 changes: 10 additions & 2 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,14 @@ const ar = {
start: "بداية",
end: "نهاية",
},
link: {
rejected: {
self: "لا يمكن أن تعتمد المهمة على نفسها.",
locked: "هذا الصف مقفل ولا يمكنه استقبال تبعية جديدة.",
group: "لا يمكن لصف التلخيص استقبال تبعية — اربط إحدى مهامه بدلاً من ذلك.",
cycle: "هذا الرابط سينشئ تبعية دائرية.",
},
},
conflict: {
title: "تعارض في الجدولة",
body: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟",
Expand All @@ -684,8 +692,8 @@ const ar = {
},
autoScheduleDlg: {
title: "الجدولة التلقائية",
body: "هل تريد تأخير {count} من المهام لتلبية روابط التبعية؟",
skipped: "{count} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.",
body: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟",
skipped: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.",
confirm: "تطبيق",
cancel: "إلغاء",
none: "جميع التبعيات مستوفاة — لا شيء لإعادة جدولته.",
Expand Down
12 changes: 10 additions & 2 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ const de = {
start: "Anfang",
end: "Ende",
},
link: {
rejected: {
self: "Ein Vorgang kann nicht von sich selbst abhängen.",
locked: "Diese Zeile ist gesperrt und kann keine neue Abhängigkeit aufnehmen.",
group: "Eine Sammelzeile kann keine Abhängigkeit aufnehmen — verknüpfen Sie stattdessen einen ihrer Vorgänge.",
cycle: "Diese Verknüpfung würde eine zirkuläre Abhängigkeit erzeugen.",
},
},
conflict: {
title: "Terminkonflikt",
body: "Diese Verschiebung verstößt gegen Abhängigkeitsbedingungen. {{count}} betroffene Vorgänge automatisch neu planen?",
Expand All @@ -680,8 +688,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
19 changes: 15 additions & 4 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,14 @@ const en = {
start: 'start',
end: 'end',
},
link: {
rejected: {
self: 'A task cannot depend on itself.',
locked: 'This row is locked and cannot take a new dependency.',
group: 'A summary row cannot take a dependency — link one of its tasks instead.',
cycle: 'That link would create a circular dependency.',
},
},
conflict: {
title: 'Schedule conflict',
body: 'This move conflicts with dependency constraints. Auto-reschedule {{count}} affected task(s)?',
Expand All @@ -757,8 +765,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 +782,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
12 changes: 10 additions & 2 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,14 @@ const es = {
start: "Inicio",
end: "Fin",
},
link: {
rejected: {
self: "Una tarea no puede depender de sí misma.",
locked: "Esta fila está bloqueada y no puede recibir una nueva dependencia.",
group: "Una fila de resumen no puede recibir dependencias: vincule una de sus tareas.",
cycle: "Ese vínculo crearía una dependencia circular.",
},
},
conflict: {
title: "Conflicto de programación",
body: "Este movimiento entra en conflicto con las restricciones de dependencia. ¿Reprogramar automáticamente {{count}} tarea(s) afectada(s)?",
Expand All @@ -684,8 +692,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
12 changes: 10 additions & 2 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ const fr = {
start: "Début",
end: "Fin",
},
link: {
rejected: {
self: "Une tâche ne peut pas dépendre d’elle-même.",
locked: "Cette ligne est verrouillée et ne peut pas recevoir de nouvelle dépendance.",
group: "Une ligne récapitulative ne peut pas recevoir de dépendance — reliez plutôt l’une de ses tâches.",
cycle: "Ce lien créerait une dépendance circulaire.",
},
},
conflict: {
title: "Conflit de planning",
body: "Ce déplacement entre en conflit avec les contraintes de dépendance. Replanifier automatiquement {{count}} tâche(s) concernée(s) ?",
Expand All @@ -680,8 +688,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
12 changes: 10 additions & 2 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ const ja = {
start: "開始",
end: "終了",
},
link: {
rejected: {
self: "タスクを自分自身に依存させることはできません。",
locked: "この行はロックされているため、依存関係を追加できません。",
group: "サマリー行は依存先にできません。配下のタスクに接続してください。",
cycle: "この接続は循環依存になります。",
},
},
conflict: {
title: "スケジュールの競合",
body: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?",
Expand All @@ -680,8 +688,8 @@ const ja = {
},
autoScheduleDlg: {
title: "自動スケジュール",
body: "依存リンクを満たすため {count} 件のタスクを後ろにずらしますか?",
skipped: "ロックされた {count} 件のタスクもリンクに違反していますが、スキップされました。",
body: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?",
skipped: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。",
confirm: "適用",
cancel: "キャンセル",
none: "依存関係はすべて満たされています — 再スケジュールの必要はありません。",
Expand Down
Loading