Skip to content

fix(plugin-form): seed create forms from the object schema's declared defaults - #4068

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4047-create-dialog-defaults
Aug 10, 2026
Merged

fix(plugin-form): seed create forms from the object schema's declared defaults#4068
yinlianghui merged 1 commit into
mainfrom
claude/issue-4047-create-dialog-defaults

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4047

Premise: confirmed, and the producer is one level up from where the card looked

The card's mechanism hypothesis was that seeding logic exists in ObjectForm.tsx and the create dialog "bypasses it — likely a wiring gap". Measured against origin/main, that is right about the symptom and wrong about the shape: there is nothing to wire. ObjectForm is not on the create dialog's path at all, and ModalForm is not a consumer of it — it is a peer container with its own create branch, which simply never read the object schema:

ModalForm.tsx (create branch, before)
  setFormData(schema.initialData || schema.initialValues || {});

The same line, verbatim, in DrawerForm, TabbedForm, SplitForm and WizardForm. ObjectForm (the flat container) was the only one of the six that seeded. The console's create dialog is the global <ModalForm> mounted by app-shell's AppContent, so nothing in app-shell needed touching — the defect and the fix are both in packages/plugin-form.

Reproduced as a failing test before any source change (createDefaults.test.tsx, 9 red / 14 green on origin/main): the four sectioned containers all showed expected 'Select an option' to contain 'Draft'.

The card's server-side ruling held up independently: ObjectQL.applyFieldDefaults (packages/objectql/src/engine.ts) resolves defaultValue on the insert path for any field that arrives absent or null, which is exactly why the issue's curl that omits status stores draft.

Which spelling is honoured — defaultValue, not the option's default: true

The card asked for this to be stated rather than invented, so: field-level defaultValue only.

Both spellings do exist in the spec vocabulary — SelectOptionSchema declares default: z.boolean().optional(). But the vocabulary is not the contract; the insert path is, and it reads field.defaultValue and nothing else. The only consumer of option-level default found anywhere on the platform is a nullability heuristic in packages/lint/src/validate-expressions.ts (isNullableField), which is an analysis, not an application.

So a form that also seeded from option-level default would preselect a value the server would not have applied had the field been omitted — a UI-only default that disagrees with the stored outcome. That is the renderer-side second dialect AGENTS.md #0.1 forbids. No precedence is invented here: one key is honoured because one key is enforced. If option-level default is meant to mean "the initial value", that belongs at the producer, not here.

Three boundaries, each pinned in both directions

1. Create only. ObjectForm's pass ran in every mode:

const finalDefaultValues = { ...schemaDefaultValues, ...initialData };   // before

initialData wins for keys the record carries, so a stored null was safe — but a key the record omits entirely fell through to the default. An edit form then displayed Draft on a column the row has never had set, arming a silent write of a value the user never chose on the next save of any other field. It is now gated on !schema.recordId || schema.mode === 'create' — deliberately the same "no persisted record" test the data-fetch effect two hundred lines above already uses, so the two cannot drift apart. The sectioned containers seed inside their existing create branch and never touch the edit path.

The edit-direction test uses a record that omits status rather than one carrying status: null. The null variant passes with or without the gate (the explicit null overwrites the default either way) — it would have been green for an empty reason. Reverse-verified: replacing the gate with true turns exactly that assertion red, expected 'Draft' not to contain 'Draft', and nothing else.

2. Static defaults only — and this half is a fix in its own right. A defaultValue may be an instruction the server resolves per insert: the NOW() / current_user runtime tokens (DEFAULT_VALUE_TOKENS) or a CEL Expression envelope { dialect, source }. ObjectForm had been seeding those verbatim, which the red baseline measured directly:

ObjectForm > does not seed a runtime default token on create
  → expected 'current_user' to be ''

That puts the literal text NOW() into a datetime input and then submits it as the field's value — which suppresses the very resolution the declaration asked for, since applyFieldDefaults only fills fields that arrive empty. Roughly a hundred platform objects declare defaultValue: 'NOW()', so propagating the old behaviour into the console's create dialog would have been a regression shipped under a bug fix. schemaDefaults seeds literals only, using the spec's own isRuntimeDefaultToken predicate rather than a local copy of the token list.

3. Callers still win. initialData / initialValues outrank a schema default — a lookup prefill or a duplicate-record seed is the more specific instruction.

Scope

