diff --git a/.changeset/27279-external-object-field-marks.md b/.changeset/27279-external-object-field-marks.md new file mode 100644 index 00000000000..cec12cff9ca --- /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. 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/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'; +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'); + +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} /> diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 43cbf44b66e..627eef52344 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -293,6 +293,15 @@ const STUDIO_EN: Record = { 'designer.canvas.removeSectionHint': 'Remove section (fields move to Ungrouped)', 'designer.canvas.renameHint': 'Double-click to rename', 'designer.canvas.sectionN': 'Section {n}', + // External (ADR-0015) objects: field source and write state + 'designer.external.from': 'From {datasource}', + 'designer.external.readOnly': 'Read-only', + 'designer.external.writable': 'Writable', + 'designer.external.unconfirmed': 'Writes unconfirmed', + 'designer.external.unconfirmedHint': + 'Writes need allowWrites on datasource {datasource} as well as writable on this object, and the datasource could not be read.', + 'designer.external.notice': + 'External object: its fields mirror a table in datasource {datasource}, so fields cannot be added, removed, renamed or retyped here.', // Field stubs (disabled runtime-preview placeholders) 'designer.stub.pickDate': 'Pick a date…', 'designer.stub.off': 'Off', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 41f8aac5d6a..426448cc84e 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -233,6 +233,13 @@ const STUDIO_ZH: Record = { 'designer.canvas.removeSectionHint': '删除分组(字段移至未分组)', 'designer.canvas.renameHint': '双击重命名', 'designer.canvas.sectionN': '分组 {n}', + // External (ADR-0015) objects: field source and write state + 'designer.external.from': '来自 {datasource}', + 'designer.external.readOnly': '只读', + 'designer.external.writable': '可写', + 'designer.external.unconfirmed': '写入未确认', + 'designer.external.unconfirmedHint': '写入需要数据源 {datasource} 开启 allowWrites,且本对象开启 writable;未能读取该数据源。', + 'designer.external.notice': '外部对象:字段映射数据源 {datasource} 中的表,不能在此添加、删除、重命名字段或更改字段类型。', // Field stubs (disabled runtime-preview placeholders) 'designer.stub.pickDate': '选择日期…', 'designer.stub.off': '关闭',