Skip to content

Commit d006128

Browse files
authored
fix(form): render object fieldGroups in create/edit modal; auto-layout parity for grouped ObjectForm (#2152) (#2154)
ModalForm ignored the object's fieldGroups designer metadata entirely — the global create modal rendered grouped objects as one flat field pile. ObjectForm's grouped path bypassed applyAutoLayout: no system-field filtering, always single column, hairline group titles on an endlessly scrolling form. - ModalForm: when no explicit sections/customFields are passed, derive sections from objectSchema.fieldGroups + field.group (same deriveFieldGroupSections as ObjectForm), with flat-path auto-layout parity: system/auto-generated fields filtered, columns inferred from field count, wide fields spanning the row, modal size auto-upgraded for multi-column. Explicit curated sections always win. - The derived groups render as ONE form with virtual section-divider fields (DrawerForm's pattern) — NOT one SchemaRenderer per section. Per-section renderers create N <form> elements sharing the footer button's form id, so only the first section's values ever submit (verified live: group 2's required status showed a value in the UI but was absent from the POST body → VALIDATION_FAILED). That latent flaw still affects the explicit-sections path; tracked in #2153. - ModalForm's flat field builder now carries Field.group so derivation sees group membership; section headers go through sectionLabel i18n. - ObjectForm grouped path: filter system/auto-generated fields, infer columns, applyAutoColSpan, container-query grid — matching the flat path. Verified against a real console (:5180) + real hotcrm backend (:4001): crm_campaign_member (fieldGroups, no form view) modal now shows both group headers in a 2-column xl dialog, and a cross-group create submits all values (record persisted, POST body contains every group's fields). crm_lead's explicit "Quick Lead Creation" view renders and submits unchanged. plugin-form: 155/155 tests, type-check green. Closes #2152
1 parent 510239c commit d006128

3 files changed

Lines changed: 271 additions & 16 deletions

File tree

packages/plugin-form/src/ModalForm.tsx

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,17 @@ import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/re
4444
import { createSafeTranslation } from '@object-ui/i18n';
4545
import { mapFieldTypeToFormType, buildValidationRules } from '@object-ui/fields';
4646
import { buildSectionFields as buildSectionFieldsShared } from './sectionFields';
47-
import { applyAutoLayout, inferModalSize, sectionFormLayout, CONTAINER_GRID_COLS } from './autoLayout';
47+
import {
48+
applyAutoColSpan,
49+
applyAutoLayout,
50+
filterAutoGeneratedFields,
51+
filterSystemFields,
52+
inferColumns,
53+
inferModalSize,
54+
sectionFormLayout,
55+
CONTAINER_GRID_COLS,
56+
} from './autoLayout';
57+
import { deriveFieldGroupSections } from './fieldGroups';
4858
import { sanitizeFormData } from './sanitize';
4959
import { usePermissions } from '@object-ui/permissions';
5060

@@ -171,7 +181,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
171181
dataSource,
172182
className,
173183
}) => {
174-
const { fieldLabel } = useSafeFieldLabel();
184+
const { fieldLabel, sectionLabel } = useSafeFieldLabel();
175185
const { t } = useDiscardTranslation();
176186
const previewMode = usePreviewMode();
177187
const perms = usePermissions();
@@ -218,11 +228,31 @@ export const ModalForm: React.FC<ModalFormProps> = ({
218228
// Stable form id for linking the external submit button to the form element
219229
const formId = useId();
220230

231+
// Field-group fallback (object-designer metadata): when the caller passes no
232+
// explicit sections, honor the object's declared `fieldGroups` the same way
233+
// ObjectForm's simple path does — one section per group, with flat-path
234+
// auto-layout parity (system/auto-generated fields filtered, columns
235+
// inferred from field count, wide fields spanning the row). An explicit
236+
// curated `sections` list from a form view always wins over this fallback.
237+
const derivedSections = useMemo(() => {
238+
if (schema.sections?.length || schema.customFields?.length) return null;
239+
let fs = filterSystemFields(formFields, objectSchema);
240+
if (schema.mode === 'create') fs = filterAutoGeneratedFields(fs, objectSchema);
241+
const sections = deriveFieldGroupSections(fs, (objectSchema as any)?.fieldGroups);
242+
if (!sections) return null;
243+
const columns = (schema.columns && schema.columns > 0
244+
? Math.min(Math.floor(schema.columns), 4)
245+
: inferColumns(fs.length)) as 1 | 2 | 3 | 4;
246+
return sections.map((s) => ({ ...s, columns })) as ModalFormSectionConfig[];
247+
}, [schema.sections, schema.customFields, schema.columns, schema.mode, formFields, objectSchema]);
248+
249+
const effectiveSections = schema.sections?.length ? schema.sections : (derivedSections ?? undefined);
250+
221251
// Compute auto-layout for flat fields (no sections) to determine inferred columns
222252
const autoLayoutResult = useMemo(() => {
223-
if (schema.sections?.length || schema.customFields?.length) return null;
253+
if (effectiveSections?.length || schema.customFields?.length) return null;
224254
return applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
225-
}, [formFields, objectSchema, schema.columns, schema.mode, schema.sections, schema.customFields]);
255+
}, [formFields, objectSchema, schema.columns, schema.mode, effectiveSections, schema.customFields]);
226256

227257
// Auto-upgrade modal size when auto-layout infers multi-column and user hasn't set modalSize
228258
const effectiveModalSize = useMemo(() => {
@@ -231,12 +261,12 @@ export const ModalForm: React.FC<ModalFormProps> = ({
231261
return inferModalSize(autoLayoutResult.columns);
232262
}
233263
// Auto-upgrade for sections: use the max columns across all sections
234-
if (schema.sections?.length) {
235-
const maxCols = Math.max(...schema.sections.map(s => Number(s.columns) || 1));
264+
if (effectiveSections?.length) {
265+
const maxCols = Math.max(...effectiveSections.map(s => Number(s.columns) || 1));
236266
if (maxCols > 1) return inferModalSize(maxCols);
237267
}
238268
return 'default';
239-
}, [schema.modalSize, autoLayoutResult, schema.sections]);
269+
}, [schema.modalSize, autoLayoutResult, effectiveSections]);
240270

241271
const sizeClass = modalSizeClasses[effectiveModalSize] || modalSizeClasses.default;
242272

@@ -339,6 +369,9 @@ export const ModalForm: React.FC<ModalFormProps> = ({
339369
field: field,
340370
options: field.options,
341371
multiple: field.multiple,
372+
// Field-group membership (Field.group → object.fieldGroups[].key) —
373+
// read by deriveFieldGroupSections for the fieldGroups fallback.
374+
group: (field as any).group,
342375
});
343376
}
344377

