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
17 changes: 17 additions & 0 deletions .changeset/managedby-useractions-single-parser.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 6 additions & 8 deletions packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,24 +36,20 @@ export interface ManagedByEmptyState {
*/
type TranslateFn = (key: string, options?: Record<string, unknown>) => 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' }),
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/utils/managedBy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
isWriteOptedIn,
isSystemWritable,
isObjectInlineEditable,
normalizeUserAction,
userActionPredicates,
} from './managedBy';

describe('resolveCrudAffordances (shared source of truth)', () => {
Expand Down Expand Up @@ -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']);
Expand Down
28 changes: 24 additions & 4 deletions packages/core/src/utils/managedBy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,16 @@ const DEFAULTS: Record<ManagedByBucket, CrudAffordances> = {

/**
* 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 } {
Expand All @@ -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,
Expand Down
16 changes: 7 additions & 9 deletions packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -723,13 +724,10 @@ export const RelatedList: React.FC<RelatedListProps> = ({
// 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.
Expand All @@ -747,8 +745,8 @@ export const RelatedList: React.FC<RelatedListProps> = ({
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.
Expand Down
30 changes: 30 additions & 0 deletions packages/plugin-form/src/ObjectForm.managedEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ describe('ObjectForm — managed-object edit affordance (ADR-0092 D4)', () => {
/>,
);

const renderCreate = (schema: any) =>
render(
<ObjectForm
schema={{ type: 'object-form', objectName: schema.name, mode: 'create' } as any}
dataSource={dsFor(schema)}
/>,
);

async function inputByName(container: HTMLElement, name: string): Promise<HTMLInputElement> {
return waitFor(() => {
const el = container.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
Expand Down Expand Up @@ -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);
});
});
37 changes: 17 additions & 20 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -457,29 +458,25 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({

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 || {});
Expand Down
48 changes: 16 additions & 32 deletions packages/plugin-grid/src/rowCrudAffordances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
};
}
Loading