Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/mobile-fullscreen-rides-the-field-metadata.md
Original file line numberDiff line numberDiff line change
@@ -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.
22 changes: 21 additions & 1 deletion packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1040,13 +1040,33 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
// ----- 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;

Expand Down
Original file line numberDiff line numberDiff line change
@@ -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<ObjectFormSchema>) {
return render(
<ObjectForm
schema={
{
type: 'object-form',
objectName: 'issue_note',
mode: 'create',
...schema,
} as ObjectFormSchema
}
dataSource={makeDataSource()}
/>,
);
}

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();
});
});
});
Original file line numberDiff line numberDiff line change
@@ -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 extends true> = T;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type IsAny<T> = 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<Equal<IsAny<BaseFieldMetadata['mobile_fullscreen']>, false>>;

// Optional boolean — never required (no form is obliged to project it) and
// never widened.
type _OptionalBoolean = Assert<
Equal<BaseFieldMetadata['mobile_fullscreen'], boolean | undefined>
>;

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<FieldMetadata['mobile_fullscreen'], boolean | undefined>
>;

// The long-text members inherit it like any other base property.
type _TextareaInheritsIt = Assert<
Equal<TextareaFieldMetadata['mobile_fullscreen'], boolean | undefined>
>;

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);
});
});
25 changes: 25 additions & 0 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*/
Expand Down
Loading