Skip to content

fix(gantt): an illegal dependency link says why it was refused - #4210

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4158-gantt-link-feedback
Aug 11, 2026
Merged

fix(gantt): an illegal dependency link says why it was refused#4210
yinlianghui merged 2 commits into
mainfrom
claude/issue-4158-gantt-link-feedback

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4158

⚠️ Stacked on #4205 (claude/issue-4157-gantt-interpolation), not on main — both cards edit the same ten locale packs and the same two source files, so they would conflict as siblings. Base moves to main once #4205 lands. A stacked PR gets no CI, so the full local gate ladder is quoted below (the #4169 / #4189 pattern).

Scope

Per the PM ruling on the card: direction 1 only — hover affordance plus a per-reason toast on drop. Direction 2 (a public rejection-reason output such as surfacing onBeforeDependencyCreate's reason) is an API-surface addition and is deliberately not here; direction 3 is subsumed.

Premise re-verified on the rebased tip — it holds exactly as filed. The refusal was silent in both places it could have shown:

  • During the drag: captureLinkTarget only records a bar as targetId when the policy accepts it, so a refused bar never became the target — no ring, no cursor, and the rubber band never showed a target end. Hovering an illegal target and hovering empty space were indistinguishable.
  • On release: onUp ran its body only under cur.targetId != null, and targetId was null, so the drop was a complete no-op — no toast, no console warning.

One classifier, two consumers

canReceiveLink's four-branch boolean becomes classifyLinkTarget, which returns which branch refused (or null); the boolean is derived from it:

const classifyLinkTarget = React.useCallback(
  (sourceId, target) => {
    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 = (sourceId, target) => classifyLinkTarget(sourceId, target) === null;

The hover affordance and the drop toast both read that one verdict, per the ruling's "wire feedback off the guard's classification, do not build a second classifier" — so the reason a user is shown cannot drift from the reason the link was actually refused. 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.

The drag state gains rejectedId / rejectedReason: a refused bar still must never become a drop target, but the release handler has to know which row was refused and why.

The affordance is inline style, not utility classes

Both the cursor and the outline are driven from style, and that is load-bearing rather than a shortcut. The bar's existing read-only cursor three lines away already does this, with the reason recorded in the source: cursor-not-allowed and the ring alpha utilities are not emitted in the prebuilt components CSS. A cursor-not-allowed class would satisfy a DOM assertion and render nothing in a browser. The tests assert style.cursor / style.boxShadow for the same reason.

What deliberately stays silent

A host veto through onBeforeDependencyCreate raises nothing. That rejection carries a reason only the host knows and the gantt has none to show; inventing a generic message would also double-toast hosts that already report their own. Surfacing it is direction 2. Pinned as a control case so it cannot drift into "always toast".

Tests

packages/plugin-gantt/src/GanttView.linkrejection.test.tsx — 10 cases, red-first, on the sibling dragLink harness from GanttView.interactions.test.tsx.

Every reason is asserted against its own message, plus a case pinning that the four messages are distinct — a single shared "link rejected" string would have satisfied a toast-was-raised assertion while telling the user no more than the silence did. Controls: a legal link is created with no toast, and the host veto stays quiet.

group has no end-to-end case, and that is a renderer fact, not a gap here. A type: 'group' row renders no bar ("a pure tree header — the timeline row carries NO bar"), so nothing can hover it, and the row's own onPointerMove is clearLinkTarget. The drag cannot reach the branch at all. The message is kept regardless — dropping it would make the branch render the raw key gantt.link.rejected.group on screen if it ever fired — and the test pins the reachability fact instead, so it goes red the day group rows gain a bar. Filed as #4209, which also raises the adjacent question: the context-menu dependency picker creates links without consulting this policy at all.

Reverse verification

Reverted the three fix files to the #4157 tip with git checkout dd9b1098e -- …, keeping the new test. Predicted 7 red / 3 green, and the 3 green must be exactly the controls. Confirmed — Tests 7 failed | 3 passed:

cycle:  expected "vi.fn()" to be called 1 times, but got 0 times
locked: expected "vi.fn()" to be called with arguments: [ undefined ]
self:   expected "vi.fn()" to be called with arguments: [ undefined ]
group:  expected 'undefined' to be 'string'
cursor: expected 'pointer' to be 'not-allowed'

The [ undefined ] is itself the signature: messageOf(reason) reads the reverted default table, so the expected argument is undefined — the message did not exist and the toast was never raised. Green throughout: legal link creates + stays quiet, host veto stays quiet, legal target keeps its normal cursor. Restored with git checkout HEAD -- ….

Gate ladder (run locally — this PR gets no CI while stacked)

pnpm exec vitest run packages/plugin-gantt/  → Test Files 41 passed, Tests 365 passed
pnpm exec vitest run packages/i18n/          → Test Files 37 passed, Tests 650 passed
node scripts/check-i18n-call-site-keys.mjs   → green; en pack 2807 → 2811 keys
node scripts/check-i18n-en-drift.mjs         → green: 4 keys added, followed by all nine packs
node scripts/check-control-bytes.mjs         → OK (3897 files)
type-check (i18n + plugin-gantt)             → Done
lint (i18n + plugin-gantt)                   → 0 errors

New keys went through the full ten-pack discipline; the non-en translations mirror the convention of the last new-key PR (#4137) rather than inventing one.


Generated by Claude Code

claude added 2 commits August 10, 2026 23:08
…le-brace string replace (#4157)

`gantt.conflict.body` was resolved with `t(key).replace('{count}', n)` while
all ten packs spell the placeholder `{{count}}`. The replace consumed the inner
seven characters and left the outer pair, so every loaded pack rendered a
literal `{2}` in the conflict dialog. The call site now passes `{ count }` to
i18next, the idiom `gantt.delete.body` already used.

The two sibling keys (`autoScheduleDlg.body`, `.skipped`) were not broken --
pack and call site both used single braces -- but they are converted with it:
two write-confirmation dialogs three lines apart carrying two interpolation
idioms is the mechanism that let `conflict.body` drift in the first place. Only
the braces moved; no translation was reworded. `quickFilter.resultSummary`
stays single-brace by design and is now the sole key on that idiom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
The four built-in drop-target refusals (self / locked / group / cycle) were
correct and completely invisible: a refused bar never became the drop target
so it got no hover treatment, and the release handler only ran its body when a
target had been registered, so the drop was a no-op -- no toast, no cursor, no
console warning.

canReceiveLink's four-branch boolean becomes classifyLinkTarget, returning
which branch refused; the boolean is derived from it. The hover affordance and
the drop toast are two consumers of that ONE classification, so the reason
shown cannot drift from the reason applied. Branch names are the leaves of the
new gantt.link.rejected.* keys (ten packs), so a later branch without a message
is a missing key rather than a wrong sentence.

Cursor and outline are inline styles, not utility classes, matching the bar's
existing read-only cursor: cursor-not-allowed and the ring alpha utilities are
not emitted in the prebuilt components CSS.

A host veto via onBeforeDependencyCreate stays silent -- that reason is the
host's and surfacing it is a public API contract, deliberately out of scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 10, 2026 11:43pm

Request Review

Base automatically changed from claude/issue-4157-gantt-interpolation to main August 10, 2026 23:45
@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 00:18
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit e1ade8f Aug 11, 2026
3 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4158-gantt-link-feedback branch August 11, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[gantt] Illegal dependency link (cycle/self/locked/group) is silently discarded — no toast, no cursor, no reason shown

2 participants