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
20 changes: 20 additions & 0 deletions .changeset/studio-capabilities-panel.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': minor
---

feat(studio): Capabilities section in the object Settings panel (framework#2707/#2727)

The `enable.*` record-surface switches went fully live in the framework, but
only source-mode authors could set them. The Data-pillar object Settings
panel now exposes them to builders — **live flags only**, each with a
one-line contract description:

- Opt-in (spec default off): `trackHistory` (History tab),
`files` (Attachments panel + server-side attachment gate).
- Opt-out (spec default on): `feeds` (discussion panel + comment 403 gate),
`activities` (record timeline mirror), `clone` (clone endpoint 403).

Checkboxes show the flag's EFFECTIVE runtime value; toggling writes an
explicit boolean into the `enable` block preserving sibling keys. Dead
flags (`searchable`/`trash`/`mru`) are deliberately not rendered — Studio
only offers switches the runtime enforces.
34 changes: 34 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1078,6 +1078,23 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'Applies to external principals only (ADR-0090 D11). Never wider than the internal model; unset falls back to Private.',
'engine.studio.settings.sharingExternalWider':
'External baseline is WIDER than the internal sharing model — publishing will be rejected (ADR-0090 D11). Narrow it to the internal model or below (private < public read < public read/write).',
'engine.studio.settings.capabilities': 'Capabilities (enable.*)',
'engine.studio.settings.capabilitiesHint': 'Record-page features — every switch here is enforced by the runtime',
'engine.studio.settings.capTrackHistory': 'History tab (trackHistory)',
'engine.studio.settings.capTrackHistoryDesc':
'Shows the read-only History tab (audit trail) on record pages. Pair with per-field history tracking for human-readable diffs; audit capture itself is always on for compliance.',
'engine.studio.settings.capFiles': 'Attachments (files)',
'engine.studio.settings.capFilesDesc':
'Generic Attachments panel on record pages (upload/download/delete). Off = the server also rejects new attachments targeting this object.',
'engine.studio.settings.capFeeds': 'Comments & feed (feeds)',
'engine.studio.settings.capFeedsDesc':
'Record discussion panel with @mentions and reactions. Off = the panel is hidden AND new comments are rejected server-side.',
'engine.studio.settings.capActivities': 'Activity timeline (activities)',
'engine.studio.settings.capActivitiesDesc':
'Mirrors create/update/delete into the record timeline. Off = no timeline rows for this object (the compliance audit log is unaffected).',
'engine.studio.settings.capClone': 'Cloning (clone)',
'engine.studio.settings.capCloneDesc':
'Allow duplicating records from the record page / API. Off = the clone endpoint returns 403 for this object.',
'engine.studio.pkg.writable': 'Writable',
'engine.studio.pkg.heading': 'Packages (apps)',
'engine.studio.pkg.none': 'No app packages yet',
Expand DownExpand Up@@ -2234,6 +2251,23 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'仅作用于外部主体(ADR-0090 D11)。不得宽于内部模型;未设置时回落为 Private。',
'engine.studio.settings.sharingExternalWider':
'外部基线比内部共享模型更宽 —— 发布会被拒绝(ADR-0090 D11)。请收窄到不超过内部模型(private < public read < public read/write)。',
'engine.studio.settings.capabilities': '能力开关(enable.*)',
'engine.studio.settings.capabilitiesHint': '记录页能力 —— 这里的每个开关都由运行时真实强制',
'engine.studio.settings.capTrackHistory': '历史标签页(trackHistory)',
'engine.studio.settings.capTrackHistoryDesc':
'在记录页显示只读的历史标签页(审计轨迹)。配合字段级历史跟踪可得到可读的字段级差异;审计采集本身始终开启(合规要求)。',
'engine.studio.settings.capFiles': '附件(files)',
'engine.studio.settings.capFilesDesc':
'记录页通用附件面板(上传/下载/删除)。关闭后服务端同时拒绝指向该对象的新附件。',
'engine.studio.settings.capFeeds': '评论与动态(feeds)',
'engine.studio.settings.capFeedsDesc':
'记录讨论面板(支持 @提及 与表情回应)。关闭后面板隐藏,且服务端拒绝新评论。',
'engine.studio.settings.capActivities': '活动时间线(activities)',
'engine.studio.settings.capActivitiesDesc':
'将创建/更新/删除镜像到记录时间线。关闭后该对象不再产生时间线记录(合规审计日志不受影响)。',
'engine.studio.settings.capClone': '克隆(clone)',
'engine.studio.settings.capCloneDesc':
'允许从记录页/API 复制记录。关闭后克隆接口对该对象返回 403。',
'engine.studio.pkg.writable': '可写',
'engine.studio.pkg.heading': '软件包(应用)',
'engine.studio.pkg.none': '暂无应用软件包',
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -93,3 +93,41 @@ describe('ObjectSettingsPanel — external OWD dial (ADR-0090 D11)', () => {
expect(screen.getByTestId('owd-external-desc').textContent).not.toMatch(/WIDER/);
});
});

