diff --git a/.changeset/mobile-fullscreen-rides-the-field-metadata.md b/.changeset/mobile-fullscreen-rides-the-field-metadata.md new file mode 100644 index 0000000000..2c6ec25ce0 --- /dev/null +++ b/.changeset/mobile-fullscreen-rides-the-field-metadata.md @@ -0,0 +1,46 @@ +--- +"@object-ui/plugin-form": minor +"@object-ui/types": minor +--- + +`mobile.fullscreenLongText` finally reaches auto-generated long-text fields, and +`mobile_fullscreen` gets one declared carrier (objectui#3245). + +FROM: `ObjectForm` stamped the flag onto the FormField itself +(`{ ...f, mobile_fullscreen: true }`). TO: it stamps the flag onto the object the +form renderer will actually forward to the widget as `field` — `f.field || f`, +resolved exactly the way `renderFieldComponent` resolves it. + +**The flag's only legal carrier is the field metadata, and its only producer is +`ObjectForm`.** That convention was already what the widget side assumed after +objectui#3232/#3233 (`TextAreaField` reads `field.mobile_fullscreen` and nothing +else, and `field` is the single metadata carrier); the producer was writing to a +different object, so for auto-generated fields the two never met. + +What was broken, end to end: `ObjectForm` builds an auto-generated field as +`type: 'field:textarea'` **and** stashes the object-field metadata on `.field`. +The renderer forwards `field: field.field || field`, so the widget received the +raw metadata — which never carried the flag — while the FormField-level copy was +dropped by `stripRegisteredFieldProps`. Every entry point into `TextAreaField` +therefore read `undefined` and the expand affordance never rendered. Only the +hand-authored `customFields` path (no `.field` to shadow the FormField) ever +worked, i.e. the feature was dead on the path virtually every form takes. Unit +tests on both ends passed the whole time, because the break lived in the seam +between them; this release adds the feature's first integration coverage — real +`ObjectForm` → real form renderer → real `TextAreaField`, no mocks — which fails +against the old producer and passes against the new one. + +`mobile_fullscreen` is now declared on `@object-ui/types`' `BaseFieldMetadata`, +hence on every member of the `FieldMetadata` union that +`FieldWidgetComponentProps.field` resolves to. It is deliberately **not** an +`@objectstack/spec` property: nobody authors it on a field definition, it is a +projection of the form-level `ObjectFormSchema.mobile.fullscreenLongText` setting +onto the field metadata at render time. Declaring it removes the last untyped +end of the chain — the producer's `as FormField` cast is gone — so the two sides +can now disagree out loud instead of silently. + +The hand-authored `customFields` path keeps working unchanged, and keeps its own +metadata: the flag is stamped on the FormField only when there is no `.field` to +carry it. Synthesizing a `field` object in that case would light the affordance +up while quietly replacing the field's `rows` / `placeholder` with defaults — the +regression test pins that too. diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 706b979e68..3a214d525b 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -1040,13 +1040,33 @@ const SimpleObjectForm: React.FC = ({ // ----- Mobile UX (round 3) ----- // 1) Propagate fullscreen-textarea opt-in to each textarea field so the // field widget can render its expand affordance + dialog. + // + // `mobile_fullscreen` is a PROJECTION of this form-level setting onto the + // field metadata — this component is its one and only producer, and the + // flag has one and only one legal carrier: the object the form renderer + // forwards to the widget as `field`. That object is `f.field || f` + // (`renderFieldComponent` in `@object-ui/components`): the stashed + // object-field metadata when there is one, else the FormField itself. + // + // Stamping it on `f` unconditionally was the objectui#3245 break. For an + // AUTO-GENERATED field `.field` exists, so the widget was handed the raw + // metadata (flag-free) while the FormField-level copy was dropped by + // `stripRegisteredFieldProps` — every generated form silently lost the + // feature, and only the hand-authored `customFields` path (no `.field`) + // ever worked. Resolving the carrier the same way the renderer does keeps + // both paths on ONE key in ONE place (objectui#3232 / #3233): no widget + // reads a second spelling, so a flag written anywhere else stays dead + // instead of being quietly caught by a fallback. const mobileOpts = schema.mobile; const fieldsWithMobile = mobileOpts?.fullscreenLongText ? autoLayoutResult.fields.map((f) => { const t = f.type as string | undefined; const isTextarea = t === 'textarea' || t === 'field:textarea' || t === 'string-multiline' || t === 'field:markdown' || t === 'field:html'; - return isTextarea ? ({ ...f, mobile_fullscreen: true } as FormField) : f; + if (!isTextarea) return f; + return f.field + ? { ...f, field: { ...f.field, mobile_fullscreen: true } } + : { ...f, mobile_fullscreen: true }; }) : autoLayoutResult.fields; diff --git a/packages/plugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx b/packages/plugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx new file mode 100644 index 0000000000..39ec970c2f --- /dev/null +++ b/packages/plugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx @@ -0,0 +1,139 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * End-to-end pin for `ObjectFormSchema.mobile.fullscreenLongText` + * (objectui#3245) — the ONLY integration coverage this feature has. + * + * Everything below is real: the real `ObjectForm` (the flag's single + * producer), the real form renderer in `@object-ui/components`, the real + * registry, and the real `TextAreaField` from `@object-ui/fields`. Nothing is + * mocked but the data source, because the bug this file exists to prevent + * lived exactly in the SEAMS between those four — each end looked correct on + * its own and the flag fell through the join: + * + * 1. `ObjectForm` auto-generates a FormField with `type: 'field:textarea'` + * and stashes the object-field metadata on `field`; + * 2. it stamped `mobile_fullscreen` onto the FormField ITSELF; + * 3. the form renderer forwards `field: field.field || field` — for an + * auto-generated field `.field` exists, so the widget received the raw + * metadata, WITHOUT the flag; + * 4. the FormField-level copy was then dropped by + * `stripRegisteredFieldProps`. + * + * Result: the flag reached `TextAreaField` only on the hand-authored + * `customFields` path (no `.field` to shadow the FormField), i.e. never on the + * path virtually every form actually takes. Unit tests on both ends passed + * throughout. The fix stamps the flag onto the metadata carrier the renderer + * will forward — `f.field || f` — so there stays exactly ONE carrier + * (objectui#3233) and it is the one the widget reads. + * + * `registerAllFields` registers each widget as a `React.lazy`, so the + * assertions sit behind a dynamic-import boundary — the shape AGENTS.md's test + * discipline says must be warmed at MODULE SCOPE (never in a `beforeAll`, + * whose 10s budget is narrower than the timeout it would replace) so the cold + * transform is billed to the import phase, which no test/hook timeout bounds. + * Here the barrel import below IS that warm-up: `@object-ui/fields`'s entry + * statically imports and re-exports `./widgets/TextAreaField`, the very + * specifier the lazy loader uses, so by the time a test renders, the widget + * module is already in the ESM cache and `React.lazy` resolves off it instead + * of racing RTL's 1000ms `findBy` budget against a cold Vite transform. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { registerAllFields } from '@object-ui/fields'; +import type { ObjectFormSchema } from '@object-ui/types'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +const objectSchema = { + name: 'issue_note', + label: 'Issue Note', + fields: { + body: { type: 'textarea', label: 'Body' }, + }, +}; + +function makeDataSource() { + return { + getObjectSchema: vi.fn().mockResolvedValue(objectSchema), + findOne: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + find: vi.fn().mockResolvedValue([]), + } as any; +} + +function renderForm(schema: Partial) { + return render( + , + ); +} + +describe('ObjectForm mobile.fullscreenLongText → TextAreaField (objectui#3245)', () => { + it('reaches the widget for an AUTO-GENERATED long-text field', async () => { + // The path almost every form takes: no `customFields`, so `ObjectForm` + // builds the FormField from the object schema and stashes the metadata on + // `.field`. This assertion was RED before the producer-side fix. + renderForm({ mobile: { fullscreenLongText: true } }); + + expect(await screen.findByTestId('textarea-fullscreen-toggle')).toBeInTheDocument(); + }); + + it('still reaches the widget for a HAND-AUTHORED customFields long-text field, without displacing its other metadata', async () => { + // Control for the fix: a hand-authored `customFields` entry carries no + // `.field` metadata object, so `field.field || field` resolves to the + // FormField ITSELF — that is the carrier, and the flag has to land on it. + // This chain works today and must keep working. + // + // The extra metadata below is what makes this a real control rather than a + // restatement of the test above. Stamping `field: { ...f.field, flag }` + // UNCONDITIONALLY also lights the affordance up here — by conjuring a + // metadata object out of `undefined` that contains nothing but the flag. + // The renderer would then forward that stub as the widget's `field`, and + // `rows` / `placeholder` (which `TextAreaField` reads off the metadata, not + // off its props) would silently revert to their defaults. Asserting them + // here is what makes that shortcut fail instead of pass. + renderForm({ + mobile: { fullscreenLongText: true }, + customFields: [ + { name: 'body', label: 'Body', type: 'field:textarea', rows: 9, placeholder: 'Say more' }, + ], + }); + + expect(await screen.findByTestId('textarea-fullscreen-toggle')).toBeInTheDocument(); + + const textarea = screen.getByPlaceholderText('Say more'); + expect(textarea).toHaveAttribute('rows', '9'); + }); + + it('does NOT render the affordance when the form did not opt in', async () => { + // Negative control: without `mobile.fullscreenLongText` there is no + // producer, so no carrier may conjure the flag. + renderForm({}); + + // Wait for the textarea itself so we are asserting on a SETTLED form, not + // on a widget that simply has not resolved yet. + expect(await screen.findByRole('textbox')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts b/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts new file mode 100644 index 0000000000..b888818721 --- /dev/null +++ b/packages/types/src/__tests__/field-metadata-mobile-fullscreen.test.ts @@ -0,0 +1,97 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `mobile_fullscreen` has exactly ONE legal carrier: the field metadata + * (objectui#3245). + * + * The flag is a projection of the form-level + * `ObjectFormSchema.mobile.fullscreenLongText` setting, produced by `ObjectForm` + * (`@object-ui/plugin-form`) and read by `TextAreaField` (`@object-ui/fields`) + * off `FieldWidgetComponentProps.field` — whose type is the `FieldMetadata` + * union pinned below. It is deliberately NOT an `@objectstack/spec` property: + * nobody authors it on an object's field definition, the form runtime stamps it. + * + * Declaring it is the point. While it was undeclared, the producer stamped it + * onto the FormField through an `as FormField` cast and the consumer read it + * through an `as any` — two untyped ends that could not disagree out loud, and + * for the whole of the feature's life they DID disagree: the form renderer + * forwards `field: field.field || field`, so an auto-generated field handed the + * widget its raw metadata (flag-free) while `stripRegisteredFieldProps` dropped + * the prop copy. Every generated form silently lost the affordance, and no type + * anywhere could report it. + * + * The compile-time pins are the load-bearing part: `tsc -p tsconfig.test.json` + * (chained off this package's `type-check`) is what runs them. Deleting the + * declaration — or moving it off the base every `FieldMetadata` member extends — + * is a compile error, not a silently passing test. + */ + +import { describe, it, expect } from 'vitest'; +import type { BaseFieldMetadata, FieldMetadata, TextareaFieldMetadata } from '../index'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — compiled by tsconfig.test.json, chained off type-check. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +describe('FieldMetadata.mobile_fullscreen (objectui#3245)', () => { + it('is declared on the metadata base as an optional boolean', () => { + // Keeps the pins below from passing vacuously: an `any`-typed property + // would satisfy nothing while looking green. + type _NotAny = Assert, false>>; + + // Optional boolean — never required (no form is obliged to project it) and + // never widened. + type _OptionalBoolean = Assert< + Equal + >; + + expect(true).toBe(true); + }); + + it('is readable off the FieldMetadata union without narrowing first', () => { + // `FieldMetadata` is what `FieldWidgetComponentProps.field` resolves to, + // and `TextAreaField` reads the flag there. Declaring it on a single union + // member instead would make that read a compile error and push the next + // author straight back to the `as any` this issue exists to remove. + type _ReadableOffTheUnion = Assert< + Equal + >; + + // The long-text members inherit it like any other base property. + type _TextareaInheritsIt = Assert< + Equal + >; + + expect(true).toBe(true); + }); + + it('is assignable on a long-text field metadata object', () => { + // The exact shape `ObjectForm` produces: the stashed object-field metadata + // with the projected flag folded in. + const stamped: TextareaFieldMetadata = { + name: 'body', + type: 'textarea', + label: 'Body', + mobile_fullscreen: true, + }; + + expect(stamped.mobile_fullscreen).toBe(true); + }); + + it('is absent, not false, when the form did not opt in', () => { + const plain: TextareaFieldMetadata = { name: 'body', type: 'textarea' }; + + expect(plain.mobile_fullscreen).toBeUndefined(); + expect('mobile_fullscreen' in plain).toBe(false); + }); +}); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 692468179b..968ea26513 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -67,6 +67,31 @@ export interface BaseFieldMetadata { */ system?: boolean; + /** + * Render a long-text field with a fullscreen-edit affordance (an "expand" + * button opening a full-height dialog) — the mobile UX for a textarea that + * would otherwise be a 4-row box wedged between other fields. + * + * **Not authored metadata, and deliberately NOT in `@objectstack/spec`.** + * It is a PROJECTION of the FORM-level setting + * `ObjectFormSchema.mobile.fullscreenLongText` onto the field metadata, + * with exactly one producer: `ObjectForm` (`@object-ui/plugin-form`) stamps + * it onto each long-text field's metadata carrier while building the form. + * Writing it on an object's field definition is meaningless — nothing + * publishes it and nothing else produces it. + * + * **Consumer**: `TextAreaField` (`@object-ui/fields`), which reads it off + * `field` and nowhere else — `field` being the single metadata carrier since + * objectui#3233. It is declared here, on the type + * `FieldWidgetComponentProps.field` resolves to, so that the one legal + * location for the flag is a typed one rather than an untyped pun: before + * objectui#3245 it was stamped onto the FormField instead, where the form + * renderer's `field: field.field || field` forwarding could not see it and + * `stripRegisteredFieldProps` stripped the prop copy — so every + * auto-generated form silently lost the feature. + */ + mobile_fullscreen?: boolean; + /** * Placeholder text */