diff --git a/.changeset/sdui-text-input-element.md b/.changeset/sdui-text-input-element.md new file mode 100644 index 0000000000..846754a884 --- /dev/null +++ b/.changeset/sdui-text-input-element.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(ui): add `element:text_input` — free-text data-entry element for SDUI pages + +SDUI pages could display and navigate but not collect free-text input. This adds +that half of the contract: + +- `ElementTextInputPropsSchema` (label, placeholder, `inputType` — + text/email/number/tel/url/password — defaultValue, required, disabled, + description) wired into `PageComponentType` and `ComponentPropsMap` as + `element:text_input`. + +The objectui renderer binds the typed value into a page variable +(`PageVariableSchema.source`); a submit `element:button` reads it back via +`{{page.}}` token interpolation in the console action runtime. Showcase: +`showcase_contact_form` (text inputs → page variables → POST web-to-lead). diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 632a691dbe..c92798eae4 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -22,7 +22,7 @@ import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; import { allReports } from './src/reports/index.js'; import { allActions } from './src/actions/index.js'; -import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage } from './src/pages/index.js'; +import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage, ContactFormPage } from './src/pages/index.js'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; import { allHooks } from './src/hooks/index.js'; @@ -155,7 +155,7 @@ export default defineStack({ apps: [ShowcaseApp], portals: allPortals, views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews], - pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage], + pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, PageVariablesPage, ContactFormPage], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset], diff --git a/examples/app-showcase/src/apps/index.ts b/examples/app-showcase/src/apps/index.ts index 06bbdc09c9..441695079f 100644 --- a/examples/app-showcase/src/apps/index.ts +++ b/examples/app-showcase/src/apps/index.ts @@ -67,6 +67,7 @@ export const ShowcaseApp = App.create({ { id: 'nav_gallery', type: 'page', pageName: 'showcase_component_gallery', label: 'Component Gallery', icon: 'layout-template' }, { id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)', icon: 'palette' }, { id: 'nav_page_variables', type: 'page', pageName: 'showcase_page_variables', label: 'Page Variables', icon: 'mouse-pointer-click' }, + { id: 'nav_contact_form', type: 'page', pageName: 'showcase_contact_form', label: 'Contact Form', icon: 'mail-plus' }, { id: 'nav_project_workspace', type: 'page', pageName: 'showcase_project_workspace', label: 'New Project + Tasks', icon: 'folder-plus' }, // ADR-0047 interface mode: same object as nav_tasks, curated surface. { id: 'nav_task_workbench', type: 'page', pageName: 'showcase_task_workbench', label: 'Task Workbench', icon: 'sliders-horizontal' }, diff --git a/examples/app-showcase/src/pages/contact-form.page.ts b/examples/app-showcase/src/pages/contact-form.page.ts new file mode 100644 index 0000000000..2add661a6e --- /dev/null +++ b/examples/app-showcase/src/pages/contact-form.page.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePage } from '@objectstack/spec/ui'; + +/** + * Contact Form — a pure-SDUI DATA-ENTRY page (the data-entry half of SDUI pages). + * + * Demonstrates the SDUI form loop end to end, with NO custom code: + * 1. `variables` declares one page variable per field, each fed by a text + * input (PageVariableSchema.source = that input's component id). + * 2. `element:text_input` writes each keystroke into its bound variable + * (objectui components/src/renderers/basic/text-input.tsx). + * 3. The submit `element:button` runs an `api` action whose params reference + * the variables as `{{page.}}`. The console action runtime resolves + * those tokens against the live page-variable snapshot (published by + * PageVariableActionBridge) and POSTs the body to the public web-to-lead + * endpoint, creating a `showcase_inquiry`. + * + * POST /api/v1/forms/contact-us/submit (ADR-0056 public form -> showcase_inquiry) + * + * This is the showcase analog of a branded cloud onboarding screen — collect a + * few free-text fields, post them to a backend endpoint — but rendered by the + * console design system (dark mode, i18n) instead of hand-rolled vanilla HTML. + */ +export const ContactFormPage = definePage({ + name: 'showcase_contact_form', + label: 'Contact Form (SDUI data entry)', + icon: 'mail-plus', + type: 'app', + template: 'header-sidebar-main', + isDefault: false, + // One page variable per field, each written by the text input whose `id` + // matches `source`. Read back at submit time as `{{page.}}`. + variables: [ + { name: 'inquiryName', type: 'string', source: 'field_name' }, + { name: 'inquiryEmail', type: 'string', source: 'field_email' }, + { name: 'inquiryCompany', type: 'string', source: 'field_company' }, + { name: 'inquiryMessage', type: 'string', source: 'field_message' }, + ], + regions: [ + { + name: 'header', + width: 'full', + components: [ + { + type: 'page:header', + properties: { + title: 'Get in touch', + subtitle: + 'A pure-SDUI form — each text input writes a page variable; Submit posts them as one request. No custom code.', + }, + }, + ], + }, + { + name: 'main', + width: 'large', + components: [ + { + type: 'element:text', + properties: { + content: + 'Tell us about yourself and we’ll reach out. Each field writes a `page.`; the Submit button reads them back as `{{page.}}` and posts them to the web-to-lead endpoint.', + variant: 'body', + }, + }, + { + type: 'element:text_input', + id: 'field_name', + properties: { label: 'Name', placeholder: 'Ada Lovelace', required: true }, + }, + { + type: 'element:text_input', + id: 'field_email', + properties: { + label: 'Email', + inputType: 'email', + placeholder: 'ada@example.com', + required: true, + }, + }, + { + type: 'element:text_input', + id: 'field_company', + properties: { label: 'Company', placeholder: 'Analytical Engines Ltd.' }, + }, + { + type: 'element:text_input', + id: 'field_message', + properties: { label: 'Message', placeholder: 'What can we help with?', required: true }, + }, + // Live page-variable feedback — appears the instant an email is typed, + // proving the input wrote `page.inquiryEmail` (re-evaluated, no reload). + { + type: 'element:text', + id: 'ready_hint', + visibility: "page.inquiryEmail != ''", + properties: { + content: '✓ Looks good — hit Submit to send your inquiry.', + variant: 'caption', + }, + }, + { type: 'element:divider' }, + { + type: 'element:button', + id: 'submit_inquiry', + properties: { + label: 'Submit inquiry', + variant: 'primary', + icon: 'send', + // `api` action -> absolute endpoint. The runtime resolves the + // `{{page.}}` tokens against the live snapshot before POSTing. + action: { + type: 'api', + target: '/api/v1/forms/contact-us/submit', + method: 'POST', + params: { + name: '{{page.inquiryName}}', + email: '{{page.inquiryEmail}}', + company: '{{page.inquiryCompany}}', + message: '{{page.inquiryMessage}}', + }, + successMessage: 'Thanks! We received your inquiry.', + refreshAfter: false, + }, + }, + }, + ], + }, + ], +}); diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index fd20f296fe..39afe4399c 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -16,6 +16,7 @@ export { SettingsPage } from './settings.page.js'; export { StylingGalleryPage } from './styling-gallery.page.js'; export { CommandCenterPage } from './command-center.page.js'; export { PageVariablesPage } from './page-variables.page.js'; +export { ContactFormPage } from './contact-form.page.js'; export { TaskBoardPage, TaskCalendarPage, diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 685dca3e6e..20d4c0b300 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3086,6 +3086,7 @@ "ElementMetadataViewerPropsSchema (const)", "ElementNumberPropsSchema (const)", "ElementRecordPickerPropsSchema (const)", + "ElementTextInputPropsSchema (const)", "ElementTextPropsSchema (const)", "EmbedConfig (type)", "EmbedConfigSchema (const)", diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index e23fec7254..4bc8886d11 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -24,7 +24,7 @@ }, "variables": { "status": "live", - "note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.` (react/src/SchemaRenderer.tsx) and ships element:record_picker, which writes its selection into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx); usePageVariableBinding resolves writer->variable. Showcase: showcase_page_variables (master/detail — picker drives a detail panel's visibility). objectui#1957." + "note": "Page-local state (ADR-0049). objectui injects variables into the visible/CEL expression context as `page.` (react/src/SchemaRenderer.tsx) and ships element:record_picker + element:text_input, which write the user's selection / typed value into the variable named by PageVariableSchema.source (components/src/renderers/basic/record-picker.tsx, text-input.tsx); usePageVariableBinding resolves writer->variable. Submit actions read them back via `{{page.}}` token interpolation in the console action runtime (app-shell/src/hooks/useConsoleActionRuntime.tsx) — the SDUI data-entry form loop. Showcases: showcase_page_variables (master/detail — picker drives a detail panel's visibility), showcase_contact_form (text inputs → page variables → submit). objectui#1957." }, "object": { "status": "live", diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 2a8a197193..f2b0ebbccf 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -16,6 +16,7 @@ import { ElementFilterPropsSchema, ElementFormPropsSchema, ElementRecordPickerPropsSchema, + ElementTextInputPropsSchema, } from './component.zod'; import { PageComponentSchema } from './page.zod'; @@ -556,6 +557,57 @@ describe('Interactive Elements — element:record_picker', () => { }); }); +// --------------------------------------------------------------------------- +// Interactive Elements — element:text_input +// --------------------------------------------------------------------------- +describe('Interactive Elements — element:text_input', () => { + it('should accept element:text_input component', () => { + expect(() => PageComponentSchema.parse({ + type: 'element:text_input', + properties: { label: 'Workspace name' }, + })).not.toThrow(); + }); + + it('should parse text_input props with defaults', () => { + const props = ElementTextInputPropsSchema.parse({}); + expect(props.inputType).toBe('text'); + expect(props.required).toBe(false); + expect(props.disabled).toBe(false); + }); + + it('should accept full text_input props', () => { + const props = ElementTextInputPropsSchema.parse({ + inputType: 'email', + label: 'Email', + placeholder: 'you@example.com', + defaultValue: 'a@b.com', + required: true, + disabled: false, + description: 'We never share it', + targetVariable: 'email', + }); + expect(props.inputType).toBe('email'); + expect(props.required).toBe(true); + expect(props.targetVariable).toBe('email'); + }); + + it('should accept all input types', () => { + const types = ['text', 'email', 'number', 'tel', 'url', 'password'] as const; + types.forEach(inputType => { + expect(() => ElementTextInputPropsSchema.parse({ inputType })).not.toThrow(); + }); + }); + + it('should accept a numeric defaultValue', () => { + const props = ElementTextInputPropsSchema.parse({ inputType: 'number', defaultValue: 42 }); + expect(props.defaultValue).toBe(42); + }); + + it('should reject an unknown input type', () => { + expect(() => ElementTextInputPropsSchema.parse({ inputType: 'color' })).toThrow(); + }); +}); + // --------------------------------------------------------------------------- // ComponentPropsMap — interactive elements // --------------------------------------------------------------------------- @@ -576,6 +628,10 @@ describe('ComponentPropsMap interactive elements', () => { expect(ComponentPropsMap['element:record_picker']).toBeDefined(); }); + it('should contain element:text_input', () => { + expect(ComponentPropsMap['element:text_input']).toBeDefined(); + }); + it('should parse element:button props', () => { const result = ComponentPropsMap['element:button'].parse({ label: 'Click Me' }); expect(result.label).toBe('Click Me'); @@ -601,6 +657,11 @@ describe('ComponentPropsMap interactive elements', () => { }); expect(result.object).toBe('account'); }); + + it('should parse element:text_input props', () => { + const result = ComponentPropsMap['element:text_input'].parse({ label: 'Name' }); + expect(result.inputType).toBe('text'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 997090ae15..92a03fedf4 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -296,6 +296,33 @@ export const ElementRecordPickerPropsSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), })); +/** + * A single-line free-text input — the data-entry half of an SDUI page (Airtable + * "text"/"number" field parity). The console renderer binds the typed value into + * a page variable via the {@link PageVariableSchema} `source` convention (the + * variable whose `source` equals this component's `id`), exposing it to + * expressions as `page.` and to submit actions as a `{{page.}}` token. + * A whole free-text family lives under one element: `inputType` selects the + * native modality (text/email/number/…), keeping genuinely-distinct inputs + * (textarea, select, checkbox) free to arrive as their own elements later. + */ +export const ElementTextInputPropsSchema = lazySchema(() => z.object({ + inputType: z.enum(['text', 'email', 'number', 'tel', 'url', 'password']) + .optional().default('text') + .describe('Native input type — drives keyboard/validation affordance and how the bound value is coerced (number → numeric).'), + label: I18nLabelSchema.optional().describe('Field label shown above the input'), + placeholder: I18nLabelSchema.optional().describe('Placeholder text shown when empty'), + defaultValue: z.union([z.string(), z.number()]).optional() + .describe('Initial value; seeds the bound page variable on mount'), + required: z.boolean().optional().default(false).describe('Mark the field as required'), + disabled: z.boolean().optional().default(false).describe('Disable the input'), + description: I18nLabelSchema.optional().describe('Helper text shown below the input'), + targetVariable: z.string().optional() + .describe('Page variable this input writes to. Declarative hint; the live binding resolves via the variable whose `source` equals this component id (see PageVariableSchema).'), + /** ARIA accessibility */ + aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +})); + /** * ---------------------------------------------------------------------- * Component Props Map @@ -346,6 +373,7 @@ export const ComponentPropsMap = { 'element:filter': ElementFilterPropsSchema, 'element:form': ElementFormPropsSchema, 'element:record_picker': ElementRecordPickerPropsSchema, + 'element:text_input': ElementTextInputPropsSchema, } as const; /** diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 3a57c17015..e6a216ccab 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -44,7 +44,7 @@ export const PageComponentType = z.enum([ // Content Elements (Airtable Interface parity) 'element:text', 'element:number', 'element:image', 'element:divider', // Interactive Elements (Phase B — Element Library) - 'element:button', 'element:filter', 'element:form', 'element:record_picker' + 'element:button', 'element:filter', 'element:form', 'element:record_picker', 'element:text_input' ]); /**