diff --git a/.changeset/formview-buttons-defaults.md b/.changeset/formview-buttons-defaults.md new file mode 100644 index 000000000..482e77743 --- /dev/null +++ b/.changeset/formview-buttons-defaults.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): structured `buttons` + `defaults` config on `FormViewSchema` (#2998) + +`FormViewSchema` gains two optional top-level keys — the spec home for the flat +renderer-invented form config ObjectUI's `ObjectForm` reads today +(`showSubmit`/`submitText`/`showCancel`/`cancelText`/`showReset`/`initialValues`, +objectui#2545), which the strip-mode container silently discards: + +- **`buttons`** — structured action-button config: per-button `{ show, label }` + for `submit` / `cancel` / `reset` (new exported leaf `FormButtonConfigSchema`, + `.strict()` per ADR-0089 D3a so typo'd keys error loudly). +- **`defaults`** — initial field values for create-mode forms, keyed by field + machine name (absorbs ObjectUI's `initialValues`). + +Both are marked `[EXPERIMENTAL — NOT ENFORCED]` per ADR-0078's escape hatch +until the ObjectUI renderer reads them (tracked in objectui#2545); authoring +them today is declared, not yet honored. Purely additive — no existing key +changes shape, no tombstone needed. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e3fd4c9d6..2afaab81c 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -16,8 +16,8 @@ Migrated to shared/http.zod.ts. Re-exported here for backward compatibility. ## TypeScript Usage ```typescript -import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; -import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; +import type { AddRecordConfig, AppearanceConfig, CalendarConfig, ColumnSummary, FormButtonConfig, FormField, FormSection, FormView, GalleryConfig, GanttConfig, GanttQuickFilter, GroupingConfig, GroupingField, KanbanConfig, ListChartConfig, ListColumn, ListView, NavigationConfig, NavigationMode, ObjectListView, ObjectUserFilters, PaginationConfig, RowColorConfig, RowHeight, SelectionConfig, TimelineConfig, TreeConfig, UserActionsConfig, UserFilterField, UserFilters, View, ViewData, ViewFilterRule, ViewItem, ViewItemName, ViewKind, ViewScope, ViewSharing, ViewTab, VisualizationType } from '@objectstack/spec/ui'; // Validate data const result = AddRecordConfig.parse(data); @@ -88,6 +88,18 @@ Aggregation function for column footer summary * `max` +--- + +## FormButtonConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **show** | `boolean` | optional | Whether the button is rendered (renderer default applies when omitted) | +| **label** | `string` | optional | Button label (i18n-capable; renderer default when omitted) | + + --- ## FormField @@ -175,6 +187,8 @@ Aggregation function for column footer summary | **defaultSort** | `Object[]` | optional | Default sort order for related list views within this form | | **sharing** | `Object` | optional | Public sharing configuration for this form | | **submitBehavior** | `Object \| Object \| Object \| Object` | optional | Post-submit behavior | +| **buttons** | `Object` | optional | [EXPERIMENTAL — NOT ENFORCED, #2998] Form action-button visibility & labels. Renderer wiring pending in ObjectUI (objectui#2545). | +| **defaults** | `Record` | optional | [EXPERIMENTAL — NOT ENFORCED, #2998] Initial field values for create-mode forms (spec home for ObjectUI `initialValues`). Renderer wiring pending (objectui#2545). | | **aria** | `Object` | optional | ARIA accessibility attributes for the form view | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index fcc9b2794..dd3f84e32 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3232,6 +3232,8 @@ "FocusManagementSchema (const)", "FocusTrapConfig (type)", "FocusTrapConfigSchema (const)", + "FormButtonConfig (type)", + "FormButtonConfigSchema (const)", "FormField (type)", "FormFieldSchema (const)", "FormSection (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index c4debcd66..e782ec1ea 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1697,6 +1697,7 @@ "ui/EvictionPolicy", "ui/FocusManagement", "ui/FocusTrapConfig", + "ui/FormButtonConfig", "ui/FormField", "ui/FormSection", "ui/FormView", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 9bed1ecbf..3b4cd76ef 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { ViewSchema, ListViewSchema, @@ -1931,6 +1932,69 @@ describe('FormViewSchema - defaultSort', () => { }); }); +// ============================================================================ +// FormView structured buttons + defaults (#2998, EXPERIMENTAL until objectui#2545) +// ============================================================================ + +describe('FormViewSchema - buttons & defaults', () => { + it('should accept structured button config with visibility and labels', () => { + const result = FormViewSchema.parse({ + type: 'simple', + sections: [{ fields: ['name'] }], + buttons: { + submit: { show: true, label: 'Save' }, + cancel: { show: true, label: 'Discard' }, + reset: { show: false }, + }, + }); + expect(result.buttons?.submit).toEqual({ show: true, label: 'Save' }); + expect(result.buttons?.reset).toEqual({ show: false }); + }); + + it('should accept a partial buttons block (each button optional)', () => { + const result = FormViewSchema.parse({ + type: 'simple', + sections: [{ fields: ['name'] }], + buttons: { submit: { label: 'Create' } }, + }); + expect(result.buttons?.submit?.label).toBe('Create'); + expect(result.buttons?.cancel).toBeUndefined(); + }); + + it('should reject unknown keys inside buttons (strict leaf, ADR-0089 D3a)', () => { + expect(() => FormViewSchema.parse({ + type: 'simple', + buttons: { submit: { text: 'Save' } }, // legacy `submitText`-style key + })).toThrow(); + expect(() => FormViewSchema.parse({ + type: 'simple', + buttons: { showSubmit: true }, // flat renderer-invented key + })).toThrow(); + }); + + it('should accept defaults as a field-name → value record', () => { + const result = FormViewSchema.parse({ + type: 'simple', + sections: [{ fields: ['name', 'status', 'priority'] }], + defaults: { status: 'open', priority: 3, tags: ['a', 'b'] }, + }); + expect(result.defaults).toEqual({ status: 'open', priority: 3, tags: ['a', 'b'] }); + }); + + it('should accept form view without buttons/defaults (optional)', () => { + const result = FormViewSchema.parse({ type: 'simple' }); + expect(result.buttons).toBeUndefined(); + expect(result.defaults).toBeUndefined(); + }); + + it('marks both keys experimental until the renderer wiring lands (ADR-0078)', () => { + const shape = (FormViewSchema as unknown as z.ZodObject).shape; + for (const key of ['buttons', 'defaults'] as const) { + expect(shape[key].description, `${key} .describe()`).toMatch(/EXPERIMENTAL — NOT ENFORCED/); + } + }); +}); + describe('defineView', () => { it('should return a parsed view with list config', () => { const result = defineView({ diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 676a3b81e..8512fa94a 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -809,10 +809,21 @@ export const FormSectionSchema = lazySchema(() => z.object({ ])), }, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); +/** + * A single form action button (submit / cancel / reset): visibility + label. + * Leaf of the [EXPERIMENTAL] {@link FormViewSchema} `buttons` block (#2998). + * `.strict()` per ADR-0089 D3a so a typo'd key errors instead of vanishing. + */ +export const FormButtonConfigSchema = lazySchema(() => z.object({ + show: z.boolean().optional().describe('Whether the button is rendered (renderer default applies when omitted)'), + label: I18nLabelSchema.optional().describe('Button label (i18n-capable; renderer default when omitted)'), +}).strict()); +export type FormButtonConfig = z.infer; + /** * Form View Schema * Defines the layout for creating or editing a single record. - * + * * @example Simple Sectioned Form * { * type: "simple", @@ -924,6 +935,32 @@ export const FormViewSchema = lazySchema(() => z.object({ z.object({ kind: z.literal('next-record') }), ]).optional().describe('Post-submit behavior'), + /** + * ⚠️ EXPERIMENTAL — NOT ENFORCED (#2998, ADR-0078). Structured action-button + * config: per-button visibility + label for `submit` / `cancel` / `reset`. + * This is the spec home for the flat renderer-invented keys ObjectUI's + * `ObjectForm` reads today (`showSubmit`/`submitText`/`showCancel`/`cancelText`/ + * `showReset`, objectui#2545) — those never existed here and are silently + * stripped by this strip-mode container. Experimental until the ObjectUI + * renderer reads `buttons.*` (the ADR-0078 escape hatch: the contract ships + * ahead of its consumer, marked so authoring it is not a false promise). + */ + buttons: z.object({ + submit: FormButtonConfigSchema.optional().describe('Submit button'), + cancel: FormButtonConfigSchema.optional().describe('Cancel button'), + reset: FormButtonConfigSchema.optional().describe('Reset button'), + }).strict().optional() + .describe('[EXPERIMENTAL — NOT ENFORCED, #2998] Form action-button visibility & labels. Renderer wiring pending in ObjectUI (objectui#2545).'), + + /** + * ⚠️ EXPERIMENTAL — NOT ENFORCED (#2998, ADR-0078). Initial field values for + * create-mode forms, keyed by field machine name. Spec home for ObjectUI's + * renderer-invented `initialValues` (objectui#2545). Experimental until the + * ObjectUI renderer reads it. + */ + defaults: z.record(z.string(), z.unknown()).optional() + .describe('[EXPERIMENTAL — NOT ENFORCED, #2998] Initial field values for create-mode forms (spec home for ObjectUI `initialValues`). Renderer wiring pending (objectui#2545).'), + /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes for the form view'), }));