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-writable-system-copy.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 54 additions & 5 deletions packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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. */
Expand All @@ -63,7 +88,10 @@ interface Variant {
tone: string;
}

const VARIANTS: Record<Exclude<Bucket, 'platform'>, Variant> = {
/** Variant keys: the non-platform buckets plus the ADR-0103 writable-system split. */
type VariantKey = Exclude<Bucket, 'platform'> | 'system-writable';

const VARIANTS: Record<VariantKey, Variant> = {
config: {
icon: Settings2,
i18nKey: 'config',
Expand All @@ -82,6 +110,20 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, 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',
Expand All @@ -102,10 +144,17 @@ const VARIANTS: Record<Exclude<Bucket, 'platform'>, 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<Bucket, 'platform'>];
// 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';
Expand Down
13 changes: 13 additions & 0 deletions packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 11 additions & 0 deletions packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,24 @@ 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,
): 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' }),
Expand Down
4 changes: 2 additions & 2 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1617,7 +1617,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
title={
<span className="inline-flex items-center gap-2">
<span className="truncate">{objectLabel(objectDef)}</span>
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
</span>
}
description={objectDef.description ? objectDesc(objectDef) : undefined}
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1829,7 +1829,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
{recordPresence.length > 0 && (
<PresenceAvatars users={recordPresence} size="sm" maxVisible={3} showStatus />
)}
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
</div>

<RecordContextProvider
Expand Down
1 change: 1 addition & 0 deletions packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
buckets via ObjectForm's own readOnly resolution. */}
<ManagedByBadge
managedBy={(objectDef as any)?.managedBy}
userActions={(objectDef as any)?.userActions}
className="ml-1"
/>
</nav>
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,11 @@ const zh = {
title: '由平台管理',
body: '当源记录上的操作运行时,这里的行会自动创建。下方列表是只读监控界面——行级操作(批准、撤回、重发等)在每一行上。',
},
systemWritable: {
short: '平台架构',
title: '平台定义,管理员可写',
body: '此对象的架构由平台定义,但其数据行可在此处创建和编辑。谁可以写入由委派管理和记录级安全控制,而非此标记。',
},
appendOnly: {
short: '只读 · 审计日志',
title: '只读历史记录',
Expand Down
Loading