Skip to content

Commit 0a3710b

Browse files
os-zhuangclaude
andauthored
refactor(managedBy): one parser for the userActions override shape (completes #2712, framework#3343) (#2724)
#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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0c3209a commit 0a3710b

8 files changed

Lines changed: 159 additions & 73 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@object-ui/core": patch
3+
"@object-ui/app-shell": patch
4+
"@object-ui/plugin-grid": patch
5+
"@object-ui/plugin-form": patch
6+
"@object-ui/plugin-detail": patch
7+
---
8+
9+
**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.
10+
11+
- **`@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.
12+
- **`app-shell/utils/managedByEmptyState.ts`** — the writable-`system` create check and its local `EmptyStateUserActions` interface are replaced by `resolveCrudAffordances({ managedBy, userActions }).create`.
13+
- **`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.
14+
- **`plugin-detail/RelatedList.tsx`** — its inline `predicatesOf` fold into `userActionPredicates`.
15+
- **`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.
16+
17+
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.

packages/app-shell/src/utils/managedByEmptyState.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
* (in the header) and the empty state (in the body) tell a consistent
2222
* story without repeating themselves verbatim.
2323
*/
24+
import { resolveCrudAffordances, type UserActionsOverride } from '@object-ui/core';
25+
2426
export interface ManagedByEmptyState {
2527
title: string;
2628
message: string;
@@ -34,24 +36,20 @@ export interface ManagedByEmptyState {
3436
*/
3537
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
3638

37-
/** Subset of `userActions` (ADR-0103) that opens generic creation. */
38-
interface EmptyStateUserActions {
39-
create?: boolean;
40-
}
41-
4239
export function resolveManagedByEmptyState(
4340
managedBy: string | undefined | null,
4441
t: TranslateFn,
4542
objectName?: string | null,
46-
userActions?: EmptyStateUserActions | null,
43+
userActions?: UserActionsOverride | null,
4744
): ManagedByEmptyState | undefined {
4845
switch (managedBy) {
4946
case 'system':
5047
// ADR-0103 — a `system` object that opened creation is admin/user-writable
5148
// data (e.g. Notification Preferences). The "entries appear automatically"
5249
// copy would be wrong; fall back to the generic empty state (which surfaces
53-
// the New button) by returning undefined.
54-
if (userActions?.create === true) return undefined;
50+
// the New button) by returning undefined. The resolved `create` affordance
51+
// (shared @object-ui/core policy) is the one place that reads the override.
52+
if (resolveCrudAffordances({ managedBy, userActions }).create) return undefined;
5553
return {
5654
icon: 'Lock',
5755
title: t('list.managedBy.system.title', { defaultValue: 'Nothing here yet' }),

packages/core/src/utils/managedBy.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
isWriteOptedIn,
66
isSystemWritable,
77
isObjectInlineEditable,
8+
normalizeUserAction,
9+
userActionPredicates,
810
} from './managedBy';
911

1012
describe('resolveCrudAffordances (shared source of truth)', () => {
@@ -96,6 +98,46 @@ describe('isObjectInlineEditable', () => {
9698
});
9799
});
98100

101+
// The ONE parser for the userActions override shape, now consumed by the grid
102+
// row affordances and related-list row predicates (objectui#2712 follow-up) so
103+
// no package re-implements the boolean / #2614 object-form parse locally.
104+
describe('normalizeUserAction (the single override parser)', () => {
105+
it('a missing flag falls back to the caller-supplied bucket default', () => {
106+
expect(normalizeUserAction(undefined, true)).toEqual({ enabled: true });
107+
expect(normalizeUserAction(undefined, false)).toEqual({ enabled: false });
108+
expect(normalizeUserAction(null, true)).toEqual({ enabled: true });
109+
});
110+
111+
it('a bare boolean wins over the default and carries no predicates', () => {
112+
expect(normalizeUserAction(true, false)).toEqual({ enabled: true });
113+
expect(normalizeUserAction(false, true)).toEqual({ enabled: false });
114+
});
115+
116+
it('object form: enabled overrides the default; predicates ride alongside', () => {
117+
expect(normalizeUserAction({ enabled: false, disabledWhen: 'record.frozen' }, true))
118+
.toEqual({ enabled: false, predicates: { disabledWhen: 'record.frozen' } });
119+
// omitted `enabled` falls back to the base; only the present predicate key is set.
120+
expect(normalizeUserAction({ visibleWhen: 'a' }, true))
121+
.toEqual({ enabled: true, predicates: { visibleWhen: 'a' } });
122+
// object form without predicates is boolean-equivalent.
123+
expect(normalizeUserAction({ enabled: true }, false)).toEqual({ enabled: true });
124+
});
125+
});
126+
127+
describe('userActionPredicates', () => {
128+
it('returns predicates independent of the enabled verdict, undefined otherwise', () => {
129+
expect(userActionPredicates(true)).toBeUndefined();
130+
expect(userActionPredicates(false)).toBeUndefined();
131+
expect(userActionPredicates(undefined)).toBeUndefined();
132+
expect(userActionPredicates({ enabled: true })).toBeUndefined();
133+
expect(userActionPredicates({ disabledWhen: 'x' })).toEqual({ disabledWhen: 'x' });
134+
expect(userActionPredicates({ visibleWhen: 'a', disabledWhen: 'b' }))
135+
.toEqual({ visibleWhen: 'a', disabledWhen: 'b' });
136+
// predicates survive even when the flag opts the action out.
137+
expect(userActionPredicates({ enabled: false, visibleWhen: 'a' })).toEqual({ visibleWhen: 'a' });
138+
});
139+
});
140+
99141
describe('MANAGED_BY_BUCKETS', () => {
100142
it('is the closed 5-bucket union in canonical order', () => {
101143
expect(MANAGED_BY_BUCKETS).toEqual(['platform', 'config', 'system', 'append-only', 'better-auth']);

packages/core/src/utils/managedBy.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,16 @@ const DEFAULTS: Record<ManagedByBucket, CrudAffordances> = {
7575

7676
/**
7777
* Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
78-
* the bucket default, surfacing any per-record predicates alongside.
78+
* the given bucket default, surfacing any per-record predicates alongside.
79+
*
80+
* THE single parser for the `userActions.{edit,delete}` override shape — every
81+
* UI package (grid row affordances, related-list row predicates, this module's
82+
* own `resolveCrudAffordances`) routes through here instead of re-implementing
83+
* the boolean/object-form parse locally. `base` is the bucket default an
84+
* omitted `enabled` falls back to (callers that only gate on an explicit
85+
* opt-out pass `true`; predicate extraction is independent of `base`).
7986
*/
80-
function normalizeOverride(
87+
export function normalizeUserAction(
8188
v: UserActionOverride | undefined | null,
8289
base: boolean,
8390
): { enabled: boolean; predicates?: RowCrudPredicates } {
@@ -91,13 +98,26 @@ function normalizeOverride(
9198
return { enabled, predicates };
9299
}
93100

101+
/**
102+
* Extract the per-record CEL predicates (#2614 object form) from a
103+
* `userActions.edit` / `delete` flag, or `undefined` for a bare boolean /
104+
* predicate-less flag. Independent of the object-level enabled verdict — row
105+
* renderers use this to gate a built-in action per record. Thin wrapper over
106+
* {@link normalizeUserAction} so the parse lives in exactly one place.
107+
*/
108+
export function userActionPredicates(
109+
v: UserActionOverride | undefined | null,
110+
): RowCrudPredicates | undefined {
111+
return normalizeUserAction(v, false).predicates;
112+
}
113+
94114
/** Resolve the effective CRUD affordances for an object schema. */
95115
export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances {
96116
const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform';
97117
const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
98118
const o = obj?.userActions ?? {};
99-
const edit = normalizeOverride(o.edit, base.edit);
100-
const del = normalizeOverride(o.delete, base.delete);
119+
const edit = normalizeUserAction(o.edit, base.edit);
120+
const del = normalizeUserAction(o.delete, base.delete);
101121
const out: CrudAffordances = {
102122
create: o.create ?? base.create,
103123
import: o.import ?? base.import,

packages/plugin-detail/src/RelatedList.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
import type { LucideIcon } from 'lucide-react';
4141
import type { DataSource, FieldMetadata } from '@object-ui/types';
4242
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog } from '@object-ui/fields';
43+
import { userActionPredicates } from '@object-ui/core';
4344
import { useSafeFieldLabel } from '@object-ui/react';
4445
import { usePermissions } from '@object-ui/permissions';
4546
import { useDetailTranslation } from './useDetailTranslation';
@@ -859,13 +860,10 @@ export const RelatedList: React.FC<RelatedListProps> = ({
859860
// Per-record CEL predicates for the built-in row Edit/Delete, from the
860861
// CHILD object's `userActions.edit` / `delete` object form (#2614) — the
861862
// master-detail case where a child row freezes on a parent state change
862-
// renders through this path. Boolean forms yield no predicates.
863-
const uaEdit = objectSchema?.userActions?.edit;
864-
const uaDelete = objectSchema?.userActions?.delete;
865-
const predicatesOf = (v: any) =>
866-
v != null && typeof v === 'object' && (v.visibleWhen != null || v.disabledWhen != null)
867-
? { visibleWhen: v.visibleWhen ?? undefined, disabledWhen: v.disabledWhen ?? undefined }
868-
: undefined;
863+
// renders through this path. Boolean forms yield no predicates. Parsed by
864+
// the shared `@object-ui/core` normalizer so there is one parser platform-wide.
865+
const rowEditPredicates = userActionPredicates(objectSchema?.userActions?.edit);
866+
const rowDeletePredicates = userActionPredicates(objectSchema?.userActions?.delete);
869867

870868
// Auto-generate schema based on type. We disable the data-table's own
871869
// search/toolbar — RelatedList provides its own filter input above.
@@ -883,8 +881,8 @@ export const RelatedList: React.FC<RelatedListProps> = ({
883881
rowActions: hasRowActions,
884882
onRowEdit,
885883
onRowDelete: onRowDelete ? handleDeleteRow : undefined,
886-
rowEditPredicates: predicatesOf(uaEdit),
887-
rowDeletePredicates: predicatesOf(uaDelete),
884+
rowEditPredicates,
885+
rowDeletePredicates,
888886
onRowClick,
889887
// Child-object row actions (locations:['list_item']) rendered in the
890888
// same overflow menu, dispatched with the clicked row as target.

packages/plugin-form/src/ObjectForm.managedEdit.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => {
4545
/>,
4646
);
4747

48+
const renderCreate = (schema: any) =>
49+
render(
50+
<ObjectForm
51+
schema={{ type: 'object-form', objectName: schema.name, mode: 'create' } as any}
52+
dataSource={dsFor(schema)}
53+
/>,
54+
);
55+
4856
async function inputByName(container: HTMLElement, name: string): Promise<HTMLInputElement> {
4957
return waitFor(() => {
5058
const el = container.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
@@ -82,4 +90,26 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => {
8290
const name = await inputByName(container, 'name');
8391
expect(name.disabled).toBe(false);
8492
});
93+
94+
// ADR-0103 alignment (objectui#2712 follow-up): the blanket lock now routes
95+
// through the SAME shared `resolveCrudAffordances` policy detail/grid use, so
96+
// an admin-editable `config` bucket (sys_webhook, sys_permission_set, …) is
97+
// editable in the form too — it was previously over-locked as "non-platform".
98+
it('config object: editable (resolved config.edit === true, matches detail/grid)', async () => {
99+
const schema = { name: 'sys_webhook', managedBy: 'config', fields: { name: { type: 'text', label: 'Name', readonly: false } } };
100+
const { container } = renderEdit(schema);
101+
const name = await inputByName(container, 'name');
102+
expect(name.disabled).toBe(false);
103+
});
104+
105+
// Create mode keys off the resolved `create` affordance (not `edit`): an
106+
// engine-owned system object stays locked, while one that opened
107+
// `userActions.create` (e.g. Notification Preferences) unlocks.
108+
it('create mode: engine-owned system locked, userActions.create unlocks', async () => {
109+
const locked = renderCreate({ name: 'sys_automation_run', managedBy: 'system', fields: { name: { type: 'text', label: 'Name', readonly: false } } });
110+
expect((await inputByName(locked.container, 'name')).disabled).toBe(true);
111+
112+
const open = renderCreate({ name: 'sys_notification_preference', managedBy: 'system', userActions: { create: true }, fields: { name: { type: 'text', label: 'Name', readonly: false } } });
113+
expect((await inputByName(open.container, 'name')).disabled).toBe(false);
114+
});
85115
});

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { ObjectFormSchema, FormField, FormSchema, DataSource } from '@objec
1818
import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react';
1919
import { mapFieldTypeToFormType, buildValidationRules, formatFileSize } from '@object-ui/fields';
2020
import { useIsMobile, toast } from '@object-ui/components';
21+
import { resolveCrudAffordances } from '@object-ui/core';
2122
import { resolveSuccessNavigate, isSameOriginUrl } from './successBehavior';
2223
import { usePermissions } from '@object-ui/permissions';
2324
import { TabbedForm } from './TabbedForm';
@@ -457,29 +458,25 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
457458

458459
const generatedFields: FormField[] = [];
459460

460-
// Managed-object blanket lock (ADR-0092 D4). Non-`platform` lifecycle
461-
// buckets (config / system / append-only / better-auth) historically had
462-
// no generic edit affordance, so every field was disabled as a defensive
463-
// default. An object can now OPEN per-record editing for the current mode
464-
// via `userActions.{edit,create}` (e.g. sys_user opens `edit` for its
465-
// profile fields); when it does, we drop the blanket lock and honor each
466-
// field's own `readonly` flag instead. Mirrors the server's
467-
// resolveCrudAffordances contract — managed buckets default the affordance
468-
// off, so only an explicit `=== true` override unlocks. The server-side
469-
// write guard remains the real boundary; this is UX only.
470-
const managed = !!objectSchema?.managedBy && objectSchema.managedBy !== 'platform';
471-
// `userActions.edit` may be the #2614 object form ({ enabled, ...CEL
472-
// predicates }); only the explicit enabled boolean unlocks — predicates
473-
// gate row action buttons, not the form's blanket lock.
474-
const uaEdit = (objectSchema as any)?.userActions?.edit;
475-
const editAffordanceOpen = uaEdit === true || (typeof uaEdit === 'object' && uaEdit !== null && uaEdit.enabled === true);
461+
// Managed-object blanket lock (ADR-0092 D4 / ADR-0103). We disable every
462+
// field when the object's resolved CRUD affordance for the CURRENT mode is
463+
// closed — `edit` for edit mode, `create` for create mode. This routes
464+
// through the SAME shared `resolveCrudAffordances` policy the detail
465+
// (`isObjectInlineEditable`) and grid surfaces use, instead of re-deriving
466+
// the bucket lock here: `platform` and admin-editable `config` resolve open;
467+
// engine-owned `system` / `append-only` / `better-auth` resolve closed
468+
// unless the object OPENED per-record writing via `userActions.{edit,create}`
469+
// (e.g. sys_user opens `edit` for its profile fields). When open, the lock
470+
// lifts and each field's own `readonly` flag decides. The server-side write
471+
// guard remains the real boundary; this is UX only.
472+
const affordances = resolveCrudAffordances(objectSchema as any);
476473
const modeAffordanceOpen =
477474
schema.mode === 'edit'
478-
? editAffordanceOpen
475+
? affordances.edit
479476
: schema.mode === 'create'
480-
? (objectSchema as any)?.userActions?.create === true
481-
: false;
482-
const managedBlanketLock = managed && !modeAffordanceOpen;
477+
? affordances.create
478+
: true; // view mode disables fields elsewhere — never double-lock here
479+
const managedBlanketLock = !modeAffordanceOpen;
483480

484481
// Determine which fields to include
485482
const fieldsToShow = schema.fields || Object.keys(objectSchema.fields || {});

packages/plugin-grid/src/rowCrudAffordances.ts

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,34 +36,15 @@
3636
* (they never affect the object-level `canEdit` / `canDelete` verdict).
3737
*/
3838

39-
/** Per-record CEL predicates for a built-in row action (objectui#2614).
40-
* Kept as authored — bare CEL string or `{ dialect, source }` envelope —
41-
* and handed to `useRowPredicate` untouched. */
42-
export interface RowCrudPredicates {
43-
visibleWhen?: unknown;
44-
disabledWhen?: unknown;
45-
}
39+
import { normalizeUserAction, type RowCrudPredicates, type UserActionOverride } from '@object-ui/core';
4640

41+
// The `userActions.{edit,delete}` override shape (bare boolean or #2614 object
42+
// form) and its per-record predicates are parsed in exactly one place —
43+
// `@object-ui/core`'s `normalizeUserAction`. Re-exported under the historical
44+
// names so existing `./rowCrudAffordances` importers keep resolving.
45+
export type { RowCrudPredicates } from '@object-ui/core';
4746
/** A `userActions.edit` / `delete` flag: bare boolean or the #2614 object form. */
48-
export type RowCrudUserAction =
49-
| boolean
50-
| { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown };
51-
52-
/** Object-level enabled verdict for a userActions flag (undefined = no opt-out). */
53-
function isOptedOut(v: RowCrudUserAction | undefined | null): boolean {
54-
if (typeof v === 'boolean') return v === false;
55-
return v?.enabled === false;
56-
}
57-
58-
/** Extract the per-record predicates from the object form, if any. */
59-
function predicatesOf(v: RowCrudUserAction | undefined | null): RowCrudPredicates | undefined {
60-
if (v == null || typeof v === 'boolean') return undefined;
61-
if (v.visibleWhen == null && v.disabledWhen == null) return undefined;
62-
const out: RowCrudPredicates = {};
63-
if (v.visibleWhen != null) out.visibleWhen = v.visibleWhen;
64-
if (v.disabledWhen != null) out.disabledWhen = v.disabledWhen;
65-
return out;
66-
}
47+
export type RowCrudUserAction = UserActionOverride;
6748

6849
export function resolveRowCrudAffordances(opts: {
6950
operationsUpdate?: boolean;
@@ -80,16 +61,19 @@ export function resolveRowCrudAffordances(opts: {
8061
editPredicates?: RowCrudPredicates;
8162
deletePredicates?: RowCrudPredicates;
8263
} {
83-
const editOptedOut = isOptedOut(opts.userActions?.edit);
84-
const deleteOptedOut = isOptedOut(opts.userActions?.delete);
64+
// Opt-out model (base = true): the generic Edit/Delete surface UNLESS the
65+
// object explicitly disabled the flag (`false` / `{ enabled: false }`). The
66+
// bucket-level lock is applied upstream via the view's `operations.*`.
67+
const edit = normalizeUserAction(opts.userActions?.edit, true);
68+
const del = normalizeUserAction(opts.userActions?.delete, true);
8569
const canEdit =
86-
!!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && !editOptedOut;
70+
!!((opts.operationsUpdate || opts.wantEditAction) && opts.hasOnEdit) && edit.enabled;
8771
const canDelete =
88-
!!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && !deleteOptedOut;
72+
!!((opts.operationsDelete || opts.wantDeleteAction) && opts.hasOnDelete) && del.enabled;
8973
return {
9074
canEdit,
9175
canDelete,
92-
editPredicates: canEdit ? predicatesOf(opts.userActions?.edit) : undefined,
93-
deletePredicates: canDelete ? predicatesOf(opts.userActions?.delete) : undefined,
76+
editPredicates: canEdit ? edit.predicates : undefined,
77+
deletePredicates: canDelete ? del.predicates : undefined,
9478
};
9579
}

0 commit comments

Comments
 (0)