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
55 changes: 55 additions & 0 deletions .changeset/8730-bulk-action-defs-unusable-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
'@object-ui/plugin-grid': patch
---

`object-grid`: a `bulkActionDefs` member that is not a usable def is skipped and
diagnosed, instead of taking the whole selection bar down (objectui#8730).

`bulkActions` and `bulkActionDefs` are one affordance authored in two vocabularies —
`bulkActions` members are bare action NAMES resolved against `objectDef.actions`,
`bulkActionDefs` members are full `BulkActionDef` OBJECTS used as authored — and
nothing refused a member written in the other one. Both keys are registered
`type: 'array'` with no `of`, both spec rows are `z.array(z.unknown())`, and a JSON
view is invisible to `tsc`.

Writing a bare name into `bulkActionDefs` did not fail quietly, it crashed:
`Array.isArray(schema.bulkActionDefs)` is true, the string travelled into the authored
list untouched, `BulkActionBar` rendered a button for it, and
`def.label ?? formatActionLabel(def.name)` threw
`TypeError: Cannot read properties of undefined (reading 'replace')` **during render**.
The author's first multi-row selection lost the entire selection bar — count, Clear and
every well-formed sibling def with it. `key={def.name}` was `undefined` too, so React
logged a duplicate-key warning on the way down.

`resolveBulkActions` now skips any member that is not an object carrying a non-empty
string `name`. "Usable" is defined by what the renderer actually reads: `name` is both
the React `key` and `formatActionLabel`'s argument, so that one test covers the reported
bare string and, identically, `null`, a number, `{}` and `{ name: '' }`. The guard sits
at the single point where the authored array becomes the list the bar maps over, so the
`key` and the label are read off the same validated def.

The skip is not silent. `ObjectGrid` reports it once per authored array through the
channel it already uses for "you declared it, the renderer dropped it" — one
`console.warn` prefixed `[ObjectUI] ObjectGrid bulkActionDefs:` — naming the block, the
index, what was seen, and what to write instead:

```
[ObjectUI] ObjectGrid bulkActionDefs: object-grid (objectName: 'os_invoice') — 1 of 3
authored bulk-action defs cannot be rendered and is skipped (2 still render).
• bulkActionDefs[0]: the entry is a string ('approve'), not a def object — this key's
members are full `BulkActionDef` objects, used as authored. Write
`{ name: 'approve', operation: 'custom' }` here, or move the bare name to
`bulkActions`, which resolves it against the object's declared actions and promotes
the match.
```

**Not a coercion, deliberately.** A bare `'approve'` is not lifted into
`{ name: 'approve' }` and resolved the way `bulkActions` is. That would make the two
vocabularies interchangeable — a product change to what a `bulkActionDefs` member means
(objectui#3002 / objectui#3139 made them distinct on purpose), not a crash fix.

**Behaviour changes for authors** beyond the crash: a `{ name: '' }` member used to
render a nameless, unlabelled button with an empty React key, and now renders nothing.
Well-formed defs are untouched — a mixed list renders exactly its usable members, in
order, and a clean array is still returned by reference. The mirror direction
(`bulkActions: [{ name: 'approve' }]`) keeps its existing silent skip.
26 changes: 25 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './row
import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts';
import { resolveLegacyRowActions } from './resolveLegacyRowActions';
import { applyRelationalMeta } from './relationalMetaKeys';
import { resolveBulkActions } from './resolveBulkActions';
import { resolveBulkActions, describeUnusableBulkActionDefs } from './resolveBulkActions';
import { partitionBulkRows } from './bulkEligibility';
import { resolvesToDataColumn, describeUnresolvedColumns } from './columnSpellingDiagnostics';
import { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
Expand Down Expand Up @@ -2226,6 +2226,30 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
if (message) console.warn(message);
}, [schema.columns, columnDiagnosticBlockType, schema.objectName, columnDiagnosticLabel]);

// [objectui#8730] The same channel, for the sibling failure on
// `bulkActionDefs`. A member that is not a usable def (a bare action name —
// the OTHER key's vocabulary — or `null`, a number, `{}`, `{ name: '' }`) is
// skipped by `resolveBulkActions`; before that guard it reached the bar and
// `formatActionLabel(undefined)` threw during render, so the author's first
// multi-row selection lost the whole selection bar.
//
// The skip alone would only relocate the failure into silence — which is what
// the mirror direction already does (`bulkActions: [{ name: 'approve' }]`,
// stepped over by the `typeof name !== 'string'` guard). Saying which member
// was dropped, and that a bare name belongs in `bulkActions`, is what makes
// this a diagnosis rather than a quieter version of the same defect. One
// `console.warn` per authored array, keyed on it — NOT a second guard: the
// predicate lives once, in `resolveBulkActions`, and this reads it.
const bulkDefsDiagnosticSlice = (schema as { bulkActionDefs?: unknown }).bulkActionDefs;
useEffect(() => {
const message = describeUnusableBulkActionDefs(bulkDefsDiagnosticSlice, {
blockType: columnDiagnosticBlockType,
objectName: schema.objectName,
label: columnDiagnosticLabel,
});
if (message) console.warn(message);
}, [bulkDefsDiagnosticSlice, columnDiagnosticBlockType, schema.objectName, columnDiagnosticLabel]);

const generateColumns = useCallback((): ObjectGridColumnDraft[] => {
// Map field type to column header icon (Airtable-style)
const getTypeIcon = (fieldType: string | null): React.ReactNode => {
Expand Down
Loading
Loading