From 3320e48c6e72c75e87a280c0dc4602ffabd7c095 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:00:10 +0800 Subject: [PATCH] refactor(managedBy): one parser for the userActions override shape (completes #2712, framework#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2712 consolidated the bucket union + affordance set mirrors but left four surfaces still parsing the `userActions.{create,edit,delete}` override shape by hand. They now all route through the shared @object-ui/core policy — no package re-implements the boolean / #2614-object-form parse locally. - core: promote internal `normalizeOverride` to exported `normalizeUserAction` (the one parser) + add `userActionPredicates` for per-record predicate extraction. - app-shell/managedByEmptyState: writable-`system` create check + local `EmptyStateUserActions` → `resolveCrudAffordances({ managedBy, userActions }).create`. - plugin-grid/rowCrudAffordances: local `isOptedOut`/`predicatesOf` + duplicated types fold into `normalizeUserAction`; historical type names stay re-exported. - plugin-detail/RelatedList: inline `predicatesOf` → `userActionPredicates`. - plugin-form/ObjectForm: hand-rolled `managedBy !== 'platform'` blanket lock + userActions unlock → resolved affordance for the mode (edit/create), the same `resolveCrudAffordances` contract detail (`isObjectInlineEditable`) and grid use. Behavior-preserving for platform/system/append-only/better-auth, with one deliberate alignment: an admin-editable `config` object (sys_webhook, sys_permission_set) is now editable in ObjectForm — it was over-locked as non-`platform` while detail/grid already treated it as editable (config resolves edit:true). New unit coverage for the shared parser + the config/create-mode form gate; all existing affordance/edit-gate tests stay green. Co-Authored-By: Claude Opus 4.8 --- .../managedby-useractions-single-parser.md | 17 +++++++ .../src/utils/managedByEmptyState.ts | 14 +++--- packages/core/src/utils/managedBy.test.ts | 42 ++++++++++++++++ packages/core/src/utils/managedBy.ts | 28 +++++++++-- packages/plugin-detail/src/RelatedList.tsx | 16 +++---- .../src/ObjectForm.managedEdit.test.tsx | 30 ++++++++++++ packages/plugin-form/src/ObjectForm.tsx | 37 +++++++------- .../plugin-grid/src/rowCrudAffordances.ts | 48 +++++++------------ 8 files changed, 159 insertions(+), 73 deletions(-) create mode 100644 .changeset/managedby-useractions-single-parser.md diff --git a/.changeset/managedby-useractions-single-parser.md b/.changeset/managedby-useractions-single-parser.md new file mode 100644 index 000000000..7d8c483e4 --- /dev/null +++ b/.changeset/managedby-useractions-single-parser.md @@ -0,0 +1,17 @@ +--- +"@object-ui/core": patch +"@object-ui/app-shell": patch +"@object-ui/plugin-grid": patch +"@object-ui/plugin-form": patch +"@object-ui/plugin-detail": patch +--- + +**Finish the `managedBy` / `userActions` de-dup — one parser for the override shape (completes objectui#2712, framework#3343).** #2712 consolidated the bucket *union* + affordance *set* mirrors but left four surfaces still parsing the `userActions.{create,edit,delete}` override shape by hand. They now all route through the shared `@object-ui/core` policy, so no package re-implements the boolean / #2614-object-form parse locally. + +- **`@object-ui/core`** promotes the internal `normalizeOverride` to the exported **`normalizeUserAction(v, base)`** (the one parser) and adds **`userActionPredicates(v)`** for per-record CEL predicate extraction. +- **`app-shell/utils/managedByEmptyState.ts`** — the writable-`system` create check and its local `EmptyStateUserActions` interface are replaced by `resolveCrudAffordances({ managedBy, userActions }).create`. +- **`plugin-grid/rowCrudAffordances.ts`** — the local `isOptedOut` / `predicatesOf` helpers (and duplicated `RowCrudUserAction` / `RowCrudPredicates` types) fold into `normalizeUserAction`; the historical type names stay re-exported for compat. +- **`plugin-detail/RelatedList.tsx`** — its inline `predicatesOf` fold into `userActionPredicates`. +- **`plugin-form/ObjectForm.tsx`** — the hand-rolled `managedBy !== 'platform'` blanket lock + `userActions` unlock is replaced by the resolved affordance for the current mode (`edit` / `create`), the **same** `resolveCrudAffordances` contract the detail (`isObjectInlineEditable`) and grid surfaces use. + +Behavior-preserving for `platform` / `system` / `append-only` / `better-auth`, with one deliberate alignment: an admin-editable **`config`**-bucket object (e.g. `sys_webhook`, `sys_permission_set`) is now editable in `ObjectForm` — it was previously over-locked as "non-`platform`", while detail/grid already treated it as editable (`config` resolves `edit: true`). New unit coverage for the shared parser and the config / create-mode form gate; all existing affordance/edit-gate tests stay green. diff --git a/packages/app-shell/src/utils/managedByEmptyState.ts b/packages/app-shell/src/utils/managedByEmptyState.ts index 726c77fca..5732d50a9 100644 --- a/packages/app-shell/src/utils/managedByEmptyState.ts +++ b/packages/app-shell/src/utils/managedByEmptyState.ts @@ -21,6 +21,8 @@ * (in the header) and the empty state (in the body) tell a consistent * story without repeating themselves verbatim. */ +import { resolveCrudAffordances, type UserActionsOverride } from '@object-ui/core'; + export interface ManagedByEmptyState { title: string; message: string; @@ -34,24 +36,20 @@ export interface ManagedByEmptyState { */ type TranslateFn = (key: string, options?: Record) => string; -/** Subset of `userActions` (ADR-0103) that opens generic creation. */ -interface EmptyStateUserActions { - create?: boolean; -} - export function resolveManagedByEmptyState( managedBy: string | undefined | null, t: TranslateFn, objectName?: string | null, - userActions?: EmptyStateUserActions | null, + userActions?: UserActionsOverride | null, ): ManagedByEmptyState | undefined { switch (managedBy) { case 'system': // ADR-0103 — a `system` object that opened creation is admin/user-writable // data (e.g. Notification Preferences). The "entries appear automatically" // copy would be wrong; fall back to the generic empty state (which surfaces - // the New button) by returning undefined. - if (userActions?.create === true) return undefined; + // the New button) by returning undefined. The resolved `create` affordance + // (shared @object-ui/core policy) is the one place that reads the override. + if (resolveCrudAffordances({ managedBy, userActions }).create) return undefined; return { icon: 'Lock', title: t('list.managedBy.system.title', { defaultValue: 'Nothing here yet' }), diff --git a/packages/core/src/utils/managedBy.test.ts b/packages/core/src/utils/managedBy.test.ts index f534b0e83..172119727 100644 --- a/packages/core/src/utils/managedBy.test.ts +++ b/packages/core/src/utils/managedBy.test.ts @@ -5,6 +5,8 @@ import { isWriteOptedIn, isSystemWritable, isObjectInlineEditable, + normalizeUserAction, + userActionPredicates, } from './managedBy'; describe('resolveCrudAffordances (shared source of truth)', () => { @@ -96,6 +98,46 @@ describe('isObjectInlineEditable', () => { }); }); +// The ONE parser for the userActions override shape, now consumed by the grid +// row affordances and related-list row predicates (objectui#2712 follow-up) so +// no package re-implements the boolean / #2614 object-form parse locally. +describe('normalizeUserAction (the single override parser)', () => { + it('a missing flag falls back to the caller-supplied bucket default', () => { + expect(normalizeUserAction(undefined, true)).toEqual({ enabled: true }); + expect(normalizeUserAction(undefined, false)).toEqual({ enabled: false }); + expect(normalizeUserAction(null, true)).toEqual({ enabled: true }); + }); + + it('a bare boolean wins over the default and carries no predicates', () => { + expect(normalizeUserAction(true, false)).toEqual({ enabled: true }); + expect(normalizeUserAction(false, true)).toEqual({ enabled: false }); + }); + + it('object form: enabled overrides the default; predicates ride alongside', () => { + expect(normalizeUserAction({ enabled: false, disabledWhen: 'record.frozen' }, true)) + .toEqual({ enabled: false, predicates: { disabledWhen: 'record.frozen' } }); + // omitted `enabled` falls back to the base; only the present predicate key is set. + expect(normalizeUserAction({ visibleWhen: 'a' }, true)) + .toEqual({ enabled: true, predicates: { visibleWhen: 'a' } }); + // object form without predicates is boolean-equivalent. + expect(normalizeUserAction({ enabled: true }, false)).toEqual({ enabled: true }); + }); +}); + +describe('userActionPredicates', () => { + it('returns predicates independent of the enabled verdict, undefined otherwise', () => { + expect(userActionPredicates(true)).toBeUndefined(); + expect(userActionPredicates(false)).toBeUndefined(); + expect(userActionPredicates(undefined)).toBeUndefined(); + expect(userActionPredicates({ enabled: true })).toBeUndefined(); + expect(userActionPredicates({ disabledWhen: 'x' })).toEqual({ disabledWhen: 'x' }); + expect(userActionPredicates({ visibleWhen: 'a', disabledWhen: 'b' })) + .toEqual({ visibleWhen: 'a', disabledWhen: 'b' }); + // predicates survive even when the flag opts the action out. + expect(userActionPredicates({ enabled: false, visibleWhen: 'a' })).toEqual({ visibleWhen: 'a' }); + }); +}); + describe('MANAGED_BY_BUCKETS', () => { it('is the closed 5-bucket union in canonical order', () => { expect(MANAGED_BY_BUCKETS).toEqual(['platform', 'config', 'system', 'append-only', 'better-auth']); diff --git a/packages/core/src/utils/managedBy.ts b/packages/core/src/utils/managedBy.ts index 68f5f024d..9c29e3869 100644 --- a/packages/core/src/utils/managedBy.ts +++ b/packages/core/src/utils/managedBy.ts @@ -75,9 +75,16 @@ const DEFAULTS: Record = { /** * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto - * the bucket default, surfacing any per-record predicates alongside. + * the given bucket default, surfacing any per-record predicates alongside. + * + * THE single parser for the `userActions.{edit,delete}` override shape — every + * UI package (grid row affordances, related-list row predicates, this module's + * own `resolveCrudAffordances`) routes through here instead of re-implementing + * the boolean/object-form parse locally. `base` is the bucket default an + * omitted `enabled` falls back to (callers that only gate on an explicit + * opt-out pass `true`; predicate extraction is independent of `base`). */ -function normalizeOverride( +export function normalizeUserAction( v: UserActionOverride | undefined | null, base: boolean, ): { enabled: boolean; predicates?: RowCrudPredicates } { @@ -91,13 +98,26 @@ function normalizeOverride( return { enabled, predicates }; } +/** + * Extract the per-record CEL predicates (#2614 object form) from a + * `userActions.edit` / `delete` flag, or `undefined` for a bare boolean / + * predicate-less flag. Independent of the object-level enabled verdict — row + * renderers use this to gate a built-in action per record. Thin wrapper over + * {@link normalizeUserAction} so the parse lives in exactly one place. + */ +export function userActionPredicates( + v: UserActionOverride | undefined | null, +): RowCrudPredicates | undefined { + return normalizeUserAction(v, false).predicates; +} + /** Resolve the effective CRUD affordances for an object schema. */ export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances { const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform'; const base = DEFAULTS[bucket] ?? DEFAULTS.platform; const o = obj?.userActions ?? {}; - const edit = normalizeOverride(o.edit, base.edit); - const del = normalizeOverride(o.delete, base.delete); + const edit = normalizeUserAction(o.edit, base.edit); + const del = normalizeUserAction(o.delete, base.delete); const out: CrudAffordances = { create: o.create ?? base.create, import: o.import ?? base.import, diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index ca6efe4e3..52d3c1487 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -40,6 +40,7 @@ import { import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@object-ui/fields'; +import { userActionPredicates } from '@object-ui/core'; import { useSafeFieldLabel } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; import { useDetailTranslation } from './useDetailTranslation'; @@ -723,13 +724,10 @@ export const RelatedList: React.FC = ({ // Per-record CEL predicates for the built-in row Edit/Delete, from the // CHILD object's `userActions.edit` / `delete` object form (#2614) — the // master-detail case where a child row freezes on a parent state change - // renders through this path. Boolean forms yield no predicates. - const uaEdit = objectSchema?.userActions?.edit; - const uaDelete = objectSchema?.userActions?.delete; - const predicatesOf = (v: any) => - v != null && typeof v === 'object' && (v.visibleWhen != null || v.disabledWhen != null) - ? { visibleWhen: v.visibleWhen ?? undefined, disabledWhen: v.disabledWhen ?? undefined } - : undefined; + // renders through this path. Boolean forms yield no predicates. Parsed by + // the shared `@object-ui/core` normalizer so there is one parser platform-wide. + const rowEditPredicates = userActionPredicates(objectSchema?.userActions?.edit); + const rowDeletePredicates = userActionPredicates(objectSchema?.userActions?.delete); // Auto-generate schema based on type. We disable the data-table's own // search/toolbar — RelatedList provides its own filter input above. @@ -747,8 +745,8 @@ export const RelatedList: React.FC = ({ rowActions: hasRowActions, onRowEdit, onRowDelete: onRowDelete ? handleDeleteRow : undefined, - rowEditPredicates: predicatesOf(uaEdit), - rowDeletePredicates: predicatesOf(uaDelete), + rowEditPredicates, + rowDeletePredicates, onRowClick, // Child-object row actions (locations:['list_item']) rendered in the // same overflow menu, dispatched with the clicked row as target. diff --git a/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx b/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx index 812b551a6..b18c310c7 100644 --- a/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx +++ b/packages/plugin-form/src/ObjectForm.managedEdit.test.tsx @@ -45,6 +45,14 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => { />, ); + const renderCreate = (schema: any) => + render( + , + ); + async function inputByName(container: HTMLElement, name: string): Promise { return waitFor(() => { const el = container.querySelector(`input[name="${name}"]`) as HTMLInputElement | null; @@ -82,4 +90,26 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => { const name = await inputByName(container, 'name'); expect(name.disabled).toBe(false); }); + + // ADR-0103 alignment (objectui#2712 follow-up): the blanket lock now routes + // through the SAME shared `resolveCrudAffordances` policy detail/grid use, so + // an admin-editable `config` bucket (sys_webhook, sys_permission_set, …) is + // editable in the form too — it was previously over-locked as "non-platform". + it('config object: editable (resolved config.edit === true, matches detail/grid)', async () => { + const schema = { name: 'sys_webhook', managedBy: 'config', fields: { name: { type: 'text', label: 'Name', readonly: false } } }; + const { container } = renderEdit(schema); + const name = await inputByName(container, 'name'); + expect(name.disabled).toBe(false); + }); + + // Create mode keys off the resolved `create` affordance (not `edit`): an + // engine-owned system object stays locked, while one that opened + // `userActions.create` (e.g. Notification Preferences) unlocks. + it('create mode: engine-owned system locked, userActions.create unlocks', async () => { + const locked = renderCreate({ name: 'sys_automation_run', managedBy: 'system', fields: { name: { type: 'text', label: 'Name', readonly: false } } }); + expect((await inputByName(locked.container, 'name')).disabled).toBe(true); + + const open = renderCreate({ name: 'sys_notification_preference', managedBy: 'system', userActions: { create: true }, fields: { name: { type: 'text', label: 'Name', readonly: false } } }); + expect((await inputByName(open.container, 'name')).disabled).toBe(false); + }); }); diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 9d7d9f7ff..a491f4efd 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -18,6 +18,7 @@ import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@objec import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { mapFieldTypeToFormType, buildValidationRules, formatFileSize } from '@object-ui/fields'; import { useIsMobile, toast } from '@object-ui/components'; +import { resolveCrudAffordances } from '@object-ui/core'; import { resolveSuccessNavigate, isSameOriginUrl } from './successBehavior'; import { usePermissions } from '@object-ui/permissions'; import { TabbedForm } from './TabbedForm'; @@ -457,29 +458,25 @@ const SimpleObjectForm: React.FC = ({ const generatedFields: FormField[] = []; - // Managed-object blanket lock (ADR-0092 D4). Non-`platform` lifecycle - // buckets (config / system / append-only / better-auth) historically had - // no generic edit affordance, so every field was disabled as a defensive - // default. An object can now OPEN per-record editing for the current mode - // via `userActions.{edit,create}` (e.g. sys_user opens `edit` for its - // profile fields); when it does, we drop the blanket lock and honor each - // field's own `readonly` flag instead. Mirrors the server's - // resolveCrudAffordances contract — managed buckets default the affordance - // off, so only an explicit `=== true` override unlocks. The server-side - // write guard remains the real boundary; this is UX only. - const managed = !!objectSchema?.managedBy && objectSchema.managedBy !== 'platform'; - // `userActions.edit` may be the #2614 object form ({ enabled, ...CEL - // predicates }); only the explicit enabled boolean unlocks — predicates - // gate row action buttons, not the form's blanket lock. - const uaEdit = (objectSchema as any)?.userActions?.edit; - const editAffordanceOpen = uaEdit === true || (typeof uaEdit === 'object' && uaEdit !== null && uaEdit.enabled === true); + // Managed-object blanket lock (ADR-0092 D4 / ADR-0103). We disable every + // field when the object's resolved CRUD affordance for the CURRENT mode is + // closed — `edit` for edit mode, `create` for create mode. This routes + // through the SAME shared `resolveCrudAffordances` policy the detail + // (`isObjectInlineEditable`) and grid surfaces use, instead of re-deriving + // the bucket lock here: `platform` and admin-editable `config` resolve open; + // engine-owned `system` / `append-only` / `better-auth` resolve closed + // unless the object OPENED per-record writing via `userActions.{edit,create}` + // (e.g. sys_user opens `edit` for its profile fields). When open, the lock + // lifts and each field's own `readonly` flag decides. The server-side write + // guard remains the real boundary; this is UX only. + const affordances = resolveCrudAffordances(objectSchema as any); const modeAffordanceOpen = schema.mode === 'edit' - ? editAffordanceOpen + ? affordances.edit : schema.mode === 'create' - ? (objectSchema as any)?.userActions?.create === true - : false; - const managedBlanketLock = managed && !modeAffordanceOpen; + ? affordances.create + : true; // view mode disables fields elsewhere — never double-lock here + const managedBlanketLock = !modeAffordanceOpen; // Determine which fields to include const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {}); diff --git a/packages/plugin-grid/src/rowCrudAffordances.ts b/packages/plugin-grid/src/rowCrudAffordances.ts index 659d3d093..5d416c4e9 100644 --- a/packages/plugin-grid/src/rowCrudAffordances.ts +++ b/packages/plugin-grid/src/rowCrudAffordances.ts @@ -36,34 +36,15 @@ * (they never affect the object-level `canEdit` / `canDelete` verdict). */ -/** Per-record CEL predicates for a built-in row action (objectui#2614). - * Kept as authored — bare CEL string or `{ dialect, source }` envelope — - * and handed to `useRowPredicate` untouched. */ -export interface RowCrudPredicates { - visibleWhen?: unknown; - disabledWhen?: unknown; -} +import { normalizeUserAction, type RowCrudPredicates, type UserActionOverride } from '@object-ui/core'; +// The `userActions.{edit,delete}` override shape (bare boolean or #2614 object +// form) and its per-record predicates are parsed in exactly one place — +// `@object-ui/core`'s `normalizeUserAction`. Re-exported under the historical +// names so existing `./rowCrudAffordances` importers keep resolving. +export type { RowCrudPredicates } from '@object-ui/core'; /** A `userActions.edit` / `delete` flag: bare boolean or the #2614 object form. */ -export type RowCrudUserAction = - | boolean - | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown }; - -/** Object-level enabled verdict for a userActions flag (undefined = no opt-out). */ -function isOptedOut(v: RowCrudUserAction | undefined | null): boolean { - if (typeof v === 'boolean') return v === false; - return v?.enabled === false; -} - -/** Extract the per-record predicates from the object form, if any. */ -function predicatesOf(v: RowCrudUserAction | undefined | null): RowCrudPredicates | undefined { - if (v == null || typeof v === 'boolean') return undefined; - if (v.visibleWhen == null && v.disabledWhen == null) return undefined; - const out: RowCrudPredicates = {}; - if (v.visibleWhen != null) out.visibleWhen = v.visibleWhen; - if (v.disabledWhen != null) out.disabledWhen = v.disabledWhen; - return out; -} +export type RowCrudUserAction = UserActionOverride; export function resolveRowCrudAffordances(opts: { operationsUpdate?: boolean; @@ -80,16 +61,19 @@ export function resolveRowCrudAffordances(opts: { editPredicates?: RowCrudPredicates; deletePredicates?: RowCrudPredicates; } { - const editOptedOut = isOptedOut(opts.userActions?.edit); - const deleteOptedOut = isOptedOut(opts.userActions?.delete); + // Opt-out model (base = true): the generic Edit/Delete surface UNLESS the + // object explicitly disabled the flag (`false` / `{ enabled: false }`). The + // bucket-level lock is applied upstream via the view's `operations.*`. + const edit = normalizeUserAction(opts.userActions?.edit, true); + const del = normalizeUserAction(opts.userActions?.delete, true); const canEdit = - !!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && !editOptedOut; + !!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && edit.enabled; const canDelete = - !!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && !deleteOptedOut; + !!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && del.enabled; return { canEdit, canDelete, - editPredicates: canEdit ? predicatesOf(opts.userActions?.edit) : undefined, - deletePredicates: canDelete ? predicatesOf(opts.userActions?.delete) : undefined, + editPredicates: canEdit ? edit.predicates : undefined, + deletePredicates: canDelete ? del.predicates : undefined, }; }