Repository navigation
fix(plugin-form): an Edit form saves the owner the user changed or cleared, and still never echoes an untouched one - #12218
Merged
objectstack-fleet[bot] merged 4 commits intoOct 12, 2026
Conversation
…d, and still never echoes an untouched one `sanitizeFormData` drops `owner_id` by name and by the spec `system` flag, so a reassignment or clear made in a record's Edit form never left the browser: Save answered 200, the dialog closed, the owner was unchanged. `dirtyEditPayload` is now handed the form's values and adds the owner back to the diff only when a loaded-record snapshot applies and the owner differs from it; field-level security, the unknown-key strip and the readonly/computed branches still judge it. With no snapshot it stays dropped, so the objectui#10108 echo cannot return. The roster, the auto-layout render filter and the rest of the transfer-grant family are unchanged. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
…r objectui#12205 The README's "What an edit save writes" and the plugin-form docs page said every layout strips the ownership columns; they now state the one edit-only exception and its limits, including that the master-detail batch still strips the header owner. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
…valued Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
…rmData The loop now takes and returns exactly what sanitizeFormData does, so the two cannot drift and no new explicit-any is spelled. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-12205-edit-form-owner-change
branch
October 12, 2026 10:09
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 #12205
Clause-②: no
What was wrong
Changing or clearing the owner in a record's Edit form was silently dropped.
sanitizeFormData(packages/plugin-form/src/sanitize.ts) removedowner_idfrom every form payload, so the value never reached the server. When the owner was the only change, the edit's dirty diff (objectui#10156) found nothing else and sent the whole unchanged payload. That is exactly the PATCH the card measured: every other field, and noowner_id.Reproduced on the base commit
9646e2ethrough the console's Edit dialog (ModalForm) andDrawerForm, with the real lookup widget: reassigning the owner sent{ subject, status }and noowner_id.The fix
dirtyEditPayloadis the one place that can tell a change from an echo, because it holds the record the form read. It now also receives the values the form holds (an optionalEditOwnerSource). It addsowner_idback to the diff when, and only when, all of these hold:changedFieldsreports it as different from the read. A clear arrives asnull;undefinedis not a clear;sanitizeFormDataasks still allows it: the caller's field-level security (canEdit), the object declaring the field, and the field not beingreadonlyor computed.The strip loop moved into a module-private
stripUnwritable, which takes anadmitOwnerflag that onlydirtyEditPayloadsets.sanitizeFormData's signature and output are unchanged, and its output still never carries the owner.SERVER_OWNED_FIELD_NAMESis unchanged: no second strip list, and no new admit list beyond the single constant namingowner_id. The server stays the authority. A role without the transfer grant gets a 403, and the form shows it as its error.Dispatch hypotheses, measured
owner_idfirst. The specsystemflag branch would drop it too, because the platform servesowner_idasOWNER_FIELD_DEF(system: true,readonly: false). Both are bypassed, forowner_idalone and only on the admission path.isSameStoredValue(null, 'u1')is false, so a clear is a change.LookupFieldcallsonChangeonly on a pick or a remove, so the untouched value compares equal, whether the record holds an id or an expanded object (both are pinned).owner_id. The spec declaresowning_business_unit_idandorganization_idreadonly(scope anchors stamped by the server), and no form edits a tenancy column. Soowning_business_unit_id,organization_id,tenant_id,company_idandspacestay refused even when they differ (pinned). The audit columns are untouched.ModalForm.tsxandDrawerForm.tsxcallsanitizeFormDataanddirtyEditPayloaddirectly. Each now passes{ values: data, objectSchema, canEdit }.formWritePayload(writePayload.ts) passes the same inputs. That coversObjectForm's simple layout and the tabbed, split and wizard layouts, with no edit toObjectForm.tsx(objectui#12209 holds it).masterDetailTx.ts,MasterDetailForm.tsx) is unchanged. Its child rows and its parent operation still drop the owner by name, so an untouched owner stays off every operation of the batch (pinned through a realMasterDetailFormwhose header renders the owner).form.noPermissionToSaveas a banner and a toast, and the dialog stays open. This is now pinned for an owner change: a 403 shows the message, and neitheronSuccessnoronOpenChange(false)is called.filterSystemFieldsstill reads the unchanged roster, so an auto-laid-out form renders no owner input. The owner is written only when its value differs from the read, which only a rendered input can cause. Pinned: an auto-laid-out Edit dialog has nolookup-trigger-owner_id, and a business-field change sends only that field.Tests
New file:
packages/plugin-form/src/editOwnerChange-12205.test.tsx, 28 rows.dirtyEditPayloadandformWritePayload: reassign, clear, a change beside other fields, untouched (beside a change and in a save with nothing changed), an untouched expanded owner, no snapshot or another record's snapshot, a create, field-level security refusing it, an object declaring itreadonly, anundefinedvalue, the rest of the family plus the audit columns, a caller passing no owner source, and inline members.ModalForm(the console's Edit dialog) andDrawerForm: reassign, clear, untouched beside a change, untouched with nothing changed;ObjectFormsimple: reassign and untouched;MasterDetailForm: untouched owner, header change, no operation carriesowner_id.Reverse verification at
78486a7. The four source files were reset to the base9646e2eand the new file was run; the mutation was proven on disk bygrep -c(0 hits for the fix's markers, 1 hit for the base drop line). Result:Tests 11 failed | 17 passed (28). All 11 red rows assert that a changed or cleared owner is sent (one of them then asserts that the 403 surfaces). The 17 that stay green are the "is not sent" rows and the guards, which the base already satisfied. The restore usedgit checkout HEAD, was proven by matching each file's HEAD blob hash, andgit diff HEADcame back empty.Final run at
268c7e9, through the verify lock:pnpm --filter @object-ui/plugin-form type-check(the script echoedtsc --noEmit && tsc -p tsconfig.test.json; the test project lists the new file): exit 0.Test Files 45 passed (45),Tests 541 passed (541):systemManagedPayload.test.tsx;turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2: 11/11 tasks, cached.Gates (at
268c7e9)pnpm check:vi-mock-specifiers,pnpm check:vi-mock-inherit,pnpm check:test-path-roots;node scripts/check-changeset-presence.mjs,pnpm check:changeset-claims,pnpm check:pending-changeset-literals;pnpm check:control-bytes,pnpm check:new-line-citations(0 new citations),pnpm check:doc-fences.pnpm check:readme-exports. Its prerequisite is a full workspace build (it stopped with "type entry is not on disk" for unbuilt packages and a collapsed population). CI runs it.eslint.config.jslints**/*.{ts,tsx}, so the five changed TS/TSX files are the whole population. The markdown files are outside it.--format jsonoutput lists 5 files, with 0 errors.parserOptions.projectorprojectService, so linting is not type-aware. No rule undereslint-rules/reads the filesystem. So this diff cannot change the verdict on any untouched file.sanitize.ts8 to 7,writePayload.ts0 to 0,ModalForm.tsx20 to 20,DrawerForm.tsx21 to 21. The new test file has 21, allno-explicit-anyin fixtures.Acceptance notes
buildMasterDetailEditBatchstrips the roster from the parent operation by name (parentWritable) and holds no parent baseline. So it cannot tell an owner the header changed from an echoed one, and giving it one is not a bounded change. It would need the headerObjectForm's snapshot, and objectui#12209 holds that file.78486a7, not committed: when the owner is the header's only change, the parent operation is nowdata: {}. On the base commit it was the full unchanged business payload. Both are no-op writes as far as the owner goes, and the user-visible outcome is the same as before.packages/plugin-form/README.md("What an edit save writes") andcontent/docs/plugins/plugin-form.mdxboth said that every layout strips the ownership columns. This change makes that false, so both now state the edit-only exception (the Docs-driven commandment in AGENTS.md).ObjectForm.tsxis not touched. Its routes get the fix throughformWritePayload.Seat session for this change:
https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sKGenerated by Claude Code