@@ -473,10 +506,21 @@ export const ModalForm: React.FC<ModalFormProps> = ({
473506
);
474507
}
475508

476-
// Sections layout
509+
// Explicit sections layout (curated form view).
510+
//
511+
// KNOWN LIMITATION: each section renders its own SchemaRenderer, i.e. its
512+
// own react-hook-form instance and <form> element, all sharing `formId`.
513+
// The footer submit button is associated with the FIRST form only, so with
514+
// 2+ sections the later sections' values never reach the submit payload.
515+
// The derived field-group branch below avoids this by rendering ONE form;
516+
// fixing the explicit path is tracked in #2153.
477517
if (schema.sections?.length) {
478518
const sections = schema.sections;
479519
const sectionKey = (sec: ModalFormSectionConfig, i: number) => sec.name || sec.label || String(i);
520+
// Section headers go through the same i18n hook ObjectForm uses, so a
521+
// translated group label wins over the raw metadata label.
522+
const sectionTitle = (sec: ModalFormSectionConfig) =>
523+
sec.name ? sectionLabel(schema.objectName, sec.name, sec.label || sec.name) : sec.label;
480524
// Lay the section's fields out in `section.columns` columns INSIDE the
481525
// form (columns + fieldContainerClass), not by wrapping the whole form in
482526
// a grid — otherwise the single <form> fills only the first grid cell and
@@ -500,7 +544,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
500544
<TabsList className="mb-4">
501545
{sections.map((section, index) => (
502546
<TabsTrigger key={sectionKey(section, index)} value={sectionKey(section, index)}>
503-
{section.label || `Section ${index + 1}`}
547+
{sectionTitle(section) || `Section ${index + 1}`}
504548
</TabsTrigger>
505549
))}
506550
</TabsList>
@@ -520,7 +564,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
520564
{sections.map((section, index) => (
521565
<FormSection
522566
key={sectionKey(section, index)}
523-
label={section.label}
567+
label={sectionTitle(section)}
524568
description={section.description}
525569
columns={1}
526570
>
@@ -531,6 +575,43 @@ export const ModalForm: React.FC<ModalFormProps> = ({
531575
);
532576
}
533577

578+
// Derived field-group sections (object `fieldGroups` metadata) — rendered
579+
// as ONE form with virtual 'section-divider' headers (same pattern as
580+
// DrawerForm), so every group's fields share a single react-hook-form
581+
// instance and the footer submit collects them all.
582+
if (derivedSections?.length) {
583+
const columns = (Number(derivedSections[0]?.columns) || 1) as 1 | 2 | 3 | 4;
584+
const allFields: FormField[] = [];
585+
derivedSections.forEach((section, index) => {
586+
// FLS first, so a group whose every field is non-readable drops its
587+
// header along with its fields.
588+
const body = applyFieldPerms(buildSectionFields(section));
589+
if (!body.length) return;
590+
const title = section.name
591+
? sectionLabel(schema.objectName, section.name, section.label || section.name)
592+
: section.label;
593+
if (title) {
594+
allFields.push({
595+
name: `__section_${section.name || index}`,
596+
label: title,
597+
type: 'section-divider',
598+
} as any);
599+
}
600+
allFields.push(...(columns > 1 ? applyAutoColSpan(body, columns) : body));
601+
});
602+
const groupedContainerClass = CONTAINER_GRID_COLS[columns];
603+
return (
604+
<SchemaRenderer
605+
schema={{
606+
...baseFormSchema,
607+
fields: allFields,
608+
columns,
609+
...(groupedContainerClass ? { fieldContainerClass: groupedContainerClass } : {}),
610+
}}
611+
/>
612+
);
613+
}
614+
534615
// Reuse pre-computed auto-layout result for flat fields
535616
const layoutResult = autoLayoutResult ?? applyAutoLayout(formFields, objectSchema, schema.columns, schema.mode);
536617

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ import { SplitForm } from './SplitForm';
2626
import { DrawerForm } from './DrawerForm';
2727
import { ModalForm } from './ModalForm';
2828
import { MasterDetailForm } from './MasterDetailForm';
29-
import { applyAutoLayout } from './autoLayout';
29+
import {
30+
applyAutoLayout,
31+
applyAutoColSpan,
32+
containerGridColsFor,
33+
filterAutoGeneratedFields,
34+
filterSystemFields,
35+
inferColumns,
36+
} from './autoLayout';
3037
import { deriveFieldGroupSections } from './fieldGroups';
3138

3239
export interface ObjectFormProps {
@@ -669,6 +676,17 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
669676
...initialData
670677
};
671678

679+
// Auto-layout parity with the flat path (which runs these inside
680+
// applyAutoLayout): drop platform-managed system fields — and, in create
681+
// mode, server-computed fields — BEFORE deriving field-group sections, so a
682+
// grouped form hides exactly the fields the flat form hides instead of
683+
// leaking created_at / formula columns into the trailing ungrouped bucket.
684+
const groupableFields = React.useMemo(() => {
685+
let fs = filterSystemFields(formFields, objectSchema);
686+
if (schema.mode === 'create') fs = filterAutoGeneratedFields(fs, objectSchema);
687+
return fs;
688+
}, [formFields, objectSchema, schema.mode]);
689+
672690
// Auto-derive sections from the object's declared `fieldGroups` when the
673691
// consumer hasn't supplied explicit sections. This makes field groups laid
674692
// out in the object designer render as sections on the actual form — not just
@@ -678,8 +696,8 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
678696
() =>
679697
schema.sections?.length
680698
? null
681-
: deriveFieldGroupSections(formFields, objectSchema?.fieldGroups),
682-
[schema.sections, formFields, objectSchema],
699+
: deriveFieldGroupSections(groupableFields, objectSchema?.fieldGroups),
700+
[schema.sections, groupableFields, objectSchema],
683701
);
684702
const effectiveSections = schema.sections?.length ? schema.sections : fieldGroupSections;
685703

@@ -728,10 +746,13 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
728746
// collapse simply hide a group's fields (`hidden: true`) while react-hook-form
729747
// retains their values, and lets cross-section conditions resolve via watch().
730748
if (effectiveSections?.length && (!schema.formType || schema.formType === 'simple')) {
749+
// Derived (fieldGroup) sections were computed from the filtered field list;
750+
// explicit sections keep the authored field selection as-is.
751+
const sourceFields = fieldGroupSections ? groupableFields : formFields;
731752
const groupedFields: FormField[] = [];
732753
effectiveSections.forEach((section, index) => {
733754
const sectionFieldNames = section.fields.map(f => typeof f === 'string' ? f : ((f as any).field ?? f.name));
734-
const sectionFields = applyFieldPerms(formFields.filter(f => sectionFieldNames.includes(f.name)));
755+
const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name)));
735756
if (sectionFields.length === 0) return;
736757

737758
const sectionKey = section.name || section.label || String(index);
@@ -764,14 +785,32 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
764785
}
765786
});
766787

