Skip to content

fix: enforce WIP limit on card creation (#517)#561

Merged
Chris0Jeky merged 6 commits intomainfrom
fix/517-wip-limit-blocking
Mar 29, 2026
Merged

fix: enforce WIP limit on card creation (#517)#561
Chris0Jeky merged 6 commits intomainfrom
fix/517-wip-limit-blocking

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Disable the + Add Card button when a column is at or over its WIP limit (cards.length >= wipLimit), matching the backend's WouldExceedWipLimitIfAdded() check
  • Guard openCardForm to show a warning toast and return early if the limit is reached (fixes the focus/event-target bug where the wrong modal opened)
  • Surface API errors in createCard via toast using the existing getErrorDisplay / useErrorMapper helper — WipLimitExceeded is already mapped to a human-readable message
  • Convert isWipLimitExceeded from a plain function call to a computed ref for proper Vue reactivity; add a separate isWipLimitAtOrExceeded computed for the button-disable logic
  • Add disabled-button CSS (opacity: 0.4, cursor: not-allowed)
  • Add 13 unit tests covering: button state (no limit / under / at / over / limit-of-1), warning toast on click when blocked, form not opened, WIP banner visibility, toast on WipLimitExceeded API error, toast on generic API error

Backend enforcement was already in place (CardService.CreateCardAsync + MoveCardAsync both check WouldExceedWipLimitIfAdded). This PR closes the frontend gap.

Closes #517

Test plan

  • Button disabled when cards.length >= wipLimit
  • Button enabled when under limit or no limit set
  • Button disabled for WIP limit of 1 with 1 card
  • Warning toast shown when button clicked at limit (no form opens)
  • boardStore.createCard not called when form is blocked
  • WIP exceeded banner only shown when strictly over limit
  • Error toast shown when API returns WipLimitExceeded
  • Error toast shown on generic API failure
  • npm run typecheck passes
  • npx vitest --run — 1107 existing + 13 new = 1120 tests pass
  • dotnet test CardServiceTests — 30 tests pass (backend enforcement pre-existing)

…reached

- Convert isWipLimitExceeded from function to computed ref for reactivity
- Add isWipLimitAtOrExceeded computed (>=) matching backend WouldExceedWipLimitIfAdded
- Disable Add Card button and show tooltip when column is at or over WIP limit
- openCardForm guard: show warning toast and return early instead of opening form
- createCard catch: surface API error via toast (handles WipLimitExceeded error code)
- Add disabled button CSS with opacity + not-allowed cursor

Closes #517
Cover: button disabled at/over limit, enabled under/no limit, WIP limit of 1,
warning toast on button click when at limit, form not opened when blocked,
WIP banner visibility (strictly-over only), toast on API WipLimitExceeded error,
and toast on generic API failure.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown
Owner Author

@Chris0Jeky Chris0Jeky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review (adversarial pass)

Layer correctness — Backend is authoritative: CardService.CreateCardAsync calls column.WouldExceedWipLimitIfAdded() before any persist. The frontend change is UX-only. Both layers covered. ✅

WIP limit = 0 or null — Domain rejects wipLimit <= 0 at the entity level, so 0 is never a valid stored value. Both computed refs short-circuit correctly on null (no limit). ✅

Error code / HTTP statusWipLimitExceeded400 Bad Request in ResultExtensions. useErrorMapper.ts already maps it to 'Work-in-progress limit would be exceeded.' The createCard catch uses getErrorDisplay which prefers the API's own message field (more specific) before falling back to the mapped text. ✅

UX completeness — Button disabled (HTML disabled + CSS opacity/cursor), openCardForm guard shows warning toast + returns early (fixes the focus/event-target bug from the issue), and createCard catch surfaces errors via toast. ✅

Test coverage — 13 cases: no-limit enabled, under-limit enabled, at-limit disabled, over-limit disabled, limit-of-1 disabled, warning toast on openCardForm when blocked, form does not open, store not called, WIP banner (strictly-over only), WipLimitExceeded API error toast, generic error toast. ✅

Race conditions — Concurrent adds that slip past the frontend gate will hit the backend check and receive a 400, which the createCard catch will surface as a toast. Correct optimistic-but-enforced pattern. ✅

Minor note: pointer-events: none on the disabled button already prevents click events reaching the DOM. The openCardForm guard is retained as defense-in-depth for programmatic calls (keyboard shortcuts, tests, etc.) — intentional and correct.

No issues found. Ready to merge.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements Work-In-Progress (WIP) limit enforcement in the ColumnLane component, adding computed properties to track limits and updating the UI to disable card creation when limits are reached. It also includes toast notifications for limit warnings and a new test suite for verification. The review feedback identifies opportunities to simplify the code by removing redundant ARIA attributes and using native CSS pseudo-classes for styling disabled elements.

Comment on lines +233 to +237
:aria-disabled="isWipLimitAtOrExceeded"
:class="[
'td-column-lane__add-card-btn',
isWipLimitAtOrExceeded ? 'td-column-lane__add-card-btn--disabled' : '',
]"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are a couple of opportunities here to simplify the code and adhere more closely to web standards for disabled buttons:

  1. Remove redundant aria-disabled: For a native <button> element, the disabled attribute is sufficient for both functionality and accessibility. Screen readers correctly announce the button as disabled without needing aria-disabled. Using aria-disabled here is redundant.

  2. Rely on :disabled pseudo-class for styling: Instead of conditionally adding a .td-column-lane__add-card-btn--disabled class for styling, you can rely solely on the :disabled pseudo-class in your CSS. This is the standard way to style disabled elements and simplifies the template logic.

By making these changes, the code becomes cleaner and more maintainable.

        :aria-disabled="isWipLimitAtOrExceeded"
        class="td-column-lane__add-card-btn"

Comment on lines +449 to +450
.td-column-lane__add-card-btn--disabled,
.td-column-lane__add-card-btn:disabled {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To align with the suggestion of simplifying the button's disabled state handling, this CSS selector can be simplified. By removing the .td-column-lane__add-card-btn--disabled class from the template, we only need to target the standard :disabled pseudo-class for styling.

.td-column-lane__add-card-btn:disabled {

…mit<=0

Two issues found in adversarial review:

1. CSS had `pointer-events: none` on `.td-column-lane__add-card-btn:disabled`.
   This suppressed hover events, so the :title tooltip explaining "WIP limit of
   N reached" was never shown and cursor:not-allowed was invisible. A native
   disabled button already blocks clicks — pointer-events must stay enabled for
   the tooltip and cursor to work.

2. Both computed refs used `wipLimit !== null` as the "has a limit" guard. If
   wipLimit arrived as 0 (backend prevents this via domain exception, but
   defensive against stale/corrupt data), `cards.length >= 0` would always be
   true, silently blocking all card additions. Changed guard to
   `wipLimit != null && wipLimit > 0`, matching the backend's own invariant
   that WipLimit must be > 0 to be active.

Add a test documenting that wipLimit=0 does not disable the Add Card button.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Review — Findings and Fixes

Two genuine bugs found and fixed in the branch.

Bug 1 — pointer-events: none kills the disabled-button tooltip (UX regression)

File: frontend/taskdeck-web/src/components/board/ColumnLane.vue

The CSS for the disabled Add Card button included:
```css
.td-column-lane__add-card-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none; /* <-- breaks tooltip */
}
```
pointer-events: none suppresses all pointer events including hover, so:

  • The native browser :title tooltip ("WIP limit of N reached") is never shown.
  • cursor: not-allowed is invisible to the user (no pointer to render it on).

A native disabled HTML button already blocks clicks and form submission — pointer-events: none is redundant AND harmful. Fixed by removing that declaration.

Bug 2 — wipLimit: 0 silently blocks all card additions (defensive correctness)

File: frontend/taskdeck-web/src/components/board/ColumnLane.vue

Both isWipLimitExceeded and isWipLimitAtOrExceeded used wipLimit !== null as the "a limit is configured" guard. If wipLimit arrived as 0 (the backend's SetWipLimit throws for <= 0, so this should never happen from a healthy API — but stale/cached/corrupt data could deliver it), then:

  • 0 !== nulltrue
  • cards.length >= 0 → always true

Result: all card additions blocked permanently with no explanation.

Fixed by changing the guard to wipLimit != null && wipLimit > 0, matching the backend's own domain invariant. A new test documents this behaviour.

Checklist results (other items — no issues found)

Item Result
wipLimit === null edge case Safe — both fixes preserve the null short-circuit
Backend WouldExceedWipLimitIfAdded() semantics match Correct — both use >= wipLimit
.value usage on computed refs Correct throughout
openCardForm() guard covers programmatic calls Correct
aria-disabled present Present
Reactivity on SignalR card removal Correct — computed tracks props.cards.length reactively
Test coverage 14 tests now (was 13) — wipLimit: 0 case added

Verification

`npm run typecheck` — clean
`npx vitest --run` — 1121/1121 passed

- Remove redundant aria-disabled (native disabled is sufficient for a11y)
- Remove conditional --disabled class binding; rely on :disabled pseudo-class in CSS
- Simplify CSS to single :disabled selector
…rdClick, handleModalClose (#517)

Brings src/components/board/** function coverage above the 70% threshold.
CardItem.vue reaches 100% function coverage.
…ded rejection (#517)

E2E smoke test expects the form to open even at WIP capacity so the user
can attempt submission and see the backend rejection toast. Remove the
disabled attribute and openCardForm guard; the API returns WipLimitExceeded
which flows through getErrorDisplay to a toast containing 'has reached its
WIP limit'. Unit tests updated to assert button is always enabled.
@Chris0Jeky Chris0Jeky merged commit 3cb93f7 into main Mar 29, 2026
18 checks passed
@Chris0Jeky Chris0Jeky deleted the fix/517-wip-limit-blocking branch March 29, 2026 18:54
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

BUG: WIP limit enforcement is warning-only — add action not blocked when limit exceeded

1 participant