Skip to content

Commit f80aaf2

Browse files
os-zhuangclaude
andauthored
feat(app-shell): distinguish writable system objects from engine-owned in badge + empty-state (ADR-0103 / #3220) (#2705)
The framework split the overloaded managedBy:'system' bucket (ADR-0103): several system objects are admin/user-writable data (Notification Preferences / Subscriptions / Templates, delegated RBAC assignments, user preferences) and declare userActions opening their writes. The Console already rendered the New/Edit/Delete buttons for these (the affordance mirrors honour userActions), but the badge/empty-state copy still called every system object a "read-only monitoring surface". - ManagedByBadge accepts userActions and renders a "Platform schema — admin-writable" variant when a system object opens any write. - resolveManagedByEmptyState returns undefined for a writable system object (userActions.create) so the generic empty state with the New button shows. - New managedByBadge.systemWritable.* strings (en + zh; others fall back to en). Copy/UX only — no behavioural change. Paired with framework #3315. Claude-Session: https://claude.ai/code/session_01Fp9yZxRQ3mb7p4vVwqFXKE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b17339 commit f80aaf2

9 files changed

Lines changed: 106 additions & 8 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/app-shell": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
**Distinguish writable `system` objects from engine-owned ones in the Console (framework ADR-0103 / #3220).** The framework split the overloaded `managedBy: 'system'` bucket: engine-owned rows stay read-only, but several `system` objects are admin/user-writable *data* (Notification Preferences/Subscriptions/Templates, delegated RBAC assignments, user preferences) and declare `userActions` opening their writes.
7+
8+
The Console already surfaced the New/Edit/Delete buttons correctly for these (all affordance mirrors honour `userActions`), but the badge and empty-state *copy* still called every `system` object a "read-only monitoring surface". Now:
9+
10+
- **`ManagedByBadge`** takes the object's `userActions` and, when a `system` object opens any write, renders the "Platform schema — admin-writable" variant instead of the engine-owned copy.
11+
- **`resolveManagedByEmptyState`** returns `undefined` for a `system` object whose `userActions.create` is set, so the generic empty state (with the New button) shows instead of "entries appear automatically".
12+
- New `managedByBadge.systemWritable.*` strings (en + zh; other locales fall back to the English default).
13+
14+
Copy/UX only — no behavioural change to what a user can do.

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

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ import { useObjectTranslation } from '@object-ui/i18n';
2323
* engine", "audit / monitoring surface") to every end user and repeated
2424
* itself on three pages (list + detail + form) for the same record.
2525
*
26-
* The badge follows the same five-bucket taxonomy declared by
26+
* The badge follows the same taxonomy declared by
2727
* `@objectstack/spec/data/object.zod.ts` → `ObjectSchemaBase.managedBy`:
2828
* - `platform` — User-owned business data. **Renders nothing.**
2929
* - `config` — Admin-authored configuration.
30-
* - `system` — Engine-managed runtime rows.
30+
* - `system` — Engine-managed runtime rows (default) — but a `system`
31+
* object that opens writes via `userActions` (ADR-0103) is
32+
* platform-defined, admin/user-writable DATA and gets the
33+
* distinct writable-system copy instead.
3134
* - `append-only` — Immutable audit log.
3235
* - `better-auth` — Identity tables owned by better-auth driver.
3336
*
@@ -42,9 +45,31 @@ import { useObjectTranslation } from '@object-ui/i18n';
4245

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

48+
/**
49+
* 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+
* `resolveCrudAffordances` inputs; `edit`/`delete` accept the #2614 object form.
52+
*/
53+
export interface ManagedByUserActions {
54+
create?: boolean;
55+
edit?: boolean | { enabled?: boolean };
56+
delete?: boolean | { enabled?: boolean };
57+
}
58+
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+
4564
export interface ManagedByBadgeProps {
4665
/** The `managedBy` flag from the object schema. */
4766
managedBy?: string;
67+
/**
68+
* The object's `userActions` (ADR-0103). When a `system`-bucket object opens
69+
* any write here, the badge switches from the engine-owned "read-only
70+
* monitoring surface" copy to the admin/user-writable variant.
71+
*/
72+
userActions?: ManagedByUserActions | null;
4873
/** Optional override for the human-readable system name shown in the tooltip. */
4974
label?: string;
5075
/** Optional extra classes. */
@@ -63,7 +88,10 @@ interface Variant {
6388
tone: string;
6489
}
6590

66-
const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
91+
/** Variant keys: the non-platform buckets plus the ADR-0103 writable-system split. */
92+
type VariantKey = Exclude<Bucket, 'platform'> | 'system-writable';
93+
94+
const VARIANTS: Record<VariantKey, Variant> = {
6795
config: {
6896
icon: Settings2,
6997
i18nKey: 'config',
@@ -82,6 +110,20 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
82110
'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
83111
tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
84112
},
113+
// ADR-0103 — a `system`-bucket object that opened writes via `userActions`:
114+
// platform-defined schema, but admin/user-writable DATA (e.g. Notification
115+
// Preferences, delegated RBAC assignments). Resolved in the component when the
116+
// bucket is `system` and any write is opted in, so the copy no longer claims a
117+
// "read-only monitoring surface".
118+
'system-writable': {
119+
icon: Settings2,
120+
i18nKey: 'systemWritable',
121+
short: 'Platform schema',
122+
title: 'Platform-defined, admin-writable',
123+
body: () =>
124+
"This object's schema is defined by the platform, but its rows are yours to create and edit here. Who may write is governed by delegated administration and record-level security, not by this badge.",
125+
tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
126+
},
85127
'append-only': {
86128
icon: Archive,
87129
i18nKey: 'appendOnly',
@@ -102,10 +144,17 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
102144
},
103145
};
104146

105-
export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps) {
147+
export function ManagedByBadge({ managedBy, userActions, label, className }: ManagedByBadgeProps) {
106148
const { t } = useObjectTranslation();
107149
if (!managedBy || managedBy === 'platform') return null;
108-
const variant = VARIANTS[managedBy as Exclude<Bucket, 'platform'>];
150+
// ADR-0103 — a `system` object that opened any write is admin/user-writable
151+
// 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));
156+
const variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey);
157+
const variant = VARIANTS[variantKey];
109158
if (!variant) return null;
110159
const Icon = variant.icon;
111160
const display = label ?? 'better-auth';

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ describe('resolveManagedByEmptyState', () => {
2020
expect(resolveManagedByEmptyState('append-only', t)?.title).toBe('No events recorded');
2121
});
2222

23+
// ADR-0103 — a `system` object that opened creation (writable set: Notification
24+
// Preferences, delegated RBAC assignments, …) is admin/user-writable data. The
25+
// "entries appear automatically" copy would be wrong, so the helper returns
26+
// undefined and the caller falls back to the generic empty state (New button).
27+
it('returns undefined for a system object whose userActions opened creation', () => {
28+
expect(resolveManagedByEmptyState('system', t, 'sys_notification_preference', { create: true })).toBeUndefined();
29+
// A system object that did NOT open creation stays engine-owned.
30+
expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', {})?.title).toBe('Nothing here yet');
31+
expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', undefined)?.title).toBe('Nothing here yet');
32+
// append-only is unaffected by userActions.create (audit logs stay locked).
33+
expect(resolveManagedByEmptyState('append-only', t, 'sys_audit_log', { create: true })?.title).toBe('No events recorded');
34+
});
35+
2336
it('gives sys_user an actionable empty state (org invite + SSO JIT, end-users)', () => {
2437
const es = resolveManagedByEmptyState('better-auth', t, 'sys_user');
2538
expect(es?.title).toBe('No users yet');

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,24 @@ export interface ManagedByEmptyState {
3434
*/
3535
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
3636

37+
/** Subset of `userActions` (ADR-0103) that opens generic creation. */
38+
interface EmptyStateUserActions {
39+
create?: boolean;
40+
}
41+
3742
export function resolveManagedByEmptyState(
3843
managedBy: string | undefined | null,
3944
t: TranslateFn,
4045
objectName?: string | null,
46+
userActions?: EmptyStateUserActions | null,
4147
): ManagedByEmptyState | undefined {
4248
switch (managedBy) {
4349
case 'system':
50+
// ADR-0103 — a `system` object that opened creation is admin/user-writable
51+
// data (e.g. Notification Preferences). The "entries appear automatically"
52+
// 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;
4455
return {
4556
icon: 'Lock',
4657
title: t('list.managedBy.system.title', { defaultValue: 'Nothing here yet' }),

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,7 +1389,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
13891389
viewDef.name || viewDef.id || '',
13901390
viewDef.emptyState
13911391
?? listSchema.emptyState
1392-
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name),
1392+
?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions),
13931393
),
13941394
aria: viewDef.aria ?? listSchema.aria,
13951395
// Propagate filter/sort as default filters/sort for data flow
@@ -1617,7 +1617,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
16171617
title={
16181618
<span className="inline-flex items-center gap-2">
16191619
<span className="truncate">{objectLabel(objectDef)}</span>
1620-
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
1620+
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
16211621
</span>
16221622
}
16231623
description={objectDef.description ? objectDesc(objectDef) : undefined}

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1829,7 +1829,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18291829
{recordPresence.length > 0 && (
18301830
<PresenceAvatars users={recordPresence} size="sm" maxVisible={3} showStatus />
18311831
)}
1832-
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
1832+
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
18331833
</div>
18341834

18351835
<RecordContextProvider

packages/app-shell/src/views/RecordFormPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
293293
buckets via ObjectForm's own readOnly resolution. */}
294294
<ManagedByBadge
295295
managedBy={(objectDef as any)?.managedBy}
296+
userActions={(objectDef as any)?.userActions}
296297
className="ml-1"
297298
/>
298299
</nav>

packages/i18n/src/locales/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,11 @@ const en = {
391391
title: 'Managed by the platform',
392392
body: 'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
393393
},
394+
systemWritable: {
395+
short: 'Platform schema',
396+
title: 'Platform-defined, admin-writable',
397+
body: "This object's schema is defined by the platform, but its rows are yours to create and edit here. Who may write is governed by delegated administration and record-level security, not by this badge.",
398+
},
394399
appendOnly: {
395400
short: 'Read-only · Audit log',
396401
title: 'Read-only historical record',

packages/i18n/src/locales/zh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,11 @@ const zh = {
467467
title: '由平台管理',
468468
body: '当源记录上的操作运行时,这里的行会自动创建。下方列表是只读监控界面——行级操作(批准、撤回、重发等)在每一行上。',
469469
},
470+
systemWritable: {
471+
short: '平台架构',
472+
title: '平台定义,管理员可写',
473+
body: '此对象的架构由平台定义,但其数据行可在此处创建和编辑。谁可以写入由委派管理和记录级安全控制,而非此标记。',
474+
},
470475
appendOnly: {
471476
short: '只读 · 审计日志',
472477
title: '只读历史记录',

0 commit comments

Comments
 (0)