From 13979332f52254ad93f270b84590db1fd4983b41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:57:49 +0000 Subject: [PATCH] feat(app-shell): distinguish writable system objects from engine-owned in badge + empty-state (ADR-0103 / #3220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fp9yZxRQ3mb7p4vVwqFXKE --- .changeset/managedby-writable-system-copy.md | 14 +++++ .../src/components/ManagedByBadge.tsx | 59 +++++++++++++++++-- .../__tests__/managedByEmptyState.test.ts | 13 ++++ .../src/utils/managedByEmptyState.ts | 11 ++++ packages/app-shell/src/views/ObjectView.tsx | 4 +- .../app-shell/src/views/RecordDetailView.tsx | 2 +- .../app-shell/src/views/RecordFormPage.tsx | 1 + packages/i18n/src/locales/en.ts | 5 ++ packages/i18n/src/locales/zh.ts | 5 ++ 9 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 .changeset/managedby-writable-system-copy.md diff --git a/.changeset/managedby-writable-system-copy.md b/.changeset/managedby-writable-system-copy.md new file mode 100644 index 000000000..5ef156f55 --- /dev/null +++ b/.changeset/managedby-writable-system-copy.md @@ -0,0 +1,14 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +**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. + +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: + +- **`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. +- **`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". +- New `managedByBadge.systemWritable.*` strings (en + zh; other locales fall back to the English default). + +Copy/UX only — no behavioural change to what a user can do. diff --git a/packages/app-shell/src/components/ManagedByBadge.tsx b/packages/app-shell/src/components/ManagedByBadge.tsx index ce65d01c8..3635ca4e5 100644 --- a/packages/app-shell/src/components/ManagedByBadge.tsx +++ b/packages/app-shell/src/components/ManagedByBadge.tsx @@ -23,11 +23,14 @@ import { useObjectTranslation } from '@object-ui/i18n'; * engine", "audit / monitoring surface") to every end user and repeated * itself on three pages (list + detail + form) for the same record. * - * The badge follows the same five-bucket taxonomy declared by + * The badge follows the same taxonomy declared by * `@objectstack/spec/data/object.zod.ts` → `ObjectSchemaBase.managedBy`: * - `platform` — User-owned business data. **Renders nothing.** * - `config` — Admin-authored configuration. - * - `system` — Engine-managed runtime rows. + * - `system` — Engine-managed runtime rows (default) — but a `system` + * object that opens writes via `userActions` (ADR-0103) is + * platform-defined, admin/user-writable DATA and gets the + * distinct writable-system copy instead. * - `append-only` — Immutable audit log. * - `better-auth` — Identity tables owned by better-auth driver. * @@ -42,9 +45,31 @@ import { useObjectTranslation } from '@object-ui/i18n'; type Bucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth'; +/** + * 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 + * `resolveCrudAffordances` inputs; `edit`/`delete` accept the #2614 object form. + */ +export interface ManagedByUserActions { + create?: boolean; + edit?: boolean | { enabled?: boolean }; + 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; + /** + * The object's `userActions` (ADR-0103). When a `system`-bucket object opens + * any write here, the badge switches from the engine-owned "read-only + * monitoring surface" copy to the admin/user-writable variant. + */ + userActions?: ManagedByUserActions | null; /** Optional override for the human-readable system name shown in the tooltip. */ label?: string; /** Optional extra classes. */ @@ -63,7 +88,10 @@ interface Variant { tone: string; } -const VARIANTS: Record, Variant> = { +/** Variant keys: the non-platform buckets plus the ADR-0103 writable-system split. */ +type VariantKey = Exclude | 'system-writable'; + +const VARIANTS: Record = { config: { icon: Settings2, i18nKey: 'config', @@ -82,6 +110,20 @@ const VARIANTS: Record, Variant> = { '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.', 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', }, + // ADR-0103 — a `system`-bucket object that opened writes via `userActions`: + // platform-defined schema, but admin/user-writable DATA (e.g. Notification + // Preferences, delegated RBAC assignments). Resolved in the component when the + // bucket is `system` and any write is opted in, so the copy no longer claims a + // "read-only monitoring surface". + 'system-writable': { + icon: Settings2, + i18nKey: 'systemWritable', + short: 'Platform schema', + title: 'Platform-defined, admin-writable', + 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.", + 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', + }, 'append-only': { icon: Archive, i18nKey: 'appendOnly', @@ -102,10 +144,17 @@ const VARIANTS: Record, Variant> = { }, }; -export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps) { +export function ManagedByBadge({ managedBy, userActions, label, className }: ManagedByBadgeProps) { const { t } = useObjectTranslation(); if (!managedBy || managedBy === 'platform') return null; - const variant = VARIANTS[managedBy as Exclude]; + // 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 variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey); + const variant = VARIANTS[variantKey]; if (!variant) return null; const Icon = variant.icon; const display = label ?? 'better-auth'; diff --git a/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts index 2f9d0cbd8..169551446 100644 --- a/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts +++ b/packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts @@ -20,6 +20,19 @@ describe('resolveManagedByEmptyState', () => { expect(resolveManagedByEmptyState('append-only', t)?.title).toBe('No events recorded'); }); + // ADR-0103 — a `system` object that opened creation (writable set: Notification + // Preferences, delegated RBAC assignments, …) is admin/user-writable data. The + // "entries appear automatically" copy would be wrong, so the helper returns + // undefined and the caller falls back to the generic empty state (New button). + it('returns undefined for a system object whose userActions opened creation', () => { + expect(resolveManagedByEmptyState('system', t, 'sys_notification_preference', { create: true })).toBeUndefined(); + // A system object that did NOT open creation stays engine-owned. + expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', {})?.title).toBe('Nothing here yet'); + expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', undefined)?.title).toBe('Nothing here yet'); + // append-only is unaffected by userActions.create (audit logs stay locked). + expect(resolveManagedByEmptyState('append-only', t, 'sys_audit_log', { create: true })?.title).toBe('No events recorded'); + }); + it('gives sys_user an actionable empty state (org invite + SSO JIT, end-users)', () => { const es = resolveManagedByEmptyState('better-auth', t, 'sys_user'); expect(es?.title).toBe('No users yet'); diff --git a/packages/app-shell/src/utils/managedByEmptyState.ts b/packages/app-shell/src/utils/managedByEmptyState.ts index 89df19c8d..726c77fca 100644 --- a/packages/app-shell/src/utils/managedByEmptyState.ts +++ b/packages/app-shell/src/utils/managedByEmptyState.ts @@ -34,13 +34,24 @@ export interface ManagedByEmptyState { */ type TranslateFn = (key: string, options?: Record) => 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, ): 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; return { icon: 'Lock', title: t('list.managedBy.system.title', { defaultValue: 'Nothing here yet' }), diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 371fe5754..9aa8f7adc 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1389,7 +1389,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an viewDef.name || viewDef.id || '', viewDef.emptyState ?? listSchema.emptyState - ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name), + ?? resolveManagedByEmptyState((objectDef as any)?.managedBy, t, objectDef.name, (objectDef as any)?.userActions), ), aria: viewDef.aria ?? listSchema.aria, // Propagate filter/sort as default filters/sort for data flow @@ -1617,7 +1617,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an title={ {objectLabel(objectDef)} - + } description={objectDef.description ? objectDesc(objectDef) : undefined} diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 819b60c42..e800ebf71 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1829,7 +1829,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {recordPresence.length > 0 && ( )} - + diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 5bd089564..6bc4271ea 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -391,6 +391,11 @@ const en = { title: 'Managed by the platform', 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.', }, + systemWritable: { + short: 'Platform schema', + title: 'Platform-defined, admin-writable', + 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.", + }, appendOnly: { short: 'Read-only · Audit log', title: 'Read-only historical record', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index c5682b6ac..4abe38ac2 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -467,6 +467,11 @@ const zh = { title: '由平台管理', body: '当源记录上的操作运行时,这里的行会自动创建。下方列表是只读监控界面——行级操作(批准、撤回、重发等)在每一行上。', }, + systemWritable: { + short: '平台架构', + title: '平台定义,管理员可写', + body: '此对象的架构由平台定义,但其数据行可在此处创建和编辑。谁可以写入由委派管理和记录级安全控制,而非此标记。', + }, appendOnly: { short: '只读 · 审计日志', title: '只读历史记录',