788+
// Multi-column layout parity with the flat path: infer a column count from
789+
// the rendered field count and let wide fields (textarea/markdown/…) span
790+
// the full row. The field grid uses container-query classes — with the
791+
// @container wrapper below — so it tracks the form's own width: a grouped
792+
// form in a wide dialog goes 2-up while a narrow drawer stays stacked.
793+
// Section dividers span the full row via their own `col-span-full`.
794+
const inputCount = groupedFields.filter(
795+
f => (f as any).type !== 'section-divider' && !(f as any).hidden,
796+
).length;
797+
const columns =
798+
schema.columns && schema.columns > 0
799+
? Math.min(Math.floor(schema.columns), 4)
800+
: inferColumns(inputCount);
801+
const laidOutFields = columns > 1 ? applyAutoColSpan(groupedFields, columns) : groupedFields;
802+
const fieldContainerClass = containerGridColsFor(columns);
803+
767804
return (
768-
<div className="w-full">
805+
<div className="w-full @container">
769806
<SchemaRenderer
770807
schema={{
771808
type: 'form',
772809
objectName: schema.objectName,
773-
fields: groupedFields,
810+
fields: laidOutFields,
774811
layout: formLayout,
812+
columns,
813+
...(fieldContainerClass ? { fieldContainerClass } : {}),
775814
defaultValues: finalDefaultValues,
776815
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',
777816
showCancel: schema.showCancel !== false,

0 commit comments

Comments
 (0)