From d47495aad5bef8bf986bc991864ef967ee862612 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:06:41 +0000 Subject: [PATCH 1/4] feat(app-shell): fold the .custom pair's landing card under its base and name the base on the pending-changes sheet Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTWKHRdA5msPPhwmw4 --- .changeset/29691-landing-pair-card.md | 13 +++++ .../src/preview/DraftChangesPanel.tsx | 26 +++++++++ .../__tests__/DraftChangesPanel.test.tsx | 58 +++++++++++++++++-- ...Landing.customizationCounts-28566.test.tsx | 54 ++++++++++++++++- .../views/studio-design/BuilderLanding.tsx | 12 +++- ...ignSurface.customizePublish-28562.test.tsx | 22 ++++++- .../studio-design/StudioDesignSurface.tsx | 3 + ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 2 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 17 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 .changeset/29691-landing-pair-card.md diff --git a/.changeset/29691-landing-pair-card.md b/.changeset/29691-landing-pair-card.md new file mode 100644 index 00000000000..46532c77d32 --- /dev/null +++ b/.changeset/29691-landing-pair-card.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): the Studio landing gives a base's `.custom` pair no card of its own, and the pending-changes sheet says where a customization is saved + +Clause-②: yes (widening) +Metadata-protocol: none + +ADR-0146 D3 folds a base's `.custom` pair under its base. The Studio landing now does the same: a pair whose base is listed draws no card under "My packages" (or with the read-only packages). The base's card keeps its "n customized" line, and that line still links into the pair. A pair whose base is not listed keeps its card. The pair's Export and Delete stay on Studio's Overview → Packages page, which lists every package. + +When Studio reviews a read-only base's pair, the pending-changes sheet now says, under its title, "Saved to BASE's customizations" (zh: 保存到 BASE 的定制). `@object-ui/i18n` adds one key, `preview.changes.savedToCustomization`, in all ten locale packs. `DraftChangesPanel` takes an optional `customizationOf` prop, the base's label, and exports `savedToCustomizationText` so other surfaces can say the same sentence. 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 f6ef5336d3b..1a3625fe4f4 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -1328,10 +1328,30 @@ export interface DraftChangesPanelProps { * nothing to say. */ renderEntryNote?: (entry: DraftChangeEntry) => ReactNode; + /** + * objectstack ADR-0146 D3 — the label of the read-only base whose + * `.custom` pair these drafts are saved to. When set, the sheet says + * so under its title ({@link savedToCustomizationText}); the host passes it + * only while it reviews a pair's drafts. + */ + customizationOf?: string; } 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. + */ +export function savedToCustomizationText(t: Translate, baseLabel: string): string { + return t('preview.changes.savedToCustomization', { + package: baseLabel, + defaultValue: 'Saved to {{package}}’s customizations', + }); +} + export function DraftChangesPanel({ open, onOpenChange, @@ -1341,6 +1361,7 @@ export function DraftChangesPanel({ publishing = false, posture = 'review', renderEntryNote, + customizationOf, }: DraftChangesPanelProps) { const { t, language } = useObjectTranslation(); const [entries, setEntries] = useState(null); @@ -1608,6 +1629,11 @@ export function DraftChangesPanel({ defaultValue: 'What publishing will change. New items are added; updates overwrite the live version.', })} + {customizationOf ? ( +

+ {savedToCustomizationText(t, customizationOf)} +

