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
15 changes: 15 additions & 0 deletions .changeset/27279-external-object-field-marks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -1012,7 +1024,7 @@ export function ObjectFieldInspector({

const headerActions = (
<div className="flex items-center gap-1">
{!readOnly && (
{!ddlLocked && (
<Button
variant="ghost"
size="sm"
Expand All @@ -1033,7 +1045,7 @@ export function ObjectFieldInspector({
</div>
);

const footer = (
const footer = external ? undefined : (
<InspectorRemoveButton
label={tFormat('designer.field.remove', locale, {
label: typeof def.label === 'string' ? def.label : entry.name,
Expand All @@ -1058,11 +1070,16 @@ export function ObjectFieldInspector({
>
{/* Basic */}
<Section title={tr('designer.field.section.basic')}>
{external && (
<div>
<ExternalFieldBadge binding={external} state={externalWriteState} locale={locale} />
</div>
)}
<InspectorTextField
label={tr('designer.field.apiName')}
value={entry.name}
onCommit={setKey}
disabled={readOnly}
disabled={ddlLocked}
mono
testId="field-apiname-input"
/>
Expand Down Expand Up @@ -1105,7 +1122,7 @@ export function ObjectFieldInspector({
label={tr('designer.field.type')}
value={type}
onCommit={changeType}
disabled={readOnly}
disabled={ddlLocked}
locale={locale}
/>
<div className="flex items-center gap-4 pt-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ import {
type FieldTypeCategory,
} from './field-types.js';
import { FieldStub } from './FieldStub.js';
import {
ExternalFieldBadge,
ExternalObjectNotice,
readExternalBinding,
useExternalWriteState,
} from './external-object.js';
import { t, tFormat } from '../i18n.js';

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

{emptyState ? (
<EmptyCanvas onAdd={readOnly ? undefined : addField} locale={locale} aiEnabled={aiEnabled} />
<EmptyCanvas onAdd={canAddFields ? addField : undefined} locale={locale} aiEnabled={aiEnabled} />
) : (
<div className="space-y-5">
{groups.map((g) => {
Expand All @@ -493,7 +506,7 @@ export function ObjectFormCanvas({
onRename={g.key ? (label) => renameSection(g.key!, label) : undefined}
onRemove={g.key ? () => removeSection(g.key!) : undefined}
onMove={g.key ? (dir) => moveSection(g.key!, dir) : undefined}
onAddField={readOnly ? undefined : (type) => addField(type, g.key)}
onAddField={canAddFields ? (type) => addField(type, g.key) : undefined}
onDropField={readOnly ? undefined : moveToGroup}
>
{g.entries.map((entry) => (
Expand All @@ -504,6 +517,11 @@ export function ObjectFormCanvas({
multiSelected={multiSel.has(entry.name)}
diffStatus={statusOf(entry.name)}
changedKeys={changedKeysOf(entry.name)}
sourceBadge={
external ? (
<ExternalFieldBadge binding={external} state={writeState} locale={locale} />
) : undefined
}
readOnly={readOnly}
locale={locale}
onClick={(e) => handleRowClick(entry, e)}
Expand Down Expand Up @@ -538,7 +556,7 @@ export function ObjectFormCanvas({

{!emptyState && !readOnly && (
<div className="flex items-center gap-2 pt-1">
<AddFieldButton onPick={(type) => addField(type)} locale={locale} />
{canAddFields && <AddFieldButton onPick={(type) => addField(type)} locale={locale} />}
<Button
variant="ghost"
size="sm"
Expand Down Expand Up @@ -681,7 +699,8 @@ function BulkActionBar({
count: number;
groups: ObjectFieldGroup[];
onMoveToGroup: (groupKey: string | null) => void;
onDelete: () => void;
/** Absent on an external object: deleting a field would drop a remote column. */
onDelete?: () => void;
onClear: () => void;
locale?: string;
}) {
Expand Down Expand Up @@ -725,15 +744,17 @@ function BulkActionBar({
</PopoverContent>
</Popover>
)}
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 bg-background/70 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
{t('designer.canvas.bulkDelete', locale)}
</Button>
{onDelete && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 bg-background/70 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" />
{t('designer.canvas.bulkDelete', locale)}
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 gap-1.5" onClick={onClear}>
<X className="h-3.5 w-3.5" />
{t('designer.canvas.bulkClear', locale)}
Expand Down Expand Up @@ -1001,6 +1022,7 @@ function FieldRow({
multiSelected,
diffStatus,
changedKeys,
sourceBadge,
readOnly,
locale,
onClick,
Expand All @@ -1014,6 +1036,8 @@ function FieldRow({
/** Review mode: how this field differs from the published baseline. */
diffStatus?: FieldDiffStatus;
changedKeys?: string[];
/** ADR-0015 — where an external object's field comes from, and its write state. */
sourceBadge?: React.ReactNode;
readOnly: boolean;
locale?: string;
/** Receives the mouse event so the host can branch on Ctrl/⌘/Shift. */
Expand Down Expand Up @@ -1200,6 +1224,7 @@ function FieldRow({
{t('designer.canvas.diffChanged', locale)}
</Badge>
)}
{sourceBadge}
<Badge variant="outline" className={cn('text-[10px] font-medium', tone.badge)}>
{typeLabel(meta, locale) ?? typeStr}
</Badge>
Expand Down
Loading
Loading