Skip to content

Commit 16d151b

Browse files
feat(studio): external-object fields show their datasource and write state; no DDL controls on an external object (#27316)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiVy7F2atKWpo5rp3YPrqddh)_ Fixes #27279 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** Studio 对象设计器(字段画布与字段检查器)里,带 `external` 绑定的外部对象:每个字段标「来自 数据源名 · 只读或可写」,画布顶部一条说明;不再出现「添加字段」、批量删除,字段检查器里 API 名与类型置灰、无复制与删除。 - **为什么改:** 外部对象的表属于远端数据库,平台从不对它做 DDL;以前设计器照常提供加列、改类型、删字段,而平台不会对远端表执行这些改动(ADR-0015 欠的 Studio 半边,裁决 D6 的 P1 部分)。 - **风险与回滚:** 只影响带 `external` 的对象的设计器界面,平台自有对象不变;不改元数据协议、不改服务端。回滚即还原本 PR。 - **先看:** `previews/external-object.tsx`(判定与写入状态,含数据源读取)→ `previews/ObjectFormCanvas.tsx`(隐藏加字段与批量删除)→ `inspectors/ObjectFieldInspector.tsx`(`ddlLocked` 一行决定四个控件)。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx`,预期 11 passed;其中一条断言中文标记为「来自 plan_gantt · 只读」且无「添加字段」按钮。 - **需要你做的:** 无,席位落地。 ## What changes An object that carries an ADR-0015 `external` binding is never a DDL target (the runtime's own test is the same presence check, `isFederatedObject` in `@objectstack/objectql`). The object designer under `ui/packages/app-shell/src/views/metadata-admin` now reads `object.external` / `object.datasource` and: - **Marks every field.** Each field row on the canvas and the field inspector carry a badge "From DATASOURCE · STATE" (zh: 「来自 DATASOURCE · 只读」), and a note above the canvas names the datasource and says why fields cannot be reshaped there. - **Write state, never guessed.** ADR-0015 writes need both `datasource.external.allowWrites` and `object.external.writable` (the engine's write gate, `assertWriteAllowed`). Without `writable: true` on the object the state is settled from the object alone: Read-only, and no request is made. With it, the datasource is read through the metadata door (`GET /meta/datasource/NAME`, the same item `DatasourcePreview` reads `external.allowWrites` from): both set ⇒ Writable, otherwise Read-only, and a datasource that cannot be read ⇒ "Writes unconfirmed" with a tooltip naming the missing half. The read is keyed on the datasource name, not on the client's identity (`ui/AGENTS.md` #10). - **Hides the DDL affordances**, each read from the code: | Control | Where | Why it is DDL | |:---|:---|:---| | Add field (bottom bar, empty state, section header "+") | `ObjectFormCanvas` `addField` | appends a field = a new remote column | | Bulk Delete | `ObjectFormCanvas` `bulkDelete` | removes fields = drops remote columns | | API name input | `ObjectFieldInspector` `setKey` | renames the field key = renames the column | | Type picker | `ObjectFieldInspector` `changeType` | retypes the column | | Duplicate | `ObjectFieldInspector` `duplicateField` | adds a field | | Remove | `ObjectFieldInspector` `removeField` | drops the column | The label-to-API-name derivation (`deriveNameFor`) is also off on an external object, since it renames the key. Kept: label, inline label rename, sections, ordering, bulk move-to-section, and the other display/validation settings, which change only platform metadata. - **Strings:** six `designer.external.*` rows in the `studio` namespace of `@object-ui/i18n` (`en` + `zh`), the folder's existing i18n mechanism. ## Tests `previews/external-object.test.tsx` (new, 11 cases): the binding reader; on the canvas, per-field and header badges "From plan_gantt · Read-only" with no datasource read, the zh mark 「来自 plan_gantt · 只读」 with no 「添加字段」, no Add field on an empty external object nor on a section header, bulk move kept and bulk delete dropped, the three `writable: true` outcomes (allowWrites ⇒ Writable, no allowWrites ⇒ Read-only, read failure ⇒ unconfirmed); in the field inspector, API name and Type disabled, Duplicate and Remove absent, Label still editable. Each group has a platform-owned-object control case that keeps every control. ## Acceptance notes - **Studio Data pillar is not covered by this PR.** `views/studio-design/StudioDesignSurface.tsx` (outside this card's file surface, `views/metadata-admin/**`) has its own add-field doors: the records grid's "+" column (`onAddColumn` → `AddFieldPopover`) and the Form layout designer's `onAddField`. Its right rail reuses `ObjectFieldInspector`, so there the badge shows and rename / retype / duplicate / remove are already gone, but the grid and form-designer "Add field" still show for an external object. Named in the dev report for a follow-up. - The Ask AI button on the canvas's bottom bar stays; it opens the assistant and adds nothing by itself. - The datasource read happens only for `writable: true` objects; the canvas and the field inspector each read it once per datasource name. ## Verification All readings at head `fdeadb17` on a shared container. <details><summary>Affected tests and type-check</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.test.tsx packages/app-shell/src/views/metadata-admin/inspectors/ packages/i18n/src/__tests__/studio-namespace.test.ts` → lock VERDICT command-exit 0, Test Files 204 passed (204), Tests 2833 passed, 1 skipped. - `pnpm --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` → VERDICT command-exit 0 (both `tsc --noEmit` and `tsc -p tsconfig.test.json`). - `pnpm --dir ui exec vitest run scripts/` → VERDICT command-exit 0, Test Files 174 passed, 2 skipped; Tests 4990 passed, 2 skipped. (An earlier run of the same command exited 1 on a 15 s timeout of the whole-tree sweep in `check-vi-mock-inherit.test.ts` under shared load; that file alone passed 83/83, and the full re-run above is green.) - ui-local: `check:new-line-citations` (0 new citations), `check:i18n-dead-keys`, `check:i18n-keys`, `check:i18n-drift` (6 keys added, 0 en values changed), `check:control-bytes` → all exit 0. </details> <details><summary>Derived gate families (dispatch-gates --commands)</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` → 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN; every command exit 0 (the list with exit codes is in the dev report). Also run outside the derivation: `pnpm check:changeset-fixed` exit 0. CI-owned type-check lanes and whole-tree families are left to CI. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6abcb72 commit 16d151b

9 files changed

Lines changed: 608 additions & 28 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): an external object's fields show their datasource and write state, and the object designer offers no control that would reshape the remote table
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An object that carries an `external` binding (ADR-0015) is never a DDL target: its table belongs to the remote database. The object designer now says so instead of offering edits the platform cannot carry out.
12+
13+
- **Every field is marked.** On the field canvas and in the field inspector, each field of an external object carries a mark such as "From plan_gantt · Read-only", and a note above the canvas names the datasource. Read-only is settled from the object alone when it does not set `external.writable: true`. When it does, the datasource is read for `external.allowWrites`: both set reads "Writable", otherwise "Read-only", and a datasource that cannot be read reads "Writes unconfirmed" rather than a guess.
14+
- **No DDL controls.** On an external object the canvas offers no "Add field" (bottom bar, empty state, section header) and its bulk bar has no Delete. The field inspector disables the API name and the Type picker and offers no Duplicate and no Remove. In the Studio Data pillar, the Records grid's "+" column and the Form toolbar's "Add field" are not offered, and a note above the grid or form names the datasource. Label, section, order and the other display settings stay editable.
15+
- **Strings.** Six new `designer.external.*` rows in the `studio` namespace of `@object-ui/i18n`, in English and Chinese.

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ import {
102102
type FieldTypeId,
103103
} from '../previews/field-types.js';
104104
import { FieldTypePicker } from './FieldTypePicker.js';
105+
import {
106+
ExternalFieldBadge,
107+
readExternalBinding,
108+
useExternalWriteState,
109+
} from '../previews/external-object.js';
105110
import { CelPredicateField } from '../CelPredicateField.js';
106111
import type { CelLintIssue } from '../celAuthoring.js';
107112
import { t, tFormat, type SupportedLocale } from '../i18n.js';
@@ -664,6 +669,13 @@ export function ObjectFieldInspector({
664669
/** The object this field belongs to — `draft` is the object document. */
665670
const objectName = String(draft.name ?? '');
666671

672+
// ADR-0015 — on an external object the field is a remote column: no rename,
673+
// retype, duplicate or remove (each would reshape the remote table), and the
674+
// field says where it comes from. A hook, so above the `!entry` return.
675+
const external = readExternalBinding(draft);
676+
const externalWriteState = useExternalWriteState(external);
677+
const ddlLocked = readOnly || !!external;
678+
667679
/* ─── Blocking CEL verdicts → the host's Save gate (objectui#4306) ─────
668680
*
669681
* Mirrors the shape PermissionAdvancedFacets uses for its per-clause map:
@@ -875,7 +887,7 @@ export function ObjectFieldInspector({
875887
// since two separate writeView() calls from the same stale `entry` closure
876888
// would have the second clobber the first.
877889
const deriveNameFor = (label: string): string | null => {
878-
if (readOnly) return null;
890+
if (ddlLocked) return null;
879891
const owner = recordedOwner();
880892
// The author's own value wins outright — including one that happens to be
881893
// SHAPED like a placeholder (objectui#7615).
@@ -1012,7 +1024,7 @@ export function ObjectFieldInspector({
10121024

10131025
const headerActions = (
10141026
<div className="flex items-center gap-1">
1015-
{!readOnly && (
1027+
{!ddlLocked && (
10161028
<Button
10171029
variant="ghost"
10181030
size="sm"
@@ -1033,7 +1045,7 @@ export function ObjectFieldInspector({
10331045
</div>
10341046
);
10351047

1036-
const footer = (
1048+
const footer = external ? undefined : (
10371049
<InspectorRemoveButton
10381050
label={tFormat('designer.field.remove', locale, {
10391051
label: typeof def.label === 'string' ? def.label : entry.name,
@@ -1058,11 +1070,16 @@ export function ObjectFieldInspector({
10581070
>
10591071
{/* Basic */}
10601072
<Section title={tr('designer.field.section.basic')}>
1073+
{external && (
1074+
<div>
1075+
<ExternalFieldBadge binding={external} state={externalWriteState} locale={locale} />
1076+
</div>
1077+
)}
10611078
<InspectorTextField
10621079
label={tr('designer.field.apiName')}
10631080
value={entry.name}
10641081
onCommit={setKey}
1065-
disabled={readOnly}
1082+
disabled={ddlLocked}
10661083
mono
10671084
testId="field-apiname-input"
10681085
/>
@@ -1105,7 +1122,7 @@ export function ObjectFieldInspector({
11051122
label={tr('designer.field.type')}
11061123
value={type}
11071124
onCommit={changeType}
1108-
disabled={readOnly}
1125+
disabled={ddlLocked}
11091126
locale={locale}
11101127
/>
11111128
<div className="flex items-center gap-4 pt-1">

‎ui/packages/app-shell/src/views/metadata-admin/previews/ObjectFormCanvas.tsx‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ import {
7272
type FieldTypeCategory,
7373
} from './field-types.js';
7474
import { FieldStub } from './FieldStub.js';
75+
import {
76+
ExternalFieldBadge,
77+
ExternalObjectNotice,
78+
readExternalBinding,
79+
useExternalWriteState,
80+
} from './external-object.js';
7581
import { t, tFormat } from '../i18n.js';
7682

7783
/* ─── locale helpers ─── */
@@ -104,6 +110,12 @@ export function ObjectFormCanvas({
104110
locale,
105111
}: ObjectFormCanvasProps) {
106112
const readOnly = !onPatch;
113+
// ADR-0015 — an external object's fields mirror a remote table, so the
114+
// controls that would reshape it (add a field, delete fields) are not
115+
// offered; each field says where it comes from instead.
116+
const external = readExternalBinding(draft);
117+
const writeState = useExternalWriteState(external);
118+
const canAddFields = !readOnly && !external;
107119
// The "Ask AI" affordances arm the global chat FAB (requestAssistantOpen).
108120
// Hide them when the runtime serves no AI — otherwise they'd dead-end with no
109121
// FAB to open. Same server-pushed signal the FAB itself gates on.
@@ -448,12 +460,13 @@ export function ObjectFormCanvas({
448460
count={multiSel.size}
449461
groups={declaredGroups}
450462
onMoveToGroup={bulkSetGroup}
451-
onDelete={bulkDelete}
463+
onDelete={external ? undefined : bulkDelete}
452464
onClear={clearMulti}
453465
locale={locale}
454466
/>
455467
)}
456468
<div className="mx-auto max-w-3xl px-6 py-6 space-y-4" onClick={handleBgClick}>
469+
{external && <ExternalObjectNotice binding={external} state={writeState} locale={locale} />}
457470
{!emptyState && (
458471
<CanvasToolbar
459472
fieldCount={view.entries.length}
@@ -470,7 +483,7 @@ export function ObjectFormCanvas({
470483
)}
471484

472485
{emptyState ? (
473-
<EmptyCanvas onAdd={readOnly ? undefined : addField} locale={locale} aiEnabled={aiEnabled} />
486+
<EmptyCanvas onAdd={canAddFields ? addField : undefined} locale={locale} aiEnabled={aiEnabled} />
474487
) : (
475488
<div className="space-y-5">
476489
{groups.map((g) => {
@@ -493,7 +506,7 @@ export function ObjectFormCanvas({
493506
onRename={g.key ? (label) => renameSection(g.key!, label) : undefined}
494507
onRemove={g.key ? () => removeSection(g.key!) : undefined}
495508
onMove={g.key ? (dir) => moveSection(g.key!, dir) : undefined}
496-
onAddField={readOnly ? undefined : (type) => addField(type, g.key)}
509+
onAddField={canAddFields ? (type) => addField(type, g.key) : undefined}
497510
onDropField={readOnly ? undefined : moveToGroup}
498511
>
499512
{g.entries.map((entry) => (
@@ -504,6 +517,11 @@ export function ObjectFormCanvas({
504517
multiSelected={multiSel.has(entry.name)}
505518
diffStatus={statusOf(entry.name)}
506519
changedKeys={changedKeysOf(entry.name)}
520+
sourceBadge={
521+
external ? (
522+
<ExternalFieldBadge binding={external} state={writeState} locale={locale} />
523+
) : undefined
524+
}
507525
readOnly={readOnly}
508526
locale={locale}
509527
onClick={(e) => handleRowClick(entry, e)}
@@ -538,7 +556,7 @@ export function ObjectFormCanvas({
538556

539557
{!emptyState && !readOnly && (
540558
<div className="flex items-center gap-2 pt-1">
541-
<AddFieldButton onPick={(type) => addField(type)} locale={locale} />
559+
{canAddFields && <AddFieldButton onPick={(type) => addField(type)} locale={locale} />}
542560
<Button
543561
variant="ghost"
544562
size="sm"
@@ -681,7 +699,8 @@ function BulkActionBar({
681699
count: number;
682700
groups: ObjectFieldGroup[];
683701
onMoveToGroup: (groupKey: string | null) => void;
684-
onDelete: () => void;
702+
/** Absent on an external object: deleting a field would drop a remote column. */
703+
onDelete?: () => void;
685704
onClear: () => void;
686705
locale?: string;
687706
}) {
@@ -725,15 +744,17 @@ function BulkActionBar({
725744
</PopoverContent>
726745
</Popover>
727746
)}
728-
<Button
729-
variant="outline"
730-
size="sm"
731-
className="h-7 gap-1.5 bg-background/70 text-destructive hover:text-destructive"
732-
onClick={onDelete}
733-
>
734-
<Trash2 className="h-3.5 w-3.5" />
735-
{t('designer.canvas.bulkDelete', locale)}
736-
</Button>
747+
{onDelete && (
748+
<Button
749+
variant="outline"
750+
size="sm"
751+
className="h-7 gap-1.5 bg-background/70 text-destructive hover:text-destructive"
752+
onClick={onDelete}
753+
>
754+
<Trash2 className="h-3.5 w-3.5" />
755+
{t('designer.canvas.bulkDelete', locale)}
756+
</Button>
757+
)}
737758
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={onClear}>
738759
<X className="h-3.5 w-3.5" />
739760
{t('designer.canvas.bulkClear', locale)}
@@ -1001,6 +1022,7 @@ function FieldRow({
10011022
multiSelected,
10021023
diffStatus,
10031024
changedKeys,
1025+
sourceBadge,
10041026
readOnly,
10051027
locale,
10061028
onClick,
@@ -1014,6 +1036,8 @@ function FieldRow({
10141036
/** Review mode: how this field differs from the published baseline. */
10151037
diffStatus?: FieldDiffStatus;
10161038
changedKeys?: string[];
1039+
/** ADR-0015 — where an external object's field comes from, and its write state. */
1040+
sourceBadge?: React.ReactNode;
10171041
readOnly: boolean;
10181042
locale?: string;
10191043
/** Receives the mouse event so the host can branch on Ctrl/⌘/Shift. */
@@ -1200,6 +1224,7 @@ function FieldRow({
12001224
{t('designer.canvas.diffChanged', locale)}
12011225
</Badge>
12021226
)}
1227+
{sourceBadge}
12031228
<Badge variant="outline" className={cn('text-[10px] font-medium', tone.badge)}>
12041229
{typeLabel(meta, locale) ?? typeStr}
12051230
</Badge>

0 commit comments

Comments
 (0)