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
49 changes: 49 additions & 0 deletions .changeset/6111-formsection-visiblewhen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
---
'@object-ui/plugin-form': minor
'@object-ui/types': minor
---

⚠️ **Behaviour change: an authored `FormSection.visibleWhen` that has been doing nothing
will now START HIDING SECTIONS.** Read this before upgrading if any of your metadata
authors a section predicate.

`@objectstack/spec` declares `FormSection.visibleWhen` and this repo's spec bridge maps it
through, but every plugin-form layout renders a section header as a virtual
`section-divider` pseudo-field and none of them copied the predicate onto it. On the
object-view chain — the create/edit modal, the drawer, the split form, and the full-page
record form — the key was declared, mapped, carried, and then dropped one hop before
anything could evaluate it. The section rendered unconditionally, with no diagnostic
(objectui#6111).

**Why nobody noticed, and why the fix is felt as a regression.** `visibleWhen` fails OPEN:
a section that renders is what you get when the predicate resolves TRUE, when the predicate
never arrives, *and* when the predicate faults. Those three worlds were indistinguishable,
so an app that authored a section predicate saw its section render and had no way to tell
that the rule was inert. Every such app has been running with the rule switched off, and
some will have been authored — or simply grown used to — that state. After this change the
predicate is evaluated for real, and sections that have always been visible will disappear
for the users the rule excludes.

This is the intended ADR-0089 contract being delivered, not a new capability: the key was
already declared, already documented, and already honoured by the console form renderer.
The object-view chain was the one that silently ignored it.

**Before upgrading**, audit any `sections[].visibleWhen` in your form-view metadata and
confirm each predicate says what you actually want, evaluated against `record` +
`current_user`. A predicate that was written speculatively, or left behind after a rework,
now takes effect.

**Measured scope of the hide.** The predicate gates the section's HEADER row. The renderer
treats `section-divider` as presentational and holds no association between it and the
fields that follow it, so a false predicate removes the heading and the section's fields
keep rendering. The console renderer (`apps/console`) drops the whole `<section>`, fields
included. That divergence is real, is pinned honestly by this change's tests rather than
implied away, and is filed separately — it needs a renderer-side grouping contract, not
another line in a layout.

Two hops were dropping the key and both are repaired: `ObjectForm` rebuilds each section
key by key when it delegates to Split/Drawer/Modal (and `ModalForm`'s own `groups` map does
it again), so a key those maps did not copy never reached the layout at all; and the six
`section-divider` synthesis sites across the four layout files.

`@object-ui/types` gains the matching `ObjectFormSection.visibleWhen` declaration.
11 changes: 11 additions & 0 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -90,6 +90,12 @@ export interface DrawerFormSectionConfig {
fields: (string | FormField)[];
collapsible?: boolean;
collapsed?: boolean;
/**
* ADR-0089 `FormSection.visibleWhen` — conditional visibility for the
* section's divider HEADER, evaluated by the form renderer with the canonical
* engine and the host predicate scope (#6010/#6111). Fails OPEN.
*/
visibleWhen?: string | { dialect?: string; source: string };
/** Custom CSS class for the section's divider header. */
className?: string;
}
Expand DownExpand Up@@ -547,6 +553,9 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
name: `__section_${sectionKey}`,
label: section.label || '',
type: 'section-divider',
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
// this pseudo-field with the host predicate scope bound (#6010).
visibleWhen: (section as any).visibleWhen,
colSpan: 4,
collapsible: section.collapsible,
collapsed: isCollapsed,
Expand DownExpand Up@@ -603,6 +612,8 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
name: `__section_${sectionKey}`,
label: title,
type: 'section-divider',
// ADR-0089 section predicate (#6111).
visibleWhen: (section as any).visibleWhen,
colSpan: 4,
collapsible: section.collapsible,
collapsed: isCollapsed,
Expand Down
14 changes: 14 additions & 0 deletions packages/plugin-form/src/ModalForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -78,6 +78,12 @@ export interface ModalFormSectionConfig {
description?: string;
columns?: 1 | 2 | 3 | 4;
fields: (string | FormField)[];
/**
* ADR-0089 `FormSection.visibleWhen` — conditional visibility for the
* section's divider HEADER, evaluated by the form renderer with the canonical
* engine and the host predicate scope (#6010/#6111). Fails OPEN.
*/
visibleWhen?: string | { dialect?: string; source: string };
/** Custom CSS class for the section's header row (stacked layout). */
className?: string;
/**
Expand DownExpand Up@@ -616,6 +622,9 @@ export const ModalForm: React.FC<ModalFormProps> = ({
key: sectionKey(section, index),
title: sectionTitle(section),
description: section.description,
// Key-by-key rebuild: an uncopied key never reaches the divider
// synthesis below (#6111).
visibleWhen: section.visibleWhen,
className: section.className,
gridClassName: section.gridClassName,
fields: formColumns > 1
Expand DownExpand Up@@ -665,6 +674,9 @@ export const ModalForm: React.FC<ModalFormProps> = ({
label: g.title,
description: g.description,
type: 'section-divider',
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
// this pseudo-field with the host predicate scope bound (#6010).
visibleWhen: g.visibleWhen,
colSpan: 4,
className: g.className,
} as any);
Expand DownExpand Up@@ -695,6 +707,8 @@ export const ModalForm: React.FC<ModalFormProps> = ({
name: `__section_${section.name || index}`,
label: title,
type: 'section-divider',
// ADR-0089 section predicate (#6111).
visibleWhen: (section as any).visibleWhen,
} as any);
}
allFields.push(...(columns > 1 ? applyAutoColSpan(body, columns) : body));
Expand Down
14 changes: 14 additions & 0 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -300,6 +300,9 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
// rebuilds each section key by key, so a key it doesn't copy is
// silently dropped — exactly how `visibleOn` once vanished here.
pane: s.pane,
// ADR-0089 section predicate (#6111) — same reason as `pane` above:
// a key this map does not copy never reaches the layout at all.
visibleWhen: (s as any).visibleWhen,
className: (s as any).className,
gridClassName: (s as any).gridClassName,
})),
Expand DownExpand Up@@ -330,6 +333,9 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
fields: s.fields,
collapsible: (s as any).collapsible,
collapsed: (s as any).collapsed,
// ADR-0089 section predicate (#6111) — key-by-key rebuild, so an
// uncopied key is silently dropped before DrawerForm ever sees it.
visibleWhen: (s as any).visibleWhen,
className: (s as any).className,
})),
open: schema.open,
Expand DownExpand Up@@ -358,6 +364,9 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
description: s.description,
columns: s.columns,
fields: s.fields,
// ADR-0089 section predicate (#6111) — key-by-key rebuild, so an
// uncopied key is silently dropped before ModalForm ever sees it.
visibleWhen: (s as any).visibleWhen,
className: (s as any).className,
gridClassName: (s as any).gridClassName,
})),
Expand DownExpand Up@@ -1195,6 +1204,11 @@ const SimpleObjectForm: React.FC<ObjectFormComponentProps> = ({
name: `__section_${sectionKey}`,
label,
type: 'section-divider',
// ADR-0089 `FormSection.visibleWhen` (#6111). The renderer evaluates
// a `visibleWhen` on this pseudo-field with the host predicate scope
// bound (#6010), so copying it here is what makes the authored
// section predicate reach an evaluator at all.
visibleWhen: (section as any).visibleWhen,
colSpan: 4,
collapsible: section.collapsible,
collapsed: isCollapsed,
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-form/src/SplitForm.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,12 @@ export interface SplitFormSectionConfig {
*/
pane?: 'primary' | 'secondary';
fields: (string | FormField)[];
/**
* ADR-0089 `FormSection.visibleWhen` — conditional visibility for the
* section's divider HEADER, evaluated by the form renderer with the canonical
* engine and the host predicate scope (#6010/#6111). Fails OPEN.
*/
visibleWhen?: string | { dialect?: string; source: string };
/** Custom CSS class for the section's header row. */
className?: string;
/**
Expand DownExpand Up@@ -339,6 +345,9 @@ export const SplitForm: React.FC<SplitFormProps> = ({
label: section.label,
description: section.description,
type: 'section-divider',
// ADR-0089 section predicate (#6111) — the renderer evaluates it on
// this pseudo-field with the host predicate scope bound (#6010).
visibleWhen: section.visibleWhen,
colSpan: 4,
className: section.className,
} as any);
Expand Down
Loading
Loading