From e76abc58c88d556d0a2e7be845f6938441a1c640 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:59:00 +0000 Subject: [PATCH 1/3] feat(app-shell): the Studio landing's read-only base cards say "n customized, m switched off" Each read-only base's card reads GET /packages/:id/customizations once and draws the served counts: customized = overrides + extensions, switched off as served. A pair draws no line and is never sent; loading shows no number; a failed read says so, with the server's sentence as its title. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx --- .../views/studio-design/BuilderLanding.tsx | 117 +++++++++++++++++- .../studio-design/packageCustomizations-io.ts | 71 +++++++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 9 ++ ui/packages/i18n/src/namespaces/studio/zh.ts | 7 ++ 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts 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 332846e337c..449d9c9a3af 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -15,7 +15,9 @@ * code and installed packages are listed last: they open for browsing, and each * card points at the routes that customize one (objectui#11808). Writability is * the server's verdict read by packages-io (`PkgEntry.writable`, ADR-0070 D2), - * and the server's write gate stays the authority. + * and the server's write gate stays the authority. Each read-only base's card + * says what this deployment changed on it, "n customized, m switched off" + * (ADR-0146 D3), from the server's per-package customization read. */ import * as React from 'react'; @@ -28,6 +30,8 @@ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js'; import { isMarketplaceEnabled } from '../../runtime-config.js'; import { fetchPackages, duplicatePackage, isSpecPackageId, type PkgEntry } from './packages-io.js'; +import { fetchPackageCustomizationCounts, type PackageCustomizationCounts } from './packageCustomizations-io.js'; +import { customizationPackageId, isCustomizationPackage } from '@objectstack/spec/kernel'; import { PackageIdInput } from './PackageIdInput.js'; import { studioOrgScopePath } from './studioScope.js'; import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js'; @@ -64,6 +68,18 @@ export const packagedAutomationPath = () => /** objectui#11863 — how many recent packages the landing lists: one row of cards. */ const RECENT_PACKAGES_SHOWN = 3; +/** + * ADR-0146 D3 — one read-only base's customization line, by the state of its + * read. No entry means the read has not answered: the line shows no number. + */ +type CustomizationLine = + | { kind: 'read'; counts: PackageCustomizationCounts } + | { kind: 'failed'; message: string }; + +/** A package's Studio address on one pillar, the way `open` below spells it. */ +const studioPillarPath = (packageId: string, pillar: 'data' | 'automations') => + `/studio/${encodeURIComponent(packageId)}/${pillar}`; + export function BuilderLanding(): React.ReactElement { const navigate = useNavigate(); const locale = useMetadataLocale(); @@ -123,6 +139,100 @@ export function BuilderLanding(): React.ReactElement { const writable = pkgs?.filter((p) => p.writable) ?? []; const readonly = pkgs?.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 + // per card drawn. A pair (`.custom`) draws no line and is never sent: + // the route refuses its id. The page does not wait for these reads. The + // effect is keyed on the joined ids, the primitive it reads (ui/AGENTS.md #10). + const customizationTargetKey = readonly + .filter((p) => !isCustomizationPackage(p.id)) + .map((p) => p.id) + .join('\n'); + const [customizationLines, setCustomizationLines] = React.useState>({}); + React.useEffect(() => { + if (!customizationTargetKey) return; + let cancelled = false; + for (const id of customizationTargetKey.split('\n')) { + fetchPackageCustomizationCounts(id).then( + (counts) => { + if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'read', counts } })); + }, + (e: unknown) => { + const message = e instanceof Error ? e.message : String(e); + if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'failed', message } })); + }, + ); + } + return () => { + cancelled = true; + }; + }, [customizationTargetKey]); + // The pair is linked only when the package list holds it: a base with no + // pair has nothing to open, and the read never creates one. + const listedIds = new Set(pkgs?.map((p) => p.id) ?? []); + const listedPairOf = (baseId: string): string | null => { + const pair = customizationPackageId(baseId); + return pair.ok && listedIds.has(pair.value) ? pair.value : null; + }; + + const renderCustomizationLine = (baseId: string): React.ReactNode => { + if (isCustomizationPackage(baseId)) return null; + const line = customizationLines[baseId]; + const lineClass = 'text-[10px] leading-4 text-muted-foreground'; + if (!line) { + return ( +

+ {t('engine.studio.landing.customizations.loading', locale)} +

+ ); + } + if (line.kind === 'failed') { + // Never a "0": the read did not answer. The server's sentence is the title. + return ( +

+ {t('engine.studio.landing.customizations.failed', locale)} +

+ ); + } + const pair = listedPairOf(baseId); + const customized = tFormat('engine.studio.landing.customizations.customized', locale, { + customized: line.counts.customized, + }); + const linkClass = 'hover:text-foreground hover:underline'; + return ( +

+ {pair ? ( + + {customized} + + ) : ( + {customized} + )} + {t('engine.studio.landing.customizations.separator', locale)} + + {tFormat('engine.studio.landing.customizations.switchedOff', locale, { + switchedOff: line.counts.switchedOff, + })} + +

+ ); + }; + // Duplicate (ADR-0070 D4) clones a writable BASE into a new writable package, // the "duplicate base" gesture, and drops the user straight into the copy's // builder. It is offered on writable bases only. A read-only code or @@ -384,12 +494,13 @@ export function BuilderLanding(): React.ReactElement { {t('engine.studio.pkg.readonly', locale)} -
+
+ {renderCustomizationLine(p.id)} {/* objectui#12513 — the link leaves Studio for Setup: its name says where it goes, the arrow that it goes out. */} diff --git a/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts b/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts new file mode 100644 index 00000000000..c7714e1860c --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The per-package customization read the Studio landing draws on each + * read-only base's card (ADR-0146 D3: one line per package, "n customized, + * m switched off"). + * + * The numbers are the server's: `GET /api/v1/packages/:id/customizations` + * (ADR-0146 D8 item 4) answers, for one installed base, the items its paired + * `.custom` package overrides, the members that pair adds to the base's + * objects and the base's items switched off in this deployment, each with its + * count. This reader computes no count of its own: "customized" is the served + * `counts.overrides` plus the served `counts.extensions`, the two kinds of + * change the pair carries, and "switched off" is the served + * `counts.switchedOff`. + * + * The answer is judged by the route's own declaration, + * `GetPackageCustomizationsResponseSchema` (strict at every level): an answer + * it refuses is a failed read, never a count read out of an off-contract body. + * A failure carries the server's sentence by the rule every package reader in + * this folder uses (`readEnvelopeFailureText`), else `HTTP `. + * + * ⛔ A pair id is never sent: the route refuses one by its shape + * (`400 VALIDATION_ERROR` naming its base), so this reader refuses it first, + * without a request. + */ + +import { GetPackageCustomizationsResponseSchema } from '@objectstack/spec/api'; +import { isCustomizationPackage } from '@objectstack/spec/kernel'; +import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; + +/** What the landing draws for one base: two served numbers. */ +export interface PackageCustomizationCounts { + /** Served overrides plus served extensions of the base's pair. */ + customized: number; + /** Served switched-off items of the base. */ + switchedOff: number; +} + +/** The route of the read, for one base package id. */ +export const packageCustomizationsUrl = (baseId: string) => + `/api/v1/packages/${encodeURIComponent(baseId)}/customizations`; + +/** + * Read one installed base's customization counts. + * + * Rejects with the server's sentence on a failed read, and with a sentence of + * its own on an answer the route's declaration refuses; resolves only with + * counts the server served. + */ +export async function fetchPackageCustomizationCounts(baseId: string): Promise { + if (isCustomizationPackage(baseId)) { + throw new Error(`Package '${baseId}' is a customization package: customizations are read for its base.`); + } + const res = await fetch(packageCustomizationsUrl(baseId), { + credentials: 'include', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + const payload = (await res.json().catch(() => null)) as Record | null; + if (!res.ok) { + throw new Error(readEnvelopeFailureText(payload) ?? `HTTP ${res.status}`); + } + // The dispatcher envelope `{ success, data }`; the declaration is the `data`. + const parsed = GetPackageCustomizationsResponseSchema.safeParse(payload?.data); + if (!parsed.success) { + throw new Error(`The customization read for '${baseId}' answered a body its declaration refuses.`); + } + const { counts } = parsed.data; + return { customized: counts.overrides + counts.extensions, switchedOff: counts.switchedOff }; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index e72cc677d74..b939c3f4617 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3183,6 +3183,15 @@ const STUDIO_EN: Record = { 'engine.studio.landing.overlay': 'Open in Setup', 'engine.studio.landing.overlayTitle': 'Open this package’s metadata: each type shows whether it accepts an environment overlay', 'engine.studio.landing.marketplace': 'Or install a template app from the marketplace', + // ADR-0146 D3 — a read-only base's card says what this deployment changed on + // it, from the server's per-package customization read. + 'engine.studio.landing.customizations.customized': '{customized} customized', + 'engine.studio.landing.customizations.separator': ', ', + 'engine.studio.landing.customizations.switchedOff': '{switchedOff} switched off', + 'engine.studio.landing.customizations.loading': 'Reading customizations…', + 'engine.studio.landing.customizations.failed': 'Couldn’t read customizations', + 'engine.studio.landing.customizations.pairTitle': 'Open the customization package {pair} in Studio', + 'engine.studio.landing.customizations.switchedOffTitle': 'Open this package’s Automations, where its items are switched on and off', // objectui#11553 — the package-less scope: the organization's own flows. 'engine.studio.landing.orgHeading': 'Not in a package', 'engine.studio.org.name': 'Organization flows', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 12143029805..a18dce80ae2 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2825,6 +2825,13 @@ const STUDIO_ZH: Record = { 'engine.studio.landing.overlay': '在设置中打开', 'engine.studio.landing.overlayTitle': '打开该软件包的元数据:每种类型都会标明是否接受环境覆盖', 'engine.studio.landing.marketplace': '或从应用市场安装一个模板应用', + 'engine.studio.landing.customizations.customized': '已定制 {customized} 项', + 'engine.studio.landing.customizations.separator': ',', + 'engine.studio.landing.customizations.switchedOff': '已停用 {switchedOff} 项', + 'engine.studio.landing.customizations.loading': '正在读取定制…', + 'engine.studio.landing.customizations.failed': '无法读取定制', + 'engine.studio.landing.customizations.pairTitle': '在 Studio 中打开定制包 {pair}', + 'engine.studio.landing.customizations.switchedOffTitle': '打开该软件包的自动化,在那里启用或停用其中的项', // objectui#11553 — the package-less scope: the organization's own flows. 'engine.studio.landing.orgHeading': '不属于软件包', 'engine.studio.org.name': '组织流程', From 4affd99e0fa20f5283362960bf4fae1c00358919 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:07:07 +0000 Subject: [PATCH 2/3] test(app-shell): pin the landing's customization line against stubbed served answers Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx --- ...Landing.customizationCounts-28566.test.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx 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 new file mode 100644 index 00000000000..a2f8b4045df --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0146 D3 — each read-only base's Studio landing card says what this + * deployment changed on it, "n customized, m switched off", from the server's + * per-package customization read (`GET /api/v1/packages/:id/customizations`). + * + * The real `BuilderLanding` and the real reader (`packageCustomizations-io`) + * run under a real router; only the package list and the wire are stubbed. + * The stubbed answers are shaped by the route's declaration, + * `GetPackageCustomizationsResponseSchema`, inside the dispatcher envelope. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { t, tFormat } from '../metadata-admin/i18n'; +import { resetRuntimeConfigForTesting } from '../../runtime-config'; + +const packages = vi.hoisted(() => ({ list: [] as Array> })); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => packages.list) }; +}); + +// The create dialog is closed throughout; its form stack is not under test. +vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); + +import { BuilderLanding } from './BuilderLanding'; + +const HOTCRM = { id: 'hotcrm', name: 'HotCRM', writable: false, namespace: 'hotcrm' }; +const HOTCRM_PAIR = { id: 'hotcrm.custom', name: 'HotCRM customizations', writable: true, namespace: 'hotcrm_custom' }; +const SHOWCASE = { id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' }; +const WRITABLE = { id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }; +/** A pair served read-only (code-shipped): still a pair, so still no line. */ +const READONLY_PAIR = { id: 'com.example.showcase.custom', name: 'Showcase pair', writable: false, namespace: 'showcase_custom' }; + +const CUSTOMIZATIONS_RE = /^\/api\/v1\/packages\/([^/]+)\/customizations$/; + +/** A served answer for `id`: `overrides` overrides, `extensions` field members, `switchedOff` flows. */ +function served(id: string, overrides: number, extensions: number, switchedOff: number) { + const data = { + packageId: id, + overrides: Array.from({ length: overrides }, (_, i) => ({ type: 'view', name: `view_${i}` })), + extensions: Array.from({ length: extensions }, (_, i) => ({ object: 'account', kind: 'field' as const, name: `field_${i}` })), + switchedOff: Array.from({ length: switchedOff }, (_, i) => ({ type: 'flow', name: `flow_${i}` })), + counts: { overrides, extensions, switchedOff }, + }; + return { status: 200, body: { success: true, data } }; +} + +type Answer = { status: number; body: unknown } | 'never'; + +/** + * Stub the wire: each customization request is answered from `answers` by + * base id; every request URL is recorded. + */ +function stubWire(answers: Record) { + const requested: string[] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + const m = CUSTOMIZATIONS_RE.exec(url); + const answer = m ? answers[decodeURIComponent(m[1])] : undefined; + if (answer === 'never') return new Promise(() => {}); + if (!answer) throw new Error(`unexpected request ${url}`); + return { + ok: answer.status >= 200 && answer.status < 300, + status: answer.status, + json: async () => answer.body, + text: async () => JSON.stringify(answer.body), + } as Response; + }); + vi.stubGlobal('fetch', fetchMock); + return { requested }; +} + +const customizationRequests = (requested: string[]) => requested.filter((u) => CUSTOMIZATIONS_RE.test(u)); + +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +function renderLanding() { + return render( + + + + } /> + } /> + + , + ); +} + +/** The card a package renders in: the element holding both its name and its links. */ +function cardOf(name: string): HTMLElement { + const card = screen.getByText(name).closest('.rounded-lg.border'); + if (!(card instanceof HTMLElement)) throw new Error(`no card found for ${name}`); + return card; +} + +const customizedText = (n: number) => tFormat('engine.studio.landing.customizations.customized', 'en-US', { customized: n }); +const switchedOffText = (n: number) => tFormat('engine.studio.landing.customizations.switchedOff', 'en-US', { switchedOff: n }); + +afterEach(() => { + cleanup(); + packages.list = []; + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +describe('Studio landing: a read-only base says "n customized, m switched off" (ADR-0146 D3)', () => { + it('reads 3 overrides + 1 extension and 2 switched off as "4 customized, 2 switched off", linking the pair and the Automations pillar', async () => { + packages.list = [HOTCRM_PAIR, HOTCRM]; + const { requested } = stubWire({ hotcrm: served('hotcrm', 3, 1, 2) }); + renderLanding(); + + const line = await within(await waitFor(() => cardOf('HotCRM'))).findByTestId('studio-landing-customizations'); + await waitFor(() => expect(line).toHaveAttribute('data-state', 'read')); + expect(line).toHaveTextContent('4 customized, 2 switched off'); + + const pairLink = within(line).getByTestId('studio-landing-customizations-pair'); + expect(pairLink).toHaveTextContent(customizedText(4)); + expect(pairLink).toHaveAttribute('href', '/studio/hotcrm.custom/data'); + const offLink = within(line).getByTestId('studio-landing-customizations-switched-off'); + expect(offLink).toHaveTextContent(switchedOffText(2)); + expect(offLink).toHaveAttribute('href', '/studio/hotcrm/automations'); + + fireEvent.click(pairLink); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/hotcrm.custom/data')); + // One read, for the base; never for its pair. + expect(customizationRequests(requested)).toEqual(['/api/v1/packages/hotcrm/customizations']); + }); + + it('reads a base with no pair as "0 customized, 0 switched off", with no pair link', async () => { + packages.list = [SHOWCASE]; + stubWire({ 'com.example.showcase': served('com.example.showcase', 0, 0, 0) }); + renderLanding(); + + const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations'); + await waitFor(() => expect(line).toHaveAttribute('data-state', 'read')); + expect(line).toHaveTextContent('0 customized, 0 switched off'); + expect(within(line).queryByTestId('studio-landing-customizations-pair')).not.toBeInTheDocument(); + expect(within(line).getByText(customizedText(0))).not.toHaveAttribute('href'); + expect(within(line).getByTestId('studio-landing-customizations-switched-off')).toHaveAttribute( + 'href', + '/studio/com.example.showcase/automations', + ); + }); + + it('shows no number on a failed read: a muted "couldn’t read" with the server’s sentence as its title', async () => { + packages.list = [SHOWCASE]; + const sentence = 'Metadata service not available'; + stubWire({ 'com.example.showcase': { status: 503, body: { success: false, error: { message: sentence } } } }); + renderLanding(); + + const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations'); + await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed')); + expect(line).toHaveTextContent(t('engine.studio.landing.customizations.failed', 'en-US')); + expect(line).toHaveAttribute('title', expect.stringContaining(sentence)); + expect(line.textContent).not.toMatch(/\d/); + }); + + it('shows no number when the answer is one the route’s declaration refuses', async () => { + packages.list = [SHOWCASE]; + // `counts` missing: off-contract, so no count is read out of it. + stubWire({ + 'com.example.showcase': { status: 200, body: { success: true, data: { packageId: 'com.example.showcase', overrides: [] } } }, + }); + renderLanding(); + + const line = await within(await waitFor(() => cardOf('Showcase'))).findByTestId('studio-landing-customizations'); + await waitFor(() => expect(line).toHaveAttribute('data-state', 'failed')); + expect(line.textContent).not.toMatch(/\d/); + }); + + it('draws no line and sends no request for a writable package or a pair', async () => { + packages.list = [WRITABLE, HOTCRM_PAIR, READONLY_PAIR, SHOWCASE]; + const { requested } = stubWire({ 'com.example.showcase': served('com.example.showcase', 1, 0, 0) }); + renderLanding(); + + await screen.findByText('Showcase pair'); + await waitFor(() => + expect(within(cardOf('Showcase')).getByTestId('studio-landing-customizations')).toHaveAttribute('data-state', 'read'), + ); + expect(within(cardOf('Acme')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); + expect(within(cardOf('HotCRM customizations')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); + expect(within(cardOf('Showcase pair')).queryByTestId('studio-landing-customizations')).not.toBeInTheDocument(); + expect(customizationRequests(requested)).toEqual(['/api/v1/packages/com.example.showcase/customizations']); + }); + + it('paints the landing before the counts arrive, with no number on the line', async () => { + packages.list = [HOTCRM, SHOWCASE]; + const { requested } = stubWire({ hotcrm: 'never', 'com.example.showcase': 'never' }); + renderLanding(); + + await screen.findByText('HotCRM'); + expect(screen.getByText('Showcase')).toBeInTheDocument(); + await waitFor(() => expect(customizationRequests(requested)).toHaveLength(2)); + for (const name of ['HotCRM', 'Showcase']) { + const line = within(cardOf(name)).getByTestId('studio-landing-customizations'); + expect(line).toHaveAttribute('data-state', 'loading'); + expect(line.textContent).not.toMatch(/\d/); + } + }); +}); From 7b29406532f2e877300eeb4cf9e0cae97a8d97a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 09:15:41 +0000 Subject: [PATCH 3/3] test(app-shell): hold the customization read pending in four landing tests; add the changeset The landing now reads each read-only base's customizations. Four landing tests that are not about it would send that read to a real socket, so they stub the reader with a pending promise. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx --- ...8566-studio-landing-customization-counts.md | 18 ++++++++++++++++++ ...ilderLanding.accessibleNames-12510.test.tsx | 5 +++++ .../BuilderLanding.orgScope-11553.test.tsx | 5 +++++ ...uilderLanding.recentPackages-11863.test.tsx | 5 +++++ .../BuilderLanding.setupExit-12513.test.tsx | 5 +++++ 5 files changed, 38 insertions(+) create mode 100644 .changeset/28566-studio-landing-customization-counts.md diff --git a/.changeset/28566-studio-landing-customization-counts.md b/.changeset/28566-studio-landing-customization-counts.md new file mode 100644 index 00000000000..317564d8255 --- /dev/null +++ b/.changeset/28566-studio-landing-customization-counts.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): each read-only base's card on the Studio landing says "n customized, m switched off" + +Clause-②: yes (widening) +Metadata-protocol: none + +Before an upgrade, or just to check, an administrator wants to see which installed apps they changed and what they switched off. Studio's landing now says it on each read-only base's card, for example "4 customized, 2 switched off" (zh 「已定制 4 项,已停用 2 项」). + +- **The numbers are the server's.** Each card reads `GET /api/v1/packages/:id/customizations` once. "Customized" is the served overrides plus the served extensions of the base's `.custom` pair, and "switched off" is the served switched-off count. The answer is judged by `GetPackageCustomizationsResponseSchema`. An answer that schema refuses counts as a failed read. +- **Zero only when the server answered.** While the read is pending, the line shows no number. A failed read shows a muted "Couldn't read customizations", with the server's sentence as its title, never "0". +- **Links.** When the package list holds the base's pair, "n customized" opens the pair in Studio (`/studio/.custom/data`). "m switched off" opens the base's Automations pillar. +- **No line on a writable package or on a pair**, and a pair id is never sent: the route refuses one. +- **The landing paints first.** The counts arrive after it. +- **New strings.** `@object-ui/i18n`'s studio namespace gains seven `engine.studio.landing.customizations.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does. They interpolate `customized`, `switchedOff` and `pair`. diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx index 180d49334dc..596e4427c46 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.accessibleNames-12510.test.tsx @@ -27,6 +27,11 @@ vi.mock('./packages-io', async (importOriginal) => { // The create dialog is closed throughout; its own names are pinned beside it. vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); +// A read-only base's customization read (ADR-0146 D3) is not under test here: +// it stays pending, so no request leaves the file. +vi.mock('./packageCustomizations-io', () => ({ + fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})), +})); import { BuilderLanding } from './BuilderLanding'; import { RecentItemsProvider } from '../../context/RecentItemsProvider'; diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx index 6d147f5bafc..932bc997fa8 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx @@ -25,6 +25,11 @@ vi.mock('./packages-io', async (importOriginal) => { // The create dialog is closed throughout; its form stack is not under test. vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); +// A read-only base's customization read (ADR-0146 D3) is not under test here: +// it stays pending, so no request leaves the file. +vi.mock('./packageCustomizations-io', () => ({ + fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})), +})); import { BuilderLanding } from './BuilderLanding'; diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx index a93d15752ab..966d57cd31e 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.recentPackages-11863.test.tsx @@ -46,6 +46,11 @@ vi.mock('./packages-io', async (importOriginal) => { // The create dialog is closed throughout; its form stack is not under test. vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); +// A read-only base's customization read (ADR-0146 D3) is not under test here: +// it stays pending, so no request leaves the file. +vi.mock('./packageCustomizations-io', () => ({ + fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})), +})); import { BuilderLanding } from './BuilderLanding'; import { RecentItemsProvider } from '../../context/RecentItemsProvider'; diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx index 207191a1056..2106228cd9d 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx @@ -21,6 +21,11 @@ vi.mock('./packages-io', async (importOriginal) => { // The create dialog is closed throughout; its form stack is not under test. vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); +// A read-only base's customization read (ADR-0146 D3) is not under test here: +// it stays pending, so no request leaves the file. +vi.mock('./packageCustomizations-io', () => ({ + fetchPackageCustomizationCounts: vi.fn(() => new Promise(() => {})), +})); import { BuilderLanding, packagedAutomationPath } from './BuilderLanding';