Skip to content
12 changes: 12 additions & 0 deletions .changeset/29691-item-header-saved-to.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
---

feat(app-shell): the Studio item header says where a customization of a read-only base is saved

Clause-②: yes (widening)
Metadata-protocol: none

ADR-0146 D3 saves every edit of a read-only base into its `.custom` pair. The Studio item header now says so, in the pending-changes sheet's own words: "Saved to BASE's customizations" (zh: 保存到 BASE 的定制). It shows on one line under the title row, above the tabs. It appears on Interfaces for an item the pair adds, overrides or has been unlocked by Customize; on Data for an object the pair adds or a base object the pair's extension already extends; and on Automations for a flow the pair adds, overrides or has been unlocked. Access is unchanged, since its chip already names the base.

`StudioItemHeader` takes an optional `note` prop, drawn outside the title row so the objectstack#29693 fold never measures it. The sentence moves to its own module, `preview/savedToCustomization.tsx`, beside a new `SavedToCustomizationNote` element, so the header does not depend on the pending-changes sheet's module; `DraftChangesPanel` still exports `savedToCustomizationText`. No i18n key is added, and nothing is removed or narrowed.
15 changes: 5 additions & 10 deletions ui/packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import { readDraftChanges, type DraftChange, type MetadataClient } from '@object
// spec-named local declaration is what `scripts/check-spec-symbol-derivation.mjs`
// refuses, and a faithful copy is exactly the fork that guard exists to prevent.
import { fetchPendingDrafts } from './usePendingDrafts.js';
import { savedToCustomizationText } from './savedToCustomization.js';
import { canonicalMetaUrlType } from '@objectstack/spec/shared';
import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui';
import { MetadataProtectionFields, OverrideDeclarationFields, stripReadDecorations } from '@objectstack/spec/kernel';
Expand Down Expand Up @@ -1384,17 +1385,11 @@ export interface DraftChangesPanelProps {
export type DraftChangesPosture = 'review' | 'confirm';

/**
* objectstack ADR-0146 D3 — the one sentence that names where a customization
* of a read-only base is saved: "Saved to BASE's customizations". The
* pending-changes sheet draws it, and the item header reuses it, so both say
* the same words.
* objectstack ADR-0146 D3 — the "Saved to BASE's customizations" sentence,
* from its own module so the item header can say it without this sheet's
* module (objectstack#29691). Re-exported here, where #29723 published it.
*/
export function savedToCustomizationText(t: Translate, baseLabel: string): string {
return t('preview.changes.savedToCustomization', {
package: baseLabel,
defaultValue: 'Saved to {{package}}’s customizations',
});
}
export { savedToCustomizationText };

export function DraftChangesPanel({
open,
Expand Down
30 changes: 30 additions & 0 deletions ui/packages/app-shell/src/preview/savedToCustomization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack ADR-0146 D3 — the one sentence that names where a customization
* of a read-only base is saved: "Saved to BASE's customizations". The
* pending-changes sheet (`DraftChangesPanel`) draws it under its title, and
* the Studio item header draws it on its note line (objectstack#29691), so
* both say the same words from the same key.
*
* Its own module, not the sheet's: many Studio tests replace the whole
* `DraftChangesPanel` module with a stub, and the header must not depend on
* what such a stub happens to export.
*/

import { useObjectTranslation } from '@object-ui/i18n';

type Translate = ReturnType<typeof useObjectTranslation>['t'];

export function savedToCustomizationText(t: Translate, baseLabel: string): string {
return t('preview.changes.savedToCustomization', {
package: baseLabel,
defaultValue: 'Saved to {{package}}’s customizations',
});
}

/** {@link savedToCustomizationText} as an element, for the Studio item header's note line. */
export function SavedToCustomizationNote({ baseLabel, testId }: { baseLabel: string; testId?: string }) {
const { t } = useObjectTranslation();
return <span data-testid={testId}>{savedToCustomizationText(t, baseLabel)}</span>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
* - a walled tenant admin sees Customize disabled, and nothing is asked;
* - a refused save and a refused publish read as the server sent them;
* - CONTROLS: the base's flow with no override offers no Restore; a
* writable package's flow keeps Delete flow and its definition switch.
* writable package's flow keeps Delete flow and its definition switch;
* - objectstack#29691: a customized flow (unlocked, or a published
* override) has the header say where it is saved ("Saved to HotCRM’s
* customizations"); the base's flow before Customize, and a writable
* package's flow, say nothing of the kind.
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -467,3 +471,39 @@ describe('Customize a read-only base’s flow into its pair (objectstack#28570)'
expect(screen.queryByTestId('studio-restore-original')).toBeNull();
});
});

describe('the flow header says where a customized flow is saved (objectstack#29691)', () => {
const SAVED_TO = 'Saved to HotCRM’s customizations';
const savedTo = () => within(header()).queryByTestId('auto-flow-saved-to');

it('Customize unlocks the flow, and the header says it is saved to the base’s customizations', async () => {
mount();
await openFlow();
expect(savedTo()).toBeNull();
await customize();
await waitFor(() => expect(savedTo()).toHaveTextContent(SAVED_TO), { timeout: 8000 });
expect(savedTo()!.closest('[data-slot="item-note"]')).not.toBeNull();
});

it('a published override says it on open', async () => {
srv.pairActive = { ...strip(BASE_FLOW), label: 'Notify owner and manager', overrides: DECLARATION };
srv.pairExists = true;
mount({ listedPair: PAIR });
await openFlow(/^Notify owner and manager$/);
await waitFor(() => expect(savedTo()).toHaveTextContent(SAVED_TO), { timeout: 8000 });
});

it('CONTROL: the base’s flow before Customize says nothing of the kind', async () => {
mount({ listedPair: PAIR });
await openFlow();
await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 });
expect(savedTo()).toBeNull();
expect(header().querySelector('[data-slot="item-note"]')).toBeNull();
});

it('CONTROL: a writable package’s flow says nothing of the kind', async () => {
mount({ readOnly: false });
await openFlow();
expect(savedTo()).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
* - 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.
* - CONTROL: a writable package's add-field and inspector are unchanged;
* - objectstack#29691: once the base object carries the pair's extension
* (published, or the first add), the object header says where it is saved
* ("Saved to HotCRM’s customizations"); before that, and on a writable
* package, it says nothing of the kind.
*/

import '@testing-library/jest-dom/vitest';
Expand Down Expand Up @@ -442,3 +446,45 @@ describe('A Chinese label names a base object\'s extension field by pinyin, suff
});
});
});

