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
29 changes: 29 additions & 0 deletions .changeset/10254-sections-customfields-drawer-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@object-ui/plugin-form': minor
---

fix(plugin-form): `customFields` members render inside explicit `sections` on the drawer, modal, tabbed, wizard and split arms

The registered description of `object-form.customFields` is one sentence for
every `formType`: "Field definitions merged over the set generated from object
metadata." With explicit `sections`, an `object-form` with `formType: 'drawer'`,
`'modal'`, `'tabbed'`, `'wizard'` or `'split'` (and a `DrawerForm`, `ModalForm`,
`TabbedForm`, `WizardForm` or `SplitForm` mounted directly) built every field a
section names from the object schema alone, so a member naming that field was
dropped: its label, its `required` and the rest of its definition never reached
the form. The default arm already drew the member.

Those five arms now take a section field's definition from the member naming
it, through the same lookup the default arm's merge uses; a field no member
names is built from the object schema as before. This holds for a bare field
name and for a spec `{ field }` entry, whose own overrides still apply on top
of the member. Over a member, the widget changes only when the entry restates
`type`. A section naming a field that only a member supplies now renders that
member, where it used to render a plain text input labelled with the field
name. A member that no section lists is still not drawn when `sections` are
given, as on the default arm. `TabbedFormSchema`, `WizardFormSchema` and
`SplitFormSchema` now declare `customFields`, as `DrawerFormSchema` and
`ModalFormSchema` already did.

Unchanged: the rules for what a section entry may override, which still differ
between the default arm and the other five.
18 changes: 10 additions & 8 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import { createSafeTranslation } from '@object-ui/i18n';
import { MasterDetailForm } from './MasterDetailForm';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { buildFlatFields } from './flatFields';
import { withCustomFieldMembers } from './customFieldsMerge';
import { useUploadGate, UploadGateProvider, UploadInFlightNotice } from './uploadGate';
import {
applyAutoColSpan,
Expand Down Expand Up @@ -344,8 +343,13 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
// A member naming a section's field is that field's definition, as it
// is in ObjectForm's merged pool — for explicit and derived sections
// alike (objectui#10254; the explicit push used to regenerate every
// named field from the object schema and drop the member).
customFields: schema.customFields,
}),
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
);

// Build fields from flat field list (when no sections provided)
Expand Down Expand Up @@ -424,12 +428,10 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
const columns = (schema.columns && schema.columns > 0
? Math.min(Math.floor(schema.columns), 4)
: inferColumns(fs.length)) as 1 | 2 | 3 | 4;
return sections.map((s) => ({
...s,
columns,
fields: withCustomFieldMembers(s.fields ?? [], schema.customFields),
})) as DrawerFormSectionConfig[];
}, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]);
// A member's definition reaches a group's body through `buildSectionFields`,
// the route an explicit section's takes (objectui#10254).
return sections.map((s) => ({ ...s, columns })) as DrawerFormSectionConfig[];
}, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]);

// Handle form submission
const handleSubmit = useCallback(async (data: Record<string, any>) => {
Expand Down
18 changes: 10 additions & 8 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/re
import { createSafeTranslation } from '@object-ui/i18n';
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
import { buildFlatFields } from './flatFields';
import { withCustomFieldMembers } from './customFieldsMerge';
import {
applyAutoColSpan,
applyAutoLayout,
Expand Down Expand Up @@ -296,12 +295,10 @@ export const ModalForm: React.FC<ModalFormProps> = ({
const columns = (schema.columns && schema.columns > 0
? Math.min(Math.floor(schema.columns), 4)
: inferColumns(fs.length)) as 1 | 2 | 3 | 4;
return sections.map((s) => ({
...s,
columns,
fields: withCustomFieldMembers(s.fields ?? [], schema.customFields),
})) as ModalFormSectionConfig[];
}, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]);
// A member's definition reaches a group's body through `buildSectionFields`,
// the route an explicit section's takes (objectui#10254).
return sections.map((s) => ({ ...s, columns })) as ModalFormSectionConfig[];
}, [schema.sections, schema.columns, schema.mode, formFields, objectSchema]);

const effectiveSections = schema.sections?.length ? schema.sections : (derivedSections ?? undefined);

Expand Down Expand Up @@ -424,8 +421,13 @@ export const ModalForm: React.FC<ModalFormProps> = ({
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
// A member naming a section's field is that field's definition, as it
// is in ObjectForm's merged pool — for explicit and derived sections
// alike (objectui#10254; the explicit path used to regenerate every
// named field from the object schema and drop the member).
customFields: schema.customFields,
}),
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
);

