Skip to content

Commit 6d4fbe6

Browse files
os-zhuangclaude
andauthored
refactor(managedBy): consolidate lifecycle-bucket logic into one shared source of truth (#2712)
The managedBy bucket taxonomy was hand-mirrored across crudAffordances.ts, ManagedByBadge.tsx (own Bucket union + isWriteOptedIn + writable-system derivation), and plugin-detail record-details.tsx (NON_EDITABLE_BUCKETS, duplicated because it can't depend on app-shell) — a drift risk. The schema managedBy type was also open-ended ((string & {})), so unknown buckets slipped through and silently defaulted to fully-editable. - @object-ui/types owns the closed ManagedByBucket union (+ MANAGED_BY_BUCKETS); ObjectSchema.managedBy is tightened to it (unknown buckets now a type error). - @object-ui/core owns the React-free runtime logic (resolveCrudAffordances, isWriteOptedIn, isSystemWritable, isObjectInlineEditable), reachable by every UI package including plugin-detail. - app-shell crudAffordances.ts is now a re-export shim; ManagedByBadge uses the shared isSystemWritable; record-details uses isObjectInlineEditable in place of the hand-mirrored NON_EDITABLE_BUCKETS. Behavior-preserving: full-graph build typechecks; existing affordance/edit-gate tests stay green; new core unit tests cover the logic incl. the previously untested isSystemWritable. Follows framework ADR-0103. Claude-Session: https://claude.ai/code/session_01Fp9yZxRQ3mb7p4vVwqFXKE Co-authored-by: Claude <noreply@anthropic.com>
1 parent eee4ded commit 6d4fbe6

10 files changed

Lines changed: 341 additions & 149 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@object-ui/types": patch
3+
"@object-ui/core": patch
4+
"@object-ui/app-shell": patch
5+
"@object-ui/plugin-detail": patch
6+
---
7+
8+
**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.
9+
10+
- **`@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.
11+
- **`@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).
12+
- **`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`.
13+
14+
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.

packages/app-shell/src/components/ManagedByBadge.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
cn,
99
} from '@object-ui/components';
1010
import { useObjectTranslation } from '@object-ui/i18n';
11+
import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances';
1112

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

46-
type Bucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth';
47+
type Bucket = ManagedByBucket;
4748

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

