diff --git a/.changeset/29691-item-header-saved-to.md b/.changeset/29691-item-header-saved-to.md new file mode 100644 index 00000000000..e0ffa7ad719 --- /dev/null +++ b/.changeset/29691-item-header-saved-to.md @@ -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. diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index 4e71d2ef1ff..6f3f248f159 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -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'; @@ -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, diff --git a/ui/packages/app-shell/src/preview/savedToCustomization.tsx b/ui/packages/app-shell/src/preview/savedToCustomization.tsx new file mode 100644 index 00000000000..02484246cba --- /dev/null +++ b/ui/packages/app-shell/src/preview/savedToCustomization.tsx @@ -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['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 {savedToCustomizationText(t, baseLabel)}; +} diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx index 2f6b8215e0c..46a1f30af25 100644 --- a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.customizeFlow-28570.test.tsx @@ -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'; @@ -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(); + }); +}); 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 index dca50243237..af4874102db 100644 --- 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 @@ -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'; @@ -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( + + + , + ); + await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 }); + expect(savedTo()).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx index dbd2a3cf958..c485425ef30 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx @@ -27,7 +27,12 @@ * - 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'; @@ -35,6 +40,8 @@ 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'; @@ -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

{savedToCustomizationText(t, baseLabel)}

; +} + vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: ({ packageId, @@ -90,6 +103,7 @@ vi.mock('../../preview/DraftChangesPanel', () => ({ customizationOf?: string; }) => (
+ {customizationOf && } {onPublish && (
) : 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 = ( : undefined} leading={ } actions={ <> {labelled && } @@ -438,3 +457,51 @@ describe('the fold order: chips and tools, then labels, then the title (objectst expect(screen.getByRole('menu')).toBeInTheDocument(); }); }); + +describe('the note line sits under the title row, outside the fold (objectstack#29691)', () => { + let rangeSpy: { mockRestore: () => void } | null = null; + beforeEach(() => { + rangeSpy = vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(() => rect(READABLE)); + }); + afterEach(() => { + rangeSpy?.mockRestore(); + rangeSpy = null; + }); + + const SAVED_TO = 'Saved to HotCRM’s customizations'; + const titleRow = () => screen.getByTestId('hdr-title').parentElement as HTMLElement; + const note = () => document.querySelector('[data-slot="item-note"]') as HTMLElement | null; + + it('the note is a line of its own, after the title row and before the tabs', () => { + render(); + expect(note()).toHaveTextContent(SAVED_TO); + expect(isRow(titleRow())).toBe(true); + expect(titleRow()).not.toContainElement(note()); + expect(titleRow().nextElementSibling).toBe(note()); + expect(note()!.nextElementSibling?.tagName).toBe('NAV'); + }); + + it('a note wider than the free room folds nothing: at 632 px the chips and the tools stay, with no ⋯', () => { + render(); + // In the row, the 400 px note would leave the title 632 − 32 − 38 − 146 − 137 − 400 = −121 px. + expect(screen.getByTestId('probe-tools')).toBeInTheDocument(); + expect(screen.getByTestId('hdr-kind-chip')).toBeInTheDocument(); + expect(screen.queryByTestId('hdr-more')).toBeNull(); + expect(titleBox()).toBe(TITLE_TEXT); + }); + + it('the row follows its own width with the note there: folds at 330 px, unfolds at 480 px', () => { + render(); + resizeRow(330); + expect(screen.queryByTestId('probe-tools')).toBeNull(); + resizeRow(480); + expect(screen.getByTestId('probe-tools')).toBeInTheDocument(); + expect(screen.queryByTestId('hdr-more')).toBeNull(); + }); + + it('CONTROL: without a note there is no note line, and the tabs follow the title row', () => { + render(); + expect(note()).toBeNull(); + expect(titleRow().nextElementSibling?.tagName).toBe('NAV'); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioItemHeader.tsx b/ui/packages/app-shell/src/views/studio-design/StudioItemHeader.tsx index efd94077c9e..7f2f0ce742f 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioItemHeader.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioItemHeader.tsx @@ -571,6 +571,12 @@ export interface StudioItemHeaderProps { * id. Omitted: nothing folds. */ foldMenu?: 'pillar' | { label: string; testId?: string }; + /** + * objectstack#29691 (ADR-0146 D3) — one line under the title row, above the + * tabs: where this item's edits are saved. Outside the title row, so the + * fold never measures it. + */ + note?: React.ReactNode; testId?: string; className?: string; } @@ -587,6 +593,7 @@ export function StudioItemHeader({ tabs, leading, foldMenu, + note, testId, className, }: StudioItemHeaderProps): React.ReactElement { @@ -664,6 +671,11 @@ export function StudioItemHeader({ )} + {note && ( +

+ {note} +

+ )} {tabs && (