Skip to content

feat(plugin-kanban): a drop that makes fields required collects them instead of dead-ending (#4254) - #4555

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4254-kanban-requiredwhen-prompt
Aug 13, 2026
Merged

feat(plugin-kanban): a drop that makes fields required collects them instead of dead-ending (#4254)#4555
yinlianghui merged 1 commit into
mainfrom
claude/issue-4254-kanban-requiredwhen-prompt

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4254

Dragging a card into a column whose value flips a field's requiredWhen predicate to TRUE used to PATCH the column value alone. The engine refused the whole update — correctly, that is what the predicate declares — and the board had no way to finish the move: closing a won deal meant abandoning the board for the record form. HotCRM's opportunity pipeline is the reported case (win_reason required when stage is closed_won), but the dead end belonged to every board whose target column carries a conditional requirement.

Implements the maintainer's approved shape (2026-08-12): pre-evaluate, then collect, then one PATCH.

What changed

On drop, the board evaluates the target column's predicates against the record with the target value applied, before writing anything. If that makes fields required while they are still empty, a small dialog collects exactly those fields and the column value plus everything collected go out as one PATCH — two writes would leave the record in the refused state if the second failed. A drop that triggers no predicate is untouched, PATCH body included.

Against the dispatch ruling

1. One evaluator, measured. The record form resolves requiredWhen through @object-ui/core's resolveFieldRuleState (packages/core/src/evaluator/fieldRules.ts), exported from the package barrel via evaluator/index.ts. plugin-kanban already declares @object-ui/core, so it is reachable with no seam edit and no new dependency — the same barrel components/renderers/form/form.tsx:9 imports from. Nothing was hand-rolled.

The CEL subset was measured against the card's own predicate rather than assumed — @objectstack/formula 17.0.0-rc.6, has(record.stage) && record.stage == "closed_won":

closed_won present     {"ok":true,"value":true}
other stage            {"ok":true,"value":false}
stage absent           {"ok":true,"value":false}
stage null             {"ok":true,"value":false}

All four ok:truehas(), == and && are genuinely evaluated, not fail-open verdicts wearing the right answer.

2. Dialog reuses the record form's renderers. Every control is @object-ui/fields' FieldEditWidget — by its own header "the SAME dedicated widgets the form renders", already the basis of the grid's inline editor and the detail page's inline edit. A select edits as a select, a date as a date picker. No parallel mini-form. Local required validation before submit uses core's isMissingForRequired, the presence contract the form and the server share, so false and 0 count as answers and are not re-asked.

3. Flow. Empty required-set leaves today's PATCH untouched and pinned byte-identical; non-empty opens the dialog and submits {stage, ...collected} as one PATCH. Cancel writes nothing and leaves the card in its source column. A combined PATCH still refused for some other reason surfaces the legible refusal and rolls back exactly as a plain rejected move does — the #4138 contract, unchanged — and does not loop the dialog.

4. Fallback clause not used. Pre-evaluation is feasible; the prompt-on-reject fallback was not needed and was not silently substituted.

5. i18n. Two new kanban.* keys through this package's existing useSafeTranslate channel, added to all ten packs in each pack's own quote convention and key order. common.cancel, common.required and kanban.moveCard are reused rather than minted twice. No I18nProvider is mounted in the new tests, per the #4514 trap.

Deliberate non-collection

Four kinds of required-and-empty field are not collected, each falling through to the unchanged PATCH where the refusal is legible (objectstack#7525 closed): one that already has a value, one visibleWhen hides, a readonly one, and a type with no edit widget. A dialog row with no control would be a worse dead end than the one being fixed.

Red-first, predicted before running

Predicted 5 red / 3 green; measured exactly that against unfixed code. The sharpest red names the defect itself rather than a missing dialog:

AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
Received:
  1st vi.fn() call:
    Array [ "opportunity", "o1", Object { "stage": "closed_won" } ]

That lone {stage} PATCH is the issue.

Reverse verification — direction predicted first: removing only the pre-evaluation gate limb (leaving the module and dialog in place) should turn the 5 feature tests red and leave the 3 pins green. Measured: exactly that, 5 failed | 3 passed. Restored via patch-file plus backup copy, never git stash, and the restore was sha256-verified on all six touched files.

Verification

check result
vitest run packages/plugin-kanban/ packages/i18n/ 56 files, 873 passed
plugin-kanban suite alone 12 files, 72 passed (incl. #4138 rollback pins)
type-check (both tsc passes, kanban + i18n) green
eslint 0 errors; my lines add 0 warnings (36 to 33 after typing three of my own)
check:i18n-keys every call-site key resolves; every inline default matches its en value
check:i18n-drift 2 keys added, 0 en values changed
check:control-bytes / check:phantom-deps / changeset gates green
.d.ts diff, both packages, measured both ways identical — no public type surface change

Changeset: @object-ui/plugin-kanban minor (new user-facing capability), @object-ui/i18n patch (graded on its measured-identical .d.ts). Never major.

Surface

packages/plugin-kanban/**, the ten locale packs, one changeset. No edits to packages/fields, data-objectstack, metadata-admin, studio inspectors, DashboardRenderer/ListView, or content/docs/releases/. Consumption of @object-ui/fields and the form machinery is read-only.


Generated by Claude Code

…instead of dead-ending (#4254)

Dragging a card into a column whose value flips a field's `requiredWhen`
predicate to TRUE PATCHed the column value alone. The engine refused the
whole update — correctly; that is what the predicate declares — and the
board had no way to finish the move, so closing a won deal meant leaving
the board for the record form.

The drop now pre-evaluates the target column's predicates against the
record WITH the target value applied. Fields the move makes required
while still empty are collected in a small dialog, and the column value
plus everything collected go out as ONE PATCH: two writes would leave the
record in the refused state if the second failed. A drop that triggers no
predicate keeps its exact path, PATCH body included.

One evaluator, not two: the verdict is `@object-ui/core`'s
`resolveFieldRuleState`, the same function the record form, the wizard and
the line-item grid already use, delegating to `@objectstack/formula`'s CEL
engine — so the board's prompt and the server's enforcement cannot drift.
Emptiness is core's `isMissingForRequired`, the presence contract the form
and the server share. Every control is `@object-ui/fields`'
`FieldEditWidget`, the widget the record form renders for that type, so no
second set of field-rendering decisions is introduced.

Four kinds of field are deliberately not collected and fall through to the
unchanged PATCH, where the refusal is legible since objectstack#7525: one
that already has a value, one `visibleWhen` hides, a readonly one, and a
type with no edit widget. Cancel writes nothing and leaves the card in its
source column; a combined PATCH refused for some other reason surfaces the
refusal and rolls back exactly as a plain rejected move does (#4138),
rather than looping the dialog on an arbitrary server error.

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

vercel Bot commented Aug 13, 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 13, 2026 8:09am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-B0x69o-m.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 158.47KB 43.13KB
fields (index.js) 230.18KB 57.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.26KB 40.01KB
plugin-grid (index.js) 189.34KB 50.32KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT ON IMPLEMENTATION; flip+arm deferred to CI convergence (session_017Qqyix2QcnpUC9XeYVDzx3)

Implementation review is complete and positive; the only outstanding item is the repo-wide Actions stall (evidence in the report is conclusive: main's own merge-queue run and a sibling PR stalled at the same ~08:04 boundary with zero step movement, while every pre-boundary job completed in minutes — infrastructure, not this change). The PM will re-read this PR's per-job conclusions when the runner pool recovers and then flip+arm.

What is accepted:


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 08:17
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit fa21254 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4254-kanban-requiredwhen-prompt branch August 13, 2026 08:17
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.

Kanban: dropping a card into a stage that makes fields required (requiredWhen) should prompt for them, not just reject

2 participants