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. 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}

)} + {!nameHint && extensionField && typed && ( +

+ {t('designer.field.apiName', locale)}: {typed} +

+ )} )} 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 77f90a4464d..ae232ba8712 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,16 @@ import { isOverridableMetadataType, type OverrideDeclarationParsed, } from '@objectstack/spec/kernel'; +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'; 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 +371,229 @@ 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 }; + +/** + * `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; 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: { + 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; + // 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); + 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') 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; +} + +/** + * 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 a098c1bf3b2..cc0162af5e9 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,18 @@ import { overrideDraftBody, useCustomizeDoorWalled, useServedOverride, + OBJECT_EXTENSION_TYPE, + baseOwnedFieldNames, + extensionFieldName, + extensionBodyForSave, + extensionFieldNames, + foldObjectExtension, + objectExtensionBodyOf, + routeFoldedFieldsEdit, type CustomizeBlock, + type ObjectExtensionBody, type ServedOverride, + BaseFieldDisplayPatchContext, } from './StudioCustomizeControls.js'; import { DraftChangesPanel, @@ -7085,6 +7095,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 @@ -7376,6 +7406,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 @@ -7419,7 +7480,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 }; @@ -7435,7 +7496,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; } @@ -7642,6 +7703,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); @@ -7907,13 +7982,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, @@ -7948,9 +8072,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 gridColumnsComputed = React.useMemo( () => - readFields(objDraft.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 @@ -7971,7 +8095,19 @@ export function DataPillar({ // 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], + [shownDraft.fields, publishedFieldNames], + ); + // 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], ); /** @@ -8003,15 +8139,22 @@ 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]); + 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 // 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( () => @@ -8149,6 +8292,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, extensionBodyForSave(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. @@ -8222,26 +8432,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 @@ -8277,7 +8489,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); @@ -8285,11 +8516,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 @@ -8346,6 +8579,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 @@ -8439,6 +8679,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. @@ -8508,6 +8768,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' }), @@ -8636,6 +8897,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} >