describe('ObjectSettingsPanel — capabilities (enable.*, framework#2707/#2727)', () => {
it('renders only the LIVE flags with effective defaults (opt-in unchecked, opt-out checked)', () => {
renderPanel(baseDraft);
expect(screen.getByTestId('capabilities-section')).toBeTruthy();
// Opt-in flags default OFF…
expect((screen.getByTestId('cap-trackHistory') as HTMLInputElement).checked).toBe(false);
expect((screen.getByTestId('cap-files') as HTMLInputElement).checked).toBe(false);
// …opt-out flags default ON.
expect((screen.getByTestId('cap-feeds') as HTMLInputElement).checked).toBe(true);
expect((screen.getByTestId('cap-activities') as HTMLInputElement).checked).toBe(true);
expect((screen.getByTestId('cap-clone') as HTMLInputElement).checked).toBe(true);
// Dead flags must NOT be offered (Studio only exposes enforced switches).
expect(screen.queryByTestId('cap-searchable')).toBeNull();
expect(screen.queryByTestId('cap-trash')).toBeNull();
expect(screen.queryByTestId('cap-mru')).toBeNull();
});

it('reflects authored values (explicit false on an opt-out, true on an opt-in)', () => {
renderPanel({ ...baseDraft, enable: { feeds: false, files: true } });
expect((screen.getByTestId('cap-feeds') as HTMLInputElement).checked).toBe(false);
expect((screen.getByTestId('cap-files') as HTMLInputElement).checked).toBe(true);
// Untouched flags keep their effective defaults.
expect((screen.getByTestId('cap-activities') as HTMLInputElement).checked).toBe(true);
});

it('toggling writes an explicit boolean into enable, preserving sibling keys', () => {
const onPatch = renderPanel({ ...baseDraft, enable: { files: true } });
fireEvent.click(screen.getByTestId('cap-feeds'));
expect(onPatch).toHaveBeenCalledWith({ enable: { files: true, feeds: false } });
});

it('enabling an opt-in flag patches enable.trackHistory: true', () => {
const onPatch = renderPanel(baseDraft);
fireEvent.click(screen.getByTestId('cap-trackHistory'));
expect(onPatch).toHaveBeenCalledWith({ enable: { trackHistory: true } });
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,10 +25,16 @@
* These are the ONLY presentation knobs the protocol carries, so the
* builder must make them directly editable — otherwise designers fall
* back to guessing which heuristic picked their title/stepper/columns.
* 4. Capabilities (framework#2707/#2727) — the `enable.*` record-surface
* switches. Only LIVE flags are exposed (every toggle is enforced at
* runtime — writer gates, 403s, or UI surfaces): trackHistory & files
* are opt-IN (spec default false), feeds/activities/clone are opt-OUT
* (spec default true, explicit false disables). Dead flags
* (searchable/trash/mru) are deliberately NOT rendered.
*/

import React from 'react';
import { Settings2, ShieldCheck, Sparkles, X } from 'lucide-react';
import { Settings2, ShieldCheck, Sparkles, ToggleRight, X } from 'lucide-react';
import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry';
import { readFields } from '../metadata-admin/previews/object-fields-io';
import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n';
Expand DownExpand Up@@ -76,6 +82,21 @@ export function ObjectSettingsPanel({
? 'engine.studio.settings.sharingDescControlledByParent'
: 'engine.studio.settings.sharingDescUnset';

// Capabilities (`enable.*`, framework#2707/#2727). Checked = the flag's
// EFFECTIVE runtime value; toggling writes an explicit boolean into the
// enable block (preserving sibling keys). trackHistory/files are opt-in
// (=== true enables); feeds/activities/clone are opt-out (only an explicit
// false disables).
const enable = (draft.enable && typeof draft.enable === 'object' ? draft.enable : {}) as Record<string, unknown>;
const patchEnable = (key: string, value: boolean) => onPatch({ enable: { ...enable, [key]: value } });
const CAPABILITIES: Array<{ key: string; optIn: boolean; labelKey: string; descKey: string }> = [
{ key: 'trackHistory', optIn: true, labelKey: 'engine.studio.settings.capTrackHistory', descKey: 'engine.studio.settings.capTrackHistoryDesc' },
{ key: 'files', optIn: true, labelKey: 'engine.studio.settings.capFiles', descKey: 'engine.studio.settings.capFilesDesc' },
{ key: 'feeds', optIn: false, labelKey: 'engine.studio.settings.capFeeds', descKey: 'engine.studio.settings.capFeedsDesc' },
{ key: 'activities', optIn: false, labelKey: 'engine.studio.settings.capActivities', descKey: 'engine.studio.settings.capActivitiesDesc' },
{ key: 'clone', optIn: false, labelKey: 'engine.studio.settings.capClone', descKey: 'engine.studio.settings.capCloneDesc' },
];

// External OWD dial (ADR-0090 D11): baseline for portal/partner principals.
// Defaults to private when unset; must never be WIDER than the internal
// model (ordering: private < public_read < public_read_write — the D7
Expand DownExpand Up@@ -290,6 +311,38 @@ export function ObjectSettingsPanel({
</div>
</div>
</section>

<section className="rounded-lg border" data-testid="capabilities-section">
<header className="flex items-center gap-2 border-b px-3 py-2">
<ToggleRight className="h-3.5 w-3.5" />
<span className="text-[13px] font-medium">{t('engine.studio.settings.capabilities', locale)}</span>
<span className="text-[11px] text-muted-foreground">
{t('engine.studio.settings.capabilitiesHint', locale)}
</span>
</header>
<div className="grid max-w-xl gap-3 p-3">
{CAPABILITIES.map(({ key, optIn, labelKey, descKey }) => {
const raw = enable[key];
const checked = optIn ? raw === true : raw !== false;
return (
<label key={key} className="flex items-start gap-2">
<input
type="checkbox"
checked={checked}
disabled={disabled}
data-testid={`cap-${key}`}
onChange={(e) => patchEnable(key, e.target.checked)}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-[12px]">{t(labelKey, locale)}</span>
<span className="block text-[11px] text-muted-foreground">{t(descKey, locale)}</span>
</span>
</label>
);
})}
</div>
</section>
</div>
);
}
Loading