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
14 changes: 14 additions & 0 deletions .changeset/managedby-single-source-of-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@object-ui/types": patch
"@object-ui/core": patch
"@object-ui/app-shell": patch
"@object-ui/plugin-detail": patch
---

**Consolidate the `managedBy` lifecycle-bucket logic into one shared source of truth (follows framework ADR-0103).** The bucket taxonomy was hand-mirrored in several places — `crudAffordances.ts`, `ManagedByBadge.tsx` (its own `Bucket` union + `isWriteOptedIn` + the writable-system derivation), and `plugin-detail`'s `record-details.tsx` (`NON_EDITABLE_BUCKETS`, duplicated because it can't depend on app-shell) — a drift risk, and the object-schema `managedBy` type was open-ended (`(string & {})`) so unknown buckets slipped through and silently defaulted to fully-editable.

- **`@object-ui/types`** now owns the closed `ManagedByBucket` union (+ `MANAGED_BY_BUCKETS`), and `ObjectSchema.managedBy` is tightened from `'platform' | 'better-auth' | (string & {})` to that union — unknown buckets are now a type error at authoring time.
- **`@object-ui/core`** now owns the React-free runtime logic — `resolveCrudAffordances`, `isWriteOptedIn`, `isSystemWritable`, `isObjectInlineEditable` — reachable by every UI package including `plugin-detail` (which could not import app-shell).
- **`app-shell/utils/crudAffordances.ts`** is now a thin re-export of `@object-ui/core` (existing imports keep working); `ManagedByBadge` consumes the shared `isSystemWritable`; `plugin-detail` `record-details.tsx` replaces its hand-mirrored `NON_EDITABLE_BUCKETS` with `isObjectInlineEditable`.

Behavior-preserving — all existing affordance/edit-gate tests stay green; the shared module adds direct unit coverage (including the previously-untested `isSystemWritable` derivation). Translated copy (badge variants, empty-state messages) stays in app-shell.
15 changes: 4 additions & 11 deletions packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
cn,
} from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances';

/**
* ManagedByBadge — replaces the verbose, full-width `ManagedByBanner` with
Expand Down Expand Up @@ -43,11 +44,11 @@ import { useObjectTranslation } from '@object-ui/i18n';
* `ObjectView` via `resolveManagedByEmptyState()`.
*/

type Bucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth';
type Bucket = ManagedByBucket;

