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
16 changes: 16 additions & 0 deletions .changeset/30394-non-grid-bulk-bar-declared-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/plugin-list': minor
'@object-ui/plugin-grid': minor
---

fix(plugin-list)!: a list's selection bar over a non-grid view (kanban, calendar, gallery, …) draws only actions the object declares, by the grid's own rule; an undeclared bare `bulkActions` name draws nothing and warns once in development (#30394)

Clause-②: yes (narrowing) — `@object-ui/plugin-grid` exports `resolveBulkActions` and `useUndeclaredActionNamesWarning` (with the `ActionLabelResolver`, `NamedActionDef`, `UndeclaredActionNameKey`, `UndeclaredActionNamesAddress` and `UndeclaredActionNamesBasis` types) so `@object-ui/plugin-list` reuses the grid's fold and warning instead of a second copy; the warning's address gains an optional `component`, defaulting to `ObjectGrid`, so the grid's message is byte-identical.
Metadata-protocol: narrowed
Skills-docs: no move — `skills/**` teaches no handler-only bulk action name (searched for `bulkActions` examples); `content/docs/ui/**` already names only declared actions.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema, export or stored row changes shape: `bulkActions` keeps its `string[]` form and meaning, and plugin-grid's new exports add, never retire. What narrows is the non-grid bar's draw set, back to the spec's own sentence (a bare name names an action the object declares). Whether an undeclared name was a misspelling or an action still to declare is a judgement about the view, so no rewrite derives the fix; the development warning names each name. -->

- **Before.** The bar ListView draws itself for every view but the grid mapped each `bulkActions` member straight to a button, labelled by the humanized name, whether or not the object declared it. A name the object does not declare (a misspelling, or an action a disabled package withholds from the served object) showed a button that toasted "not executable" on click. The grid stopped drawing such names in #30169.
- **Now.** The bar folds the names through the grid's `resolveBulkActions`: a name the object declares draws under that action's label (through the app's i18n bundle), the built-in `delete` keeps its own path and gate, and any other name draws nothing. In development the console logs one warning per list naming the key, the view and each name (`[ObjectUI] ListView bulkActions: kanban (objectName: …) — …`), the grid's own message. A bar with nothing left to draw is not shown.
- **Remedy.** Declare the named action on the object, or remove the name from `bulkActions`. An action withheld by a disabled package needs nothing: it draws again when the package is enabled.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ describe('describeUndeclaredActionNames', () => {
expect(line).not.toContain('matches no action');
});

it('heads the message with ObjectGrid unless the address names another component (objectstack#30394)', () => {
const grid = describeUndeclaredActionNames('bulkActions', ['create_campaign'], ADDRESS);
expect(grid?.startsWith('[ObjectUI] ObjectGrid bulkActions: object-grid (')).toBe(true);
const list = describeUndeclaredActionNames('bulkActions', ['create_campaign'], {
component: 'ListView', blockType: 'kanban', objectName: 'lead',
});
expect(list?.startsWith("[ObjectUI] ListView bulkActions: kanban (objectName: 'lead')")).toBe(true);
expect(list).toContain('the selection bar draws no button');
});

it('says nothing for no names', () => {
expect(describeUndeclaredActionNames('bulkActions', [], ADDRESS)).toBeNull();
});
Expand Down
11 changes: 11 additions & 0 deletions ui/packages/plugin-grid/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export { useGroupedData } from './useGroupedData';
export { GroupRow } from './GroupRow';
export { RowActionMenu, formatActionLabel } from './components/RowActionMenu';
export { BulkActionBar } from './components/BulkActionBar';
// The bare-name fold and its development warning, for the one other surface
// that draws a selection bar of its own: plugin-list's ListView over a
// non-grid view (objectstack#30394). One rule, one copy.
export { resolveBulkActions } from './resolveBulkActions';
export { useUndeclaredActionNamesWarning } from './undeclaredActionNames';
export { useCellClipboard } from './useCellClipboard';
export { useGradientColor } from './useGradientColor';
export { useGroupReorder } from './useGroupReorder';
Expand All @@ -55,6 +60,12 @@ export type { GroupEntry, UseGroupedDataResult, AggregationType, GroupAggregatio
export type { GroupRowProps } from './GroupRow';
export type { RowActionMenuProps } from './components/RowActionMenu';
export type { BulkActionBarProps } from './components/BulkActionBar';
export type { ActionLabelResolver, NamedActionDef } from './resolveBulkActions';
export type {
UndeclaredActionNameKey,
UndeclaredActionNamesAddress,
UndeclaredActionNamesBasis,
} from './undeclaredActionNames';
export type { CellRange, UseCellClipboardOptions, UseCellClipboardResult } from './useCellClipboard';
export type { GradientStop, UseGradientColorOptions } from './useGradientColor';
export type { UseGroupReorderOptions, UseGroupReorderResult } from './useGroupReorder';
Expand Down
24 changes: 19 additions & 5 deletions ui/packages/plugin-grid/src/undeclaredActionNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,16 @@ export type UndeclaredActionNameKey = 'bulkActions' | 'rowActions';

/** Where the offending block lives, for the first line of the message. */
export interface UndeclaredActionNamesAddress {
/** The schema node's `type`: `object-grid`, or the `view:grid` alias. */
/**
* The component whose bar or menu left the names undrawn, for the message's
* head. Omitted: `ObjectGrid`. `@object-ui/plugin-list`'s ListView passes
* `ListView` for the selection bar it draws itself over a non-grid view.
*/
component?: unknown;
/**
* The schema node's `type`: `object-grid`, or the `view:grid` alias; for
* ListView, the view on screen (`kanban`, `calendar`, …).
*/
blockType?: unknown;
/** The object the grid queries, when it names one. */
objectName?: unknown;
Expand All @@ -67,6 +76,10 @@ const SURFACE: Record<UndeclaredActionNameKey, string> = {
rowActions: 'the row menu draws no entry',
};

function describeComponent(component: unknown): string {
return typeof component === 'string' && component.length > 0 ? component : 'ObjectGrid';
}

function describeAddress({ blockType, objectName, label }: UndeclaredActionNamesAddress): string {
const block = typeof blockType === 'string' && blockType.length > 0 ? blockType : 'object-grid';
const parts: string[] = [];
Expand All @@ -92,13 +105,13 @@ export function describeUndeclaredActionNames(
const rule = ` A bare \`${key}\` name must name an action declared on the object.`;
if (basis === 'unread') {
const count = names.length === 1 ? '1 name' : `${names.length} names`;
return `[ObjectUI] ObjectGrid ${key}: ${describeAddress(address)} — the object's schema could not `
return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — the object's schema could not `
+ `be read (no getObjectSchema on the data source, or the read failed), so its declared actions `
+ `are unknown and ${SURFACE[key]} for ${count}: ${listed}.\n`
+ `${rule} Give the grid a data source that answers getObjectSchema, or check why the read failed.`;
}
const count = names.length === 1 ? '1 name matches' : `${names.length} names match`;
return `[ObjectUI] ObjectGrid ${key}: ${describeAddress(address)} — ${count} no action the object `
return `[ObjectUI] ${describeComponent(address.component)} ${key}: ${describeAddress(address)} — ${count} no action the object `
+ `declares, so ${SURFACE[key]} for ${them}: ${listed}.\n`
+ `${rule} Declare the action on the `
+ 'object, or remove the name. An action that a disabled package withholds is left out of the '
Expand Down Expand Up @@ -141,16 +154,17 @@ export function useUndeclaredActionNamesWarning(input: {
// Keyed on the names' VALUES, never on the array the fold returns, which is
// a fresh one on every render.
const namesKey = ready ? JSON.stringify(input.names) : '';
const { blockType, objectName, label } = input.address;
const { component, blockType, objectName, label } = input.address;
useEffect(() => {
if (!namesKey || !isDevelopment()) return;
const message = describeUndeclaredActionNames(key, JSON.parse(namesKey) as string[], {
component,
blockType,
objectName,
label,
}, basis);
if (!message || warnedMessages.has(message)) return;
warnedMessages.add(message);
console.warn(message);
}, [key, namesKey, basis, blockType, objectName, label]);
}, [key, namesKey, basis, component, blockType, objectName, label]);
}
2 changes: 2 additions & 0 deletions ui/packages/plugin-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@object-ui/mobile": "workspace:^",
"@object-ui/permissions": "workspace:^",
"@object-ui/plugin-detail": "workspace:^",
"@object-ui/plugin-grid": "workspace:^",
"@object-ui/react": "workspace:^",
"@object-ui/types": "workspace:^",
"@objectstack/spec": "workspace:^",
Expand All @@ -62,6 +63,7 @@
"@object-ui/mobile": "workspace:*",
"@object-ui/permissions": "workspace:*",
"@object-ui/plugin-detail": "workspace:*",
"@object-ui/plugin-grid": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/test-support": "workspace:*",
"@object-ui/types": "workspace:*",
Expand Down
56 changes: 51 additions & 5 deletions ui/packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { resolveInjectedSystemColumns, resolveSearchFieldResolution, type Search
import { SystemFieldName } from '@objectstack/spec/system';
import { usePermissions } from '@object-ui/permissions';
import { RecordDetailPanel } from '@object-ui/plugin-detail';
import { resolveBulkActions, useUndeclaredActionNamesWarning } from '@object-ui/plugin-grid';

/**
* The `case 'map'` branch below builds an `object-map` schema by flattening
Expand Down Expand Up @@ -2535,6 +2536,51 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
return declared.filter((a: unknown) => String(a).toLowerCase() !== 'delete');
}, [schema.bulkActions, bulkDeleteVerdict]);

/**
* [objectstack#30394] What the non-grid bar DRAWS out of `permittedBulkActions`:
* the grid's own rule (objectstack#30169, ruling C), folded by the grid's own
* `resolveBulkActions` so the two bars cannot drift.
*
* A bare name draws only when the object declares an action of that name,
* labelled from that declaration (through the app's i18n bundle, as the grid
* labels it). The canonical `delete` keeps its own path: it is not an object
* action but the built-in bulk delete, gated above by `bulkDeleteVerdict`,
* so it is held out of the fold exactly as the grid holds it out. Any other
* name draws nothing; `undeclared` exists only for the development warning
* below. Before this fold every member drew a button, so a name the object
* never declared, an action a disabled package withholds included, showed a
* button that only toasted "not executable" when pressed.
*/
const nonGridBulkBar = React.useMemo(() => {
const names = (permittedBulkActions ?? []).filter((a: unknown) => typeof a === 'string' && a !== '') as string[];
const { defs, undeclared } = resolveBulkActions({
bulkActions: names.filter((a) => a.toLowerCase() !== 'delete'),
objectActions: objectDef?.actions,
localizeLabel: (actionName, fallback) =>
schema.objectName && typeof resolveActionLabel === 'function'
? resolveActionLabel(schema.objectName, actionName, fallback)
: fallback,
});
const labels = new Map(defs.map((def) => [def.name, def.label]));
// Authored order, the built-in `delete` where the author put it.
const drawn = names
.filter((a) => a.toLowerCase() === 'delete' || labels.has(a))
.map((name) => ({ name, label: labels.get(name) }));
return { drawn, undeclared };
}, [permittedBulkActions, objectDef, schema.objectName, resolveActionLabel]);
// The grid says its own (ObjectGrid warns for the names it leaves undrawn),
// so this one speaks only while a non-grid view is on screen, and only once
// the object's schema read has settled. A list with an object but no schema
// in hand (no `getObjectSchema`, or the read failed) reports the names as
// unread, never as undeclared: the grid's own reading.
useUndeclaredActionNamesWarning({
key: 'bulkActions',
names: nonGridBulkBar.undeclared,
ready: objectDefLoaded && currentView !== 'grid',
basis: schema.objectName && objectDef == null ? 'unread' : 'declared',
address: { component: 'ListView', blockType: currentView, objectName: schema.objectName, label: schema.label },
});

/**
* [objectui#4420] The PER-RECORD half of the same key, for the same bar.
*
Expand Down Expand Up @@ -7352,7 +7398,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
)}

{/* Bulk Actions Bar — skip for grid view since ObjectGrid renders its own BulkActionBar */}
{permittedBulkActions && permittedBulkActions.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
{nonGridBulkBar.drawn.length > 0 && selectedRows.length > 0 && currentView !== 'grid' && (
<div
className="border-t border-primary/30 px-4 py-2 flex items-center gap-2 text-xs bg-primary/10 text-foreground shrink-0 shadow-sm"
role="region"
Expand All @@ -7364,8 +7410,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
{selectedRows.length} {selectedRows.length === 1 ? 'item' : 'items'} selected
</span>
<div className="flex items-center gap-1.5 ml-3">
{permittedBulkActions.map((action: any) => {
const actionStr = String(action).toLowerCase();
{nonGridBulkBar.drawn.map(({ name: action, label }) => {
const actionStr = action.toLowerCase();
const isDestructive = actionStr.includes('delete') || actionStr.includes('remove') || actionStr.includes('destroy');
const Icon = isDestructive ? Trash2 : null;
// [objectui#4420] The built-in `delete` runs over the ALLOWED
Expand All @@ -7388,7 +7434,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
data-testid={`bulk-action-${action}`}
>
{Icon && <Icon className="h-3 w-3" />}
{tActionLabel(action)}
{label ?? tActionLabel(action)}
</Button>
);
})}
Expand All @@ -7402,7 +7448,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
the dialog's slot: one name for one fact, whichever surface says
it. */}
{bulkDeleteEligibility.skipped > 0
&& permittedBulkActions.some((a: any) => String(a).toLowerCase() === 'delete') && (
&& nonGridBulkBar.drawn.some(({ name }) => name.toLowerCase() === 'delete') && (
<span
className="text-muted-foreground ml-3"
data-testid="bulk-skipped-notice"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ function makeDataSource(userActionsDelete?: unknown) {
name: { type: 'text' },
status: { type: 'text' },
},
// `archive` is declared: the non-grid bar draws a bare `bulkActions`
// name only when the object declares it (objectstack#30394).
actions: [{ name: 'archive', label: 'Archive' }],
...(userActionsDelete === undefined ? {} : { userActions: { delete: userActionsDelete } }),
}),
};
Expand Down
Loading
Loading