From 6bf898dd0e3b98d02d33053b693d0ee1f8cb79fb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 12:59:28 +0000 Subject: [PATCH] feat(spec): userActions.create/import accept the edit/delete CEL predicate union (#7692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3076 (objectui#2614) gave `userActions.edit`/`delete` a boolean-or-predicates union so the built-in row affordances could be gated on record state. `create`/`import` were left as bare booleans with no other lever, so a child object's related-list [+ New] button could not be gated on the parent record's state while the row Edit/Delete beside it could. On a frozen parent the row actions grey out and [+ New] still renders; the server guard 409s the insert, so it is an affordance leak an app has no way to close. Both keys now take the SAME union — the same RowCrudActionOverrideSchema, not a new dialect. resolveCrudAffordances carries the predicates through as createPredicates/importPredicates alongside the existing edit/delete pair, via the same normalizeRowCrudOverride collapse. What `record.*` binds to differs between the two positions, and the schema says so rather than implying a symmetry it does not have: edit/delete evaluate per row against that row's own record; create/import gate a record that does not exist yet, so they evaluate once per toolbar against the record in scope — the host (parent) record on a related list, and nothing on a standalone object list, where a `record.*` predicate therefore hides the button under the fail-closed rule. plugin-hono-server tracks the widened producer: the /me/permissions managed-write clamp tested `create` with a bare `!== true`, which would have clamped away a legitimate `create: { enabled: true, visibleWhen: … }` opt-in. It now reads `create` through the same opt-in helper as edit/delete. The renderer half (related-list toolbar honouring create.visibleWhen) is objectui's downstream card and is not part of this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017aiuit7rFUsQWropgqtfnR --- .../useractions-create-import-predicates.md | 52 +++++++++ content/docs/references/data/object.mdx | 8 +- .../src/current-user-endpoints.ts | 14 ++- .../src/fold-wildcard-superuser.test.ts | 38 ++++++ packages/spec/src/data/object.test.ts | 110 ++++++++++++++++++ packages/spec/src/data/object.zod.ts | 97 ++++++++++----- 6 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 .changeset/useractions-create-import-predicates.md diff --git a/.changeset/useractions-create-import-predicates.md b/.changeset/useractions-create-import-predicates.md new file mode 100644 index 0000000000..db28e5462e --- /dev/null +++ b/.changeset/useractions-create-import-predicates.md @@ -0,0 +1,52 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-hono-server": patch +--- + +feat(spec): `userActions.create` / `.import` accept the same CEL-predicate object form as `edit` / `delete` (#7692) + +#3076 (objectui#2614) gave `userActions.edit` and `userActions.delete` a +boolean-or-predicates union so the built-in row affordances could be gated on +record state. `create` and `import` were left as bare booleans, and there was no +other lever for them — which means a child object's related-list `[+ New]` button +could not be gated on the parent record's state at all, while the row `Edit` / +`Delete` beside it could. On a frozen parent the row actions correctly grey out +and `[+ New]` still renders; the server-side guard rejects the insert, so this is +an affordance leak rather than a data-integrity hole, but it is one an app has no +way to close. + +Both keys now take the union `edit` / `delete` already carry — the **same** +`RowCrudActionOverrideSchema`, not a new dialect: + +```ts +userActions: { + create: { visibleWhen: 'record.version_status == "draft"' }, + import: { enabled: true, disabledWhen: 'record.frozen == true' }, +} +``` + +`enabled` keeps the bare boolean's meaning (omitted → the `managedBy` bucket +default), `visibleWhen` is fail-closed and `disabledWhen` fail-soft, exactly as +for the row pair. `resolveCrudAffordances` carries the predicates through as +`createPredicates` / `importPredicates`, alongside the existing +`editPredicates` / `deletePredicates`. + +**What `record.*` binds to differs between the two positions, and the schema says +so rather than implying symmetry it does not have.** `edit` / `delete` evaluate +per row against that row's own record. `create` / `import` gate a record that does +not exist yet, so they evaluate once per toolbar against the record in scope where +the toolbar renders — the host (parent) record on a record page's related list, +and nothing at all on a standalone object list, where a predicate reading +`record.*` therefore hides the button under the fail-closed rule. + +Back-compatible: the boolean forms parse and resolve exactly as before, and the +boolean-only path still produces no predicate keys. Unknown keys inside the object +form are rejected, same as for `edit` / `delete`. + +`@objectstack/plugin-hono-server` tracks the widened producer: the `/me/permissions` +managed-write clamp tested `create` with a bare `!== true`, which would have clamped +away a legitimate `create: { enabled: true, visibleWhen: … }` opt-in; it now reads +`create` through the same opt-in helper as `edit` / `delete`. + +The renderer half — the related-list toolbar honouring `create.visibleWhen` — is +objectui's downstream card and is not part of this change. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 60959be336..5a51273350 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -117,7 +117,7 @@ const result = ApiMethod.parse(data); | **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) | | **managedBy** | `Enum<'platform' \| 'config' \| 'system-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. | | **ownership** | `Enum<'user' \| 'business_unit' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id plus owning_business_unit_id) \| business_unit (unit-owned: owning_business_unit_id only, no owner_id) \| org \| none (no per-record owner, neither anchor). Distinct from the package own/extend contribution kind. | -| **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| object; delete?: boolean \| object; … }` | optional | Per-object override of the resolved CRUD affordance matrix. | +| **userActions** | `{ create?: boolean \| object; import?: boolean \| object; edit?: boolean \| object; delete?: boolean \| object; … }` | optional | Per-object override of the resolved CRUD affordance matrix. | | **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. | | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | @@ -293,15 +293,15 @@ Type: `string[]` ## RowCrudActionOverride -Boolean-or-predicates override for a built-in row CRUD affordance. +Boolean-or-predicates override for a built-in CRUD affordance. ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | optional | Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-record CEL predicate; false → hide the row button for that record. Fail-closed. | -| **disabledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); false → hide the button. Fail-closed. | +| **disabledWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); true → render the button disabled. Fail-soft. | --- diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index ead5fba89d..77dd09e3ad 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -298,10 +298,11 @@ interface ResolvedPermissionSetLike { export interface ManagedSchemaLike { managedBy?: string; userActions?: { - create?: boolean; - // edit/delete accept the #2614 object form ({ enabled, visibleWhen, - // disabledWhen }); only the object-level `enabled` matters here — the - // per-record predicates are UI gating, not a permission grant. + // create/edit/delete all accept the object form + // ({ enabled, visibleWhen, disabledWhen }) — #2614 gave it to the row + // pair, #7692 to `create`. Only the object-level `enabled` matters + // here: the predicates are UI gating, not a permission grant. + create?: boolean | { enabled?: boolean }; edit?: boolean | { enabled?: boolean }; delete?: boolean | { enabled?: boolean }; } | null; @@ -441,7 +442,10 @@ export function clampManagedObjectWrites( if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; const ua = schema.userActions ?? {}; if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; - if (ua.create !== true) acc.allowCreate = false; + // `create` reads through the same opt-in helper as edit/delete since + // #7692 widened it to the object form — a bare `ua.create !== true` + // would clamp away a legitimate `{ enabled: true, visibleWhen: … }`. + if (!isWriteOptedIn(ua.create)) acc.allowCreate = false; if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; } } diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index 70c9d32d34..c99952a38e 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -181,6 +181,44 @@ describe('clampManagedObjectWrites', () => { expect(objects.sys_account.allowEdit).toBe(false); }); + /** + * #7692 widened `userActions.create` to the same boolean-or-object union, so + * this clamp had to stop testing it with a bare `create !== true`. Without + * that change an object opting the create affordance IN through the object + * form would be clamped OFF here — a silent permission-hint tightening that + * `edit`/`delete` beside it do not suffer. Read `create` the same way, with + * the same fail-closed rule when `enabled` is omitted. + */ + it('treats the #7692 create object form by its enabled flag only, like edit/delete', () => { + const schemas: Record = { + sys_user: { + managedBy: 'better-auth', + userActions: { create: { enabled: true, visibleWhen: 'record.status == "draft"' } as never }, + }, + sys_account: { + managedBy: 'better-auth', + // enabled omitted → NOT an explicit opt-in; the clamp stays fail-closed. + userActions: { create: { visibleWhen: 'record.status == "draft"' } as never }, + }, + }; + const objects: Record = { + sys_user: { allowCreate: true }, + sys_account: { allowCreate: true }, + }; + clampManagedObjectWrites(objects, (n) => schemas[n]); + expect(objects.sys_user.allowCreate).toBe(true); + expect(objects.sys_account.allowCreate).toBe(false); + // The bare boolean form is untouched by the widening. + const boolObjects: Record = { sys_user: { allowCreate: true }, sys_member: { allowCreate: true } }; + clampManagedObjectWrites(boolObjects, (n) => ( + n === 'sys_user' + ? { managedBy: 'better-auth', userActions: { create: true } } + : { managedBy: 'better-auth' } + )); + expect(boolObjects.sys_user.allowCreate).toBe(true); + expect(boolObjects.sys_member.allowCreate).toBe(false); + }); + it('fold + clamp compose to permission ∩ guard for a platform admin', () => { // As produced for a platform admin (admin_full_access '*' modifyAll) who // also holds organization_admin (explicit managed denies). diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index f1cc49c862..99763df6b1 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1784,6 +1784,116 @@ describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', }); }); +// #7692 completes the symmetry #3076/objectui#2614 left half-done: `create` +// and `import` were boolean-only, so a related-list `[+ New]` could not be +// gated on the host record's state while the row Edit/Delete beside it could. +// Same union, same schema piece — only the binding differs (per toolbar rather +// than per row), which the schema's docblock states rather than inventing a +// second dialect. +describe('userActions.create / .import toolbar predicates (#7692)', () => { + it('keeps parsing the plain boolean form for create and import (back-compat)', () => { + const obj = ObjectSchema.parse({ + name: 'invoice', + fields: { name: { type: 'text' } }, + userActions: { create: false, import: true }, + }); + expect(obj.userActions?.create).toBe(false); + expect(obj.userActions?.import).toBe(true); + const aff = resolveCrudAffordances(obj); + expect(aff.create).toBe(false); + expect(aff.import).toBe(true); + // Boolean-only path stays byte-identical to the pre-#7692 result: no + // predicate keys appear at all. + expect(aff.createPredicates).toBeUndefined(); + expect(aff.importPredicates).toBeUndefined(); + }); + + it('accepts the object form with a visibleWhen CEL shorthand on create', () => { + const obj = ObjectSchema.parse({ + name: 'task_version_check_item', + fields: { name: { type: 'text' }, version_status: { type: 'text' } }, + userActions: { create: { visibleWhen: 'record.version_status == "draft"' } }, + }); + // String shorthand normalizes to the canonical CEL envelope, exactly as it + // does for edit/delete — no new dialect. + expect((obj.userActions?.create as any).visibleWhen) + .toEqual({ dialect: 'cel', source: 'record.version_status == "draft"' }); + }); + + it('accepts the object form on import too, with the same keys', () => { + const obj = ObjectSchema.parse({ + name: 'task_position', + fields: { name: { type: 'text' } }, + userActions: { import: { enabled: true, disabledWhen: 'record.frozen == true' } }, + }); + expect((obj.userActions?.import as any).enabled).toBe(true); + expect((obj.userActions?.import as any).disabledWhen) + .toEqual({ dialect: 'cel', source: 'record.frozen == true' }); + }); + + it('resolveCrudAffordances carries the predicates through and defaults enabled from the bucket', () => { + const aff = resolveCrudAffordances({ + managedBy: 'platform', + userActions: { + create: { visibleWhen: { dialect: 'cel', source: 'record.version_status == "draft"' } }, + import: { enabled: false, disabledWhen: { dialect: 'cel', source: 'record.frozen == true' } }, + }, + } as never); + // No `enabled` on create → platform bucket default (true) applies. + expect(aff.create).toBe(true); + expect(aff.createPredicates?.visibleWhen) + .toEqual({ dialect: 'cel', source: 'record.version_status == "draft"' }); + expect(aff.createPredicates?.disabledWhen).toBeUndefined(); + // Explicit enabled:false wins over the bucket default; predicates still surface. + expect(aff.import).toBe(false); + expect(aff.importPredicates?.disabledWhen) + .toEqual({ dialect: 'cel', source: 'record.frozen == true' }); + }); + + it('object form without predicates behaves exactly like the boolean form', () => { + const aff = resolveCrudAffordances({ + managedBy: 'config', + userActions: { create: { enabled: false }, import: {} }, + } as never); + expect(aff.create).toBe(false); + expect(aff.import).toBe(false); // config bucket default: import is opt-in + expect(aff.createPredicates).toBeUndefined(); + expect(aff.importPredicates).toBeUndefined(); + }); + + it('the object form still respects the import opt-in on a non-platform bucket', () => { + // #4671: `import` is opt-IN outside `platform`. Widening the key must not + // turn the object form into a back door around that — `{}` inherits the + // bucket default (false above), and only an explicit enabled:true grants it. + const aff = resolveCrudAffordances({ + managedBy: 'system-data', + userActions: { import: { enabled: true, visibleWhen: 'record.frozen != true' } }, + } as never); + expect(aff.import).toBe(true); + }); + + it('rejects unknown keys inside the create/import object form', () => { + for (const key of ['create', 'import'] as const) { + const result = ObjectSchema.safeParse({ + name: 'invoice', + fields: { name: { type: 'text' } }, + userActions: { [key]: { hideWhen: 'record.frozen == true' } }, + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('hideWhen'); + } + }); + + it('rejects a non-predicate value where the union accepts neither arm', () => { + const result = ObjectSchema.safeParse({ + name: 'invoice', + fields: { name: { type: 'text' } }, + userActions: { create: 'record.frozen != true' }, + }); + expect(result.success).toBe(false); + }); +}); + // ADR-0100: a `password` field on a generic (non-better-auth) object is masked // on read but plaintext at rest — not hashed. create() warns (non-fatally) to // steer authors toward `secret` or the auth subsystem. The warning is deduped diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index a7425a3458..13e0d3c4fd 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1051,38 +1051,54 @@ export type ObjectExternalBinding = z.input; export type ObjectExternalBindingParsed = z.infer; /** - * Object form of a `userActions.edit` / `userActions.delete` override — - * extends the plain boolean with **per-record** CEL predicates so the - * built-in row Edit/Delete affordances can be hidden or disabled for a - * subset of rows (objectstack-ai/objectui#2614). + * Object form of a `userActions` CRUD override — extends the plain boolean + * with CEL predicates so a built-in affordance can be hidden or disabled + * against record state rather than only per object + * (objectstack-ai/objectui#2614). * - * Semantics (mirrors custom row actions' `visible` / `disabled`): + * Semantics (mirrors custom actions' `visible` / `disabled`): * - `enabled` — object-level on/off, same meaning as the bare boolean. * Omitted → the `managedBy` bucket default. - * - `visibleWhen` — CEL over `record.*`; evaluates **false** → the row's - * button is not rendered. Fail-closed (a faulting - * predicate hides, and warns once). - * - `disabledWhen` — CEL over `record.*`; evaluates **true** → the row's - * button renders greyed / non-clickable. Fail-soft (a - * faulting predicate leaves the button enabled). + * - `visibleWhen` — CEL over `record.*`; evaluates **false** → the button + * is not rendered. Fail-closed (a faulting predicate + * hides, and warns once). + * - `disabledWhen` — CEL over `record.*`; evaluates **true** → the button + * renders greyed / non-clickable. Fail-soft (a faulting + * predicate leaves the button enabled). * * The predicates are advisory UI gating only — server-side enforcement * stays with permissions / hooks (e.g. `beforeUpdate` rejecting frozen - * rows). Evaluation happens on the canonical CEL engine, per row, with the - * record bound as `record.*` (and bare fields) — the same machinery custom - * actions already use, so authoring is identical. + * rows). Evaluation happens on the canonical CEL engine, with the record + * bound as `record.*` (and bare fields) — the same machinery custom actions + * already use, so authoring is identical. + * + * **What `record.*` binds to depends on where the affordance renders, and + * the two cases are not the same fact** (#7692): + * - Row affordances (`edit`, `delete`) evaluate **per row**, against that + * row's own record. This is the original #2614 case and the reason for + * the `RowCrud` name, kept for export compatibility. + * - Toolbar affordances (`create`, `import`) have no row to bind — the + * record they gate does not exist yet. They evaluate **once per toolbar** + * against the record in scope where the toolbar renders: on a record + * page's related list that is the **host (parent) record**. On a + * standalone object list there is no record in scope, so a predicate + * reading `record.*` has nothing to bind and — per the fail-closed rule + * above — hides the button. Gate a toolbar action on parent state only + * where a parent is actually in scope; anything else the child row must + * carry itself (the denormalised parent-status snapshot pattern that + * `edit`/`delete` already use). */ export const RowCrudActionOverrideSchema = z.object({ enabled: z.boolean().optional().describe( 'Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default.', ), visibleWhen: ExpressionInputSchema.optional().describe( - 'Per-record CEL predicate; false → hide the row button for that record. Fail-closed.', + 'CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); false → hide the button. Fail-closed.', ), disabledWhen: ExpressionInputSchema.optional().describe( - 'Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft.', + 'CEL predicate over the record in scope (row record for edit/delete, host record for a related-list create/import toolbar); true → render the button disabled. Fail-soft.', ), -}).strict().describe('Boolean-or-predicates override for a built-in row CRUD affordance.'); +}).strict().describe('Boolean-or-predicates override for a built-in CRUD affordance.'); export type RowCrudActionOverride = z.input; /** Post-parse shape of {@link RowCrudActionOverride} — defaults applied, transforms run (ADR-0122). */ export type RowCrudActionOverrideParsed = z.infer; @@ -1418,6 +1434,14 @@ const ObjectSchemaBase = strictObject( * * Omitting the block (or leaving individual flags `undefined`) keeps * the {@link managedBy}-derived default. + * + * Every CRUD flag except `exportCsv` also accepts the object form + * {@link RowCrudActionOverrideSchema} — `{ enabled?, visibleWhen?, + * disabledWhen? }` — which gates the affordance on record state instead + * of only per object. `edit`/`delete` evaluate it per row (objectui#2614); + * `create`/`import` evaluate it once per toolbar against the record in + * scope (#7692). Read that schema's docblock for what `record.*` binds to + * in each position — they are different records. */ userActions: strictObject({ surface: "this object's `userActions` block", @@ -1468,8 +1492,12 @@ const ObjectSchemaBase = strictObject( 'Visibility is governed by permissions (`requiredPermissions`) and `access.default`.', }, }, { - create: z.boolean().optional().describe('Show generic "New" button.'), - import: z.boolean().optional().describe('Show CSV import wizard entry.'), + create: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe( + 'Show generic "New" button. Boolean, or an object adding visibleWhen/disabledWhen CEL predicates evaluated once per toolbar against the record in scope (the host record on a related list).', + ), + import: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe( + 'Show CSV import wizard entry. Boolean, or an object adding visibleWhen/disabledWhen CEL predicates evaluated once per toolbar against the record in scope (the host record on a related list).', + ), edit: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe( 'Allow inline / form edit of existing rows. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.', ), @@ -2316,6 +2344,17 @@ export interface CrudAffordances { editPredicates?: RowCrudPredicates; /** Per-record CEL predicates for the built-in row Delete action. */ deletePredicates?: RowCrudPredicates; + /** + * CEL predicates for the generic New button, present only when + * `userActions.create` used the object form (#7692). Unlike the row + * predicates above these evaluate **once per toolbar**, against the record + * in scope where the toolbar renders — the host record on a related list, + * and nothing at all on a standalone object list. See + * {@link RowCrudActionOverrideSchema}. + */ + createPredicates?: RowCrudPredicates; + /** Toolbar-scope CEL predicates for the CSV import entry; same binding as {@link createPredicates}. */ + importPredicates?: RowCrudPredicates; } /** @@ -2386,26 +2425,32 @@ export function resolveCrudAffordances( const bucket = (obj?.managedBy ?? 'platform') as keyof typeof CRUD_AFFORDANCE_DEFAULTS; const base = CRUD_AFFORDANCE_DEFAULTS[bucket] ?? CRUD_AFFORDANCE_DEFAULTS.platform; const overrides = obj?.userActions ?? {}; + const create = normalizeRowCrudOverride(overrides.create, base.create); + const imp = normalizeRowCrudOverride(overrides.import, base.import); const edit = normalizeRowCrudOverride(overrides.edit, base.edit); const del = normalizeRowCrudOverride(overrides.delete, base.delete); const out: CrudAffordances = { - create: overrides.create ?? base.create, - import: overrides.import ?? base.import, + create: create.enabled, + import: imp.enabled, edit: edit.enabled, delete: del.enabled, exportCsv: overrides.exportCsv ?? base.exportCsv, }; + if (create.predicates) out.createPredicates = create.predicates; + if (imp.predicates) out.importPredicates = imp.predicates; if (edit.predicates) out.editPredicates = edit.predicates; if (del.predicates) out.deletePredicates = del.predicates; return out; } /** - * Collapse a `userActions.edit` / `userActions.delete` override — bare - * boolean or `{ enabled, visibleWhen, disabledWhen }` object — onto the - * bucket default. The predicates pass through as authored; `predicates` is - * only set when at least one predicate is present, so the boolean-only path - * stays byte-identical to the pre-#2614 result. + * Collapse a `userActions` CRUD override — bare boolean or + * `{ enabled, visibleWhen, disabledWhen }` object — onto the bucket default. + * The predicates pass through as authored; `predicates` is only set when at + * least one predicate is present, so the boolean-only path stays + * byte-identical to the pre-#2614 result. Shared by all four predicate- + * carrying flags: `edit`/`delete` (per row) and `create`/`import` (per + * toolbar, #7692) — the collapse is the same, only the binding differs. */ function normalizeRowCrudOverride( override: boolean | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown } | null | undefined,