One shared module, packages/plugin-form/src/schemaDefaults.ts, consumed by all six containers. Fixing only ModalForm would have left the identical line in four siblings, so the same card returns the moment an object's form view is authored as a drawer or a wizard; the wizard's "create another" reset is included for the same reason (a second entry must open like the first). No GridField / grid-column file is touched, per the declared region exclusion against #3569.

Verification

  • vitest run packages/plugin-form39 files, 375 tests passed.
  • vitest run packages/plugin-view packages/plugin-designer packages/app-shell326 files, 3056 passed, 1 skipped (the consumers of these containers).
  • pnpm --filter @object-ui/plugin-form type-check — clean (tsc --noEmit + type tests), after building the dependency closure --filter '@object-ui/plugin-form^...'.
  • pnpm --filter @object-ui/plugin-form lint — 0 errors.
  • node scripts/check-control-bytes.mjs — OK.
  • Before/after on the new suite: 9 failed / 14 passed → 23 passed.

Changeset: .changeset/create-form-seeds-declared-defaults-4047.md (patch, user-visible). Docs updated per AGENTS.md #2packages/plugin-form/README.md gains a "What a create form opens with" table, and content/docs/guide/building-crud-app.md step 6 states the create/edit split.


Generated by Claude Code

… defaults

A field declared `required: true, defaultValue: 'draft'` opened the console's
create dialog empty, with a required marker — forcing the user to pick a value
the system already knew, with side-effecting neighbours one click away.

The server was never at fault: omitting the field from a create request stores
the declared default (`ObjectQL.applyFieldDefaults`). The gap was container-side.
`ObjectForm` seeded its opening values from the object schema; the five other
object-form containers did not — their create branch set the form data to
`initialData || initialValues || {}` and never looked at the schema. The
console's create dialog is the global `<ModalForm>`, one of those five.

Modal / Drawer / Tabbed / Split / Wizard now seed through one shared module,
`schemaDefaults`, with three boundaries pinned in both directions:

  - create only — `ObjectForm`'s pass ran in every mode, so an edit form showed
    a default over a column the record leaves unset, arming a silent write of a
    value the user never chose;
  - static defaults only — `NOW()` / `current_user` tokens and CEL Expression
    envelopes are instructions the server resolves per insert; `ObjectForm` had
    been seeding them verbatim, submitting the literal text `NOW()` as a field
    value and suppressing the resolution the declaration asked for;
  - caller values still win over a schema default.

Field-level `defaultValue` only, never a select option's `default: true`: the
insert path resolves the former and nothing else, so seeding from the latter
would preselect values the server would never have applied.

Fixes #4047

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 3:43am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DCZmAP8B.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
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) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 483.72KB 106.71KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 228.51KB 56.69KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.84KB 10.80KB
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.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.50KB 30.66KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 113.81KB 27.53KB
plugin-gantt (index.js) 162.79KB 39.67KB
plugin-grid (index.js) 187.97KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 17.00KB 5.32KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 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.71KB 7.95KB
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 (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) 2.71KB 1.34KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 03:54
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit f0c9a90 Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4047-create-dialog-defaults branch August 10, 2026 03:54
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 10, 2026
…ubmittable on create (objectstack-ai#4069) (objectstack-ai#4084)

`@objectstack/spec` lets `defaultValue` be a runtime instruction rather than a
value — the `DEFAULT_VALUE_TOKENS` family (`NOW()` / `current_user`) or a CEL
Expression envelope — which `ObjectQL.applyFieldDefaults` resolves per insert
for any field arriving absent or null. objectstack-ai#4068 therefore leaves such fields empty
in a create form: seeding the literal text `NOW()` and submitting it would
suppress the very resolution the declaration asked for.

Correct for an optional field; combined with `required: true` it deadlocked.
The control opened empty, the client-side required rule refused the submit, and
there was nothing sensible for the user to type. Measured on origin/main:
`dataSource.create` was never called, on both the flat and sectioned paths.

Per the maintainer's 2026-08-10 ruling on objectstack-ai#4069 (option A), in CREATE mode a
runtime `defaultValue` now suppresses the client-side required rule and the
field is omitted from the payload — omitted, not sent empty, because a rendered
control registers regardless of seeding and would otherwise carry `undefined`
(a key a data source may still write) or `''` (neither absent nor null, so it
stores a blank and defeats the declaration).

Seeding and the required rule read ONE predicate, `isRuntimeDefault`, so a form
can never seed a field it also refuses to submit. Edit mode, static literal
defaults and typed values are unchanged, each pinned in both directions.


Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation plugin tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create dialog ignores a field's defaultValue — a required select with a default renders empty and forces a manual choice

2 participants