+ ) : null} {posture === 'confirm' && entries && entries.length > 0 && !error ? ( // objectui#12517 — what the publish carries, counted off the same diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx index a26c767a65c..4bb97c601f6 100644 --- a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx @@ -11,17 +11,26 @@ import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -vi.mock('@object-ui/i18n', async (importOriginal) => ({ - ...(await importOriginal>()), - useObjectTranslation: () => ({ - t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, - }), -})); +// `t()` answers each key's default text; a describe that sets `i18nMode.real` +// reads the shipped packs through the real hook instead. +const i18nMode = vi.hoisted(() => ({ real: false })); +vi.mock('@object-ui/i18n', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useObjectTranslation: (ns?: string) => + i18nMode.real + ? actual.useObjectTranslation(ns) + : { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }, + }; +}); +import { createI18n, I18nProvider } from '@object-ui/i18n'; import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel'; afterEach(() => { vi.restoreAllMocks(); + i18nMode.real = false; }); /* ─────────────── computeChangeDetail (pure) ─────────────── */ @@ -249,3 +258,40 @@ describe('DraftChangesPanel — /meta routes are addressed in the singular', () expect(screen.getByRole('heading', { level: 4 }).textContent).toBe('{{count}} objects'); }); }); + +/* ─────────────── where a customization is saved (ADR-0146 D3) ─────────────── */ + +/** + * objectstack#29691 — reviewing a read-only base's `.custom` pair, the sheet + * says where the changes are saved, in the one sentence the item header + * shares: 保存到 BASE 的定制. Rendered through the real i18next hook and the + * shipped packs. + */ +describe('DraftChangesPanel — names the base whose customizations it saves (objectstack#29691)', () => { + function renderIn(lang: 'en' | 'zh', extra: Partial> = {}) { + i18nMode.real = true; + mockRoutes(); + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + return render( + + {}} packageId="com.x" {...extra} /> + , + ); + } + + it('says 保存到 HotCRM 的定制 under the title for a pair, in zh', async () => { + renderIn('zh', { customizationOf: 'HotCRM' }); + await waitFor(() => expect(screen.getByTestId('draft-changes-saved-to')).toHaveTextContent(/^保存到 HotCRM 的定制$/)); + }); + + it('says “Saved to HotCRM’s customizations” in en', async () => { + renderIn('en', { customizationOf: 'HotCRM' }); + expect(await screen.findByTestId('draft-changes-saved-to')).toHaveTextContent(/^Saved to HotCRM’s customizations$/); + }); + + it('CONTROL: a writable package’s own drafts carry no such sentence', async () => { + renderIn('en'); + await waitFor(() => expect(screen.getByText('ticket')).toBeInTheDocument()); + expect(screen.queryByTestId('draft-changes-saved-to')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx index 83e19b9b9cc..046171f047d 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx @@ -185,13 +185,15 @@ describe('Studio landing: a read-only base says "n customized, m switched off" ( const { requested } = stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) }); renderLanding(); - await screen.findByText('Showcase pair'); + await screen.findByText('HotCRM customizations'); await waitFor(() => expect(within(cardOf('Showcase')).getByTestId('studio-landing-customizations')).toHaveAttribute('data-state', 'read'), ); expect(within(cardOf('Acme')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); + // A pair whose base is not listed keeps its card, with no line. expect(within(cardOf('HotCRM customizations')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); - expect(within(cardOf('Showcase pair')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); + // A pair whose base is listed has no card at all (#29691's describe below). + expect(screen.queryByText('Showcase pair')).not.toBeInTheDocument(); expect(customizationRequests(requested)).toEqual(['/api/v1/packages/com.example.showcase/customizations']); }); @@ -210,3 +212,51 @@ describe('Studio landing: a read-only base says "n customized, m switched off" ( } }); }); + +/** + * objectstack#29691 (ADR-0146 D3) — the landing gives a base's `.custom` pair + * no card of its own: the pair is folded under its base, as the package + * switcher folds it, and the base's card carries it through its customized + * line and link. The pair's export and delete stay on the Packages page. + */ +describe('Studio landing: a base’s .custom pair has no card of its own (ADR-0146 D3)', () => { + /** Every package card the landing draws, by the id line each card shows. */ + const cardIds = () => + Array.from(document.querySelectorAll('.rounded-lg.border .font-mono')).map((el) => el.textContent); + + it('draws one card for a base and its writable pair: the base’s, with its customized count and the pair link', async () => { + packages.list = [HOTCRM, HOTCRM_PAIR, WRITABLE]; + stubWire({ hotcrm: served('hotcrm', 2, 1, 0) }); + renderLanding(); + + const line = await within(await waitFor(() => cardOf('HotCRM'))).findByTestId('studio-landing-customizations'); + await waitFor(() => expect(line).toHaveAttribute('data-state', 'read')); + expect(within(line).getByTestId('studio-landing-customizations-pair')).toHaveTextContent(customizedText(3)); + expect(within(line).getByTestId('studio-landing-customizations-pair')).toHaveAttribute('href', '/studio/hotcrm.custom/data'); + + // The pair draws no card, under 我的软件包 or anywhere else. + expect(screen.queryByText('HotCRM customizations')).not.toBeInTheDocument(); + expect(cardIds()).not.toContain('hotcrm.custom'); + // CONTROL: a writable package that is no pair keeps its card. + expect(cardIds()).toEqual(['com.acme.app', 'hotcrm']); + }); + + it('folds a read-only pair under its listed base too', async () => { + packages.list = [SHOWCASE, READONLY_PAIR]; + stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) }); + renderLanding(); + + await waitFor(() => cardOf('Showcase')); + expect(screen.queryByText('Showcase pair')).not.toBeInTheDocument(); + expect(cardIds()).toEqual(['com.example.showcase']); + }); + + it('keeps the card of a pair whose base is not listed', async () => { + packages.list = [HOTCRM_PAIR]; + stubWire({}); + renderLanding(); + + await waitFor(() => cardOf('HotCRM customizations')); + expect(cardIds()).toEqual(['hotcrm.custom']); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index eb4a371becf..fe5e9f00ad3 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -36,6 +36,7 @@ import { PackageIdInput } from './PackageIdInput.js'; import { studioOrgScopePath } from './studioScope.js'; import { STUDIO_ALL_PACKAGES_SEGMENT, studioMetadataAreaPath } from './metadataArea.js'; import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js'; +import { foldCustomizationPairs } from './StudioRailCustomizations.js'; /** * objectui#11808 — where a read-only package's card sends the author beyond @@ -117,8 +118,15 @@ export function BuilderLanding(): React.ReactElement { .filter((item): item is RecentNamedItem => item.type === 'package') .slice(0, RECENT_PACKAGES_SHOWN); - const writable = pkgs?.filter((p) => p.writable) ?? []; - const readonly = pkgs?.filter((p) => !p.writable) ?? []; + // ADR-0146 D3 — a base's `.custom` pair has no card of its own: it is + // folded under its base, the way the package switcher folds it + // (`foldCustomizationPairs`). The base's card carries the pair through its + // customized line and link, and the pair's export and delete stay on the + // Packages page, which lists every package. A pair whose base is not listed + // keeps its card. + const cards = pkgs === null ? [] : foldCustomizationPairs(pkgs).rows; + const writable = cards.filter((p) => p.writable); + const readonly = cards.filter((p) => !p.writable); // ADR-0146 D3 — each read-only BASE's card carries "n customized, m switched // off", the server's counts (`GET /packages/:id/customizations`), one read 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 6c26c43d570..dbd2a3cf958 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 @@ -25,7 +25,9 @@ * - a walled admin (a group posture, not the platform operator) sees * Customize disabled, and the platform operator does not; * - CONTROL: a base with no pair keeps the read-only Publish and reviews - * the base, as before. + * 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. */ import '@testing-library/jest-dom/vitest'; @@ -78,8 +80,16 @@ vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPan vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); vi.mock('../../preview/DraftChangesPanel', () => ({ - DraftChangesPanel: ({ packageId, onPublish }: { packageId: string | null; onPublish?: () => void | Promise }) => ( -
+ DraftChangesPanel: ({ + packageId, + onPublish, + customizationOf, + }: { + packageId: string | null; + onPublish?: () => void | Promise; + customizationOf?: string; + }) => ( +
{onPublish && (
), -})); + }; +}); import { StudioDesignSurface } from './StudioDesignSurface'; import { registerMetadataPreview } from '../metadata-admin/preview-registry'; @@ -337,3 +352,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(); + }); +}); 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 6d874e81e4d..713235b08d5 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -437,6 +437,7 @@ import { } from './StudioCustomizeControls.js'; import { DraftChangesPanel, + SavedToCustomizationNote, type DraftChangeEntry, type DraftChangesPosture, type DraftPublishOptions, @@ -6300,12 +6301,18 @@ export function InterfacesPillar({ )}
) : 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 && (