From 45b5af70a30666c87e975c65db40ebdc2f12a074 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 00:26:21 +0000
Subject: [PATCH 1/5] feat(studio): add a field to a read-only base object as
an extension in its pair
On a base object of a read-only base, Studio's Add field writes the
customer's __c field into the pair's object_extension of that object,
and the field inspector edits a base field only within the spec's
display-property whitelist, written as the extension's fieldPatches.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jVPKQuoz3QHyQ76RnU
---
.../inspectors/ObjectFieldInspector.tsx | 106 +++--
.../views/studio-design/AddFieldPopover.tsx | 24 +-
.../studio-design/StudioCustomizeControls.tsx | 198 ++++++++++
.../studio-design/StudioDesignSurface.tsx | 362 +++++++++++++++---
ui/packages/i18n/src/namespaces/studio/en.ts | 4 +
ui/packages/i18n/src/namespaces/studio/zh.ts | 3 +
6 files changed, 617 insertions(+), 80 deletions(-)
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 d902058a724..5079425445c 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
@@ -49,6 +49,7 @@
import * as React from 'react';
import {
+ BASE_FIELD_DISPLAY_WHITELIST,
FieldSchema,
isAggregateCompatibleWithFieldType,
isBodyTableRelationship,
@@ -131,6 +132,7 @@ import { FilterConditionField } from './FilterConditionField.js';
import type { FilterCondition } from './datasetFilterCondition.js';
import { usePickerLoad, type LoadState } from '../loadState.js';
import { fieldNameTakenRefusal } from '../../studio-design/AddFieldPopover.js';
+import { BaseFieldDisplayPatchContext } from '../../studio-design/StudioCustomizeControls.js';
/**
@@ -689,6 +691,25 @@ function writePredicate(orig: unknown, next: string): unknown {
/* ─────────────── Inspector ─────────────── */
+/** The inspector label of each whitelisted field key; a key with none is named as it is spelled. */
+const DISPLAY_PATCH_KEY_LABEL: Readonly> = {
+ label: 'designer.field.label',
+ description: 'designer.field.description',
+ inlineHelpText: 'designer.field.helpText',
+ placeholder: 'designer.field.placeholder',
+};
+
+/** The sentence a base field's inspector reads under: the whitelist, as the inspector names its inputs. */
+function displayPatchSentence(locale: SupportedLocale): string {
+ const names: string[] = BASE_FIELD_DISPLAY_WHITELIST.field.map((key) =>
+ DISPLAY_PATCH_KEY_LABEL[key] ? t(DISPLAY_PATCH_KEY_LABEL[key], locale) : key,
+ );
+ if (BASE_FIELD_DISPLAY_WHITELIST.option.length > 0) names.push(t('designer.field.basePatchOnly.optionLabels', locale));
+ return tFormat('designer.field.basePatchOnly', locale, {
+ properties: names.join(t('designer.field.basePatchOnly.separator', locale)),
+ });
+}
+
/**
* The CEL editors this inspector mounts, as aggregation keys. Errors are
* counted PER SITE rather than into one running total: the four editors lint
@@ -799,7 +820,15 @@ export function ObjectFieldInspector({
// 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;
+ // objectstack#28575 (ADR-0146 D10) — a read-only base's own field, edited
+ // through its pair: only the spec's display-property whitelist
+ // (BASE_FIELD_DISPLAY_WHITELIST, read here, never copied) stays editable
+ // (`displayLocked`), every other control is `locked`, under one sentence.
+ const displayPatchOnly = React.useContext(BaseFieldDisplayPatchContext);
+ const locked = readOnly || displayPatchOnly;
+ const ddlLocked = locked || !!external;
+ const whitelisted = (key: string) => (BASE_FIELD_DISPLAY_WHITELIST.field as readonly string[]).includes(key);
+ const displayLocked = (key: string) => readOnly || (displayPatchOnly && !whitelisted(key));
/* ─── Blocking CEL verdicts → the host's Save gate (objectui#4306) ─────
*
@@ -1209,7 +1238,7 @@ export function ObjectFieldInspector({
index={idx}
total={view.entries.length}
onMove={moveTo}
- disabled={readOnly}
+ disabled={locked}
/>
);
@@ -1220,13 +1249,13 @@ export function ObjectFieldInspector({
label: typeof def.label === 'string' ? def.label : entry.name,
})}
onClick={removeField}
- disabled={readOnly}
+ disabled={locked}
/>
);
const typeMetaLabel = typeMeta ? t(`engine.fieldType.${typeMeta.id}`, locale) : undefined;
// objectui#11786 — read off the field as it stands, for the hints below.
- const heldNeed = readOnly ? null : guardHeldNeed(entry.name, def);
+ const heldNeed = locked ? null : guardHeldNeed(entry.name, def);
return (
{/* Basic */}
+ {displayPatchOnly && (
+
+ {displayPatchSentence(locale)}
+
+ )}
{external && (
@@ -1276,7 +1310,7 @@ export function ObjectFieldInspector({
onSelectionChange?.({ kind: 'field', id: derivedName, label: v });
}
}}
- disabled={readOnly}
+ disabled={displayLocked('label')}
testId="field-label-input"
/>
patchDef({ required: v || undefined })}
// Locked once it is on: the box can still be ticked on a stored
// master that lacks it, never cleared (objectui#12326).
- disabled={readOnly || (cbpMasterDetail && def.required === true)}
+ disabled={locked || (cbpMasterDetail && def.required === true)}
/>
patchDef({ unique: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
/>
{cbpMasterDetail && (
@@ -1319,7 +1353,7 @@ export function ObjectFieldInspector({
label={tr('designer.field.description')}
value={typeof def.description === 'string' ? (def.description as string) : ''}
onCommit={(v) => patchDef({ description: v || undefined })}
- disabled={readOnly}
+ disabled={displayLocked('description')}
rows={2}
/>
{defaultValueKind(type) && (
@@ -1328,7 +1362,7 @@ export function ObjectFieldInspector({
value={def.defaultValue}
options={offeredOptions}
onCommit={(v) => patchDef({ defaultValue: v })}
- disabled={readOnly}
+ disabled={locked}
locale={locale}
/>
)}
@@ -1336,7 +1370,7 @@ export function ObjectFieldInspector({
label={tr('designer.field.helpText')}
value={typeof def.inlineHelpText === 'string' ? (def.inlineHelpText as string) : ''}
onCommit={(v) => patchDef({ inlineHelpText: v || undefined })}
- disabled={readOnly}
+ disabled={displayLocked('inlineHelpText')}
rows={2}
placeholder={tr('designer.field.helpTextPlaceholder')}
/>
@@ -1350,7 +1384,7 @@ export function ObjectFieldInspector({
boundPicklist={typeof def.picklist === 'string' ? def.picklist : undefined}
roster={picklistRoster}
onCommit={bindPicklist}
- disabled={readOnly}
+ disabled={locked}
locale={locale}
/>
)}
@@ -1369,7 +1403,8 @@ export function ObjectFieldInspector({
onChange={patchOptions}
kindAllowed={kindAllowed}
drivesPath={drivesPath}
- disabled={readOnly}
+ disabled={locked}
+ labelsEditable={!readOnly && displayPatchOnly && BASE_FIELD_DISPLAY_WHITELIST.option.some((k) => k === 'label')}
locale={locale}
/>
)}
@@ -1383,7 +1418,7 @@ export function ObjectFieldInspector({
label={tr('designer.field.relatedObject')}
value={typeof def.reference === 'string' ? (def.reference as string) : ''}
onCommit={(v) => patchDef({ reference: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
placeholder={tr('designer.field.objectNamePlaceholder')}
locale={locale}
/>
@@ -1392,7 +1427,7 @@ export function ObjectFieldInspector({
def={def}
patchDef={patchDef}
hostFieldNames={view.entries.map((e) => e.name).filter((n) => n !== entry.name)}
- readOnly={readOnly}
+ readOnly={locked}
locale={locale}
/>
{(type === 'lookup' || type === 'master_detail') && (
@@ -1401,7 +1436,7 @@ export function ObjectFieldInspector({
patchDef={patchDef}
unsetDefKey={unsetDefKey}
ownFields={view.entries}
- readOnly={readOnly}
+ readOnly={locked}
locale={locale}
/>
)}
@@ -1434,7 +1469,7 @@ export function ObjectFieldInspector({
settleFormulaType({ errors: issues.filter((i) => i.severity === 'error').length });
}}
onInferredTypeChange={(inferred) => settleFormulaType({ typed: true, inferred })}
- disabled={readOnly}
+ disabled={locked}
placeholder="record.amount * 0.2"
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}
// A formula may reference every sibling, not itself (circular).
@@ -1456,7 +1491,7 @@ export function ObjectFieldInspector({
)}
@@ -1466,27 +1501,27 @@ export function ObjectFieldInspector({
label={tr('designer.field.precision')}
value={typeof def.precision === 'number' ? (def.precision as number) : undefined}
onCommit={(v) => patchDef({ precision: v })}
- disabled={readOnly}
+ disabled={locked}
/>
{offersScale(type) && (
patchDef({ scale: v })}
- disabled={readOnly}
+ disabled={locked}
/>
)}
patchDef({ min: v })}
- disabled={readOnly}
+ disabled={locked}
/>
patchDef({ max: v })}
- disabled={readOnly}
+ disabled={locked}
/>
)}
@@ -1496,7 +1531,7 @@ export function ObjectFieldInspector({
candidates={currencyCodeFieldCandidates(view.entries, name)}
onCommit={(next) => patchDef({ currencyConfig: next })}
onCreateCodeField={createCurrencyCodeField}
- disabled={readOnly}
+ disabled={locked}
tr={tr}
/>
)}
@@ -1506,14 +1541,14 @@ export function ObjectFieldInspector({
label={tr('designer.field.minLength')}
value={typeof def.minLength === 'number' ? (def.minLength as number) : undefined}
onCommit={(v) => patchDef({ minLength: v })}
- disabled={readOnly}
+ disabled={locked}
placeholder="0"
/>
patchDef({ maxLength: v })}
- disabled={readOnly}
+ disabled={locked}
placeholder="255"
/>
@@ -1531,7 +1566,7 @@ export function ObjectFieldInspector({
// a declaration the write path enforces — "no domain" is the
// key's absence, never a value of it (objectui#7597).
onCommit={(v) => (v ? patchDef({ valueDomain: v }) : unsetDefKey('valueDomain'))}
- disabled={readOnly}
+ disabled={locked}
/>
)}
@@ -1544,26 +1579,26 @@ export function ObjectFieldInspector({
label={tr('designer.field.readonly')}
value={!!def.readonly}
onCommit={(v) => patchDef({ readonly: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
/>
patchDef({ hidden: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
/>
patchDef({ trackHistory: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
/>
patchDef({ placeholder: v || undefined })}
- disabled={readOnly}
+ disabled={displayLocked('placeholder')}
/>
{/* Conditional rules (ADR-0036 B2) — CEL editors with live lint +
field autocomplete against THIS object's fields (objectui#1582). */}
@@ -1577,7 +1612,7 @@ export function ObjectFieldInspector({
value={readPredicate(def.visibleWhen)}
onChange={(v) => patchDef({ visibleWhen: writePredicate(def.visibleWhen, v) })}
onLintChange={(issues) => reportCel('visibleWhen', issues)}
- disabled={readOnly}
+ disabled={locked}
placeholder="record.status != 'draft'"
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}
fieldNames={view.entries.map((e) => e.name)}
@@ -1597,7 +1632,7 @@ export function ObjectFieldInspector({
value={readPredicate(def.readonlyWhen)}
onChange={(v) => patchDef({ readonlyWhen: writePredicate(def.readonlyWhen, v) })}
onLintChange={(issues) => reportCel('readonlyWhen', issues)}
- disabled={readOnly}
+ disabled={locked}
placeholder="record.status == 'closed'"
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}
fieldNames={view.entries.map((e) => e.name)}
@@ -1617,7 +1652,7 @@ export function ObjectFieldInspector({
value={readPredicate(def.requiredWhen)}
onChange={(v) => patchDef({ requiredWhen: writePredicate(def.requiredWhen, v) })}
onLintChange={(issues) => reportCel('requiredWhen', issues)}
- disabled={readOnly}
+ disabled={locked}
placeholder="record.amount > 10000"
objectName={typeof (draft as any).name === 'string' ? ((draft as any).name as string) : undefined}
fieldNames={view.entries.map((e) => e.name)}
@@ -1646,7 +1681,7 @@ export function ObjectFieldInspector({
.map((g) => ({ value: g.key as string, label: String(g.label ?? g.key) })),
]}
onCommit={(v) => patchDef({ group: v || undefined })}
- disabled={readOnly}
+ disabled={locked}
/>
)}
@@ -1869,6 +1904,7 @@ function OptionsEditor({
kindAllowed,
drivesPath,
disabled,
+ labelsEditable = false,
locale,
}: {
rows: OptionRow[];
@@ -1878,6 +1914,8 @@ function OptionsEditor({
/** The record path follows this field, so its options' kinds steer it. */
drivesPath: boolean;
disabled?: boolean;
+ /** objectstack#28575 — under `disabled`, each option's label stays editable (a base field's display patch). */
+ labelsEditable?: boolean;
locale?: string;
}) {
// Local editing buffer. We keep a blank trailing row visible for input but
@@ -2027,7 +2065,7 @@ function OptionsEditor({
onChange={(e) => updateLabel(i, e.target.value)}
placeholder={t('designer.field.optLabel', locale)}
aria-label={t('designer.field.optLabel', locale)}
- disabled={disabled}
+ disabled={disabled && !labelsEditable}
data-testid="option-label"
className="h-7 min-w-0 flex-1 text-xs"
/>
diff --git a/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx
index 80b0cce6108..53f0a814253 100644
--- a/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx
@@ -25,6 +25,11 @@
* its authoring context with no element to hand over): the host passes
* `open` / `onOpenChange`, `children` is the element it is placed against,
* and `returnFocusTo` names where focus goes back when it closes.
+ *
+ * objectstack#28575 (ADR-0146 D10) — on a read-only base's object the field
+ * is the customer's extension field (`extensionField`): its api name takes
+ * the spec's suffix (`region` → `region__c`), shown before Create, a typed
+ * name the same, and Create always hands the suffixed name.
*/
import * as React from 'react';
@@ -40,6 +45,7 @@ import type { FieldTypeId } from '../metadata-admin/previews/field-types.js';
import { FieldTypePicker } from '../metadata-admin/inspectors/FieldTypePicker.js';
import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../metadata-admin/i18n.js';
import { toFieldNameLoose } from '../metadata-admin/previews/object-fields-io.js';
+import { extensionFieldName } from './StudioCustomizeControls.js';
/**
* The refusal for an api name another field of the object already holds, or
@@ -76,6 +82,11 @@ export interface AddFieldPopoverProps {
returnFocusTo?: () => HTMLElement | null;
/** Which edge of `children` the popover lines up with. */
align?: 'start' | 'end';
+ /**
+ * objectstack#28575 — the field is a customer extension field of a base
+ * object: its api name is suffixed (`__c`), and Create hands that name.
+ */
+ extensionField?: boolean;
}
export function AddFieldPopover({
@@ -86,6 +97,7 @@ export function AddFieldPopover({
onOpenChange,
returnFocusTo,
align = 'end',
+ extensionField = false,
}: AddFieldPopoverProps): React.ReactElement {
const locale = useMetadataLocale();
const [openState, setOpenState] = React.useState(false);
@@ -111,18 +123,19 @@ export function AddFieldPopover({
};
const trimmed = label.trim();
- const derived = toFieldNameLoose(trimmed).replace(/_+$/, '');
+ const suffixed = (name: string) => (extensionField ? extensionFieldName(name) : name);
+ const derived = suffixed(toFieldNameLoose(trimmed).replace(/_+$/, ''));
const derivedTaken = !!derived && takenNames.includes(derived);
// The label alone cannot name the field: the author types the api name.
const asksName = !!trimmed && (!derived || derivedTaken);
- const typed = apiName.replace(/_+$/, '');
+ const typed = suffixed(apiName.replace(/_+$/, ''));
const typedRefusal = fieldNameTakenRefusal(typed, takenNames, locale);
const typedTaken = !!typedRefusal;
const canCreate = !!trimmed && (!asksName || (!!typed && !typedTaken));
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (!canCreate) return;
- onCreate(trimmed, type, asksName ? typed : undefined);
+ onCreate(trimmed, type, asksName ? typed : extensionField ? derived : undefined);
setOpen(false);
};
const nameHint = !asksName
@@ -203,6 +216,11 @@ export function AddFieldPopover({
{nameHint}
+ )}
)}
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
index 77f90a4464d..cbcd1694c00 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
@@ -49,9 +49,15 @@ import {
isOverridableMetadataType,
type OverrideDeclarationParsed,
} from '@objectstack/spec/kernel';
+import {
+ BASE_FIELD_DISPLAY_WHITELIST,
+ EXTENSION_FIELD_SUFFIX,
+ isExtensionFieldName,
+} from '@objectstack/spec/data';
import { Layers, Pencil, RotateCcw } from 'lucide-react';
import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js';
import { t, tFormat } from '../metadata-admin/i18n.js';
+import { readFields, writeFields, type FieldEntry } from '../metadata-admin/previews/object-fields-io.js';
/**
* The types Studio's Customize door covers: the Interfaces pillar's view,
@@ -364,3 +370,195 @@ export function StudioRestoreOriginalDialog({
);
}
+
+/* ───────────── Object extensions (objectstack#28575, ADR-0146 D10) ───────────── */
+
+/**
+ * The selected field is a read-only base package's own field, edited through
+ * the base's pair: the field inspector, which the registry mounts with the
+ * shared inspector props, keeps only the inputs of the spec's display-property
+ * whitelist editable. The Data pillar provides it around the inspector.
+ */
+export const BaseFieldDisplayPatchContext = React.createContext(false);
+
+/**
+ * The metadata type a base object's extension is written as: one row per
+ * package per target object, filed under the target's name, its body
+ * `ObjectExtensionSchema` (`extend`, `fields`, `fieldPatches`, …).
+ */
+export const OBJECT_EXTENSION_TYPE = 'object_extension';
+
+/** The pair's extension of one base object: `extend` names the object. */
+export type ObjectExtensionBody = Record & { extend: string };
+
+const isPlainRecord = (v: unknown): v is Record =>
+ v !== null && typeof v === 'object' && !Array.isArray(v);
+
+/**
+ * `name` spelled as a customer extension field: the spec's suffix appended
+ * once (`region` → `region__c`, `region__c` unchanged). Empty stays empty.
+ */
+export function extensionFieldName(name: string): string {
+ if (!name) return name;
+ return isExtensionFieldName(name) ? name : `${name}${EXTENSION_FIELD_SUFFIX}`;
+}
+
+/**
+ * The extension body a read served for `objectName`: the document without
+ * its `_` keys (the envelope a read stamps, which the strict schema refuses),
+ * `extend` set to the object. `null` when the read served no document.
+ */
+export function objectExtensionBodyOf(doc: unknown, objectName: string): ObjectExtensionBody | null {
+ if (!isPlainRecord(doc)) return null;
+ const body: Record = {};
+ for (const [key, value] of Object.entries(doc)) {
+ if (!key.startsWith('_')) body[key] = value;
+ }
+ return { ...body, extend: objectName };
+}
+
+/** The names `ext.fields` declares. */
+export function extensionFieldNames(ext: Record | null | undefined): string[] {
+ return ext && isPlainRecord(ext.fields) ? Object.keys(ext.fields) : [];
+}
+
+/** `def` with one field patch applied: the whitelisted keys, and option labels by stored value. */
+function applyFieldPatch(def: Record, patch: Record): Record {
+ const out: Record = { ...def };
+ for (const key of BASE_FIELD_DISPLAY_WHITELIST.field) {
+ if (patch[key] !== undefined) out[key] = patch[key];
+ }
+ const optionPatches = patch.options;
+ if (isPlainRecord(optionPatches) && Array.isArray(def.options)) {
+ out.options = def.options.map((option) => {
+ if (!isPlainRecord(option) || typeof option.value !== 'string') return option;
+ const optionPatch = optionPatches[option.value];
+ if (!isPlainRecord(optionPatch)) return option;
+ const next: Record = { ...option };
+ for (const key of BASE_FIELD_DISPLAY_WHITELIST.option) {
+ if (optionPatch[key] !== undefined) next[key] = optionPatch[key];
+ }
+ return next;
+ });
+ }
+ return out;
+}
+
+/**
+ * The object the Data pillar shows for a base object with the pair's
+ * extension of it: the object as served (which already holds the pair's
+ * PUBLISHED extension, merged by the registry), with the extension's
+ * `fieldPatches` applied to the fields they name and its `fields` laid over
+ * by name, the extension winning, so a published field the extension still
+ * declares shows once. A field the published extension declared and the
+ * extension no longer does (`published`) is left out: the extension
+ * removed it.
+ */
+export function foldObjectExtension(
+ object: Record,
+ ext: Record | null,
+ published: ReadonlySet = new Set(),
+): Record {
+ if (!ext) return object;
+ const view = readFields(object.fields);
+ const own = isPlainRecord(ext.fields) ? ext.fields : {};
+ const patches = isPlainRecord(ext.fieldPatches) ? ext.fieldPatches : {};
+ const entries: FieldEntry[] = [];
+ for (const entry of view.entries) {
+ if (published.has(entry.name) && !Object.prototype.hasOwnProperty.call(own, entry.name)) continue;
+ const patch = patches[entry.name];
+ entries.push(isPlainRecord(patch) ? { name: entry.name, def: applyFieldPatch(entry.def, patch) } : entry);
+ }
+ for (const [name, def] of Object.entries(own)) {
+ if (!isPlainRecord(def)) continue;
+ const at = entries.findIndex((e) => e.name === name);
+ if (at >= 0) entries[at] = { name, def };
+ else entries.push({ name, def });
+ }
+ return { ...object, fields: writeFields({ shape: view.shape, entries }) };
+}
+
+/**
+ * The base object's own fields: the names the served object holds that are
+ * neither the extension's nor the published extension's.
+ */
+export function baseOwnedFieldNames(
+ object: Record,
+ ext: Record | null,
+ published: ReadonlySet = new Set(),
+): Set {
+ const ours = new Set([...extensionFieldNames(ext), ...published]);
+ return new Set(readFields(object.fields).entries.map((e) => e.name).filter((n) => !ours.has(n)));
+}
+
+const optionLabels = (options: unknown): Map => {
+ const out = new Map();
+ if (!Array.isArray(options)) return out;
+ for (const option of options) {
+ if (isPlainRecord(option) && typeof option.value === 'string') out.set(option.value, option.label);
+ }
+ return out;
+};
+
+/**
+ * The extension after an edit of the folded object's fields (`before` →
+ * `after`, both as the folded object holds them): every field that is not
+ * the base's is the extension's, declared whole in `fields`, in the edited
+ * order; on a base field, only a change to a whitelisted property
+ * ({@link BASE_FIELD_DISPLAY_WHITELIST}, option labels by stored value) is
+ * written, into its `fieldPatches` entry, and a property cleared drops from
+ * the patch, so the base's own text shows again. Any other change to a base
+ * field is not the extension's to make, and is not written.
+ */
+export function routeFoldedFieldsEdit(input: {
+ before: unknown;
+ after: unknown;
+ baseOwned: ReadonlySet;
+ ext: ObjectExtensionBody;
+}): ObjectExtensionBody {
+ const { before, after, baseOwned, ext } = input;
+ const was = new Map(readFields(before).entries.map((e) => [e.name, e.def]));
+ const fields: Record = {};
+ const patches: Record> = {};
+ if (isPlainRecord(ext.fieldPatches)) {
+ for (const [name, patch] of Object.entries(ext.fieldPatches)) {
+ if (isPlainRecord(patch)) patches[name] = { ...patch };
+ }
+ }
+ for (const entry of readFields(after).entries) {
+ if (!baseOwned.has(entry.name)) {
+ Object.defineProperty(fields, entry.name, { value: entry.def, enumerable: true, writable: true, configurable: true });
+ continue;
+ }
+ const prev = was.get(entry.name);
+ if (!prev) continue;
+ const patch: Record = { ...(patches[entry.name] ?? {}) };
+ for (const key of BASE_FIELD_DISPLAY_WHITELIST.field) {
+ const next = entry.def[key];
+ if (next === prev[key]) continue;
+ if (next === undefined || next === '') delete patch[key];
+ else patch[key] = next;
+ }
+ const prevLabels = optionLabels(prev.options);
+ const nextLabels = optionLabels(entry.def.options);
+ const optionPatch: Record = isPlainRecord(patch.options) ? { ...patch.options } : {};
+ for (const [value, label] of nextLabels) {
+ if (!prevLabels.has(value) || prevLabels.get(value) === label) continue;
+ const entryPatch: Record = isPlainRecord(optionPatch[value]) ? { ...optionPatch[value] } : {};
+ if (typeof label === 'string' && label !== '') entryPatch.label = label;
+ else delete entryPatch.label;
+ if (Object.keys(entryPatch).length > 0) optionPatch[value] = entryPatch;
+ else delete optionPatch[value];
+ }
+ if (Object.keys(optionPatch).length > 0) patch.options = optionPatch;
+ else delete patch.options;
+ if (Object.keys(patch).length > 0) patches[entry.name] = patch;
+ else delete patches[entry.name];
+ }
+ const out: ObjectExtensionBody = { ...ext };
+ delete out.fields;
+ delete out.fieldPatches;
+ if (Object.keys(fields).length > 0) out.fields = fields;
+ if (Object.keys(patches).length > 0) out.fieldPatches = patches;
+ return out;
+}
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 5b5010618d5..9d73ca66b4f 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -417,8 +417,17 @@ import {
overrideDraftBody,
useCustomizeDoorWalled,
useServedOverride,
+ OBJECT_EXTENSION_TYPE,
+ baseOwnedFieldNames,
+ extensionFieldName,
+ extensionFieldNames,
+ foldObjectExtension,
+ objectExtensionBodyOf,
+ routeFoldedFieldsEdit,
type CustomizeBlock,
+ type ObjectExtensionBody,
type ServedOverride,
+ BaseFieldDisplayPatchContext,
} from './StudioCustomizeControls.js';
import {
DraftChangesPanel,
@@ -7083,6 +7092,26 @@ function addedFieldName(label: string, existing: string[]): string {
return derived && !existing.includes(derived) ? derived : nextFieldName(existing);
}
+/**
+ * objectstack#28575 — {@link addedFieldName} for a customer extension field:
+ * the name the Add field popover shows, suffixed (`region__c`), or the next
+ * free `field___c`.
+ */
+function addedExtensionFieldName(label: string, existing: string[]): string {
+ const derived = extensionFieldName(toFieldNameLoose(label).replace(/_+$/, ''));
+ if (derived && !existing.includes(derived)) return derived;
+ let i = existing.length + 1;
+ let name = extensionFieldName(`field_${i}`);
+ while (existing.includes(name)) name = extensionFieldName(`field_${++i}`);
+ return name;
+}
+
+/** objectstack#28575 — an extension body's `fields` map; `{}` when it declares none. */
+function extensionFieldsOf(body: ObjectExtensionBody): Record {
+ const fields = body.fields;
+ return fields && typeof fields === 'object' && !Array.isArray(fields) ? (fields as Record) : {};
+}
+
/**
* Data pillar — the package's objects: a records grid (Airtable parity) plus
* table-based field management. Add a field, or click a column header's edit
@@ -7374,6 +7403,37 @@ export function DataPillar({
const requestedSurface = useRequestedSurface();
const appliedRequestRef = React.useRef(0);
const [objDraft, setObjDraft] = React.useState>({});
+ // objectstack#28575 (ADR-0146 D10) — a base object of a read-only base takes
+ // fields as an extension in the base's pair, never in its own buffer: the
+ // pair's `object_extension` of it (`extend` = the object), filed under the
+ // object's name. The base object stays as shipped; `objDraft` holds it as
+ // served (which already folds the pair's PUBLISHED extension), and what the
+ // pillar shows is `shownDraft`, the two folded. `published` names the
+ // fields the published extension declares, so one the draft drops is gone.
+ const extendsBase = packageReadOnly && customization !== null && current !== null && !current.addedIn;
+ const [ext, setExt] = React.useState<{
+ for: string;
+ body: ObjectExtensionBody;
+ published: ReadonlySet;
+ } | null>(null);
+ const [extDirty, setExtDirty] = React.useState(false);
+ const [extHasDraft, setExtHasDraft] = React.useState(false);
+ const [extReloadNonce, setExtReloadNonce] = React.useState(0);
+ const extLoadedRef = React.useRef(null);
+ const extOpen = extendsBase && ext !== null && ext.for === current?.name ? ext : null;
+ // Adding a field asks the ensure door for the pair once per base: its
+ // answer names every write, ⛔ never a `.custom` spelled here.
+ const extWalled = customization?.walled === true;
+ const extWritable = extOpen !== null && !extWalled;
+ const shownDraft = React.useMemo(
+ () => (extOpen ? foldObjectExtension(objDraft, extOpen.body, extOpen.published) : objDraft),
+ [objDraft, extOpen],
+ );
+ // The served object's own fields: the ones a base-field patch addresses.
+ const baseOwnedFields = React.useMemo(
+ () => (extOpen ? baseOwnedFieldNames(objDraft, extOpen.body, extOpen.published) : null),
+ [objDraft, extOpen],
+ );
// 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
@@ -7417,7 +7477,7 @@ export function DataPillar({
// and the open inspector's live verdict always wins.
const [celVerdicts, setCelVerdicts] = React.useState>({});
const liveFieldReport = fieldSel?.kind === 'field' && blockingReport.key === fieldSelKey;
- const openFieldStamp = fieldSel?.kind === 'field' ? fieldCelStamp(objDraft, fieldSel.id) : null;
+ const openFieldStamp = fieldSel?.kind === 'field' ? fieldCelStamp(shownDraft, fieldSel.id) : null;
React.useEffect(() => {
if (!liveFieldReport || fieldSel === null) return;
const kept = { field: fieldSel.id, count: blockingReport.count, stamp: openFieldStamp };
@@ -7433,7 +7493,7 @@ export function DataPillar({
const celFieldPrefix = `${current?.name ?? ''}:field:`;
for (const [key, kept] of Object.entries(celVerdicts)) {
if (!key.startsWith(celFieldPrefix) || key === fieldSelKey) continue;
- if (kept.count > 0 && kept.stamp === fieldCelStamp(objDraft, kept.field)) {
+ if (kept.count > 0 && kept.stamp === fieldCelStamp(shownDraft, kept.field)) {
celHeldFields.push(kept.field);
inspectorBlocking += kept.count;
}
@@ -7640,6 +7700,20 @@ export function DataPillar({
recordRead: recordObjRead,
dialog: objConflictDialog,
} = useDraftSaveGuard(client, reloadObjDraft);
+ // objectstack#28575 — the extension buffer's own guard, pinned by its draft read at the pair.
+ const reloadExt = React.useCallback(() => setExtReloadNonce((n) => n + 1), []);
+ const {
+ save: saveExtDraft,
+ recordRead: recordExtRead,
+ dialog: extConflictDialog,
+ } = useDraftSaveGuard(client, reloadExt);
+ // The pair a READ names is the surface's (`StudioBaseCustomization.pairId`),
+ // read through this ref, so the pair a first add creates never reloads the
+ // buffer it is about to save.
+ const extReadPairRef = React.useRef(customization?.pairId ?? null);
+ React.useLayoutEffect(() => {
+ extReadPairRef.current = customization?.pairId ?? null;
+ });
// Left-rail search + inline "new object" creator (design §4: rail = search + New).
const [query, setQuery] = React.useState('');
const [creating, setCreating] = React.useState(false);
@@ -7905,13 +7979,62 @@ export function DataPillar({
};
}, [client, current, packageId, publishNonce, objReloadNonce, recordObjRead]);
+ // objectstack#28575 — the pair's extension of the open base object: its
+ // pending draft at the pair, else its published row there, else a new
+ // `{ extend }`. Loaded once per object, and again after a publish. A draft
+ // starts from the published body, so a publish never drops a field the
+ // published extension already declares.
+ React.useEffect(() => {
+ if (!extendsBase || !current) {
+ extLoadedRef.current = null;
+ return;
+ }
+ const name = current.name;
+ const loadKey = `${name}#${publishNonce}#${extReloadNonce}`;
+ if (extLoadedRef.current === loadKey) return;
+ extLoadedRef.current = loadKey;
+ let cancelled = false;
+ let settled = false;
+ setExt(null);
+ setExtDirty(false);
+ setExtHasDraft(false);
+ const pair = extReadPairRef.current;
+ (async () => {
+ try {
+ let draft: ObjectExtensionBody | null = null;
+ let published: ObjectExtensionBody | null = null;
+ if (pair) {
+ const [draftRead, publishedRow] = await Promise.all([
+ readDraftForSave>(client, OBJECT_EXTENSION_TYPE, name, pair),
+ client.get>(OBJECT_EXTENSION_TYPE, name, { packageId: pair }),
+ ]);
+ if (cancelled) return;
+ draft = objectExtensionBodyOf(extractDraftBody(draftRead.answer), name);
+ published = objectExtensionBodyOf(publishedRow, name);
+ recordExtRead(OBJECT_EXTENSION_TYPE, name, pair, draftRead.pin);
+ }
+ setExt({ for: name, body: draft ?? published ?? { extend: name }, published: new Set(extensionFieldNames(published)) });
+ setExtHasDraft(draft !== null);
+ } catch (e) {
+ if (!cancelled) setError(plainRefusal(e));
+ } finally {
+ settled = true;
+ }
+ })();
+ return () => {
+ cancelled = true;
+ // A load cancelled before it settled takes its claim back (see the object load above).
+ if (!settled) extLoadedRef.current = null;
+ };
+ }, [client, current, extendsBase, publishNonce, extReloadNonce, recordExtRead]);
+
// The header counts the fields the canvas shows, and names the platform's
// own columns apart: "2 fields (+8 system)".
const fieldCount = React.useMemo(() => {
- const all = readFields(objDraft.fields).entries.length;
- const own = formFieldNames(objDraft.fields).length;
+ const all = readFields(shownDraft.fields).entries.length;
+ const own = formFieldNames(shownDraft.fields).length;
return { own, system: all - own };
- }, [objDraft]);
+ }, [shownDraft]);
const fieldCountText =
tFormat(fieldCount.own === 1 ? 'engine.studio.data.fieldCountOne' : 'engine.studio.data.fieldCount', locale, {
count: fieldCount.own,
@@ -7946,9 +8069,9 @@ export function DataPillar({
* a PRODUCER-side fix; ListView's by-identity dependency is correct for a genuine
* column change and is left alone.
*/
- const gridColumns = React.useMemo(
+ const gridColumnsKey = React.useMemo(
() =>
- readFields(objDraft.fields)
+ JSON.stringify(readFields(shownDraft.fields)
// objectui#11780 — a column the platform injects AND hides (`__search`,
// `owning_business_unit_id`) is no column an author manages. Read off
// the definition this memo already holds, so the key stays
@@ -7968,9 +8091,13 @@ export function DataPillar({
// what the grid asks for. The new field is still selected in the
// inspector, which is where it gets configured; it joins the grid once
// it is published and therefore queryable.
- .filter((n) => publishedFieldNames.has(n)),
- [objDraft.fields, publishedFieldNames],
+ .filter((n) => publishedFieldNames.has(n))),
+ [shownDraft.fields, publishedFieldNames],
);
+ // objectstack#28575 — keyed on the column NAMES: the folded object
+ // (`shownDraft`) is rebuilt on every extension edit, and a label typed into
+ // an extension field must not re-key this fetch input.
+ const gridColumns = React.useMemo(() => JSON.parse(gridColumnsKey) as string[], [gridColumnsKey]);
/**
* The design-mode FORM's fields: the object's own fields, dropping
@@ -8001,15 +8128,17 @@ export function DataPillar({
* `fields` identical, so the looser key would churn (and re-run the form's
* field-generation effect) on every unrelated draft edit (icon, label).
*/
- const formFields = React.useMemo(() => formFieldNames(objDraft.fields), [objDraft.fields]);
+ // objectstack#28575 — keyed on the names, as `gridColumns` is.
+ const formFieldsKey = React.useMemo(() => JSON.stringify(formFieldNames(shownDraft.fields)), [shownDraft.fields]);
+ const formFields = React.useMemo(() => JSON.parse(formFieldsKey) as string[], [formFieldsKey]);
// objectui#12515 — what the Form tab's Preview renders. The draft differs
// from the published definition when there is no published one, a pending
// draft, or an unsaved edit; then Preview shows the draft unless the author
// picked Published (offered only when a published version exists).
- const draftDiffers = !hasBaseline || hasDraft || dirty;
+ const draftDiffers = !hasBaseline || hasDraft || dirty || (extOpen !== null && (extHasDraft || extDirty));
const previewShowsDraft = !hasBaseline || (draftDiffers && previewSource === 'draft');
- const previewDraft = previewShowsDraft ? objDraft : null;
+ const previewDraft = previewShowsDraft ? shownDraft : null;
const previewObjectName = current?.name ?? '';
const previewDataSource = React.useMemo(
() =>
@@ -8147,6 +8276,73 @@ export function DataPillar({
snapshot: objDraft,
save: doSave,
});
+
+ // objectstack#28575 — the ensure door's answer for this base's extension
+ // writes: asked by the first add (no prompt), once per base, and again only
+ // after a refusal.
+ const extPairRef = React.useRef<{ base: string; asked: Promise } | null>(null);
+ const extPairFor = React.useCallback((custom: StudioBaseCustomization): Promise => {
+ const held = extPairRef.current;
+ if (held && held.base === custom.base) return held.asked;
+ const asked = custom.ensure();
+ extPairRef.current = { base: custom.base, asked };
+ asked.catch(() => {
+ if (extPairRef.current?.asked === asked) extPairRef.current = null;
+ });
+ return asked;
+ }, []);
+ // The extension's draft save, into the pair the door answered. A refusal
+ // (the suffix, a structural key, the ensure door's walled 403) is shown as
+ // the server sent it.
+ const doSaveExt = React.useCallback(
+ async (sent: DraftSend) => {
+ if (!extOpen || !customization) return;
+ const { for: name, body } = extOpen;
+ setSaving('draft');
+ setError(null);
+ try {
+ const pair = await extPairFor(customization);
+ const outcome = await saveExtDraft(OBJECT_EXTENSION_TYPE, name, body, {
+ mode: 'draft',
+ packageId: pair,
+ surface: 'studio',
+ });
+ if (outcome === 'reloaded') return;
+ setExtHasDraft(true);
+ if (sent.unmoved()) setExtDirty(false);
+ setSavedAt(new Date());
+ onDraftSaved?.({ type: OBJECT_EXTENSION_TYPE, name });
+ } catch (e) {
+ setError(plainRefusal(e));
+ } finally {
+ setSaving(false);
+ }
+ },
+ [extOpen, customization, extPairFor, saveExtDraft, onDraftSaved],
+ );
+ const { flush: flushExtSave } = useDraftAutoSave({
+ target: `${OBJECT_EXTENSION_TYPE}:${current?.name ?? ''}`,
+ loadedFor: extOpen ? `${OBJECT_EXTENSION_TYPE}:${extOpen.for}` : '',
+ dirty: extDirty,
+ blocked: !extWritable || !!saving || saveBlocking > 0,
+ snapshot: extOpen?.body ?? null,
+ save: doSaveExt,
+ });
+ // An edit of the folded object's fields, written into the extension: an
+ // extension field whole, a base field's whitelisted change as its patch.
+ const onExtFieldsPatch = React.useCallback(
+ (patch: Record) => {
+ if (!extWritable || !('fields' in patch)) return;
+ setExt((prev) => {
+ if (!prev) return prev;
+ const before = foldObjectExtension(objDraft, prev.body, prev.published).fields;
+ const baseOwned = baseOwnedFieldNames(objDraft, prev.body, prev.published);
+ return { ...prev, body: routeFoldedFieldsEdit({ before, after: patch.fields, baseOwned, ext: prev.body }) };
+ });
+ setExtDirty(true);
+ },
+ [extWritable, objDraft],
+ );
// objectstack#27485 — an unsent edit the CEL verdict holds is named like an
// incomplete one: the held line, the surface's Publish and the rail's
// question all read it, so it outlives the inspector without going unseen.
@@ -8220,26 +8416,28 @@ export function DataPillar({
setCurrent(next);
return true;
}
- if (saving || (dirty && objHeld === null && flushObjSave())) {
+ // objectstack#28575 — and the base object's extension buffer the same way.
+ if (saving || (dirty && objHeld === null && flushObjSave()) || (extDirty && flushExtSave())) {
setSwitchTo(next);
return true;
}
- if (!confirmDropUnsavedWork(dirty, locale)) return false;
+ if (!confirmDropUnsavedWork(dirty || extDirty, locale)) return false;
setSwitchTo(null);
setCurrent(next);
return true;
},
- [current, saving, dirty, objHeld, flushObjSave, locale],
+ [current, saving, dirty, extDirty, objHeld, flushObjSave, flushExtSave, locale],
);
React.useEffect(() => {
if (!switchTo || saving) return;
if (dirty && objHeld === null && flushObjSave()) return;
+ if (extDirty && flushExtSave()) return;
setSwitchTo(null);
// Still dirty: the save was refused (on screen) or an edit taken meanwhile
// is held. The author stays; the next switch asks.
- if (dirty) return;
+ if (dirty || extDirty) return;
setCurrent(switchTo);
- }, [switchTo, saving, dirty, objHeld, flushObjSave]);
+ }, [switchTo, saving, dirty, extDirty, objHeld, flushObjSave, flushExtSave]);
// The live request's apply (see `requestedSurface` above). objectstack#26845
// — another object's load replaces the buffer a held edit lives in, so it
// goes through the rail's own switch (`openObject`): an unsent edit is sent
@@ -8275,7 +8473,26 @@ 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 || objIsExternal) return;
+ if (!objLoaded || objIsExternal) return;
+ // objectstack#28575 (ADR-0146 D10) — on a read-only base's object the
+ // field is the customer's own, `__c`, declared in the pair's extension of
+ // it; the base object's buffer is never touched. Create asks the ensure
+ // door for the pair (no prompt); the extension's autosave writes there.
+ if (extendsBase) {
+ if (!extWritable || !customization) return;
+ const existing = readFields(shownDraft.fields).entries.map((e) => e.name);
+ const asked = typedName ? extensionFieldName(typedName) : '';
+ const name = asked && !existing.includes(asked) ? asked : addedExtensionFieldName(label, existing);
+ const { def } = newField(name, type, label);
+ setExt((prev) =>
+ prev ? { ...prev, body: { ...prev.body, fields: { ...extensionFieldsOf(prev.body), [name]: def } } } : prev,
+ );
+ setExtDirty(true);
+ setFieldSel({ kind: 'field', id: name });
+ extPairFor(customization).catch((e: unknown) => setError(plainRefusal(e)));
+ return;
+ }
+ if (readOnly) return;
const view = readFields(objDraft.fields);
const existing = view.entries.map((e) => e.name);
const name = typedName && !existing.includes(typedName) ? typedName : addedFieldName(label, existing);
@@ -8283,11 +8500,13 @@ export function DataPillar({
setObjDraft((d) => ({ ...d, fields: writeFields(view) }));
setDirty(true);
setFieldSel({ kind: 'field', id: name });
- }, [objDraft, readOnly, objLoaded, objIsExternal]);
- const canAddFields = !readOnly && !objIsExternal;
+ }, [objDraft, readOnly, objLoaded, objIsExternal, extendsBase, extWritable, customization, shownDraft, extPairFor]);
+ // objectstack#28575 — a read-only base's object takes fields into its pair,
+ // unless the ensure door is walled for this caller (drawn disabled below).
+ const canAddFields = !objIsExternal && (extendsBase ? extWritable : !readOnly);
const addFieldTakenNames = React.useMemo(
- () => readFields(objDraft.fields).entries.map((e) => e.name),
- [objDraft.fields],
+ () => readFields(shownDraft.fields).entries.map((e) => e.name),
+ [shownDraft.fields],
);
// objectui#12516 — the Records grid's "+" column opens the same popover. The
@@ -8344,6 +8563,13 @@ export function DataPillar({
// Observed (objectui#11939): an object inspector registered after this pillar
// rendered fills the field rail below without a remount.
const inspector = useRegisteredMetadataInspector('object');
+ // objectstack#28575 — on a base object the field rail is writable (into the
+ // pair's extension) for a field, never for a group; a base field opens in
+ // the inspector's display-property mode.
+ const fieldRailReadOnly = readOnly && !(extWritable && fieldSel?.kind === 'field');
+ const selectedBaseField =
+ extWritable && fieldSel?.kind === 'field' && baseOwnedFields !== null && baseOwnedFields.has(String(fieldSel.id));
+ const addFieldWalledId = React.useId();
// objectui#12725 — the field rail is up exactly when the render below draws
// it; that is when the pillar holds the slot's properties half. A selection
@@ -8437,6 +8663,26 @@ export function DataPillar({
);
+ // objectstack#28575 — on a base object the Form toolbar's Add field sits
+ // beside the Layout / Preview control: the designer, read-only on a base
+ // object, draws none, and the field goes into the pair's extension.
+ const formToolbarLeading =
+ extendsBase && canAddFields ? (
+
+ {formModeControl}
+
+
+
+
+ ) : (
+ formModeControl
+ );
// The selected object's own icon (from its metadata) — prefer the loaded
// draft body, fall back to the rail header. getIcon degrades to Database.
@@ -8506,6 +8752,7 @@ export function DataPillar({
return (
{objConflictDialog}
+ {extConflictDialog}
{isMobile && railOpen && (
{t('engine.studio.autoSaving', locale)}
- ) : savedAt && !dirty ? (
+ ) : savedAt && !dirty && !extDirty ? (
{tFormat('engine.studio.data.lastSaved', locale, {
time: savedAt.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }),
@@ -8634,6 +8881,29 @@ export function DataPillar({
action left to put in one. */}
{/* objectui#12453 — on the Form tab it carries the layout's
read-only sentence the Layout control no longer does. */}
+ {/* objectstack#28575 — the ensure door refuses this caller: Add
+ field draws disabled, in the console's own sentence. */}
+ {extendsBase && extWalled && objLoaded && !objIsExternal && (viewMode === 'grid' || viewMode === 'form') && (
+
+
+
+ {t('engine.studio.customize.walled', locale)}
+
+
+ )}
{readOnly && (
gridAddFieldButton.current}
+ extensionField={extendsBase}
>
@@ -8825,7 +9096,7 @@ export function DataPillar({
: {}),
onEditColumn: (fieldName) => {
// ignore non-field columns (e.g. the row-actions column)
- if (readFields(objDraft.fields).entries.some((e) => e.name === fieldName)) {
+ if (readFields(shownDraft.fields).entries.some((e) => e.name === fieldName)) {
setFieldSel({ kind: 'field', id: fieldName });
}
},
@@ -8876,7 +9147,7 @@ export function DataPillar({
to trail it is each item's title. */}
{formMode === 'layout' ? (
setFieldSel({ kind: 'group', id: key })}
onAddField={canAddFields ? addField : undefined}
readOnly={readOnly}
- modeControl={formModeControl}
+ modeControl={formToolbarLeading}
/>
) : (
<>
{
const el = (e.target as HTMLElement).closest('[data-field]');
const name = el?.getAttribute('data-field');
- if (name && readFields(objDraft.fields).entries.some((f) => f.name === name)) {
+ if (name && readFields(shownDraft.fields).entries.some((f) => f.name === name)) {
setFieldSel({ kind: 'field', id: name });
} else if (!el && inSlot) {
// objectui#12725 — the space between the fields is empty canvas.
@@ -9037,7 +9308,7 @@ export function DataPillar({
// objectui#12725 — the copilot's 草稿 chip on a field it changed.
actions={fieldAgentMark ? : undefined}
// objectui#12453 — a read-only package saves no draft: no footer.
- footer={readOnly ? null : t('engine.studio.inspector.draftFooter', locale)}
+ footer={fieldRailReadOnly ? null : t('engine.studio.inspector.draftFooter', locale)}
bodyClassName="flex min-h-0 flex-1 flex-col"
>
@@ -9053,19 +9324,24 @@ export function DataPillar({
locale={locale}
/>
) : inspector ? (
- React.createElement(inspector, {
- type: 'object',
- name: current.name,
- draft: objDraft,
- selection: fieldSel,
- onPatch,
- onClearSelection: () => setFieldSel(null),
- onSelectionChange: setFieldSel,
- onBlockingIssuesChange: (count: number) =>
- setBlockingReport({ key: fieldSelKey, count }),
- readOnly,
- locale,
- })
+ // objectstack#28575 — on a base object the inspector edits
+ // the pair's extension: its own fields whole, a base field
+ // within the display-property whitelist only.
+
+ {React.createElement(inspector, {
+ type: 'object',
+ name: current.name,
+ draft: shownDraft,
+ selection: fieldSel,
+ onPatch: extOpen ? onExtFieldsPatch : onPatch,
+ onClearSelection: () => setFieldSel(null),
+ onSelectionChange: setFieldSel,
+ onBlockingIssuesChange: (count: number) =>
+ setBlockingReport({ key: fieldSelKey, count }),
+ readOnly: fieldRailReadOnly,
+ locale,
+ })}
+
) : (
/* No field inspector registered. ⛔ Not "loading…": the read
* is observed (objectui#11939), so a late registration does
@@ -9085,7 +9361,7 @@ export function DataPillar({
) : (
fieldRailContent
);
- if (!readOnly) return
{railContent}
;
+ if (!fieldRailReadOnly) return
{railContent}
;
// objectui#12303 — on a read-only package every input and the
// Remove button in this rail are disabled for one reason, the
// package's. It rides the record page's Archive pattern
diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts
index 8c4de9bdf49..f238be8fc77 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -349,6 +349,10 @@ const STUDIO_EN: Record = {
'designer.field.hidden': 'Hidden',
'designer.field.trackHistory': 'Track history',
'designer.field.placeholder': 'Placeholder',
+ // objectstack#28575 — a read-only base's own field: only the spec's display-property whitelist is editable.
+ 'designer.field.basePatchOnly': 'This field belongs to the installed package: you can change only its {properties}, saved as your customization, and everything else stays as the package defines it.',
+ 'designer.field.basePatchOnly.optionLabels': 'Option labels',
+ 'designer.field.basePatchOnly.separator': ', ',
'designer.field.group': 'Group',
'designer.field.noGroup': '— No group —',
'designer.field.picklistValues': 'Picklist values',
diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts
index e2eacc1902d..9e1e9b15730 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -282,6 +282,9 @@ const STUDIO_ZH: Record = {
'designer.field.hidden': '隐藏',
'designer.field.trackHistory': '记录历史',
'designer.field.placeholder': '占位符',
+ 'designer.field.basePatchOnly': '此字段属于已安装的包:你只能修改它的{properties},修改会保存为你的定制,其余属性保持包中的定义。',
+ 'designer.field.basePatchOnly.optionLabels': '选项标签',
+ 'designer.field.basePatchOnly.separator': '、',
'designer.field.group': '分组',
'designer.field.noGroup': '— 无分组 —',
'designer.field.picklistValues': '选项值',
From 309cec89663106f85da0f3a168b6081b868beb7b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 00:36:13 +0000
Subject: [PATCH 2/5] test(studio): pin the base-object extension path and its
pure pieces
A cleared patch value is now held as '' in the edit buffer and dropped on
save, so a base field's label can be emptied and retyped and an empty
patch restores the package's own text.
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jVPKQuoz3QHyQ76RnU
---
...aPillar.baseObjectExtension-28575.test.tsx | 417 ++++++++++++++++++
...mizeControls.objectExtension-28575.test.ts | 136 ++++++
.../studio-design/StudioCustomizeControls.tsx | 45 +-
.../studio-design/StudioDesignSurface.tsx | 3 +-
4 files changed, 596 insertions(+), 5 deletions(-)
create mode 100644 ui/packages/app-shell/src/views/studio-design/DataPillar.baseObjectExtension-28575.test.tsx
create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.objectExtension-28575.test.ts
diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.baseObjectExtension-28575.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.baseObjectExtension-28575.test.tsx
new file mode 100644
index 00000000000..90fa6818c33
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.baseObjectExtension-28575.test.tsx
@@ -0,0 +1,417 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#28575 (ADR-0146 D10, D3) — Studio adds a field to a read-only
+ * base's object as the customer's extension of it, in the base's paired
+ * `.custom` package: the field is `__c`, the extension is the pair's
+ * `object_extension` item filed under the object's name, the base object's
+ * body is never sent, and a base field takes only the spec's display-property
+ * whitelist, written as the extension's `fieldPatches`.
+ *
+ * The REAL `DataPillar` and the REAL field inspector, over a metadata client
+ * double; the records grid is stubbed to its "+" column (drawn from the
+ * grid's authoring context, as the real grid draws it). The customization the
+ * surface hands the pillar is built here: its `ensure` is the ensure door.
+ *
+ * Pinned:
+ * - Add field "Region" asks the ensure door once, writes `region__c` into an
+ * extension at the pair, and sends no body of the base object; the field
+ * shows in the Form at once and its inspector edits the extension;
+ * - a second add does not ask the door again;
+ * - a published `region__c` (served on the object AND declared by the pair's
+ * published extension) shows once, a second add keeps it in the draft, and
+ * the popover refuses `region__c` as taken;
+ * - a label edit on a base field writes only `label`, into `fieldPatches`;
+ * - `required` on a base field is not editable, under the whitelist sentence;
+ * - a refusal (the ensure door's, the save door's) is shown as sent;
+ * - walled: Add field draws disabled in the console's sentence, and nothing
+ * is asked or written;
+ * - CONTROL: a writable package's add-field and inspector are unchanged.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+// Module scope as well as in the `vi.mock` factory below: the cold transform is paid
+// during collection, which no test or hook timeout clocks (AGENTS.md, flaky-test discipline).
+import '@object-ui/components';
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+
+const BASE = 'com.example.hotcrm';
+const PAIR = 'com.example.hotcrm.custom';
+const OBJECT = 'crm_account';
+
+const srv = vi.hoisted(() => ({
+ /** The object as the base serves it (the pair's PUBLISHED extension already merged in). */
+ served: {} as Record,
+ /** The pair's published extension row of the object, `null` when it has none. */
+ publishedExt: null as Record | null,
+ /** The pair's pending extension draft, `null` when it has none. */
+ draftExt: null as Record | null,
+ /** The save door's refusal for an extension write, when set. */
+ refuse: null as Error | null,
+}));
+
+const baseServed = () => ({
+ name: OBJECT,
+ label: 'Account',
+ fields: {
+ name: { type: 'text', label: 'Account Name', required: true },
+ industry: { type: 'select', label: 'Industry', options: [{ value: 'technology', label: 'Technology' }] },
+ },
+});
+
+const mockClient = {
+ list: vi.fn(async (_type: string, opts?: { packageId?: string }) =>
+ opts?.packageId === PAIR ? [] : [{ name: OBJECT, label: 'Account' }],
+ ),
+ listDrafts: vi.fn(async () => []),
+ layered: vi.fn(async () => ({ effective: srv.served, code: srv.served })),
+ getDraft: vi.fn(async (type: string, name: string, opts?: { packageId?: string }) =>
+ type === 'object_extension' && name === OBJECT && opts?.packageId === PAIR && srv.draftExt
+ ? { type, name, item: srv.draftExt }
+ : null,
+ ),
+ get: vi.fn(async (type: string, name: string, opts?: { packageId?: string }) =>
+ type === 'object_extension' && name === OBJECT && opts?.packageId === PAIR ? srv.publishedExt : null,
+ ),
+ save: vi.fn(async (type: string, _name: string, _body: unknown, _opts?: { packageId?: string }) => {
+ if (type === 'object_extension' && srv.refuse) throw srv.refuse;
+ return { ok: true, version: 'v2', state: 'draft' };
+ }),
+};
+
+vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
+});
+
+vi.mock('./packages-io', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, fetchPackages: vi.fn(async () => []) };
+});
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useAdapter: () => ({}) };
+});
+
+vi.mock('@object-ui/plugin-view', async (importOriginal) => {
+ const mod = await importOriginal>();
+ const { useGridFieldAuthoring } = await import('@object-ui/components');
+ function GridDouble() {
+ const authoring = useGridFieldAuthoring();
+ return (
+
+ {authoring?.onAddColumn && (
+
+ )}
+
+ );
+ }
+ return { ...mod, ObjectView: GridDouble };
+});
+
+import { DataPillar, type StudioBaseCustomization } from './StudioDesignSurface';
+import { t, tFormat } from '../metadata-admin/i18n';
+import { registerMetadataInspector } from '../metadata-admin/inspector-registry';
+import { ObjectFieldInspector } from '../metadata-admin/inspectors/ObjectFieldInspector';
+
+registerMetadataInspector('object', ObjectFieldInspector);
+
+const FORM = t('engine.studio.data.tab.form', 'en');
+const ADD_FIELD = t('engine.studio.data.addField', 'en');
+const WALLED = t('engine.studio.customize.walled', 'en');
+
+const ensure = vi.fn(async (): Promise => PAIR);
+
+function customizationOf(over: Partial = {}): StudioBaseCustomization {
+ return {
+ base: BASE,
+ baseLabel: 'HotCRM',
+ basedOn: '4.0.0',
+ walled: false,
+ pairId: PAIR,
+ ensure,
+ ensurePackage: vi.fn(async () => ({
+ basePackageId: BASE,
+ packageId: PAIR,
+ namespace: 'hotcrm_custom',
+ dependencyRange: '^4',
+ created: false,
+ writable: true,
+ })) as unknown as StudioBaseCustomization['ensurePackage'],
+ marks: { status: 'off' },
+ ...over,
+ };
+}
+
+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 = () => {};
+ }
+ srv.served = baseServed();
+ srv.publishedExt = null;
+ srv.draftExt = null;
+ srv.refuse = null;
+ ensure.mockImplementation(async () => PAIR);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+async function renderBase(over: Partial = {}) {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
+}
+
+async function openForm() {
+ await userEvent.click(screen.getAllByRole('button', { name: FORM })[0]);
+ await screen.findByRole('button', { name: 'Layout', pressed: true });
+}
+
+type SaveCall = [string, string, Record, { packageId?: string; mode?: string }];
+const saves = () => mockClient.save.mock.calls as unknown as SaveCall[];
+const extensionSaves = () => saves().filter(([type]) => type === 'object_extension');
+
+/** Open the Form toolbar's Add field, fill it and press Create. */
+async function addFromFormToolbar(label: string): Promise {
+ await userEvent.click(await screen.findByTestId('data-form-add-extension-field'));
+ const popover = await screen.findByTestId('add-field-popover');
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), label);
+ await userEvent.click(within(popover).getByTestId('add-field-create'));
+}
+
+/** A field card of the Form layout, by its label (a base object's layout is read-only). */
+const cardOf = (label: string) =>
+ screen.queryAllByLabelText(tFormat('engine.studio.designer.fieldAriaReadOnly', 'en', { label }));
+
+describe('Studio adds a field to a read-only base object as an extension in its pair (objectstack#28575)', () => {
+ it('Add field "Region" asks the ensure door once, writes region__c into an extension at the pair, and sends no body of the base object', async () => {
+ await renderBase();
+ await openForm();
+ await userEvent.click(await screen.findByTestId('data-form-add-extension-field'));
+ const popover = await screen.findByTestId('add-field-popover');
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Region');
+ // The api name carries the suffix before Create.
+ expect(within(popover).getByTestId('add-field-api-name-preview')).toHaveTextContent('region__c');
+ await userEvent.click(within(popover).getByTestId('add-field-create'));
+
+ await waitFor(() => expect(extensionSaves()).toHaveLength(1), { timeout: 8000 });
+ expect(ensure).toHaveBeenCalledTimes(1);
+ const [[type, name, body, opts]] = extensionSaves();
+ expect([type, name]).toEqual(['object_extension', OBJECT]);
+ expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR });
+ expect(body).toStrictEqual({ extend: OBJECT, fields: { region__c: { type: 'text', label: 'Region' } } });
+ // The base object's body is never sent.
+ expect(saves().filter(([t]) => t === 'object')).toEqual([]);
+
+ // Wanted 3 — it shows in the Form at once, and its inspector edits the extension.
+ expect(cardOf('Region')).toHaveLength(1);
+ const inspector = await screen.findByTestId('data-field-inspector');
+ expect(within(inspector).getByText('region__c')).toBeInTheDocument();
+ const label = within(inspector).getByTestId('field-label-input');
+ expect(label).toBeEnabled();
+ expect(within(inspector).queryByTestId('field-base-patch-only')).toBeNull();
+ await userEvent.type(label, ' code');
+ await waitFor(
+ () =>
+ expect(extensionSaves().at(-1)?.[2]).toStrictEqual({
+ extend: OBJECT,
+ fields: { region__c: { type: 'text', label: 'Region code' } },
+ }),
+ { timeout: 8000 },
+ );
+ expect(saves().filter(([t]) => t === 'object')).toEqual([]);
+ });
+
+ it('a second add does not ask the ensure door again', async () => {
+ await renderBase();
+ await openForm();
+ await addFromFormToolbar('Region');
+ await waitFor(() => expect(extensionSaves()).toHaveLength(1), { timeout: 8000 });
+ await addFromFormToolbar('Tier');
+ await waitFor(
+ () => expect(Object.keys((extensionSaves().at(-1)?.[2].fields ?? {}) as object)).toEqual(['region__c', 'tier__c']),
+ { timeout: 8000 },
+ );
+ expect(ensure).toHaveBeenCalledTimes(1);
+ });
+
+ it('the Records grid "+" offers it too, suffixed the same way', async () => {
+ await renderBase();
+ await userEvent.click(screen.getByTestId('grid-add-column'));
+ const popover = await screen.findByTestId('add-field-popover');
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Region');
+ expect(within(popover).getByTestId('add-field-api-name-preview')).toHaveTextContent('region__c');
+ await userEvent.click(within(popover).getByTestId('add-field-create'));
+ await waitFor(() => expect(extensionSaves()).toHaveLength(1), { timeout: 8000 });
+ expect(Object.keys(extensionSaves()[0]![2].fields as object)).toEqual(['region__c']);
+ });
+
+ it('a published region__c shows once, a second add keeps it, and the popover refuses region__c as taken', async () => {
+ // Published: the base serves the merged field, and the pair's row declares it.
+ const region = { type: 'text', label: 'Region' };
+ srv.served = { ...baseServed(), fields: { ...baseServed().fields, region__c: region } };
+ srv.publishedExt = { extend: OBJECT, fields: { region__c: region }, _packageId: PAIR };
+ await renderBase();
+ await openForm();
+ await waitFor(() => expect(cardOf('Region')).toHaveLength(1), { timeout: 8000 });
+
+ await userEvent.click(await screen.findByTestId('data-form-add-extension-field'));
+ const popover = await screen.findByTestId('add-field-popover');
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Region');
+ // `region__c` is taken: the popover asks for another name, and says why.
+ expect(within(popover).getByTestId('add-field-api-name-hint')).toHaveTextContent(
+ tFormat('engine.studio.data.addFieldApiNameTaken', 'en', { name: 'region__c' }),
+ );
+ expect(within(popover).getByTestId('add-field-create')).toHaveAttribute('aria-disabled', 'true');
+ await userEvent.clear(within(popover).getByTestId('add-field-label-input'));
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Tier');
+ await userEvent.click(within(popover).getByTestId('add-field-create'));
+
+ await waitFor(() => expect(extensionSaves()).toHaveLength(1), { timeout: 8000 });
+ // The draft starts from the published body: publishing it keeps region__c.
+ expect(extensionSaves()[0]![2]).toStrictEqual({
+ extend: OBJECT,
+ fields: { region__c: region, tier__c: { type: 'text', label: 'Tier' } },
+ });
+ expect(cardOf('Region')).toHaveLength(1);
+ expect(cardOf('Tier')).toHaveLength(1);
+ });
+
+ it('a label edit on a base field writes only `label`, into the extension’s fieldPatches', async () => {
+ await renderBase();
+ await openForm();
+ await userEvent.click(cardOf('Account Name')[0]!);
+ const inspector = await screen.findByTestId('data-field-inspector');
+ const label = within(inspector).getByTestId('field-label-input');
+ expect(label).toBeEnabled();
+ await userEvent.clear(label);
+ await userEvent.type(label, 'Company');
+ await waitFor(
+ () => expect(extensionSaves().at(-1)?.[2]).toStrictEqual({ extend: OBJECT, fieldPatches: { name: { label: 'Company' } } }),
+ { timeout: 8000 },
+ );
+ expect(saves().filter(([t]) => t === 'object')).toEqual([]);
+ // Shown with the patch applied, and still the base's field (its api name stays).
+ expect(cardOf('Company')).toHaveLength(1);
+ });
+
+ it('an option label of a base field is patched by its stored value', async () => {
+ await renderBase();
+ await openForm();
+ await userEvent.click(cardOf('Industry')[0]!);
+ const inspector = await screen.findByTestId('data-field-inspector');
+ const optionLabel = within(inspector).getByTestId('option-label');
+ expect(optionLabel).toBeEnabled();
+ expect(within(inspector).getByTestId('option-value')).toBeDisabled();
+ await userEvent.clear(optionLabel);
+ await userEvent.type(optionLabel, 'Tech');
+ await waitFor(
+ () =>
+ expect(extensionSaves().at(-1)?.[2]).toStrictEqual({
+ extend: OBJECT,
+ fieldPatches: { industry: { options: { technology: { label: 'Tech' } } } },
+ }),
+ { timeout: 8000 },
+ );
+ });
+
+ it('`required` on a base field is not editable, and one sentence names the whitelist', async () => {
+ await renderBase();
+ await openForm();
+ await userEvent.click(cardOf('Account Name')[0]!);
+ const inspector = await screen.findByTestId('data-field-inspector');
+ expect(within(inspector).getByRole('checkbox', { name: t('designer.field.required', 'en') })).toBeDisabled();
+ expect(within(inspector).getByRole('checkbox', { name: t('designer.field.unique', 'en') })).toBeDisabled();
+ for (const key of ['designer.field.description', 'designer.field.helpText', 'designer.field.placeholder']) {
+ expect(within(inspector).getByLabelText(t(key, 'en'))).toBeEnabled();
+ }
+ const sentence = within(inspector).getByTestId('field-base-patch-only');
+ for (const name of ['designer.field.label', 'designer.field.helpText', 'designer.field.placeholder']) {
+ expect(sentence).toHaveTextContent(t(name, 'en'));
+ }
+ expect(sentence).toHaveTextContent(t('designer.field.basePatchOnly.optionLabels', 'en'));
+ });
+
+ it('a refusal is shown as the server sent it: the ensure door’s, and the save door’s', async () => {
+ const walled = 'Only the platform operator may customize an installed package on this deployment.';
+ ensure.mockImplementation(async () => {
+ throw new Error(walled);
+ });
+ await renderBase();
+ await openForm();
+ await addFromFormToolbar('Region');
+ await waitFor(() => expect(screen.getByText(walled, { exact: false })).toBeInTheDocument(), { timeout: 8000 });
+
+ cleanup();
+ vi.clearAllMocks();
+ ensure.mockImplementation(async () => PAIR);
+ const suffix = 'Field `region__c` is refused: an extension field must end in __c.';
+ srv.refuse = Object.assign(new Error(suffix), { status: 400, code: 'VALIDATION_ERROR' });
+ await renderBase();
+ await openForm();
+ await addFromFormToolbar('Region');
+ await waitFor(() => expect(screen.getByText(suffix, { exact: false })).toBeInTheDocument(), { timeout: 8000 });
+ });
+
+ it('walled: Add field draws disabled in the console’s own sentence, and nothing is asked or written', async () => {
+ await renderBase({ walled: true });
+ expect(screen.queryByTestId('grid-add-column')).toBeNull();
+ const blocked = screen.getByTestId('data-add-field-walled');
+ expect(blocked).toHaveAttribute('title', WALLED);
+ expect(within(blocked).getByRole('button', { name: new RegExp(ADD_FIELD) })).toBeDisabled();
+ expect(blocked).toHaveTextContent(WALLED);
+ await openForm();
+ expect(screen.queryByTestId('data-form-add-extension-field')).toBeNull();
+ expect(screen.getByTestId('data-add-field-walled')).toBeInTheDocument();
+ // A base field opens read-only, as on any read-only package.
+ await userEvent.click(cardOf('Account Name')[0]!);
+ const inspector = await screen.findByTestId('data-field-inspector');
+ expect(within(inspector).getByTestId('field-label-input')).toBeDisabled();
+ await new Promise((r) => setTimeout(r, 300));
+ expect(ensure).not.toHaveBeenCalled();
+ expect(mockClient.save).not.toHaveBeenCalled();
+ });
+});
+
+describe('CONTROL: a writable package’s add-field is unchanged (objectstack#28575)', () => {
+ it('a plain name into the object’s own body, and a fully editable inspector', async () => {
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
+ await openForm();
+ expect(screen.queryByTestId('data-form-add-extension-field')).toBeNull();
+ await userEvent.click(within(screen.getByTestId('form-toolbar')).getByRole('button', { name: ADD_FIELD }));
+ const popover = await screen.findByTestId('add-field-popover');
+ await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Region');
+ expect(within(popover).getByTestId('add-field-api-name-preview')).toHaveTextContent(/^.*region$/);
+ await userEvent.click(within(popover).getByTestId('add-field-create'));
+ await waitFor(() => expect(saves().some(([type]) => type === 'object')).toBe(true), { timeout: 8000 });
+ const [, , body] = saves().filter(([type]) => type === 'object').at(-1)!;
+ expect(Object.keys(body.fields as object)).toEqual(['name', 'industry', 'region']);
+ expect(extensionSaves()).toEqual([]);
+ expect(ensure).not.toHaveBeenCalled();
+ const inspector = await screen.findByTestId('data-field-inspector');
+ expect(within(inspector).queryByTestId('field-base-patch-only')).toBeNull();
+ expect(within(inspector).getByRole('checkbox', { name: t('designer.field.required', 'en') })).toBeEnabled();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.objectExtension-28575.test.ts b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.objectExtension-28575.test.ts
new file mode 100644
index 00000000000..b2bd4853c0d
--- /dev/null
+++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.objectExtension-28575.test.ts
@@ -0,0 +1,136 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#28575 (ADR-0146 D10) — the pure pieces the Data pillar writes a
+ * base object's extension with, each pinned on its own:
+ *
+ * - the `__c` suffix, appended once;
+ * - the extension body read from a stored document: no `_` keys, `extend` set;
+ * - the fold: patches applied, the extension's fields laid over by name (the
+ * draft winning, so a published field shows once), a removed published
+ * field left out;
+ * - the base's own field names;
+ * - an edit routed: a new field whole into `fields`, a whitelisted change on
+ * a base field into `fieldPatches`, a non-whitelisted change dropped;
+ * - a cleared patch value held as '' in the buffer and dropped on save.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ baseOwnedFieldNames,
+ extensionBodyForSave,
+ extensionFieldName,
+ foldObjectExtension,
+ objectExtensionBodyOf,
+ routeFoldedFieldsEdit,
+} from './StudioCustomizeControls.js';
+
+const OBJECT = 'crm_account';
+const served = {
+ name: OBJECT,
+ fields: {
+ name: { type: 'text', label: 'Account Name', required: true },
+ industry: { type: 'select', label: 'Industry', options: [{ value: 'technology', label: 'Technology' }] },
+ },
+};
+
+describe('the extension field name (#28575)', () => {
+ it('appends `__c` once, and leaves an empty name empty', () => {
+ expect(extensionFieldName('region')).toBe('region__c');
+ expect(extensionFieldName('region__c')).toBe('region__c');
+ expect(extensionFieldName('')).toBe('');
+ });
+});
+
+describe('the extension body of a stored document (#28575)', () => {
+ it('strips the envelope keys and names the extended object', () => {
+ expect(objectExtensionBodyOf({ _packageId: 'p', _version: 3, fields: { a__c: { type: 'text' } } }, OBJECT)).toEqual({
+ extend: OBJECT,
+ fields: { a__c: { type: 'text' } },
+ });
+ expect(objectExtensionBodyOf(null, OBJECT)).toBeNull();
+ });
+});
+
+describe('the fold of an object with its extension (#28575)', () => {
+ it('applies patches and appends the extension’s fields', () => {
+ const folded = foldObjectExtension(served, {
+ extend: OBJECT,
+ fields: { region__c: { type: 'text', label: 'Region' } },
+ fieldPatches: { name: { label: 'Company' }, industry: { options: { technology: { label: 'Tech' } } } },
+ });
+ const fields = folded.fields as Record>;
+ expect(Object.keys(fields)).toEqual(['name', 'industry', 'region__c']);
+ expect(fields.name).toEqual({ type: 'text', label: 'Company', required: true });
+ expect(fields.industry!.options).toEqual([{ value: 'technology', label: 'Tech' }]);
+ });
+
+ it('shows a published field the draft still declares once, the draft winning', () => {
+ const withPublished = { ...served, fields: { ...served.fields, region__c: { type: 'text', label: 'Region' } } };
+ const folded = foldObjectExtension(
+ withPublished,
+ { extend: OBJECT, fields: { region__c: { type: 'text', label: 'Region (edited)' } } },
+ new Set(['region__c']),
+ );
+ const fields = folded.fields as Record>;
+ expect(Object.keys(fields)).toEqual(['name', 'industry', 'region__c']);
+ expect(fields.region__c!.label).toBe('Region (edited)');
+ });
+
+ it('leaves out a published field the draft removed, and counts only the base’s fields as base-owned', () => {
+ const withPublished = { ...served, fields: { ...served.fields, region__c: { type: 'text', label: 'Region' } } };
+ const ext = { extend: OBJECT };
+ const published = new Set(['region__c']);
+ expect(Object.keys(foldObjectExtension(withPublished, ext, published).fields as object)).toEqual(['name', 'industry']);
+ expect([...baseOwnedFieldNames(withPublished, ext, published)]).toEqual(['name', 'industry']);
+ });
+});
+
+describe('an edit of the folded fields, routed (#28575)', () => {
+ const baseOwned = new Set(['name', 'industry']);
+
+ it('writes a whitelisted change on a base field into its patch, and drops a non-whitelisted one', () => {
+ const after = {
+ ...served.fields,
+ name: { type: 'text', label: 'Company', required: false, description: 'Who' },
+ };
+ expect(routeFoldedFieldsEdit({ before: served.fields, after, baseOwned, ext: { extend: OBJECT } })).toEqual({
+ extend: OBJECT,
+ fieldPatches: { name: { label: 'Company', description: 'Who' } },
+ });
+ });
+
+ it('declares a field that is not the base’s whole in `fields`', () => {
+ const after = { ...served.fields, region__c: { type: 'text', label: 'Region' } };
+ expect(routeFoldedFieldsEdit({ before: served.fields, after, baseOwned, ext: { extend: OBJECT } })).toEqual({
+ extend: OBJECT,
+ fields: { region__c: { type: 'text', label: 'Region' } },
+ });
+ });
+
+ it('writes an option label by its stored value', () => {
+ const after = {
+ ...served.fields,
+ industry: { type: 'select', label: 'Industry', options: [{ value: 'technology', label: 'Tech' }] },
+ };
+ expect(routeFoldedFieldsEdit({ before: served.fields, after, baseOwned, ext: { extend: OBJECT } })).toEqual({
+ extend: OBJECT,
+ fieldPatches: { industry: { options: { technology: { label: 'Tech' } } } },
+ });
+ });
+
+ it('holds a cleared label as \'\' while editing, and drops it on save so the package’s text comes back', () => {
+ const ext = { extend: OBJECT, fieldPatches: { name: { label: 'Company' } } };
+ const before = foldObjectExtension(served, ext).fields;
+ const after = { ...(before as object), name: { type: 'text', label: '', required: true } };
+ const routed = routeFoldedFieldsEdit({ before, after, baseOwned, ext });
+ expect(routed).toEqual({ extend: OBJECT, fieldPatches: { name: { label: '' } } });
+ expect(extensionBodyForSave(routed)).toEqual({ extend: OBJECT });
+ expect(
+ extensionBodyForSave({
+ extend: OBJECT,
+ fieldPatches: { name: { label: 'Company', description: '' }, industry: { options: { technology: { label: '' } } } },
+ }),
+ ).toEqual({ extend: OBJECT, fieldPatches: { name: { label: 'Company' } } });
+ });
+});
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
index cbcd1694c00..2dd6014e04f 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
@@ -506,8 +506,9 @@ const optionLabels = (options: unknown): Map => {
* the base's is the extension's, declared whole in `fields`, in the edited
* order; on a base field, only a change to a whitelisted property
* ({@link BASE_FIELD_DISPLAY_WHITELIST}, option labels by stored value) is
- * written, into its `fieldPatches` entry, and a property cleared drops from
- * the patch, so the base's own text shows again. Any other change to a base
+ * written, into its `fieldPatches` entry; a property cleared is held as ''
+ * until {@link extensionBodyForSave} drops it on save, so the base's own text
+ * comes back. Any other change to a base
* field is not the extension's to make, and is not written.
*/
export function routeFoldedFieldsEdit(input: {
@@ -536,7 +537,10 @@ export function routeFoldedFieldsEdit(input: {
for (const key of BASE_FIELD_DISPLAY_WHITELIST.field) {
const next = entry.def[key];
if (next === prev[key]) continue;
- if (next === undefined || next === '') delete patch[key];
+ // A cleared value stays in the edit buffer as '' (so the input can be
+ // emptied and retyped); `extensionBodyForSave` drops it on the wire,
+ // which restores the package's text.
+ if (next === undefined) delete patch[key];
else patch[key] = next;
}
const prevLabels = optionLabels(prev.options);
@@ -545,7 +549,7 @@ export function routeFoldedFieldsEdit(input: {
for (const [value, label] of nextLabels) {
if (!prevLabels.has(value) || prevLabels.get(value) === label) continue;
const entryPatch: Record = isPlainRecord(optionPatch[value]) ? { ...optionPatch[value] } : {};
- if (typeof label === 'string' && label !== '') entryPatch.label = label;
+ if (typeof label === 'string') entryPatch.label = label;
else delete entryPatch.label;
if (Object.keys(entryPatch).length > 0) optionPatch[value] = entryPatch;
else delete optionPatch[value];
@@ -562,3 +566,36 @@ export function routeFoldedFieldsEdit(input: {
if (Object.keys(patches).length > 0) out.fieldPatches = patches;
return out;
}
+
+/**
+ * The extension body as sent: every cleared ('') patch value dropped, and a
+ * field or option patch left empty by that dropped too, so clearing a base
+ * field's label (say) restores the package's own text rather than writing an
+ * empty one.
+ */
+export function extensionBodyForSave(body: ObjectExtensionBody): ObjectExtensionBody {
+ if (!isPlainRecord(body.fieldPatches)) return body;
+ const patches: Record = {};
+ for (const [name, raw] of Object.entries(body.fieldPatches)) {
+ if (!isPlainRecord(raw)) continue;
+ const patch: Record = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (key === 'options' || value === '' || value === undefined) continue;
+ patch[key] = value;
+ }
+ if (isPlainRecord(raw.options)) {
+ const options: Record = {};
+ for (const [value, optionRaw] of Object.entries(raw.options)) {
+ if (!isPlainRecord(optionRaw)) continue;
+ const kept = Object.fromEntries(Object.entries(optionRaw).filter(([, v]) => v !== '' && v !== undefined));
+ if (Object.keys(kept).length > 0) options[value] = kept;
+ }
+ if (Object.keys(options).length > 0) patch.options = options;
+ }
+ if (Object.keys(patch).length > 0) patches[name] = patch;
+ }
+ const out: ObjectExtensionBody = { ...body };
+ delete out.fieldPatches;
+ if (Object.keys(patches).length > 0) out.fieldPatches = patches;
+ return out;
+}
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 9d73ca66b4f..2c502f622c8 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -420,6 +420,7 @@ import {
OBJECT_EXTENSION_TYPE,
baseOwnedFieldNames,
extensionFieldName,
+ extensionBodyForSave,
extensionFieldNames,
foldObjectExtension,
objectExtensionBodyOf,
@@ -8302,7 +8303,7 @@ export function DataPillar({
setError(null);
try {
const pair = await extPairFor(customization);
- const outcome = await saveExtDraft(OBJECT_EXTENSION_TYPE, name, body, {
+ const outcome = await saveExtDraft(OBJECT_EXTENSION_TYPE, name, extensionBodyForSave(body), {
mode: 'draft',
packageId: pair,
surface: 'studio',
From 302381326e36d9f4d3b605e4734d9f5e9ac6d214 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 00:37:15 +0000
Subject: [PATCH 3/5] chore(changeset): studio base-object extension field
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jVPKQuoz3QHyQ76RnU
---
.../28575-studio-base-object-extension-field.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 .changeset/28575-studio-base-object-extension-field.md
diff --git a/.changeset/28575-studio-base-object-extension-field.md b/.changeset/28575-studio-base-object-extension-field.md
new file mode 100644
index 00000000000..d3bd501a8ae
--- /dev/null
+++ b/.changeset/28575-studio-base-object-extension-field.md
@@ -0,0 +1,15 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/i18n': patch
+---
+
+feat(studio): add a field to a read-only base object, written as an extension in its customization pair
+
+Clause-②: no
+Metadata-protocol: none
+
+- **Before.** In Studio's Data pillar, a base object of an installed (read-only) package offered no Add field: the grid "+" and the Form toolbar's add were hidden with the rest of the read-only package, and the field inspector locked every property. The only way to add a field was to take the object over.
+- **Now.** On a base object of a read-only base with a customization pair, Add field is offered in the Form toolbar and the grid "+". Create ensures the pair `.custom` (once, without a prompt) and writes an `object_extension` draft of the object into the pair through the `/api/v1/meta` doors. The base object's own body is never sent. The popover shows the api name with its `__c` suffix before Create, a typed name is suffixed too, and the taken-name refusal covers the fields of the object as shown, a published extension's fields included. The new field shows in the form at once and its inspector edits the extension; it joins the Records grid's columns once published, like any draft field.
+- **Base fields.** Selecting one of the package's own fields opens the inspector with only `BASE_FIELD_DISPLAY_WHITELIST` editable (label, description, help text, placeholder, option labels), read from the spec constant, under one sentence naming them. An edit is written as the field's `fieldPatches` entry in the extension, and clearing a property drops it from the patch so the package's text shows again. Every other property draws read-only.
+- **Refusals and walls.** A refusal from the ensure door or the save is shown in the server's own words. Under a walled posture the Add field action draws disabled with the console's walled sentence.
+- A writable package's add-field and inspector are unchanged.
From fe34b830d5f9131e878d35872232558f8d9f083b Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 01:39:34 +0000
Subject: [PATCH 4/5] fix(studio): keep the grid columns' identity semantics
outside an open extension
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jVPKQuoz3QHyQ76RnU
---
.../studio-design/StudioDesignSurface.tsx | 33 +++++++++++++------
1 file changed, 23 insertions(+), 10 deletions(-)
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 2c502f622c8..ab2ae6e4fd1 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -8070,9 +8070,9 @@ export function DataPillar({
* a PRODUCER-side fix; ListView's by-identity dependency is correct for a genuine
* column change and is left alone.
*/
- const gridColumnsKey = React.useMemo(
+ const gridColumnsComputed = React.useMemo(
() =>
- JSON.stringify(readFields(shownDraft.fields)
+ readFields(shownDraft.fields)
// objectui#11780 — a column the platform injects AND hides (`__search`,
// `owning_business_unit_id`) is no column an author manages. Read off
// the definition this memo already holds, so the key stays
@@ -8092,13 +8092,21 @@ export function DataPillar({
// what the grid asks for. The new field is still selected in the
// inspector, which is where it gets configured; it joins the grid once
// it is published and therefore queryable.
- .filter((n) => publishedFieldNames.has(n))),
+ .filter((n) => publishedFieldNames.has(n)),
[shownDraft.fields, publishedFieldNames],
);
- // objectstack#28575 — keyed on the column NAMES: the folded object
- // (`shownDraft`) is rebuilt on every extension edit, and a label typed into
- // an extension field must not re-key this fetch input.
- const gridColumns = React.useMemo(() => JSON.parse(gridColumnsKey) as string[], [gridColumnsKey]);
+ // objectstack#28575 — with a base object's extension open, the folded object
+ // (`shownDraft`) is rebuilt on every extension edit, so a label typed into an
+ // extension field would hand the grid a fresh columns array per keystroke:
+ // then the columns are keyed on their NAMES. Otherwise the array keeps the
+ // identity semantics above, which `StudioDesignSurface.gridColumns.test.tsx`'s
+ // identity-churn liveness case pins as a must-not-change.
+ const gridColumnsExtKey = extOpen ? JSON.stringify(gridColumnsComputed) : null;
+ const gridColumns = React.useMemo(
+ () => (gridColumnsExtKey === null ? gridColumnsComputed : (JSON.parse(gridColumnsExtKey) as string[])),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- with an extension open, the names alone key it
+ [gridColumnsExtKey ?? gridColumnsComputed],
+ );
/**
* The design-mode FORM's fields: the object's own fields, dropping
@@ -8129,9 +8137,14 @@ export function DataPillar({
* `fields` identical, so the looser key would churn (and re-run the form's
* field-generation effect) on every unrelated draft edit (icon, label).
*/
- // objectstack#28575 — keyed on the names, as `gridColumns` is.
- const formFieldsKey = React.useMemo(() => JSON.stringify(formFieldNames(shownDraft.fields)), [shownDraft.fields]);
- const formFields = React.useMemo(() => JSON.parse(formFieldsKey) as string[], [formFieldsKey]);
+ const formFieldsComputed = React.useMemo(() => formFieldNames(shownDraft.fields), [shownDraft.fields]);
+ // objectstack#28575 — with an extension open, keyed on the names, as `gridColumns` is.
+ const formFieldsExtKey = extOpen ? JSON.stringify(formFieldsComputed) : null;
+ const formFields = React.useMemo(
+ () => (formFieldsExtKey === null ? formFieldsComputed : (JSON.parse(formFieldsExtKey) as string[])),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- with an extension open, the names alone key it
+ [formFieldsExtKey ?? formFieldsComputed],
+ );
// objectui#12515 — what the Form tab's Preview renders. The draft differs
// from the published definition when there is no published one, a pending
From 10f4f254e94fd67249536d4c0f3952efe5a96493 Mon Sep 17 00:00:00 2001
From: Claude
Date: Mon, 2 Nov 2026 02:01:03 +0000
Subject: [PATCH 5/5] fix(studio): read isPlainRecord from the spec instead of
a local copy
Co-Authored-By: Claude
Claude-Session: https://claude.ai/code/session_01QsR2jVPKQuoz3QHyQ76RnU
---
.../src/views/studio-design/StudioCustomizeControls.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
index 2dd6014e04f..ae232ba8712 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx
@@ -53,6 +53,7 @@ import {
BASE_FIELD_DISPLAY_WHITELIST,
EXTENSION_FIELD_SUFFIX,
isExtensionFieldName,
+ isPlainRecord,
} from '@objectstack/spec/data';
import { Layers, Pencil, RotateCcw } from 'lucide-react';
import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js';
@@ -391,9 +392,6 @@ export const OBJECT_EXTENSION_TYPE = 'object_extension';
/** The pair's extension of one base object: `extend` names the object. */
export type ObjectExtensionBody = Record & { extend: string };
-const isPlainRecord = (v: unknown): v is Record =>
- v !== null && typeof v === 'object' && !Array.isArray(v);
-
/**
* `name` spelled as a customer extension field: the spec's suffix appended
* once (`region` → `region__c`, `region__c` unchanged). Empty stays empty.