// Build fields from flat field list (when no sections)
Expand Down
12 changes: 11 additions & 1 deletion packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ export interface SplitFormSchema {
/** Record ID (for edit/view modes). A string, per the one record-id rule on `DataSource` (objectui#9511) — `ObjectForm` builds this schema from the authorable `ObjectFormSchema.recordId`, which is a string, and `findOne` takes a string. */
recordId?: string;
sections: SplitFormSectionConfig[];
/**
* Inline field definitions (`object-form.customFields`). A member naming a
* field a section lists is that field's definition, as on every other
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
* `{...schema}` spread.
*/
customFields?: FormField[];

/**
* Split direction.
Expand Down Expand Up @@ -240,8 +247,11 @@ export const SplitForm: React.FC<SplitFormProps> = ({
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
// A member naming a section's field is that field's definition, as it
// is on every other arm (objectui#10254).
customFields: schema.customFields,
}),
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
);

// Handle form submission
Expand Down
13 changes: 12 additions & 1 deletion packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export interface TabbedFormSchema {
* Tab sections configuration
*/
sections: FormSectionConfig[];

/**
* Inline field definitions (`object-form.customFields`). A member naming a
* field a section lists is that field's definition, as on every other
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
* `{...schema}` spread.
*/
customFields?: FormField[];

/**
* Grid width for the whole form (1–4). Aligns with @objectstack/spec
Expand Down Expand Up @@ -343,8 +351,11 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// `defaultValue` excuses a field from `required` (#4069).
recordId: schema.recordId,
fieldLabel,
// A member naming a section's field is that field's definition, as it
// is on every other arm (objectui#10254).
customFields: schema.customFields,
}),
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
);

// Handle form submission
Expand Down
13 changes: 12 additions & 1 deletion packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ export interface WizardFormSchema {
* Wizard step sections
*/
sections: WizardStepConfig[];

/**
* Inline field definitions (`object-form.customFields`). A member naming a
* field a section lists is that field's definition, as on every other
* `formType` (objectui#10254); `ObjectForm` hands the key over in its
* `{...schema}` spread.
*/
customFields?: FormField[];

/**
* Allow navigation to any step (not just sequential).
Expand Down Expand Up @@ -531,8 +539,11 @@ export const WizardForm: React.FC<WizardFormProps> = ({
// this produces, so it agrees with the renderer for free.
recordId: schema.recordId,
fieldLabel,
// A member naming a section's field is that field's definition, as it
// is on every other arm (objectui#10254).
customFields: schema.customFields,
}),
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, fieldLabel],
[objectSchema, schema.readOnly, schema.mode, schema.recordId, schema.objectName, schema.customFields, fieldLabel],
);

// The same "no persisted record" test the seeding and the create-mode
Expand Down
181 changes: 181 additions & 0 deletions packages/plugin-form/src/__tests__/sectionsCustomFields-10254.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* 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.
*/

/**
* `object-form.customFields` under explicit `sections`, on every arm that
* takes them (objectui#10254).
*
* The registered description of `customFields` is one sentence for every
* `formType`: "Field definitions merged over the set generated from object
* metadata." objectui#10073 brought the drawer and modal to the default arm's
* merge for a form with NO sections (`drawerModalCustomFieldsMerge-10073`).
* With explicit `sections`, the `drawer`, `modal`, `tabbed`, `wizard` and
* `split` arms still rebuilt every section member from the object schema
* alone, so the authored member was dropped: same schema, `INLINE NOTE` on the
* default arm, `Note` on the other five.
*
* What this card unifies is the BASE definition a section member starts from:
* a member naming the field supplies the whole definition, in place of the
* generated one, through the one lookup the default arm's merge uses
* (`findCustomFieldMember`). What it deliberately does NOT unify is each arm's
* section-entry override rules, which differ (the default arm copies only
* `visibleOn` / `colSpan` / `span` from a spec entry; the other five apply
* `normalizeSectionField`'s full set) — no row below authors an override the
* two rule sets answer differently.
*
* Every row mounts the real `ObjectForm` with the arm's `formType`, the path an
* author reaches. The `simple` rows are the default-arm control: that arm
* already resolved section members against its merged pool, and they stay
* green without the fix. Every schema below has ONE section, so the wizard's
* rendered step is the section under test — and a wizard draws only its
* current step's fields, which the `drawnFields()` equalities pin. On a tree
* where an arm's `buildSectionFields` context does not carry `customFields`,
* that arm's LABEL, REQUIRED, SPEC ENTRY and UNDECLARED NAME rows are red;
* UNNAMED MEMBER is green on every arm either way (the arms already agreed
* there, and it pins that the fix did not start drawing members no section
* lists).
*/

import { describe, it, expect, vi } from 'vitest';
import { render, waitFor, fireEvent } from '@testing-library/react';
import React from 'react';
import { registerAllFields } from '@object-ui/fields';
import { ObjectForm } from '../ObjectForm';

registerAllFields();

const OBJECT_SCHEMA = {
name: 'invoice',
fields: {
customer: { type: 'text', label: 'Customer' },
note: { type: 'text', label: 'Note' },
amount: { type: 'text', label: 'Amount' },
},
};

const makeDataSource = () =>
({
getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA),
findOne: vi.fn(),
create: vi.fn(async (_object: string, data: Record<string, unknown>) => ({ id: 'r1', ...data })),
update: vi.fn(),
}) as any;

