Skip to content

Commit e76abc5

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jRL4qcnwHDD4M1e4wx
1 parent 0e78922 commit e76abc5

4 files changed

Lines changed: 201 additions & 3 deletions

File tree

‎ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx‎

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
* code and installed packages are listed last: they open for browsing, and each
1616
* card points at the routes that customize one (objectui#11808). Writability is
1717
* the server's verdict read by packages-io (`PkgEntry.writable`, ADR-0070 D2),
18-
* and the server's write gate stays the authority.
18+
* and the server's write gate stays the authority. Each read-only base's card
19+
* says what this deployment changed on it, "n customized, m switched off"
20+
* (ADR-0146 D3), from the server's per-package customization read.
1921
*/
2022

2123
import * as React from 'react';
@@ -28,6 +30,8 @@ import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js';
2830
import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js';
2931
import { isMarketplaceEnabled } from '../../runtime-config.js';
3032
import { fetchPackages, duplicatePackage, isSpecPackageId, type PkgEntry } from './packages-io.js';
33+
import { fetchPackageCustomizationCounts, type PackageCustomizationCounts } from './packageCustomizations-io.js';
34+
import { customizationPackageId, isCustomizationPackage } from '@objectstack/spec/kernel';
3135
import { PackageIdInput } from './PackageIdInput.js';
3236
import { studioOrgScopePath } from './studioScope.js';
3337
import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js';
@@ -64,6 +68,18 @@ export const packagedAutomationPath = () =>
6468
/** objectui#11863 — how many recent packages the landing lists: one row of cards. */
6569
const RECENT_PACKAGES_SHOWN = 3;
6670

71+
/**
72+
* ADR-0146 D3 — one read-only base's customization line, by the state of its
73+
* read. No entry means the read has not answered: the line shows no number.
74+
*/
75+
type CustomizationLine =
76+
| { kind: 'read'; counts: PackageCustomizationCounts }
77+
| { kind: 'failed'; message: string };
78+
79+
/** A package's Studio address on one pillar, the way `open` below spells it. */
80+
const studioPillarPath = (packageId: string, pillar: 'data' | 'automations') =>
81+
`/studio/${encodeURIComponent(packageId)}/${pillar}`;
82+
6783
export function BuilderLanding(): React.ReactElement {
6884
const navigate = useNavigate();
6985
const locale = useMetadataLocale();
@@ -123,6 +139,100 @@ export function BuilderLanding(): React.ReactElement {
123139
const writable = pkgs?.filter((p) => p.writable) ?? [];
124140
const readonly = pkgs?.filter((p) => !p.writable) ?? [];
125141

142+
// ADR-0146 D3 — each read-only BASE's card carries "n customized, m switched
143+
// off", the server's counts (`GET /packages/:id/customizations`), one read
144+
// per card drawn. A pair (`<base>.custom`) draws no line and is never sent:
145+
// the route refuses its id. The page does not wait for these reads. The
146+
// effect is keyed on the joined ids, the primitive it reads (ui/AGENTS.md #10).
147+
const customizationTargetKey = readonly
148+
.filter((p) => !isCustomizationPackage(p.id))
149+
.map((p) => p.id)
150+
.join('\n');
151+
const [customizationLines, setCustomizationLines] = React.useState<Record<string, CustomizationLine>>({});
152+
React.useEffect(() => {
153+
if (!customizationTargetKey) return;
154+
let cancelled = false;
155+
for (const id of customizationTargetKey.split('\n')) {
156+
fetchPackageCustomizationCounts(id).then(
157+
(counts) => {
158+
if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'read', counts } }));
159+
},
160+
(e: unknown) => {
161+
const message = e instanceof Error ? e.message : String(e);
162+
if (!cancelled) setCustomizationLines((prev) => ({ ...prev, [id]: { kind: 'failed', message } }));
163+
},
164+
);
165+
}
166+
return () => {
167+
cancelled = true;
168+
};
169+
}, [customizationTargetKey]);
170+
// The pair is linked only when the package list holds it: a base with no
171+
// pair has nothing to open, and the read never creates one.
172+
const listedIds = new Set(pkgs?.map((p) => p.id) ?? []);
173+
const listedPairOf = (baseId: string): string | null => {
174+
const pair = customizationPackageId(baseId);
175+
return pair.ok && listedIds.has(pair.value) ? pair.value : null;
176+
};
177+
178+
const renderCustomizationLine = (baseId: string): React.ReactNode => {
179+
if (isCustomizationPackage(baseId)) return null;
180+
const line = customizationLines[baseId];
181+
const lineClass = 'text-[10px] leading-4 text-muted-foreground';
182+
if (!line) {
183+
return (
184+
<p data-testid="studio-landing-customizations" data-state="loading" className={lineClass}>
185+
{t('engine.studio.landing.customizations.loading', locale)}
186+
</p>
187+
);
188+
}
189+
if (line.kind === 'failed') {
190+
// Never a "0": the read did not answer. The server's sentence is the title.
191+
return (
192+
<p
193+
data-testid="studio-landing-customizations"
194+
data-state="failed"
195+
title={line.message}
196+
className={`${lineClass} italic opacity-80`}
197+
>
198+
{t('engine.studio.landing.customizations.failed', locale)}
199+
</p>
200+
);
201+
}
202+
const pair = listedPairOf(baseId);
203+
const customized = tFormat('engine.studio.landing.customizations.customized', locale, {
204+
customized: line.counts.customized,
205+
});
206+
const linkClass = 'hover:text-foreground hover:underline';
207+
return (
208+
<p data-testid="studio-landing-customizations" data-state="read" className={lineClass}>
209+
{pair ? (
210+
<Link
211+
to={studioPillarPath(pair, 'data')}
212+
title={tFormat('engine.studio.landing.customizations.pairTitle', locale, { pair })}
213+
data-testid="studio-landing-customizations-pair"
214+
className={linkClass}
215+
>
216+
{customized}
217+
</Link>
218+
) : (
219+
<span>{customized}</span>
220+
)}
221+
{t('engine.studio.landing.customizations.separator', locale)}
222+
<Link
223+
to={studioPillarPath(baseId, 'automations')}
224+
title={t('engine.studio.landing.customizations.switchedOffTitle', locale)}
225+
data-testid="studio-landing-customizations-switched-off"
226+
className={linkClass}
227+
>
228+
{tFormat('engine.studio.landing.customizations.switchedOff', locale, {
229+
switchedOff: line.counts.switchedOff,
230+
})}
231+
</Link>
232+
</p>
233+
);
234+
};
235+
126236
// Duplicate (ADR-0070 D4) clones a writable BASE into a new writable package,
127237
// the "duplicate base" gesture, and drops the user straight into the copy's
128238
// builder. It is offered on writable bases only. A read-only code or
@@ -384,12 +494,13 @@ export function BuilderLanding(): React.ReactElement {
384494
<Lock className="h-2.5 w-2.5" /> {t('engine.studio.pkg.readonly', locale)}
385495
</span>
386496
</button>
387-
<div className="border-t px-3 py-1.5">
497+
<div className="flex flex-col gap-1 border-t px-3 py-1.5">
498+
{renderCustomizationLine(p.id)}
388499
<Link
389500
to={overlayPath(p.id)}
390501
title={t('engine.studio.landing.overlayTitle', locale)}
391502
data-testid="studio-landing-overlay"
392-
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground hover:underline"
503+
className="inline-flex self-start items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground hover:underline"
393504
>
394505
{/* objectui#12513 — the link leaves Studio for Setup: its
395506
name says where it goes, the arrow that it goes out. */}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The per-package customization read the Studio landing draws on each
5+
* read-only base's card (ADR-0146 D3: one line per package, "n customized,
6+
* m switched off").
7+
*
8+
* The numbers are the server's: `GET /api/v1/packages/:id/customizations`
9+
* (ADR-0146 D8 item 4) answers, for one installed base, the items its paired
10+
* `<base>.custom` package overrides, the members that pair adds to the base's
11+
* objects and the base's items switched off in this deployment, each with its
12+
* count. This reader computes no count of its own: "customized" is the served
13+
* `counts.overrides` plus the served `counts.extensions`, the two kinds of
14+
* change the pair carries, and "switched off" is the served
15+
* `counts.switchedOff`.
16+
*
17+
* The answer is judged by the route's own declaration,
18+
* `GetPackageCustomizationsResponseSchema` (strict at every level): an answer
19+
* it refuses is a failed read, never a count read out of an off-contract body.
20+
* A failure carries the server's sentence by the rule every package reader in
21+
* this folder uses (`readEnvelopeFailureText`), else `HTTP <status>`.
22+
*
23+
* ⛔ A pair id is never sent: the route refuses one by its shape
24+
* (`400 VALIDATION_ERROR` naming its base), so this reader refuses it first,
25+
* without a request.
26+
*/
27+
28+
import { GetPackageCustomizationsResponseSchema } from '@objectstack/spec/api';
29+
import { isCustomizationPackage } from '@objectstack/spec/kernel';
30+
import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js';
31+
32+
/** What the landing draws for one base: two served numbers. */
33+
export interface PackageCustomizationCounts {
34+
/** Served overrides plus served extensions of the base's pair. */
35+
customized: number;
36+
/** Served switched-off items of the base. */
37+
switchedOff: number;
38+
}
39+
40+
/** The route of the read, for one base package id. */
41+
export const packageCustomizationsUrl = (baseId: string) =>
42+
`/api/v1/packages/${encodeURIComponent(baseId)}/customizations`;
43+
44+
/**
45+
* Read one installed base's customization counts.
46+
*
47+
* Rejects with the server's sentence on a failed read, and with a sentence of
48+
* its own on an answer the route's declaration refuses; resolves only with
49+
* counts the server served.
50+
*/
51+
export async function fetchPackageCustomizationCounts(baseId: string): Promise<PackageCustomizationCounts> {
52+
if (isCustomizationPackage(baseId)) {
53+
throw new Error(`Package '${baseId}' is a customization package: customizations are read for its base.`);
54+
}
55+
const res = await fetch(packageCustomizationsUrl(baseId), {
56+
credentials: 'include',
57+
headers: { Accept: 'application/json' },
58+
cache: 'no-store',
59+
});
60+
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
61+
if (!res.ok) {
62+
throw new Error(readEnvelopeFailureText(payload) ?? `HTTP ${res.status}`);
63+
}
64+
// The dispatcher envelope `{ success, data }`; the declaration is the `data`.
65+
const parsed = GetPackageCustomizationsResponseSchema.safeParse(payload?.data);
66+
if (!parsed.success) {
67+
throw new Error(`The customization read for '${baseId}' answered a body its declaration refuses.`);
68+
}
69+
const { counts } = parsed.data;
70+
return { customized: counts.overrides + counts.extensions, switchedOff: counts.switchedOff };
71+
}

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3183,6 +3183,15 @@ const STUDIO_EN: Record<string, string> = {
31833183
'engine.studio.landing.overlay': 'Open in Setup',
31843184
'engine.studio.landing.overlayTitle': 'Open this package’s metadata: each type shows whether it accepts an environment overlay',
31853185
'engine.studio.landing.marketplace': 'Or install a template app from the marketplace',
3186+
// ADR-0146 D3 — a read-only base's card says what this deployment changed on
3187+
// it, from the server's per-package customization read.
3188+
'engine.studio.landing.customizations.customized': '{customized} customized',
3189+
'engine.studio.landing.customizations.separator': ', ',
3190+
'engine.studio.landing.customizations.switchedOff': '{switchedOff} switched off',
3191+
'engine.studio.landing.customizations.loading': 'Reading customizations…',
3192+
'engine.studio.landing.customizations.failed': 'Couldn’t read customizations',
3193+
'engine.studio.landing.customizations.pairTitle': 'Open the customization package {pair} in Studio',
3194+
'engine.studio.landing.customizations.switchedOffTitle': 'Open this package’s Automations, where its items are switched on and off',
31863195
// objectui#11553 — the package-less scope: the organization's own flows.
31873196
'engine.studio.landing.orgHeading': 'Not in a package',
31883197
'engine.studio.org.name': 'Organization flows',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2825,6 +2825,13 @@ const STUDIO_ZH: Record<string, string> = {
28252825
'engine.studio.landing.overlay': '在设置中打开',
28262826
'engine.studio.landing.overlayTitle': '打开该软件包的元数据:每种类型都会标明是否接受环境覆盖',
28272827
'engine.studio.landing.marketplace': '或从应用市场安装一个模板应用',
2828+
'engine.studio.landing.customizations.customized': '已定制 {customized} 项',
2829+
'engine.studio.landing.customizations.separator': ',',
2830+
'engine.studio.landing.customizations.switchedOff': '已停用 {switchedOff} 项',
2831+
'engine.studio.landing.customizations.loading': '正在读取定制…',
2832+
'engine.studio.landing.customizations.failed': '无法读取定制',
2833+
'engine.studio.landing.customizations.pairTitle': '在 Studio 中打开定制包 {pair}',
2834+
'engine.studio.landing.customizations.switchedOffTitle': '打开该软件包的自动化,在那里启用或停用其中的项',
28282835
// objectui#11553 — the package-less scope: the organization's own flows.
28292836
'engine.studio.landing.orgHeading': '不属于软件包',
28302837
'engine.studio.org.name': '组织流程',

0 commit comments

Comments
 (0)