From 1b9d11ac67edc05c721e395d40e3e39db52c3034 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:22:56 +0000 Subject: [PATCH 1/4] feat(studio): mark external-object fields with their datasource and write state; no DDL controls on an external object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object with an ADR-0015 `external` binding is never a DDL target. The object designer canvas and the field inspector now mark each field "From · " and stop offering add field, bulk delete, rename, retype, duplicate and remove on such an object. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- .../27279-external-object-field-marks.md | 15 ++ .../inspectors/ObjectFieldInspector.tsx | 27 ++- .../previews/ObjectFormCanvas.tsx | 53 +++-- .../previews/external-object.test.tsx | 188 ++++++++++++++++++ .../previews/external-object.tsx | 147 ++++++++++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 9 + ui/packages/i18n/src/namespaces/studio/zh.ts | 7 + 7 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 .changeset/27279-external-object-field-marks.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/previews/external-object.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/previews/external-object.tsx diff --git a/.changeset/27279-external-object-field-marks.md b/.changeset/27279-external-object-field-marks.md new file mode 100644 index 00000000000..ff17f6ad9b0 --- /dev/null +++ b/.changeset/27279-external-object-field-marks.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **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. Label, section, order and the other display settings stay editable. +- **Strings.** Six new `designer.external.*` rows in the `studio` namespace of `@object-ui/i18n`, in English and Chinese. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index eec70145449..d43cdfb3889 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -102,6 +102,11 @@ import { type FieldTypeId, } from '../previews/field-types.js'; import { FieldTypePicker } from './FieldTypePicker.js'; +import { + ExternalFieldBadge, + readExternalBinding, + useExternalWriteState, +} from '../previews/external-object.js'; import { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; import { t, tFormat, type SupportedLocale } from '../i18n.js'; @@ -664,6 +669,13 @@ export function ObjectFieldInspector({ /** The object this field belongs to — `draft` is the object document. */ const objectName = String(draft.name ?? ''); + // ADR-0015 — on an external object the field is a remote column: no rename, + // retype, duplicate or remove (each would reshape the remote table), and the + // field says where it comes from. A hook, so above the `!entry` return. + const external = readExternalBinding(draft); + const externalWriteState = useExternalWriteState(external); + const ddlLocked = readOnly || !!external; + /* ─── Blocking CEL verdicts → the host's Save gate (objectui#4306) ───── * * Mirrors the shape PermissionAdvancedFacets uses for its per-clause map: @@ -875,7 +887,7 @@ export function ObjectFieldInspector({ // since two separate writeView() calls from the same stale `entry` closure // would have the second clobber the first. const deriveNameFor = (label: string): string | null => { - if (readOnly) return null; + if (ddlLocked) return null; const owner = recordedOwner(); // The author's own value wins outright — including one that happens to be // SHAPED like a placeholder (objectui#7615). @@ -1012,7 +1024,7 @@ export function ObjectFieldInspector({ const headerActions = (
- {!readOnly && ( + {!ddlLocked && ( + {onDelete && ( + + )} + )} + {authoring?.onEditColumn && ( + + )} +
+ ); + } + return { ...mod, ObjectView: GridDouble }; +}); + +import { DataPillar } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +const FORM = t('engine.studio.data.tab.form', 'en'); +const ADD_FIELD = t('engine.studio.data.addField', 'en'); + +beforeEach(() => { + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!(m in Element.prototype)) { + (Element.prototype as unknown as Record)[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } + if (!('scrollIntoView' in Element.prototype)) { + (Element.prototype as unknown as Record).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderPillar(object: Record) { + servedObject = object; + render( + + + , + ); + await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 }); +} + +async function openFormToolbar() { + await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]); + return screen.findByTestId('form-toolbar'); +} + +describe('Data pillar — external object (ADR-0015)', () => { + const EXTERNAL = { name: 'gantt_task', label: 'Task', datasource: 'plan_gantt', external: {}, fields: FIELDS }; + + it('offers no Add field in the Records grid or the Form toolbar, and names the datasource', async () => { + await renderPillar(EXTERNAL); + expect(screen.queryByTestId('grid-add-column')).toBeNull(); + expect(within(screen.getByTestId('external-object-notice')).getByTestId('external-field-badge')).toHaveTextContent( + 'From plan_gantt · Read-only', + ); + const toolbar = await openFormToolbar(); + expect(within(toolbar).queryByRole('button', { name: ADD_FIELD })).toBeNull(); + // A writable: false object never asks the datasource. + expect(mockClient.get).not.toHaveBeenCalled(); + }); + + it('the column editor still opens the field inspector, which marks the field and gates rename and retype', async () => { + await renderPillar(EXTERNAL); + await userEvent.click(screen.getByTestId('grid-edit-task_name')); + const inspector = await screen.findByTestId('data-field-inspector'); + expect(within(inspector).getByTestId('external-field-badge')).toHaveTextContent('From plan_gantt · Read-only'); + expect(within(inspector).getByTestId('field-apiname-input')).toBeDisabled(); + expect(within(inspector).getByRole('combobox', { name: t('designer.field.type', 'en') })).toBeDisabled(); + }); + + it('CONTROL — a platform-owned object keeps both Add field doors and shows no source note', async () => { + await renderPillar({ name: 'plan_task', label: 'Task', fields: FIELDS }); + expect(screen.getByTestId('grid-add-column')).toBeInTheDocument(); + expect(screen.queryByTestId('external-object-notice')).toBeNull(); + const toolbar = await openFormToolbar(); + expect(within(toolbar).getByRole('button', { name: ADD_FIELD })).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index f36bcb4e28a..99ad7e72911 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -342,6 +342,11 @@ import { toFieldNameLoose, } from '../metadata-admin/previews/object-fields-io.js'; import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; +import { + ExternalObjectNotice, + readExternalBinding, + useExternalWriteState, +} from '../metadata-admin/previews/external-object.js'; import { CreateItemDialog } from './CreateItemDialog.js'; import { AddFieldPopover } from './AddFieldPopover.js'; // objectui#11787 — the shared draft autosave, moved to its own module unchanged. @@ -6383,6 +6388,13 @@ export function DataPillar({ const requestedSurface = useRequestedSurface(); const appliedRequestRef = React.useRef(0); const [objDraft, setObjDraft] = React.useState>({}); + // ADR-0015 — an external object's table belongs to the remote database: the + // pillar offers no Add field on it (grid "+" column, Form toolbar) and says + // where its fields come from. Rename / retype / duplicate / remove are gated + // in the shared field inspector. + const objExternal = readExternalBinding(objDraft); + const objExternalWriteState = useExternalWriteState(objExternal); + const objIsExternal = objExternal !== null; // objectui#11272 — the object `objDraft` was loaded for, `object:NAME`: // written where the load below installs it, and nowhere else. const [objDraftFor, setObjDraftFor] = React.useState(''); @@ -7008,7 +7020,7 @@ export function DataPillar({ // the label derived none (or a taken one); otherwise it is derived here. const addField = React.useCallback((label: string, type: FieldTypeId, typedName?: string) => { // objectui#11272 — never onto another object's buffer (not offered then). - if (readOnly || !objLoaded) return; + if (readOnly || !objLoaded || objIsExternal) return; const view = readFields(objDraft.fields); const existing = view.entries.map((e) => e.name); const name = typedName && !existing.includes(typedName) ? typedName : addedFieldName(label, existing); @@ -7016,7 +7028,8 @@ export function DataPillar({ setObjDraft((d) => ({ ...d, fields: writeFields(view) })); setDirty(true); setFieldSel({ kind: 'field', id: name }); - }, [objDraft, readOnly, objLoaded]); + }, [objDraft, readOnly, objLoaded, objIsExternal]); + const canAddFields = !readOnly && !objIsExternal; const addFieldTakenNames = React.useMemo( () => readFields(objDraft.fields).entries.map((e) => e.name), [objDraft.fields], @@ -7405,6 +7418,11 @@ export function DataPillar({ className={cn('mb-2 px-3 py-1.5 text-[11px]', bodyFlush && 'mx-6 mt-3')} /> )} + {objLoaded && objExternal && ( +
+ +
+ )} {!objLoaded ? ( // objectui#11272 — no view of another object's buffer under // this one; after a failed load, the error above says why. @@ -7487,7 +7505,7 @@ export function DataPillar({ } }} > - {!readOnly && ( + {canAddFields && ( setGridAddFieldOpen(true), addColumnLabel: t('engine.studio.data.addField', locale), - onReorderFields: doReorderFields, - }), + } + : {}), onEditColumn: (fieldName) => { // ignore non-field columns (e.g. the row-actions column) if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) { @@ -7570,7 +7590,7 @@ export function DataPillar({ onSelectField={(name) => setFieldSel({ kind: 'field', id: name })} selectedGroup={fieldSel?.kind === 'group' ? fieldSel.id : null} onSelectGroup={(key) => setFieldSel({ kind: 'group', id: key })} - onAddField={addField} + onAddField={canAddFields ? addField : undefined} readOnly={readOnly} modeControl={formModeControl} /> From edaebc8a4cf2f7073ffe75d7d025f49c8fdc9d1c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 16:44:07 +0000 Subject: [PATCH 4/4] test(studio): Data pillar external-object test reads the registered field inspector; changeset names the pillar Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2c5z3q4h8inrn5kwqV --- .changeset/27279-external-object-field-marks.md | 2 +- .../studio-design/DataPillar.externalObject-27279.test.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.changeset/27279-external-object-field-marks.md b/.changeset/27279-external-object-field-marks.md index ff17f6ad9b0..cec12cff9ca 100644 --- a/.changeset/27279-external-object-field-marks.md +++ b/.changeset/27279-external-object-field-marks.md @@ -11,5 +11,5 @@ Metadata-protocol: none 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. - **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. -- **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. Label, section, order and the other display settings stay editable. +- **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. - **Strings.** Six new `designer.external.*` rows in the `studio` namespace of `@object-ui/i18n`, in English and Chinese. diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx index 064675eb531..af69b83f77f 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.externalObject-27279.test.tsx @@ -73,6 +73,11 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { import { DataPillar } from './StudioDesignSurface'; import { t } from '../metadata-admin/i18n'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { ObjectFieldInspector } from '../metadata-admin/inspectors/ObjectFieldInspector'; + +// The rail holds the REAL registered field inspector, as the console registers it. +registerMetadataInspector('object', ObjectFieldInspector); const FORM = t('engine.studio.data.tab.form', 'en'); const ADD_FIELD = t('engine.studio.data.addField', 'en');