const ARMS = ['simple', 'drawer', 'modal', 'tabbed', 'wizard', 'split'] as const;
type Arm = (typeof ARMS)[number];

/** The drawer and modal portal their content, so every read is off `document.body`. */
const drawnFields = (): string[] =>
[...document.body.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field') as string);

const labelOf = (name: string): string | null =>
document.body.querySelector(`[data-field="${name}"] label`)?.textContent ?? null;

const controlOf = (name: string): HTMLInputElement | HTMLTextAreaElement | null =>
document.body.querySelector(`[data-field="${name}"] input, [data-field="${name}"] textarea`);

const MAIN_SECTION = (fields: Array<string | Record<string, unknown>>) => [
{ name: 'main', label: 'Main', fields },
];

async function mount(arm: Arm, schema: Record<string, unknown>) {
const adapter = makeDataSource();
const utils = render(
<ObjectForm
schema={
{
type: 'object-form',
objectName: 'invoice',
mode: 'create',
...(arm === 'simple' ? {} : { formType: arm, open: true }),
...schema,
} as any
}
dataSource={adapter}
/>,
);
await waitFor(() => {
if (!document.body.querySelector('form [data-field]')) throw new Error('form not ready');
});
return { ...utils, adapter };
}

describe.each(ARMS)('`object-form` `formType: %s` — explicit `sections` draw a `customFields` member as the field', (arm) => {
it('LABEL — a section naming a member field renders the member\'s label', async () => {
const { unmount } = await mount(arm, {
sections: MAIN_SECTION(['customer', 'note']),
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text' }],
});
expect(drawnFields()).toEqual(['customer', 'note']);
expect(labelOf('note'), 'the member supplies the whole definition').toBe('INLINE NOTE');
expect(labelOf('customer'), 'a field no member names keeps the object\'s definition').toBe('Customer');
unmount();
});

it('REQUIRED — a member\'s `required` refuses an empty submit and lets a filled one through', async () => {
const { adapter, unmount } = await mount(arm, {
sections: MAIN_SECTION(['customer', 'note']),
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'text', required: true }],
});
expect(controlOf('note')?.getAttribute('aria-required')).toBe('true');
expect(controlOf('customer')?.getAttribute('aria-required'), 'the object does not require customer').toBeNull();

const form = document.body.querySelector('form') as HTMLFormElement;
fireEvent.submit(form);
await waitFor(() => expect(document.body.textContent).toContain('INLINE NOTE is required'));
expect(adapter.create, 'the member\'s required refused the empty submit').not.toHaveBeenCalled();

fireEvent.change(controlOf('note') as HTMLInputElement, { target: { value: 'filled' } });
fireEvent.submit(form);
await waitFor(() =>
expect(adapter.create).toHaveBeenCalledWith('invoice', expect.objectContaining({ note: 'filled' })),
);
unmount();
});

it('SPEC ENTRY — a spec `{ field }` entry starts from the member, not the generated field', async () => {
const { unmount } = await mount(arm, {
// `colSpan` is a layout key both override rule sets carry; nothing here
// restates `label`, `required` or `type`, so the member is what shows.
sections: MAIN_SECTION(['customer', { field: 'note', colSpan: 1 }]),
customFields: [{ name: 'note', label: 'INLINE NOTE', type: 'textarea', required: true }],
});
expect(labelOf('note')).toBe('INLINE NOTE*');
expect(controlOf('note')?.getAttribute('aria-required')).toBe('true');
expect(
controlOf('note')?.tagName,
'the member\'s own `type` renders — the object schema\'s `text` is the generated definition\'s',
).toBe('TEXTAREA');
unmount();
});

it('UNDECLARED NAME — a section naming a field only a member supplies renders the member', async () => {
const { unmount } = await mount(arm, {
sections: MAIN_SECTION(['customer', 'extra']),
customFields: [{ name: 'extra', label: 'EXTRA', type: 'text' }],
});
expect(drawnFields()).toEqual(['customer', 'extra']);
expect(labelOf('extra')).toBe('EXTRA');
unmount();
});

it('UNNAMED MEMBER — a member no section lists is not drawn under explicit sections', async () => {
const { unmount } = await mount(arm, {
sections: MAIN_SECTION(['customer', 'note']),
customFields: [
{ name: 'extra', label: 'EXTRA', type: 'text' },
{ name: 'amount', label: 'INLINE AMOUNT', type: 'text' },
],
});
expect(
drawnFields(),
'sections draw what they list — neither an appended member nor an overriding one',
).toEqual(['customer', 'note']);
unmount();
});
});
Loading
Loading