describe('the object header says where a base object’s extension is saved (objectstack#29691)', () => {
const SAVED_TO = 'Saved to HotCRM’s customizations';
const savedTo = () => within(screen.getByTestId('data-object-header')).queryByTestId('data-object-saved-to');

it('the first add makes the extension, and the header says it is saved to the base’s customizations', async () => {
await renderBase();
await openForm();
expect(savedTo()).toBeNull();
await addFromFormToolbar('Region');
await waitFor(() => expect(extensionSaves()).toHaveLength(1), { timeout: 8000 });
await waitFor(() => expect(savedTo()).toHaveTextContent(SAVED_TO), { timeout: 8000 });
expect(savedTo()!.closest('[data-slot="item-note"]')).not.toBeNull();
});

it('a base object the pair’s published extension already extends says it on open', async () => {
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 waitFor(() => expect(savedTo()).toHaveTextContent(SAVED_TO), { timeout: 8000 });
});

it('CONTROL: a base object with no extension says nothing of the kind', async () => {
await renderBase();
await openForm();
// The extension has been read: its Add field is offered.
await screen.findByTestId('data-form-add-extension-field', undefined, { timeout: 8000 });
expect(savedTo()).toBeNull();
expect(screen.getByTestId('data-object-header').querySelector('[data-slot="item-note"]')).toBeNull();
});

it('CONTROL: an object of a writable package says nothing of the kind', async () => {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<DataPillar packageId="com.example.showcase" />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
expect(savedTo()).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,21 @@
* - CONTROL: a base with no pair keeps the read-only Publish and reviews
* the base, as before;
* - objectstack#29691: reviewing the pair, the panel is handed the base's
* label for its "saved to" sentence, and nothing without a pair.
* label for its "saved to" sentence, and nothing without a pair;
* - objectstack#29691: after Customize, the item header says the same
* sentence (the REAL `SavedToCustomizationNote`; the stubbed panel draws
* its line with the REAL `savedToCustomizationText` from the label the
* host hands it, so the two lines read the same), and nothing before.
* Both come from `preview/savedToCustomization`, which no stub replaces.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useObjectTranslation } from '@object-ui/i18n';
import { savedToCustomizationText } from '../../preview/savedToCustomization';

const BASE = 'com.example.hotcrm';
const PAIR = 'com.example.hotcrm.custom';
Expand Down Expand Up @@ -79,6 +86,12 @@ vi.mock('sonner', () => ({
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
/** objectstack#29691 — the sheet's "saved to" line, in the sheet's own words. */
function SheetSavedTo({ baseLabel }: { baseLabel: string }) {
const { t } = useObjectTranslation();
return <p data-testid="sheet-saved-to">{savedToCustomizationText(t, baseLabel)}</p>;
}

vi.mock('../../preview/DraftChangesPanel', () => ({
DraftChangesPanel: ({
packageId,
Expand All @@ -90,6 +103,7 @@ vi.mock('../../preview/DraftChangesPanel', () => ({
customizationOf?: string;
}) => (
<div data-testid="changes-panel" data-package={packageId ?? ''} data-customization-of={customizationOf ?? ''}>
{customizationOf && <SheetSavedTo baseLabel={customizationOf} />}
{onPublish && (
<button type="button" data-testid="publish-all-drafts" onClick={() => void onPublish()}>
publish
Expand Down Expand Up @@ -337,3 +351,34 @@ describe('the wall is read from what the server publishes (objectstack#28562)',
expect(within(header()).queryByTestId('studio-customize-blocked')).toBeNull();
});
});

describe('the item header says where the customization is saved (objectstack#29691)', () => {
const SAVED_TO = 'Saved to HotCRM’s customizations';
const savedTo = () => within(header()).queryByTestId('if-item-saved-to');

it('after Customize the header says it, and after the first edit it still does, in the sheet’s own words', async () => {
renderWorkbench();
await openPipeline();
fireEvent.click(await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 }));
await waitFor(() => expect(savedTo()).toHaveTextContent(SAVED_TO), { timeout: 8000 });
// On its own line under the title row, never in it (the fold measures the row).
expect(savedTo()!.closest('[data-slot="item-note"]')).not.toBeNull();
expect(within(header()).getByRole('heading', { level: 1 }).parentElement).not.toContainElement(savedTo());

fireEvent.click(screen.getByRole('button', { name: 'patch the board' }));
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 });
await waitFor(() => expect(panelPackage()).toBe(PAIR), { timeout: 8000 });
expect(savedTo()).toHaveTextContent(SAVED_TO);
// The header line and the sheet line read the same.
expect(screen.getByTestId('sheet-saved-to').textContent).toBe(savedTo()!.textContent);
});

it('CONTROL: before Customize the header says nothing of the kind', async () => {
renderWorkbench();
await openPipeline();
await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 });
expect(savedTo()).toBeNull();
expect(header().querySelector('[data-slot="item-note"]')).toBeNull();
expect(screen.queryByTestId('sheet-saved-to')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ import {
type DraftChangesPosture,
type DraftPublishOptions,
} from '../../preview/DraftChangesPanel.js';
import { SavedToCustomizationNote } from '../../preview/savedToCustomization.js';
import { toast } from 'sonner';

/** The batch door's `backfillApplied` receipt, by reference to what the client returns (objectui#12929). */
Expand Down Expand Up @@ -6300,12 +6301,18 @@ export function InterfacesPillar({
)}
</div>
) : null;
// objectstack#29691 (ADR-0146 D3) — an item the pair adds, overrides or is
// unlocked to customize saves to the base's pair: the header says where,
// in the pending-changes sheet's own words.
const ifSavedTo =
customization !== null && (currentAddedIn !== null || headerCustomize?.inPair === true || headerUnlocked);
const ifHeaderEl = (
<StudioItemHeader
testId="if-item-header"
// objectstack#29693 — on a narrow row the chips and the item tools fold
// into the ⋯ menu before the title drops below its readable width.
foldMenu={itemMoreEl ? 'pillar' : { label: t('engine.studio.if.more', locale), testId: 'if-item-header-more' }}
note={ifSavedTo ? <SavedToCustomizationNote baseLabel={customizeBaseLabel} testId="if-item-saved-to" /> : undefined}
leading={
<button
type="button"
Expand Down Expand Up @@ -8953,6 +8960,19 @@ export function DataPillar({
// objectstack#29693 — the header has no ⋯ of its own (objectui#12511),
// so on a narrow row it draws one for the chips and the item tools.
foldMenu={{ label: t('engine.studio.if.more', locale), testId: 'data-object-header-more' }}
// objectstack#29691 (ADR-0146 D3) — an object the pair adds, or a
// base object the pair extends, saves to the base's pair. Named
// by the base's label: `packageId` names the pair for a pair's object.
note={
customization !== null &&
(!!current?.addedIn ||
(extOpen !== null && (extOpen.published.size > 0 || extHasDraft || extDirty))) ? (
<SavedToCustomizationNote
baseLabel={customization.baseLabel}
testId="data-object-saved-to"
/>
) : undefined
}
leading={
<button
type="button"
Expand Down Expand Up @@ -11260,6 +11280,13 @@ export function AutomationsPillar({
foldMenu={
flowMoreShown ? 'pillar' : { label: t('engine.studio.if.more', locale), testId: 'auto-flow-header-more' }
}
// objectstack#29691 (ADR-0146 D3) — a flow the pair adds, overrides
// or is unlocked to customize saves to the base's pair.
note={
customization !== null && (!!current?.addedIn || flowInPair || flowUnlocked) ? (
<SavedToCustomizationNote baseLabel={customizeBaseLabel} testId="auto-flow-saved-to" />
) : undefined
}
leading={
<button
type="button"
Expand Down
Loading
Loading