/**
* Subset of `userActions` (ADR-0103) the badge needs to tell an engine-owned
* `system` object apart from an admin/user-writable one. Mirrors the server's
* `system` object apart from an admin/user-writable one. Mirrors the shared
* `resolveCrudAffordances` inputs; `edit`/`delete` accept the #2614 object form.
*/
export interface ManagedByUserActions {
Expand All @@ -56,11 +57,6 @@ export interface ManagedByUserActions {
delete?: boolean | { enabled?: boolean };
}

/** True only when a userActions flag (bare boolean or object form) opts the write in. */
function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean {
return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
}

export interface ManagedByBadgeProps {
/** The `managedBy` flag from the object schema. */
managedBy?: string;
Expand Down Expand Up @@ -149,10 +145,7 @@ export function ManagedByBadge({ managedBy, userActions, label, className }: Man
if (!managedBy || managedBy === 'platform') return null;
// ADR-0103 — a `system` object that opened any write is admin/user-writable
// data, not an engine-owned monitoring surface: pick the writable variant/copy.
const ua = userActions ?? undefined;
const systemWritable =
managedBy === 'system' &&
(ua?.create === true || isWriteOptedIn(ua?.edit) || isWriteOptedIn(ua?.delete));
const systemWritable = isSystemWritable({ managedBy, userActions });
const variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey);
const variant = VARIANTS[variantKey];
if (!variant) return null;
Expand Down
131 changes: 22 additions & 109 deletions packages/app-shell/src/utils/crudAffordances.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,26 @@
/**
* crudAffordances — UI-side mirror of the framework's
* `resolveCrudAffordances()` helper (see
* `@objectstack/spec/data/object.zod.ts`).
* crudAffordances — re-export shim.
*
* The framework tags every object with a `managedBy` lifecycle bucket
* (`platform | config | system | append-only | better-auth`) and an
* optional `userActions` override block. UI components ask this helper
* "should I show the New / Import / Edit / Delete / Export buttons?" so
* the toolbar tracks the object's lifecycle automatically — no need for
* each view to special-case `sys_*` names.
*
* Keep this in lockstep with the framework helper. The bucket defaults
* mirror what Salesforce / ServiceNow / Workday / Notion do for the
* equivalent categories of system tables.
*/

export type ManagedByBucket =
| 'platform'
| 'config'
| 'system'
| 'append-only'
| 'better-auth';

export interface CrudAffordances {
/** Generic "New" button for single-record creation. */
create: boolean;
/** CSV bulk-import wizard. */
import: boolean;
/** Inline + form editing of existing rows. */
edit: boolean;
/** Row-level + bulk delete. */
delete: boolean;
/** CSV / clipboard export. */
exportCsv: boolean;
/**
* Per-record CEL predicates for the built-in row Edit/Delete actions,
* present only when `userActions.edit` / `delete` used the object form
* (objectui#2614). Carried through as authored (bare CEL string or
* `{ dialect, source }` envelope) for row renderers to evaluate via
* `useRowPredicate`; they never affect the object-level booleans above.
*/
editPredicates?: RowCrudPredicates;
deletePredicates?: RowCrudPredicates;
}

/** Per-record predicates from the #2614 object form of a userActions flag. */
export interface RowCrudPredicates {
visibleWhen?: unknown;
disabledWhen?: unknown;
}

/** `edit`/`delete` accept a bare boolean or the #2614 object form. */
export type UserActionOverride =
| boolean
| { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown };

export interface UserActionsOverride {
create?: boolean;
import?: boolean;
edit?: UserActionOverride;
delete?: UserActionOverride;
exportCsv?: boolean;
}

const DEFAULTS: Record<ManagedByBucket, CrudAffordances> = {
platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
};

export interface SchemaLike {
managedBy?: string | null;
userActions?: UserActionsOverride | null;
}

/**
* Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
* the bucket default, surfacing any per-record predicates alongside.
* The canonical bucket/affordance logic now lives in `@object-ui/core`
* (`utils/managedBy.ts`) so it is defined ONCE and shared by every UI package
* — app-shell, plugin-detail, plugin-form, plugin-grid — instead of being
* hand-mirrored. This file is kept so existing app-shell imports of
* `./crudAffordances` keep working; prefer importing from `@object-ui/core`
* directly in new code.
*/
function normalizeOverride(
v: UserActionOverride | undefined | null,
base: boolean,
): { enabled: boolean; predicates?: RowCrudPredicates } {
if (v == null) return { enabled: base };
if (typeof v === 'boolean') return { enabled: v };
const enabled = v.enabled ?? base;
if (v.visibleWhen == null && v.disabledWhen == null) return { enabled };
const predicates: RowCrudPredicates = {};
if (v.visibleWhen != null) predicates.visibleWhen = v.visibleWhen;
if (v.disabledWhen != null) predicates.disabledWhen = v.disabledWhen;
return { enabled, predicates };
}

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 out: CrudAffordances = {
create: o.create ?? base.create,
import: o.import ?? base.import,
edit: edit.enabled,
delete: del.enabled,
exportCsv: o.exportCsv ?? base.exportCsv,
};
if (edit.predicates) out.editPredicates = edit.predicates;
if (del.predicates) out.deletePredicates = del.predicates;
return out;
}
export type {
ManagedByBucket,
CrudAffordances,
RowCrudPredicates,
UserActionOverride,
UserActionsOverride,
SchemaLike,
} from '@object-ui/core';

export {
resolveCrudAffordances,
isWriteOptedIn,
isSystemWritable,
isObjectInlineEditable,
} from '@object-ui/core';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './registry/WidgetRegistry.js';
export * from './validation/index.js';
export * from './builder/schema-builder.js';
export * from './utils/filter-converter.js';
export * from './utils/managedBy.js';
export * from './utils/extract-records.js';
export * from './utils/expand-fields.js';
export * from './evaluator/index.js';
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/utils/managedBy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { MANAGED_BY_BUCKETS } from '@object-ui/types';
import {
resolveCrudAffordances,
isWriteOptedIn,
isSystemWritable,
isObjectInlineEditable,
} from './managedBy';

describe('resolveCrudAffordances (shared source of truth)', () => {
it('defaults to the platform bucket (full CRUD) when managedBy is unset', () => {
expect(resolveCrudAffordances({})).toEqual({
create: true, import: true, edit: true, delete: true, exportCsv: true,
});
expect(resolveCrudAffordances(null)).toEqual(resolveCrudAffordances({ managedBy: 'platform' }));
});

it('config: New/Edit/Delete + export, no import', () => {
expect(resolveCrudAffordances({ managedBy: 'config' })).toEqual({
create: true, import: false, edit: true, delete: true, exportCsv: true,
});
});

it('system / append-only / better-auth: export-only by default', () => {
for (const managedBy of ['system', 'append-only', 'better-auth']) {
expect(resolveCrudAffordances({ managedBy })).toEqual({
create: false, import: false, edit: false, delete: false, exportCsv: true,
});
}
});

it('userActions overrides the bucket default (ADR-0103 writable system)', () => {
const aff = resolveCrudAffordances({ managedBy: 'system', userActions: { create: true, edit: true, delete: true } });
expect(aff).toMatchObject({ create: true, edit: true, delete: true, import: false });
});

it('unknown bucket falls back to platform (defensive)', () => {
expect(resolveCrudAffordances({ managedBy: 'totally-unknown' }).edit).toBe(true);
});

it('#2614 object form: carries predicates, keys off enabled, boolean path unchanged', () => {
const withPreds = resolveCrudAffordances({
managedBy: 'platform',
userActions: { edit: { enabled: true, disabledWhen: 'record.locked == true' } },
});
expect(withPreds.edit).toBe(true);
expect(withPreds.editPredicates).toEqual({ disabledWhen: 'record.locked == true' });
// enabled omitted → falls back to the bucket default (platform edit = true)
expect(resolveCrudAffordances({ managedBy: 'platform', userActions: { edit: { disabledWhen: 'x' } } }).edit).toBe(true);
// boolean form leaves predicates absent
expect(resolveCrudAffordances({ managedBy: 'system', userActions: { edit: true } }).editPredicates).toBeUndefined();
});
});

describe('isWriteOptedIn', () => {
it('true only for boolean true or { enabled: true }', () => {
expect(isWriteOptedIn(true)).toBe(true);
expect(isWriteOptedIn({ enabled: true })).toBe(true);
expect(isWriteOptedIn(false)).toBe(false);
expect(isWriteOptedIn({ enabled: false })).toBe(false);
expect(isWriteOptedIn({ disabledWhen: 'x' })).toBe(false);
expect(isWriteOptedIn(undefined)).toBe(false);
expect(isWriteOptedIn(null)).toBe(false);
});
});

describe('isSystemWritable (ADR-0103)', () => {
it('true only for a system object that opened create, edit, or delete', () => {
expect(isSystemWritable({ managedBy: 'system', userActions: { create: true } })).toBe(true);
expect(isSystemWritable({ managedBy: 'system', userActions: { edit: { enabled: true } } })).toBe(true);
expect(isSystemWritable({ managedBy: 'system', userActions: { delete: true } })).toBe(true);
});
it('false for engine-owned system and for other buckets even with userActions', () => {
expect(isSystemWritable({ managedBy: 'system' })).toBe(false);
expect(isSystemWritable({ managedBy: 'system', userActions: { edit: false } })).toBe(false);
// append-only / better-auth are never "system-writable" regardless of userActions
expect(isSystemWritable({ managedBy: 'append-only', userActions: { create: true } })).toBe(false);
expect(isSystemWritable({ managedBy: 'better-auth', userActions: { edit: true } })).toBe(false);
expect(isSystemWritable({ managedBy: 'platform' })).toBe(false);
expect(isSystemWritable(null)).toBe(false);
});
});

describe('isObjectInlineEditable', () => {
it('mirrors the resolved edit affordance (replaces the old NON_EDITABLE_BUCKETS set)', () => {
// Non-editable buckets by default...
for (const managedBy of ['system', 'append-only', 'better-auth']) {
expect(isObjectInlineEditable({ managedBy })).toBe(false);
}
// ...editable buckets and opened-up system objects.
expect(isObjectInlineEditable({ managedBy: 'platform' })).toBe(true);
expect(isObjectInlineEditable({ managedBy: 'config' })).toBe(true);
expect(isObjectInlineEditable({ managedBy: 'system', userActions: { edit: true } })).toBe(true);
// an explicit edit:false disables even on an otherwise-editable bucket
expect(isObjectInlineEditable({ managedBy: 'platform', userActions: { edit: false } })).toBe(false);
});
});

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']);
});
});
Loading
Loading