fix(gantt): an illegal dependency link says why it was refused - #4210
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4158
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:
captureLinkTargetonly records a bar astargetIdwhen 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.onUpran its body only undercur.targetId != null, andtargetIdwas null, so the drop was a complete no-op — no toast, no console warning.One classifier, two consumers
canReceiveLink's four-branch boolean becomesclassifyLinkTarget, which returns which branch refused (ornull); the boolean is derived from it: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-allowedand the ring alpha utilities are not emitted in the prebuilt components CSS. Acursor-not-allowedclass would satisfy a DOM assertion and render nothing in a browser. The tests assertstyle.cursor/style.boxShadowfor the same reason.What deliberately stays silent
A host veto through
onBeforeDependencyCreateraises 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 siblingdragLinkharness fromGanttView.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.
grouphas no end-to-end case, and that is a renderer fact, not a gap here. Atype: 'group'row renders no bar ("a pure tree header — the timeline row carries NO bar"), so nothing can hover it, and the row's ownonPointerMoveisclearLinkTarget. The drag cannot reach the branch at all. The message is kept regardless — dropping it would make the branch render the raw keygantt.link.rejected.groupon 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:The
[ undefined ]is itself the signature:messageOf(reason)reads the reverted default table, so the expected argument isundefined— 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 withgit checkout HEAD -- ….Gate ladder (run locally — this PR gets no CI while stacked)
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