diff --git a/.changeset/gantt-count-interpolation-4157.md b/.changeset/gantt-count-interpolation-4157.md new file mode 100644 index 0000000000..20fc1ddb8b --- /dev/null +++ b/.changeset/gantt-count-interpolation-4157.md @@ -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. diff --git a/.changeset/gantt-link-rejection-feedback-4158.md b/.changeset/gantt-link-rejection-feedback-4158.md new file mode 100644 index 0000000000..a3258cb302 --- /dev/null +++ b/.changeset/gantt-link-rejection-feedback-4158.md @@ -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. diff --git a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 2c9039f15a..e5217689de 100644 --- a/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -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 = /(? diff --git a/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts b/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts new file mode 100644 index 0000000000..8419cc216b --- /dev/null +++ b/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts @@ -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 = /(? + dotted + .split('.') + .reduce((n, p) => (n as Record | undefined)?.[p], (builtInLocales as Record)[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.', + ); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 17a4c9d325..d616c041a1 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -676,6 +676,14 @@ const ar = { start: "بداية", end: "نهاية", }, + link: { + rejected: { + self: "لا يمكن أن تعتمد المهمة على نفسها.", + locked: "هذا الصف مقفل ولا يمكنه استقبال تبعية جديدة.", + group: "لا يمكن لصف التلخيص استقبال تبعية — اربط إحدى مهامه بدلاً من ذلك.", + cycle: "هذا الرابط سينشئ تبعية دائرية.", + }, + }, conflict: { title: "تعارض في الجدولة", body: "يتعارض هذا النقل مع قيود التبعية. هل تريد إعادة جدولة {{count}} من المهام المتأثرة تلقائيًا؟", @@ -684,8 +692,8 @@ const ar = { }, autoScheduleDlg: { title: "الجدولة التلقائية", - body: "هل تريد تأخير {count} من المهام لتلبية روابط التبعية؟", - skipped: "{count} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", + body: "هل تريد تأخير {{count}} من المهام لتلبية روابط التبعية؟", + skipped: "{{count}} من المهام المقفلة تخالف الروابط أيضًا وقد تم تخطيها.", confirm: "تطبيق", cancel: "إلغاء", none: "جميع التبعيات مستوفاة — لا شيء لإعادة جدولته.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 04046308e6..5128ecf42a 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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?", @@ -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.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 97c44c0305..1641fe60ac 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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)?', @@ -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.', @@ -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', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 8f7ae2d842..90ee4e7862 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -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)?", @@ -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.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f323806569..19e15852c6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -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) ?", @@ -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.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 95e14da163..3cc3ed97a9 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -672,6 +672,14 @@ const ja = { start: "開始", end: "終了", }, + link: { + rejected: { + self: "タスクを自分自身に依存させることはできません。", + locked: "この行はロックされているため、依存関係を追加できません。", + group: "サマリー行は依存先にできません。配下のタスクに接続してください。", + cycle: "この接続は循環依存になります。", + }, + }, conflict: { title: "スケジュールの競合", body: "この移動は依存関係の制約と競合します。影響を受ける {{count}} 件のタスクを自動で再スケジュールしますか?", @@ -680,8 +688,8 @@ const ja = { }, autoScheduleDlg: { title: "自動スケジュール", - body: "依存リンクを満たすため {count} 件のタスクを後ろにずらしますか?", - skipped: "ロックされた {count} 件のタスクもリンクに違反していますが、スキップされました。", + body: "依存リンクを満たすため {{count}} 件のタスクを後ろにずらしますか?", + skipped: "ロックされた {{count}} 件のタスクもリンクに違反していますが、スキップされました。", confirm: "適用", cancel: "キャンセル", none: "依存関係はすべて満たされています — 再スケジュールの必要はありません。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c66cf795dd..a5b405a690 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -672,6 +672,14 @@ const ko = { start: "시작", end: "종료", }, + link: { + rejected: { + self: "작업은 자기 자신에 종속될 수 없습니다.", + locked: "이 행은 잠겨 있어 새 종속성을 추가할 수 없습니다.", + group: "요약 행은 종속 대상이 될 수 없습니다. 하위 작업에 연결하세요.", + cycle: "이 연결은 순환 종속성을 만듭니다.", + }, + }, conflict: { title: "일정 충돌", body: "이 이동은 종속성 제약과 충돌합니다. 영향을 받는 작업 {{count}}건의 일정을 자동으로 조정할까요?", @@ -680,8 +688,8 @@ const ko = { }, autoScheduleDlg: { title: "자동 일정 조정", - body: "종속성 연결을 충족하도록 작업 {count}건을 뒤로 미룰까요?", - skipped: "잠긴 작업 {count}건도 연결을 위반하지만 건너뛰었습니다.", + body: "종속성 연결을 충족하도록 작업 {{count}}건을 뒤로 미룰까요?", + skipped: "잠긴 작업 {{count}}건도 연결을 위반하지만 건너뛰었습니다.", confirm: "적용", cancel: "취소", none: "모든 종속성이 충족되었습니다 — 조정할 일정이 없습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index c291de0961..93a676dbaa 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -671,6 +671,14 @@ const pt = { start: "Início", end: "Fim", }, + link: { + rejected: { + self: "Uma tarefa não pode depender de si mesma.", + locked: "Esta linha está bloqueada e não pode receber uma nova dependência.", + group: "Uma linha de resumo não pode receber dependências: vincule uma das suas tarefas.", + cycle: "Esse vínculo criaria uma dependência circular.", + }, + }, conflict: { title: "Conflito de agendamento", body: "Esta movimentação conflita com as restrições de dependência. Reagendar automaticamente {{count}} tarefa(s) afetada(s)?", @@ -679,8 +687,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.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 7adf593a54..83d063c1de 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -678,6 +678,14 @@ const ru = { start: "Начало", end: "Конец", }, + link: { + rejected: { + self: "Задача не может зависеть от самой себя.", + locked: "Эта строка заблокирована и не может принять новую зависимость.", + group: "Сводная строка не может быть целью зависимости — свяжите одну из её задач.", + cycle: "Эта связь создаст циклическую зависимость.", + }, + }, conflict: { title: "Конфликт расписания", body: "Это перемещение противоречит ограничениям зависимостей. Автоматически перепланировать затронутые задачи ({{count}})?", @@ -686,8 +694,8 @@ const ru = { }, autoScheduleDlg: { title: "Автопланирование", - body: "Сдвинуть {count} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?", - skipped: "{count} заблокированных задач(и) также нарушают связи и были пропущены.", + body: "Сдвинуть {{count}} задач(и) на более поздний срок, чтобы соблюсти связи зависимостей?", + skipped: "{{count}} заблокированных задач(и) также нарушают связи и были пропущены.", confirm: "Применить", cancel: "Отмена", none: "Все зависимости соблюдены — перепланировать нечего.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 79f694f206..4ddf351e5e 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -721,6 +721,14 @@ const zh = { start: '开始', end: '结束', }, + link: { + rejected: { + self: "任务不能依赖自身。", + locked: "该行已锁定,无法新增依赖。", + group: "汇总行不能作为依赖目标,请改为连接其下的具体任务。", + cycle: "该连接会形成循环依赖。", + }, + }, conflict: { title: '排程冲突', body: '此次移动与依赖约束冲突。是否自动重新排程 {{count}} 个受影响的任务?', @@ -729,8 +737,8 @@ const zh = { }, autoScheduleDlg: { title: '自动排程', - body: '将顺延 {count} 个任务以满足依赖约束,是否执行?', - skipped: '另有 {count} 项因锁定/无权限跳过。', + body: '将顺延 {{count}} 个任务以满足依赖约束,是否执行?', + skipped: '另有 {{count}} 项因锁定/无权限跳过。', confirm: '执行', cancel: '取消', none: '依赖均满足,无需排程', diff --git a/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx b/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx new file mode 100644 index 0000000000..3ea36fdaf3 --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.countinterp.i18n.test.tsx @@ -0,0 +1,178 @@ +/** + * The gantt's two write-confirmation dialogs interpolate their `{{count}}` + * through i18next, under a real loaded locale pack (objectui#4157). + * + * ## Why this has to render inside an I18nProvider + * + * The reported symptom — a literal `{2}` in the conflict dialog — is invisible + * to a provider-less render. `useGanttTranslation` falls back to the plugin's + * own `GANTT_DEFAULT_TRANSLATIONS` when the host returns the key unchanged, and + * the bundled default was authored with the same SINGLE-brace spelling the old + * `t(key).replace('{count}', n)` call site expected, so the fallback path + * rendered correctly and masked the bug. It only appears once a pack is loaded: + * every pack spells the placeholder `{{count}}` (i18next convention), and + * `"…{{count}}…".replace("{count}", "2")` eats the inner seven characters and + * leaves `{2}`. + * + * So each case here is pinned under BOTH `en` and `zh`. `en` is not redundant + * with the provider-less tests: the `en` *pack* is a different string source + * from the bundled fallback map, and it carried the same defect. + * + * The auto-schedule dialog is the control. Its two keys were never broken — + * pack and call site both used single braces — so these cases were green before + * the fix and stay green after it. They fail only if the packs are converted + * without the call site (or the reverse), which is exactly the half-migration + * that produced the defect. + */ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +// Same package `useGanttTranslation` reads `useObjectTranslation` from, so the +// provider's i18next instance is the one GanttView resolves against. +import { I18nProvider } from '@object-ui/react'; +import { GanttView, type GanttTask } from './GanttView'; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); +}); + +const D = (s: string) => new Date(s); + +function makeTask(id: string, start: string, end: string, extra: Partial = {}): GanttTask { + return { id, title: `Task ${id}`, start: D(start), end: D(end), progress: 0, ...extra }; +} + +/** + * Render pinned to `lang`. `detectBrowserLanguage: false` + `persistLanguage` + * are load-bearing: the provider bootstrap otherwise reads a persisted language + * out of localStorage and overrides `defaultLanguage`, which would make the + * `en` case depend on whether the `zh` case ran first. + * + * `onTaskUpdate` is equally load-bearing and is supplied by default: BOTH + * dialogs are gated on a write handler, and without one they never open, so + * every case here would fail on "the dialog did not open" instead of on the + * placeholder residue it means to pin. The bar only gets an `onPointerDown` + * when it is draggable (`canDrag`, GanttView.tsx), so the drag never commits + * and `maybeFlagConflict` never runs; the toolbar wand is rendered only under + * `autoSchedule && onTaskUpdate`, and `runAutoSchedule` returns early without + * it. Same gating as the sibling harnesses in `GanttView.interactions.test.tsx` + * and `GanttView.autoscheduledlg.test.tsx`. + */ +function renderIn( + lang: string, + tasks: GanttTask[], + props: Partial> = {}, +) { + return render( + +
+ +
+
, + ); +} + +function pointer(type: string, clientX: number, clientY = 100) { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + pointerType: 'mouse', + button: 0, + isPrimary: true, + } as PointerEventInit); +} + +/** Drag a bar horizontally by whole day-columns (columnWidth=110 at innerWidth=1280). */ +function dragBar(container: HTMLElement, id: string, deltaCols: number) { + const bar = container.querySelector(`[data-testid="gantt-task-bar-${id}"]`) as HTMLElement; + expect(bar, `no bar for ${id}`).toBeTruthy(); + const originX = 800; + fireEvent.pointerDown(bar, { button: 0, clientX: originX, clientY: 100 }); + act(() => { window.dispatchEvent(pointer('pointermove', originX + deltaCols * 110)); }); + act(() => { window.dispatchEvent(pointer('pointerup', originX + deltaCols * 110)); }); +} + +/** + * Every way an uninterpolated placeholder can reach the screen. `{2}` is the + * exact reported symptom (the single-brace `.replace` biting a `{{count}}` + * pack string); the other two are the failure modes of the opposite + * half-migration. + */ +function expectNoPlaceholderResidue(text: string, count: number) { + expect(text, 'the count never reached the sentence').toContain(String(count)); + expect(text, `rendered the reported literal {${count}}`).not.toContain(`{${count}}`); + expect(text, 'rendered a raw i18next placeholder').not.toContain('{{count}}'); + expect(text, 'rendered a raw single-brace placeholder').not.toContain('{count}'); +} + +describe('gantt conflict dialog interpolates its count under a loaded pack (objectui#4157)', () => { + // B depends on A (FS): A ends 06-13, B starts 06-17 with slack. + const linked = () => [ + makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }), + makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z', { + dependencies: [{ id: 'a', type: 'fs' }], + }), + ]; + + it.each(['en', 'zh'])('renders the number, not a placeholder, under a `%s` session', (lang) => { + const { container } = renderIn(lang, linked(), { rescheduleOnConflict: true }); + + // Drag B 6 days earlier → 06-11, which violates the FS link (A ends 06-13). + dragBar(container, 'b', -6); + + const dialog = container.querySelector('[data-testid="gantt-conflict-dialog"]'); + expect(dialog, 'the conflict dialog did not open').toBeTruthy(); + expectNoPlaceholderResidue(dialog!.textContent ?? '', 1); + }); + + it('serves the localized sentence, not the bundled English fallback, under `zh`', () => { + // Guards the other direction, and it is not hypothetical: `t(key, { count })` + // hands i18next its PLURAL selector, so a pack whose key has no plural form + // could in principle resolve to a miss, land on `useGanttTranslation`'s + // per-key English fallback, and "fix" the residue by un-localizing the + // sentence. This case is what proves the zh pack value is what reaches the + // screen. + const { container } = renderIn('zh', linked(), { rescheduleOnConflict: true }); + dragBar(container, 'b', -6); + + const text = container.querySelector('[data-testid="gantt-conflict-dialog"]')!.textContent ?? ''; + expect(text).toContain('自动重新排程'); + expect(text).not.toContain('Auto-reschedule'); + }); +}); + +describe('gantt auto-schedule dialog keeps interpolating its counts (objectui#4157 control)', () => { + // P is the predecessor; B1/B2 violate the link and will shift; C violates it + // too but is locked, so it is reported as skipped instead of moved. + const violating = (): GanttTask[] => [ + makeTask('p', '2024-06-01T00:00:00.000Z', '2024-06-10T00:00:00.000Z'), + makeTask('b1', '2024-06-05T00:00:00.000Z', '2024-06-08T00:00:00.000Z', { dependencies: ['p'] }), + makeTask('b2', '2024-06-05T00:00:00.000Z', '2024-06-07T00:00:00.000Z', { dependencies: ['p'] }), + makeTask('c', '2024-06-05T00:00:00.000Z', '2024-06-06T00:00:00.000Z', { dependencies: ['p'], locked: true }), + ]; + + it.each(['en', 'zh'])('body and skipped both interpolate under a `%s` session', (lang) => { + const { container, baseElement } = renderIn(lang, violating(), { autoSchedule: true }); + + const wand = container.querySelector('[data-testid="gantt-auto-schedule"]') as HTMLElement; + expect(wand, 'the auto-schedule wand is not in the toolbar').toBeTruthy(); + act(() => { fireEvent.click(wand); }); + + const dialog = baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]'); + expect(dialog, 'the auto-schedule dialog did not open').toBeTruthy(); + // Two unlocked violators shift; the locked one is reported separately. + expectNoPlaceholderResidue(dialog!.textContent ?? '', 2); + + const skipped = baseElement.querySelector('[data-testid="gantt-autoschedule-skipped"]'); + expect(skipped, 'the skipped notice did not render').toBeTruthy(); + expectNoPlaceholderResidue(skipped!.textContent ?? '', 1); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.linkrejection.test.tsx b/packages/plugin-gantt/src/GanttView.linkrejection.test.tsx new file mode 100644 index 0000000000..b182b07f24 --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.linkrejection.test.tsx @@ -0,0 +1,254 @@ +/** + * An illegal dependency link tells the user WHY it was refused (objectui#4158). + * + * ## What was wrong + * + * The built-in drop-target policy (`canReceiveLink`) correctly refuses four + * kinds of link — self, locked row, group row, and one that would close a + * dependency cycle — but it refused them as a bare boolean, and the refusal + * was invisible in both places a user could have noticed it: + * + * - **During the drag**: a bar only registers itself as the drop target when + * the policy accepts it, so a rejected bar never became `targetId`. It got + * no ring, no cursor change, and the rubber band never showed a target end. + * Hovering an illegal target and hovering empty space looked identical. + * - **On release**: `onUp` runs `if (cur.targetId != null && onDependencyCreate)`, + * and `targetId` was null, so the drop did nothing at all — no toast, no + * console warning. + * + * Users re-drew a legitimate-looking dependency over and over with no way to + * learn the constraint. + * + * ## What this pins + * + * Every reason is asserted SEPARATELY, against its own message. That is the + * point of the change: a single "link rejected" string would have satisfied a + * toast-was-raised assertion while telling the user nothing more than the + * silence did. The messages come from `gantt.link.rejected.`, whose + * leaves are the classifier's own branch names, so a new branch that forgets + * its message is a missing key rather than a wrong one. + * + * The legal-link control is what stops the feature from degenerating into + * "always toast": it must create the link and raise nothing. + */ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { toast } from 'sonner'; +import { GanttView, type GanttTask } from './GanttView'; +import { GANTT_DEFAULT_TRANSLATIONS } from './useGanttTranslation'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +beforeEach(() => { + vi.clearAllMocks(); + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); +}); + +const D = (s: string) => new Date(s); + +function makeTask(id: string, start: string, end: string, extra: Partial = {}): GanttTask { + return { id, title: `Task ${id}`, start: D(start), end: D(end), progress: 0, ...extra }; +} + +const A = () => makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }); +const B = () => makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z'); + +function renderView(tasks: GanttTask[], props: Partial> = {}) { + return render( +
+ +
, + ); +} + +function pointer(type: string, clientX: number, clientY = 100) { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + pointerType: 'mouse', + button: 0, + isPrimary: true, + } as PointerEventInit); +} + +/** Same drag the sibling harness in `GanttView.interactions.test.tsx` uses. */ +function dragLink(container: HTMLElement, fromDot: string, toBar: string) { + const dot = container.querySelector(`[data-testid="${fromDot}"]`) as HTMLElement; + expect(dot, `no link dot ${fromDot}`).toBeTruthy(); + fireEvent.pointerDown(dot, { button: 0, clientX: 600, clientY: 20 }); + act(() => { window.dispatchEvent(pointer('pointermove', 700, 60)); }); + const bar = container.querySelector(`[data-testid="${toBar}"]`) as HTMLElement; + expect(bar, `no bar ${toBar}`).toBeTruthy(); + fireEvent.pointerMove(bar, { clientX: 980, clientY: 60 }); + act(() => { window.dispatchEvent(pointer('pointerup', 980, 60)); }); +} + +/** Drag and HOLD over `toBar` — stops before release so hover state is readable. */ +function hoverLinkOver(container: HTMLElement, fromDot: string, toBar: string): HTMLElement { + const dot = container.querySelector(`[data-testid="${fromDot}"]`) as HTMLElement; + expect(dot, `no link dot ${fromDot}`).toBeTruthy(); + fireEvent.pointerDown(dot, { button: 0, clientX: 600, clientY: 20 }); + act(() => { window.dispatchEvent(pointer('pointermove', 700, 60)); }); + const bar = container.querySelector(`[data-testid="${toBar}"]`) as HTMLElement; + expect(bar, `no bar ${toBar}`).toBeTruthy(); + act(() => { fireEvent.pointerMove(bar, { clientX: 980, clientY: 60 }); }); + return bar; +} + +const messageOf = (reason: string) => GANTT_DEFAULT_TRANSLATIONS[`gantt.link.rejected.${reason}`]; + +/** + * The four illegal drops, each against its OWN message. Every case also + * asserts the link was not created — the guard's existing behaviour, which + * the feedback must not weaken into "warn and allow". + */ +describe('rejected dependency link explains itself (objectui#4158)', () => { + it('cycle: dropping onto a task the source already depends on', () => { + const onDependencyCreate = vi.fn(); + // a already depends on b (edge b→a); dragging a→b would close the loop. + const a = makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { dependencies: ['b'] }); + const { container } = renderView([a, B()], { onDependencyCreate }); + + dragLink(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(onDependencyCreate).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.error).toHaveBeenCalledWith(messageOf('cycle')); + }); + + it('locked: dropping onto a 仅查看 row', () => { + const onDependencyCreate = vi.fn(); + const lockedB = makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z', { locked: true }); + const { container } = renderView([A(), lockedB], { onDependencyCreate }); + + dragLink(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(onDependencyCreate).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith(messageOf('locked')); + }); + + /** + * The `group` branch is the one reason with NO end-to-end case, and that is + * a property of the renderer rather than a gap in this file: a + * `type: 'group'` row is "a pure tree header — the timeline row carries NO + * bar" (`GanttView.tsx`), so there is nothing to hover, no bar to register + * a drop target, and the row's own `onPointerMove` is `clearLinkTarget`. + * The drag interaction therefore cannot reach the branch at all today. + * + * The message is kept regardless, because the branch is live in the + * classifier: dropping it would make a future group row that DOES render a + * bar surface the raw key `gantt.link.rejected.group` on screen, which is + * strictly worse than an unused string. This case pins the reachability + * fact instead, so it goes red the day group rows gain a bar — at which + * point whoever adds it owns wiring the toast and the end-to-end case. + * Filed as objectui#4209. + */ + it('group: the row renders no bar, so the drag cannot reach the branch', () => { + const onDependencyCreate = vi.fn(); + const group = makeTask('g', '2024-06-15T00:00:00.000Z', '2024-06-25T00:00:00.000Z', { type: 'group' }); + const { container } = renderView([A(), group], { onDependencyCreate }); + + expect(container.querySelector('[data-testid="gantt-task-bar-g"]'), 'group row grew a task bar').toBeFalsy(); + expect(container.querySelector('[data-testid="gantt-summary-bar-g"]'), 'group row grew a summary bar').toBeFalsy(); + // The message exists and is distinct, so the branch cannot render a raw key. + expect(typeof messageOf('group')).toBe('string'); + }); + + it('self: dropping a task onto its own bar', () => { + const onDependencyCreate = vi.fn(); + const { container } = renderView([A(), B()], { onDependencyCreate }); + + dragLink(container, 'gantt-link-dot-end-a', 'gantt-task-bar-a'); + + expect(onDependencyCreate).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith(messageOf('self')); + }); + + it('the four reasons carry four DIFFERENT messages', () => { + // A single shared string would satisfy every case above while telling the + // user no more than the silence did. + const messages = ['self', 'locked', 'group', 'cycle'].map(messageOf); + expect(messages.every((m) => typeof m === 'string' && m.length > 0)).toBe(true); + expect(new Set(messages).size).toBe(4); + }); +}); + +describe('a legal link is still created silently (objectui#4158 control)', () => { + it('creates the dependency and raises no toast', () => { + const onDependencyCreate = vi.fn(); + const { container } = renderView([A(), B()], { onDependencyCreate }); + + dragLink(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(onDependencyCreate).toHaveBeenCalledTimes(1); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('a host veto is NOT reported by the built-in policy', () => { + // `onBeforeDependencyCreate` is the HOST's decision and the host knows its + // own reason; the gantt has none to show. Surfacing it is the public + // rejection-reason API that objectui#4158 deliberately scoped out, so the + // built-in toast must stay quiet here rather than invent a message. + const onDependencyCreate = vi.fn(); + const onBeforeDependencyCreate = vi.fn().mockReturnValue(false); + const { container } = renderView([A(), B()], { onDependencyCreate, onBeforeDependencyCreate }); + + dragLink(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(onBeforeDependencyCreate).toHaveBeenCalledTimes(1); + expect(onDependencyCreate).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); + +/** + * The hover half of the affordance. Asserted through INLINE style, not through + * Tailwind classes, and that is deliberate rather than a shortcut: the bar's + * read-only cursor three lines away in `GanttView.tsx` is already driven from + * `style` because `cursor-not-allowed` is not emitted in the prebuilt + * components CSS. A class assertion would pass in jsdom and render nothing in + * a browser. + */ +describe('an illegal drop target refuses under the cursor (objectui#4158)', () => { + it('marks the rejected bar not-allowed while the link drag is live', () => { + const a = makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { dependencies: ['b'] }); + const { container } = renderView([a, B()], { onDependencyCreate: vi.fn() }); + + const bar = hoverLinkOver(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(bar.style.cursor).toBe('not-allowed'); + expect(bar.style.boxShadow, 'no rejection outline on the target row').toContain('destructive'); + }); + + it('leaves a LEGAL target alone — no not-allowed, no rejection outline', () => { + const { container } = renderView([A(), B()], { onDependencyCreate: vi.fn() }); + + const bar = hoverLinkOver(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + + expect(bar.style.cursor).not.toBe('not-allowed'); + expect(bar.style.boxShadow ?? '').not.toContain('destructive'); + }); + + it('clears the rejection when the pointer leaves the bar for empty row space', () => { + const a = makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { dependencies: ['b'] }); + const { container } = renderView([a, B()], { onDependencyCreate: vi.fn() }); + + const bar = hoverLinkOver(container, 'gantt-link-dot-end-a', 'gantt-task-bar-b'); + expect(bar.style.cursor).toBe('not-allowed'); + + // The row clears the target when the pointer is over empty row space + // (target === currentTarget), which is how the bar's own handler is bypassed. + const row = bar.parentElement as HTMLElement; + act(() => { fireEvent.pointerMove(row, { clientX: 1200, clientY: 60 }); }); + + expect(bar.style.cursor).not.toBe('not-allowed'); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index d6af5a1dd2..17acbcb8de 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -37,6 +37,7 @@ import { Separator, useResizeObserver, } from "@object-ui/components" +import { toast } from "sonner" import { computeCriticalPath, computeProjectRescheduleDetailed, wouldCreateDependencyCycle, type WorkingCalendar, type RescheduleChange, type RescheduleOptions } from "./scheduling" import { shiftDayStart, type NormShiftSegments } from "./shifts" import { useGanttTranslation } from "./useGanttTranslation" @@ -110,6 +111,16 @@ function rowHeightForContainer(width: number) { */ export type GanttLinkType = 'fs' | 'ss' | 'ff' | 'sf'; +/** + * Why the gantt's built-in drop-target policy refuses a dependency link + * (objectui#4158). These names are the leaves of `gantt.link.rejected.*`, so + * the message a user reads is selected by the same branch that did the + * refusing. Not a host-facing rejection channel — a host's own veto via + * `onBeforeDependencyCreate` carries a reason only the host knows, and + * surfacing that is a separate contract. + */ +export type GanttLinkRejection = 'self' | 'locked' | 'group' | 'cycle'; + export interface GanttDependencyObject { id: string | number type?: GanttLinkType @@ -1355,14 +1366,25 @@ export function GanttView({ // the hover target (no candidate highlight) and again on release (pointer // ordering isn't trusted): locked rows (仅查看) and group headers can't // receive a dependency, and a link that would close a cycle is rejected. - const canReceiveLink = React.useCallback( - (sourceId: string | number, target: GanttTask) => - String(target.id) !== String(sourceId) && - !target.locked && - target.type !== 'group' && - !wouldCreateDependencyCycle(dependencyEdges, sourceId, target.id), + // ONE classifier, two consumers. The hover affordance and the drop toast + // both read this verdict, so the reason a user is shown cannot drift from + // the reason the link was actually refused (objectui#4158) — the branch + // names ARE the `gantt.link.rejected.*` key leaves, so a new branch without + // a message shows up as a missing key rather than a wrong sentence. + const classifyLinkTarget = React.useCallback( + (sourceId: string | number, target: GanttTask): GanttLinkRejection | null => { + if (String(target.id) === String(sourceId)) return 'self'; + if (target.locked) return 'locked'; + if (target.type === 'group') return 'group'; + if (wouldCreateDependencyCycle(dependencyEdges, sourceId, target.id)) return 'cycle'; + return null; + }, [dependencyEdges], ); + const canReceiveLink = React.useCallback( + (sourceId: string | number, target: GanttTask) => classifyLinkTarget(sourceId, target) === null, + [classifyLinkTarget], + ); const contentRef = React.useRef(null); const [linkDrag, setLinkDrag] = React.useState<{ sourceId: string | number; @@ -1371,6 +1393,13 @@ export function GanttView({ y: number; targetId: string | number | null; targetEnd: 'start' | 'end' | null; + // The bar under the pointer that the policy REFUSED. Tracked separately + // from `targetId` because a refused bar must never become a drop target, + // yet the release handler still has to know which row was refused and + // why — before objectui#4158 that information was simply dropped, which + // is what made the rejection silent. + rejectedId: string | number | null; + rejectedReason: GanttLinkRejection | null; } | null>(null); const linkDragRef = React.useRef(null); React.useEffect(() => { linkDragRef.current = linkDrag; }, [linkDrag]); @@ -1406,6 +1435,13 @@ export function GanttView({ ) { onDependencyCreate(source, target, type); } + } else if (cur && cur.rejectedReason && onDependencyCreate) { + // The drop landed on a bar the policy refused. Before objectui#4158 + // this branch did not exist and the release was a no-op, so the user + // got no dialog, no toast and no console warning. The message is + // selected by the classifier's own verdict — not re-derived here, + // which would be a second classifier free to disagree with the guard. + toast.error(t(`gantt.link.rejected.${cur.rejectedReason}`)); } suppressNextClickRef.current = true; window.setTimeout(() => { suppressNextClickRef.current = false; }, 0); @@ -1419,7 +1455,7 @@ export function GanttView({ window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; - }, [linkDrag, tasks, onDependencyCreate, onBeforeDependencyCreate, canReceiveLink, dependencyTypes]); + }, [linkDrag, tasks, onDependencyCreate, onBeforeDependencyCreate, canReceiveLink, dependencyTypes, t]); // --- Context menu --------------------------------------------------------- const [ctxMenu, setCtxMenu] = React.useState<{ x: number; y: number; taskId: string | number } | null>(null); @@ -3842,6 +3878,23 @@ export function GanttView({ linkDrag.targetId != null && String(linkDrag.targetId) === String(task.id) && String(linkDrag.sourceId) !== String(task.id); + // The negative half of the same affordance (objectui#4158): + // this bar is under the pointer and the policy refused it. + // Unlike `isLinkTarget` the source bar is NOT excluded — a + // self-drop is one of the four refusals, and it is the one + // case where the bar under the pointer IS the source. + const isLinkRejected = + linkDrag != null && + linkDrag.rejectedId != null && + String(linkDrag.rejectedId) === String(task.id); + // Inline, not a utility class, for the same reason the + // read-only cursor below is inline: `cursor-not-allowed` + // and the ring alpha utilities are not emitted in the + // prebuilt components CSS, so a class here would look right + // in a DOM test and render nothing in a browser. + const linkRejectStyle = isLinkRejected + ? { cursor: 'not-allowed', boxShadow: '0 0 0 2px hsl(var(--destructive))' } + : undefined; // While a connector drag is live, bars report themselves as // the drop target on pointermove; the row clears it when the // pointer is over empty row space (target === currentTarget). @@ -3853,15 +3906,23 @@ export function GanttView({ const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const half: 'start' | 'end' = r.width > 0 && e.clientX - r.left > r.width / 2 ? 'end' : 'start'; - setLinkDrag((prev) => - prev && canReceiveLink(prev.sourceId, task) - ? { ...prev, targetId: task.id, targetEnd: half } - : prev - ); + setLinkDrag((prev) => { + if (!prev) return prev; + // One classification, used for both halves of the + // feedback: a refused bar records WHY here so the + // release handler can name it, and still never becomes + // a drop target (objectui#4158). + const rejection = classifyLinkTarget(prev.sourceId, task); + return rejection === null + ? { ...prev, targetId: task.id, targetEnd: half, rejectedId: null, rejectedReason: null } + : { ...prev, targetId: null, targetEnd: null, rejectedId: task.id, rejectedReason: rejection }; + }); } : undefined; const clearLinkTarget = linkDrag ? (e: React.PointerEvent) => { if (e.target === e.currentTarget) { - setLinkDrag((prev) => (prev ? { ...prev, targetId: null, targetEnd: null } : prev)); + setLinkDrag((prev) => + prev ? { ...prev, targetId: null, targetEnd: null, rejectedId: null, rejectedReason: null } : prev, + ); } } : undefined; const durationDays = Math.max(1, Math.round( @@ -3989,6 +4050,9 @@ export function GanttView({ backgroundColor: summaryColor, borderColor: isCrit ? CRIT_COLOR : task.borderColor || 'hsl(var(--primary-foreground) / 0.2)', boxShadow: isCrit ? `0 0 0 2px ${CRIT_COLOR}` : task.borderColor ? `0 0 0 2px ${task.borderColor}` : undefined, + // Last: the rejection affordance overrides both the cursor and the + // outline while a refused link drag hovers this bar. + ...linkRejectStyle, }} data-critical={isCrit ? 'true' : undefined} data-testid={`gantt-summary-bar-${task.id}`} @@ -4097,6 +4161,8 @@ export function GanttView({ y: rect ? e.clientY - rect.top : 0, targetId: null, targetEnd: null, + rejectedId: null, + rejectedReason: null, }); }} onClick={(e) => e.stopPropagation()} @@ -4161,6 +4227,9 @@ export function GanttView({ backgroundColor: isCrit ? CRIT_COLOR : task.color || '#3b82f6', borderColor: isCrit ? CRIT_COLOR : task.borderColor || 'hsl(var(--primary-foreground) / 0.2)', boxShadow: isCrit ? `0 0 0 2px ${CRIT_COLOR}` : task.borderColor ? `0 0 0 2px ${task.borderColor}` : undefined, + // Last: the rejection affordance overrides both the cursor and the + // outline while a refused link drag hovers this bar. + ...linkRejectStyle, }} data-critical={isCrit ? 'true' : undefined} data-testid={`gantt-milestone-${task.id}`} @@ -4238,6 +4307,9 @@ export function GanttView({ backgroundColor: task.color || '#3b82f6', borderColor: isCrit ? CRIT_COLOR : task.borderColor || 'hsl(var(--primary-foreground) / 0.2)', boxShadow: isCrit ? `0 0 0 2px ${CRIT_COLOR}` : task.borderColor ? `0 0 0 2px ${task.borderColor}` : undefined, + // Last: the rejection affordance overrides both the cursor and the + // outline while a refused link drag hovers this bar. + ...linkRejectStyle, }} data-critical={isCrit ? 'true' : undefined} data-testid={`gantt-task-bar-${task.id}`} @@ -4400,6 +4472,8 @@ export function GanttView({ y: rect ? e.clientY - rect.top : 0, targetId: null, targetEnd: null, + rejectedId: null, + rejectedReason: null, }); }} onClick={(e) => e.stopPropagation()} @@ -4921,12 +4995,12 @@ export function GanttView({ {t('gantt.autoScheduleDlg.title')}
- {t('gantt.autoScheduleDlg.body').replace('{count}', String(pendingAutoSchedule.changes.length))} + {t('gantt.autoScheduleDlg.body', { count: pendingAutoSchedule.changes.length })} {pendingAutoSchedule.skipped > 0 && ( <> {' '} - {t('gantt.autoScheduleDlg.skipped').replace('{count}', String(pendingAutoSchedule.skipped))} + {t('gantt.autoScheduleDlg.skipped', { count: pendingAutoSchedule.skipped })} )} @@ -4981,7 +5055,7 @@ export function GanttView({ {t('gantt.conflict.title')}
- {t('gantt.conflict.body').replace('{count}', String(pendingConflict.length))} + {t('gantt.conflict.body', { count: pendingConflict.length })}