59-
/** True only when a userActions flag (bare boolean or object form) opts the write in. */
60-
function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean {
61-
return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
62-
}
63-
6460
export interface ManagedByBadgeProps {
6561
/** The `managedBy` flag from the object schema. */
6662
managedBy?: string;
@@ -149,10 +145,7 @@ export function ManagedByBadge({ managedBy, userActions, label, className }: Man
149145
if (!managedBy || managedBy === 'platform') return null;
150146
// ADR-0103 — a `system` object that opened any write is admin/user-writable
151147
// data, not an engine-owned monitoring surface: pick the writable variant/copy.
152-
const ua = userActions ?? undefined;
153-
const systemWritable =
154-
managedBy === 'system' &&
155-
(ua?.create === true || isWriteOptedIn(ua?.edit) || isWriteOptedIn(ua?.delete));
148+
const systemWritable = isSystemWritable({ managedBy, userActions });
156149
const variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey);
157150
const variant = VARIANTS[variantKey];
158151
if (!variant) return null;
Lines changed: 22 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,26 @@
11
/**
2-
* crudAffordances — UI-side mirror of the framework's
3-
* `resolveCrudAffordances()` helper (see
4-
* `@objectstack/spec/data/object.zod.ts`).
2+
* crudAffordances — re-export shim.
53
*
6-
* The framework tags every object with a `managedBy` lifecycle bucket
7-
* (`platform | config | system | append-only | better-auth`) and an
8-
* optional `userActions` override block. UI components ask this helper
9-
* "should I show the New / Import / Edit / Delete / Export buttons?" so
10-
* the toolbar tracks the object's lifecycle automatically — no need for
11-
* each view to special-case `sys_*` names.
12-
*
13-
* Keep this in lockstep with the framework helper. The bucket defaults
14-
* mirror what Salesforce / ServiceNow / Workday / Notion do for the
15-
* equivalent categories of system tables.
16-
*/
17-
18-
export type ManagedByBucket =
19-
| 'platform'
20-
| 'config'
21-
| 'system'
22-
| 'append-only'
23-
| 'better-auth';
24-
25-
export interface CrudAffordances {
26-
/** Generic "New" button for single-record creation. */
27-
create: boolean;
28-
/** CSV bulk-import wizard. */
29-
import: boolean;
30-
/** Inline + form editing of existing rows. */
31-
edit: boolean;
32-
/** Row-level + bulk delete. */
33-
delete: boolean;
34-
/** CSV / clipboard export. */
35-
exportCsv: boolean;
36-
/**
37-
* Per-record CEL predicates for the built-in row Edit/Delete actions,
38-
* present only when `userActions.edit` / `delete` used the object form
39-
* (objectui#2614). Carried through as authored (bare CEL string or
40-
* `{ dialect, source }` envelope) for row renderers to evaluate via
41-
* `useRowPredicate`; they never affect the object-level booleans above.
42-
*/
43-
editPredicates?: RowCrudPredicates;
44-
deletePredicates?: RowCrudPredicates;
45-
}
46-
47-
/** Per-record predicates from the #2614 object form of a userActions flag. */
48-
export interface RowCrudPredicates {
49-
visibleWhen?: unknown;
50-
disabledWhen?: unknown;
51-
}
52-
53-
/** `edit`/`delete` accept a bare boolean or the #2614 object form. */
54-
export type UserActionOverride =
55-
| boolean
56-
| { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown };
57-
58-
export interface UserActionsOverride {
59-
create?: boolean;
60-
import?: boolean;
61-
edit?: UserActionOverride;
62-
delete?: UserActionOverride;
63-
exportCsv?: boolean;
64-
}
65-
66-
const DEFAULTS: Record<ManagedByBucket, CrudAffordances> = {
67-
platform: { create: true, import: true, edit: true, delete: true, exportCsv: true },
68-
config: { create: true, import: false, edit: true, delete: true, exportCsv: true },
69-
system: { create: false, import: false, edit: false, delete: false, exportCsv: true },
70-
'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true },
71-
'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true },
72-
};
73-
74-
export interface SchemaLike {
75-
managedBy?: string | null;
76-
userActions?: UserActionsOverride | null;
77-
}
78-
79-
/**
80-
* Collapse an `edit`/`delete` override (boolean or #2614 object form) onto
81-
* the bucket default, surfacing any per-record predicates alongside.
4+
* The canonical bucket/affordance logic now lives in `@object-ui/core`
5+
* (`utils/managedBy.ts`) so it is defined ONCE and shared by every UI package
6+
* — app-shell, plugin-detail, plugin-form, plugin-grid — instead of being
7+
* hand-mirrored. This file is kept so existing app-shell imports of
8+
* `./crudAffordances` keep working; prefer importing from `@object-ui/core`
9+
* directly in new code.
8210
*/
83-
function normalizeOverride(
84-
v: UserActionOverride | undefined | null,
85-
base: boolean,
86-
): { enabled: boolean; predicates?: RowCrudPredicates } {
87-
if (v == null) return { enabled: base };
88-
if (typeof v === 'boolean') return { enabled: v };
89-
const enabled = v.enabled ?? base;
90-
if (v.visibleWhen == null && v.disabledWhen == null) return { enabled };
91-
const predicates: RowCrudPredicates = {};
92-
if (v.visibleWhen != null) predicates.visibleWhen = v.visibleWhen;
93-
if (v.disabledWhen != null) predicates.disabledWhen = v.disabledWhen;
94-
return { enabled, predicates };
95-
}
9611

97-
export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances {
98-
const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform';
99-
const base = DEFAULTS[bucket] ?? DEFAULTS.platform;
100-
const o = obj?.userActions ?? {};
101-
const edit = normalizeOverride(o.edit, base.edit);
102-
const del = normalizeOverride(o.delete, base.delete);
103-
const out: CrudAffordances = {
104-
create: o.create ?? base.create,
105-
import: o.import ?? base.import,
106-
edit: edit.enabled,
107-
delete: del.enabled,
108-
exportCsv: o.exportCsv ?? base.exportCsv,
109-
};
110-
if (edit.predicates) out.editPredicates = edit.predicates;
111-
if (del.predicates) out.deletePredicates = del.predicates;
112-
return out;
113-
}
12+
export type {
13+
ManagedByBucket,
14+
CrudAffordances,
15+
RowCrudPredicates,
16+
UserActionOverride,
17+
UserActionsOverride,
18+
SchemaLike,
19+
} from '@object-ui/core';
20+
21+
export {
22+
resolveCrudAffordances,
23+
isWriteOptedIn,
24+
isSystemWritable,
25+
isObjectInlineEditable,
26+
} from '@object-ui/core';

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './registry/WidgetRegistry.js';
1515
export * from './validation/index.js';
1616
export * from './builder/schema-builder.js';
1717
export * from './utils/filter-converter.js';
18+
export * from './utils/managedBy.js';
1819
export * from './utils/extract-records.js';
1920
export * from './utils/expand-fields.js';
2021
export * from './evaluator/index.js';
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { MANAGED_BY_BUCKETS } from '@object-ui/types';
3+
import {
4+
resolveCrudAffordances,
5+
isWriteOptedIn,
6+
isSystemWritable,
7+
isObjectInlineEditable,
8+
} from './managedBy';
9+
10+
describe('resolveCrudAffordances (shared source of truth)', () => {
11+
it('defaults to the platform bucket (full CRUD) when managedBy is unset', () => {
12+
expect(resolveCrudAffordances({})).toEqual({
13+
create: true, import: true, edit: true, delete: true, exportCsv: true,
14+
});
15+
expect(resolveCrudAffordances(null)).toEqual(resolveCrudAffordances({ managedBy: 'platform' }));
16+
});
17+
18+
it('config: New/Edit/Delete + export, no import', () => {
19+
expect(resolveCrudAffordances({ managedBy: 'config' })).toEqual({
20+
create: true, import: false, edit: true, delete: true, exportCsv: true,
21+
});
22+
});
23+
24+
it('system / append-only / better-auth: export-only by default', () => {
25+
for (const managedBy of ['system', 'append-only', 'better-auth']) {
26+
expect(resolveCrudAffordances({ managedBy })).toEqual({
27+
create: false, import: false, edit: false, delete: false, exportCsv: true,
28+
});
29+
}
30+
});
31+
32+
it('userActions overrides the bucket default (ADR-0103 writable system)', () => {
33+
const aff = resolveCrudAffordances({ managedBy: 'system', userActions: { create: true, edit: true, delete: true } });
34+
expect(aff).toMatchObject({ create: true, edit: true, delete: true, import: false });
35+
});
36+
37+
it('unknown bucket falls back to platform (defensive)', () => {
38+
expect(resolveCrudAffordances({ managedBy: 'totally-unknown' }).edit).toBe(true);
39+
});
40+
41+
it('#2614 object form: carries predicates, keys off enabled, boolean path unchanged', () => {
42+
const withPreds = resolveCrudAffordances({
43+
managedBy: 'platform',
44+
userActions: { edit: { enabled: true, disabledWhen: 'record.locked == true' } },
45+
});
46+
expect(withPreds.edit).toBe(true);
47+
expect(withPreds.editPredicates).toEqual({ disabledWhen: 'record.locked == true' });
48+
// enabled omitted → falls back to the bucket default (platform edit = true)
49+
expect(resolveCrudAffordances({ managedBy: 'platform', userActions: { edit: { disabledWhen: 'x' } } }).edit).toBe(true);
50+
// boolean form leaves predicates absent
51+
expect(resolveCrudAffordances({ managedBy: 'system', userActions: { edit: true } }).editPredicates).toBeUndefined();
52+
});
53+
});
54+
55+
describe('isWriteOptedIn', () => {
56+
it('true only for boolean true or { enabled: true }', () => {
57+
expect(isWriteOptedIn(true)).toBe(true);
58+
expect(isWriteOptedIn({ enabled: true })).toBe(true);
59+
expect(isWriteOptedIn(false)).toBe(false);
60+
expect(isWriteOptedIn({ enabled: false })).toBe(false);
61+
expect(isWriteOptedIn({ disabledWhen: 'x' })).toBe(false);
62+
expect(isWriteOptedIn(undefined)).toBe(false);
63+
expect(isWriteOptedIn(null)).toBe(false);
64+
});
65+
});
66+
67+
describe('isSystemWritable (ADR-0103)', () => {
68+
it('true only for a system object that opened create, edit, or delete', () => {
69+
expect(isSystemWritable({ managedBy: 'system', userActions: { create: true } })).toBe(true);
70+
expect(isSystemWritable({ managedBy: 'system', userActions: { edit: { enabled: true } } })).toBe(true);
71+
expect(isSystemWritable({ managedBy: 'system', userActions: { delete: true } })).toBe(true);
72+
});
73+
it('false for engine-owned system and for other buckets even with userActions', () => {
74+
expect(isSystemWritable({ managedBy: 'system' })).toBe(false);
75+
expect(isSystemWritable({ managedBy: 'system', userActions: { edit: false } })).toBe(false);
76+
// append-only / better-auth are never "system-writable" regardless of userActions
77+
expect(isSystemWritable({ managedBy: 'append-only', userActions: { create: true } })).toBe(false);
78+
expect(isSystemWritable({ managedBy: 'better-auth', userActions: { edit: true } })).toBe(false);
79+
expect(isSystemWritable({ managedBy: 'platform' })).toBe(false);
80+
expect(isSystemWritable(null)).toBe(false);
81+
});
82+
});
83+
84+
describe('isObjectInlineEditable', () => {
85+
it('mirrors the resolved edit affordance (replaces the old NON_EDITABLE_BUCKETS set)', () => {
86+
// Non-editable buckets by default...
87+
for (const managedBy of ['system', 'append-only', 'better-auth']) {
88+
expect(isObjectInlineEditable({ managedBy })).toBe(false);
89+
}
90+
// ...editable buckets and opened-up system objects.
91+
expect(isObjectInlineEditable({ managedBy: 'platform' })).toBe(true);
92+
expect(isObjectInlineEditable({ managedBy: 'config' })).toBe(true);
93+
expect(isObjectInlineEditable({ managedBy: 'system', userActions: { edit: true } })).toBe(true);
94+
// an explicit edit:false disables even on an otherwise-editable bucket
95+
expect(isObjectInlineEditable({ managedBy: 'platform', userActions: { edit: false } })).toBe(false);
96+
});
97+
});
98+
99+
describe('MANAGED_BY_BUCKETS', () => {
100+
it('is the closed 5-bucket union in canonical order', () => {
101+
expect(MANAGED_BY_BUCKETS).toEqual(['platform', 'config', 'system', 'append-only', 'better-auth']);
102+
});
103+
});

0 commit comments

Comments
 (0)