From 5cf0ee174260e0c72933c830b525d615b511f85e Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 16:29:05 +0800 Subject: [PATCH] feat(studio): Capabilities section in the object Settings panel (framework#2707/#2727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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), 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. en + zh-CN strings. Verified: app-shell 152 files / 1175 tests green (4 new panel tests: effective defaults, authored values, sibling-preserving patch, opt-in patch). Browser-verified on a live Studio (fresh backend + console dev): section renders localized, toggles patch the draft, and the draft meta API returns enable {trackHistory:true, files:true, feeds:false} after save — untouched flags stay unmaterialized. Co-Authored-By: Claude Fable 5 --- .changeset/studio-capabilities-panel.md | 20 +++++++ .../src/views/metadata-admin/i18n.ts | 34 ++++++++++++ .../ObjectSettingsPanel.test.tsx | 38 +++++++++++++ .../studio-design/ObjectSettingsPanel.tsx | 55 ++++++++++++++++++- 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 .changeset/studio-capabilities-panel.md diff --git a/.changeset/studio-capabilities-panel.md b/.changeset/studio-capabilities-panel.md new file mode 100644 index 0000000000..6d050f7003 --- /dev/null +++ b/.changeset/studio-capabilities-panel.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 04afbc6d65..f126a740e7 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1078,6 +1078,23 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -2234,6 +2251,23 @@ const ENGINE_STRINGS_ZH: Record = { '仅作用于外部主体(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': '暂无应用软件包', diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx index 6ef7b2dfca..b560a19d14 100644 --- a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.test.tsx @@ -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 } }); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx index 18874ffa55..818358ce3b 100644 --- a/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx +++ b/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx @@ -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'; @@ -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; + 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 @@ -290,6 +311,38 @@ export function ObjectSettingsPanel({ + +
+
+ + {t('engine.studio.settings.capabilities', locale)} + + {t('engine.studio.settings.capabilitiesHint', locale)} + +
+
+ {CAPABILITIES.map(({ key, optIn, labelKey, descKey }) => { + const raw = enable[key]; + const checked = optIn ? raw === true : raw !== false; + return ( + + ); + })} +
+
); }