Skip to content

Commit 9182ff6

Browse files
feat(studio): the All metadata lists mark a base's customizations, with an only-customizations filter and tile counts (#28667)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbYJa9oMBxT4YrnmXWABr1eP)_ Fixes #28568 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「全部元数据」区里,打开一个已安装的基础包(如 HotCRM)时,每个类型列表同时列出基础包和它的定制包 `hotcrm.custom` 的项。被覆盖的项标「已定制(HotCRM)」,定制包新增的项标「新增」;列表多一个「仅看定制(n)」筛选;目录页每个类型卡片显示已定制数(对象卡片另计扩展字段等),页头显示基础包的定制总数。跨包视图(`~all`)里,定制包的项标「HotCRM 的定制」。 - **为什么改**:ADR-0146 D3「一个界面」:管理员要在同一处看出「我们在 HotCRM 上改了什么、加了什么」,包括四大支柱不覆盖的类型(翻译、邮件模板等)。 - **风险与回滚**:低。只读展示,数据全部来自服务器的 `GET /api/v1/packages/:id/customizations`,不做任何比对或推断;读取中或失败时不画标记、不显示数字(失败时显示服务器原话),绝不显示 0;Setup 页面完全不变、也不发这个请求。回滚即还原本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/customizationMarks.ts`(哪一行标什么的规则)、`ResourceListPage.tsx` 里列表范围加入定制包那一行、`CustomizationMark.tsx`(全控制台唯一的标记组件,N-19 复用)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx packages/app-shell/src/views/studio-design/customizationMarks.test.ts`,预期 2 个文件、16 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **One reader, extended.** `studio-design/packageCustomizations-io.ts` gains `fetchPackageCustomizations(baseId)`. It returns the whole answer of `GET /api/v1/packages/:id/customizations`, judged by `GetPackageCustomizationsResponseSchema`. The landing's `fetchPackageCustomizationCounts` now calls it, so there is still one request path, one schema check and one failure-text rule (`readEnvelopeFailureText`). The pair-id refusal before any request is kept. - **The rule, pure.** `studio-design/customizationMarks.ts` decides which mark a row carries, from two served facts: - the read's `overrides` (type, name and, for a type identified by a pair such as `email_template`'s locale, its `member`); - the `_packageId` the list serves the row under. A served override is served under the base's id (ADR-0146 D8 item 2). An item of the pair that overrides nothing is served under the pair's id. A row the read lists as an override is `customized`, whether it is served under the base or the pair. Any other row of the pair is `added`. The base's other rows and every other package carry nothing. There is no diff and no `basedOn` verdict (D2, D6). The same module counts by type for the directory: the overrides of the type, plus the extension members on `object`. The header total is served overrides plus served extensions, the landing's "customized" figure. - **One mark component.** `studio-design/CustomizationMark.tsx` exports `CustomizationMark` (kinds `customized`, `added`, `customizationOf`) and `usePackageCustomizations(baseId)`. The hook has four states: off, loading, failed with the server's sentence, and read. It names the base from the package list the area already reads, shared through `packages-io.ts`, and falls back to the id. N-19's pillar rails can reuse both. - **List (`ResourceListPage.tsx`).** - Under the area with a base open, the scope admits the pair's rows beside the base's (`pkg === activePackage || pkg === pairId`). - Each row's first cell draws its mark. - "Only customizations (n)" appears once the read has answered and the base has a customization. It narrows the list to the marked rows. - A failed read shows its sentence under the filter row. - In the cross-package leg, a row served under a `BASE.custom` id is labelled "Customization of BASE" (`customizationBasePackageId`). - **Directory (`DirectoryPage.tsx`).** Each tile shows the type's served customization count, and only when it is above zero. The header shows the base's total. A failed read shows the sentence and no number. - **Setup is unchanged.** With no area provider, the host is `null`, so no read is made and no pair row is admitted. - **Strings.** Thirteen keys go into `@object-ui/i18n`'s studio namespace, in `en` and `zh`: `engine.customizationMark.*`, `engine.directory.customizations.*` and `engine.list.customizations.only`. The placeholders are `base`, `pair`, `customized`, `marked` and `message`. There is no `count` token. ## Tests `metadataAreaHost.customizations-28568.test.tsx` runs the real list, the real directory and the real reader under an area provider. Only `fetch` and the metadata client are stubbed. The fixture is base HotCRM with one overridden translation, one added email template, one extension field, and an override of a different email-template member. The cases are the card's five plus three controls: - The translation list marks the override "Customized (HotCRM)". Toggling the filter shows that row alone, and toggling back restores the rest. The base's unlisted row is unmarked. - The email-template list holds the pair's `renewal_notice` marked "Added". The base's `welcome` in `en` is unmarked, because the read overrides only its `zh-CN` member. - The directory's object tile reads "1 customized" from the extension. The translation and email-template tiles read 1 each, and the header reads "3 customized on HotCRM". A type with no customization draws no number. - A failed read (503 with an error envelope) draws no mark, no filter and no number on the list or the directory. It shows the server's sentence. - A base without a pair (an empty read) is unchanged: no mark, no filter, no failure line. - Control: Setup (no area) makes no customization read and marks nothing. - The cross-package leg labels a pair's permission set with its base. It makes no read, and the base's own item names no base. `customizationMarks.test.ts` pins the pure rule (10 cases): override under the base or the pair, member identity, type identity, other packages, a pair id that has no pair, the per-type counts and the total. ## Acceptance notes - **Tiles and the header count what the read lists.** That is overrides, plus extensions on `object`. An "added" item of the pair is marked and filterable in its list, but no tile or header counts it, because N-15's read does not list items the pair adds. So a list's "Only customizations (n)" can be larger than its tile's number. Counting added items needs the read to serve them (a change to N-15's declaration), or a second count, which the card rules out ("from N-15's served counts"). - **The directory's item counts and tile list are the base's alone.** The diagnostics sweep behind them (`useGlobalDiagnostics` in `useMetadata.ts`) is scoped to one package. A type that only the pair holds therefore draws no tile on the base's directory, and a tile's item count does not include the pair's rows. That type's list does show them. Closing this needs a second, pair-scoped sweep in `useMetadata.ts`, which is outside the claimed file surface. It is left for the seat to card or carry. - **A pair row opens in the pair's area.** Its link carries `?package=PAIR` (the row's owning package, ADR-0048), and the area re-roots a `?package=` naming another package onto that package (`areaLocationRedirect`). An added item therefore opens writable, in `/studio/PAIR/metadata/...`. This is the area's existing rule, unchanged here. - **Type identity.** The list route's `:type` is matched against the read's canonical singular `type`. Both come from the same registry vocabulary, so no alias is folded. - **Clause-② measured.** Every new string is in the studio namespace (a plain string table, outside the `locales/*` packs), so the claim's `Clause-②: no` holds. - **NOT MEASURED: a live Console.** No dev server was run. The route was mounted by #28635 and is read live by the landing (#28634). This PR exercises it only through the stubbed answers above. ## Verification Readings are at head `879da8ff`, unless a step names another head. `f42f397b`, the head after it, changes only the changeset text. <details><summary>New and neighbouring suites · 39 files · 356 tests</summary> The set is every ui test file that names `DirectoryPage`, `ResourceListPage`, `packageCustomizations-io`, `CustomizationMark`, `customizationMarks`, `metadataAreaHost` or `StudioMetadataArea` (`git grep -l`), the two new files included. It ran under `os-verify-lock` with `--maxWorkers=2`: Test Files 39 passed (39), Tests 356 passed (356). This is a declared narrowing; CI's Test UI shards run the rest of app-shell. </details> <details><summary>Ablations · both red</summary> Both ran through `scripts/ablation-replace.mjs` against the committed fix. Each restored the file with blob equal to HEAD and an empty `git diff HEAD`. 1. The override key drops `member`: 2 failed (the member-identity unit case and the email-template list case). 2. The list scope drops the pair's rows: 2 failed (the email-template list case and the failed-read case, which still lists the pair's row). </details> <details><summary>Type-check and lint</summary> - `pnpm --filter @object-ui/app-shell run type-check`: exit 0, at `52eb6b11` and again at `879da8ff`. - `pnpm --filter @object-ui/i18n run type-check`: exit 0. - `eslint` on the nine touched files: 0 errors (warnings only). </details> <details><summary>Derived gates · `dispatch-gates --commands` · 34 commands</summary> Every command exited 0. - At `f42f397b`, 31 of them ran, the PR-body gates against this body among them. - Three ran at `52eb6b11`, and the later commits touch none of their inputs beyond one five-line refactor in `ResourceListPage.tsx`: - `pnpm --dir ui exec vitest run scripts/`: 169 files and 4900 tests passed, 2 skipped; - `check:query-options-erasure`; - `check:type-check-debt`, under the lock. `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSKpVAotd33JtFuXkP --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSKpVAotd33JtFuXkP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7dcbc03 commit 9182ff6

10 files changed

Lines changed: 783 additions & 7 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): the All metadata area marks a base's customizations, filters to them and counts them
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
In Studio's All metadata area (ADR-0146 D3, "On top, one surface"), an installed base's lists now show its own items and the items of its paired `BASE.custom` package together. Every mark and number comes from the server's customization read, `GET /api/v1/packages/:id/customizations`. Nothing is computed from the documents: there is no diff and no `basedOn` verdict.
12+
13+
- **Marks.** An item the read lists as an override is marked "Customized (BASE)". For a type identified by name and member, such as an email template's locale, only that member is marked. Any other item of the pair is marked "Added". The base's other items carry no mark.
14+
- **Filter.** Each list offers "Only customizations (n)" once the read has answered and the base has a customization.
15+
- **Counts.** Each directory tile shows how many items of its type the read lists as overrides. The object tile also counts the extension members (fields, validation rules, indexes). The directory header shows the base's total, the served overrides plus the served extensions, which is the landing's "customized" figure.
16+
- **Pending or failed read.** While the read is pending, or after it fails, the area draws no mark, no filter and no number, never a "0". A failure shows the server's sentence. A base with no pair is unchanged, and so is Setup, which makes no customization read.
17+
- **Across packages.** In `/studio/~all/metadata`, an item of a `BASE.custom` package is labelled "Customization of BASE".
18+
- **One mark component.** `CustomizationMark` (`views/studio-design/CustomizationMark.tsx`) draws these marks for the whole console, and the pillar rails will reuse it. `fetchPackageCustomizations` returns the read in full, and `fetchPackageCustomizationCounts` (the landing's reader) now uses it.

‎ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import { buildPackageScopeOptions } from './package-scope.js';
4141
import { diagnosticsHeaderTone, type DiagnosticsHeaderTone } from './diagnostics-tone.js';
4242
import { ENVIRONMENT_AREA_TYPES, SETUP_DATASOURCES_PATH, useMetadataAreaHost } from './metadataAreaHost.js';
4343
import { ENVIRONMENT_SCOPE_QUERY } from './catalog-scope.js';
44+
import { usePackageCustomizations } from '../studio-design/CustomizationMark.js';
45+
import { customizationCountsByType, customizationTotal } from '../studio-design/customizationMarks.js';
4446

4547
/**
4648
* The "View all issues" link's look per sweep tone (objectui#12768). The
@@ -169,6 +171,15 @@ export function MetadataDirectoryPage() {
169171
} = useGlobalDiagnostics(client, 'warning', activePackage ?? undefined);
170172
const diagTone = diagnosticsHeaderTone(diagSummary.entries);
171173

174+
// objectstack ADR-0146 D3 (#28568) — under the area, a base's directory
175+
// counts its customizations: each tile the type's, the header the base's
176+
// total, all from the server's customization read. Setup reads nothing.
177+
const customizations = usePackageCustomizations(host?.packageId ?? null);
178+
const customizedByType = React.useMemo(
179+
() => (customizations.status === 'read' ? customizationCountsByType(customizations.read) : null),
180+
[customizations],
181+
);
182+
172183
// Base set: visible (non-hidden) metadata types contributed by the active
173184
// project package. Fail closed — show nothing until a concrete project
174185
// package is active, so system-only types never surface.
@@ -248,6 +259,19 @@ export function MetadataDirectoryPage() {
248259
}),
249260
}}
250261
/>
262+
{customizations.status === 'read' && customizationTotal(customizations.read) > 0 && (
263+
<p className="text-sm text-muted-foreground mt-1" data-testid="directory-customizations-total">
264+
{tFormat('engine.directory.customizations.total', locale, {
265+
customized: customizationTotal(customizations.read),
266+
baseName: customizations.baseName,
267+
})}
268+
</p>
269+
)}
270+
{customizations.status === 'failed' && (
271+
<p className="text-xs text-muted-foreground mt-1" data-testid="directory-customizations-failed">
272+
{tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })}
273+
</p>
274+
)}
251275

252276
{/* Filter row */}
253277
<div className="flex items-center gap-3 mt-4 flex-wrap">
@@ -348,6 +372,7 @@ export function MetadataDirectoryPage() {
348372
warnCount={warnByType[e.type] ?? 0}
349373
lockedCount={lockedByType[e.type] ?? 0}
350374
itemCount={totalCount}
375+
customizedCount={customizedByType?.[e.type]}
351376
packageFilter={activePackage ?? undefined}
352377
environmentScope={!!host && host.packageId === null}
353378
// ADR-0146 D1 — under the area, the datasource tile
@@ -409,6 +434,7 @@ function TypeTile({
409434
packageFilter,
410435
environmentScope = false,
411436
href: hrefOverride,
437+
customizedCount,
412438
}: {
413439
entry: RichMetadataTypeEntry;
414440
locale?: string;
@@ -423,6 +449,8 @@ function TypeTile({
423449
environmentScope?: boolean;
424450
/** Where the tile leads instead of the type's list (the Studio area's datasource tile). */
425451
href?: string;
452+
/** The type's served customization count (ADR-0146 D3); absent while unread, failed, or none. */
453+
customizedCount?: number;
426454
}) {
427455
// Prefer the locale-table translation; fall back to server's `label` (typically English).
428456
const label = translateMetadataType(entry.type, locale, entry.label);
@@ -467,6 +495,16 @@ function TypeTile({
467495
>
468496
{itemCount}
469497
</Badge>
498+
{customizedCount !== undefined && customizedCount > 0 && (
499+
<Badge
500+
variant="outline"
501+
className="text-[10px] tabular-nums border-violet-500/40 text-violet-700 dark:text-violet-300 bg-violet-500/[0.06]"
502+
title={tFormat('engine.directory.customizations.tileTitle', locale ?? 'en', { customized: customizedCount })}
503+
data-testid={`directory-tile-customized-${entry.type}`}
504+
>
505+
{tFormat('engine.directory.customizations.tile', locale ?? 'en', { customized: customizedCount })}
506+
</Badge>
507+
)}
470508
{invalidCount > 0 && (
471509
<Badge
472510
variant="outline"

‎ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx‎

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'
7373
import { buildPackageScopeOptions } from './package-scope.js';
7474
import { ENVIRONMENT_SCOPE_QUERY, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js';
7575
import { useMetadataAreaHost } from './metadataAreaHost.js';
76+
import { customizationPackageId } from '@objectstack/spec/kernel';
77+
import { CustomizationMark, usePackageCustomizations } from '../studio-design/CustomizationMark.js';
78+
import {
79+
customizationBaseOfRow,
80+
customizationMarkOf,
81+
customizationTotal,
82+
type CustomizationMarkKind,
83+
} from '../studio-design/customizationMarks.js';
7684
import {
7785
audienceAnchorsOf,
7886
hasCatalogActivation,
@@ -195,6 +203,19 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
195203
// package scope is the area's, never picked or repaired here, and a
196204
// read-only package offers no create.
197205
const host = useMetadataAreaHost();
206+
// objectstack ADR-0146 D3 (#28568) — "On top, one surface": with a base open
207+
// in the area, its list holds the base's items and its paired
208+
// `<base>.custom` package's items together, each item of the pair marked
209+
// from the server's customization read ("customized (base)" for a served
210+
// override, "added" for the rest), filterable to those alone. A pending or
211+
// failed read draws no mark; Setup (no host) reads nothing.
212+
const customizations = usePackageCustomizations(host?.packageId ?? null);
213+
const pairId = React.useMemo(() => {
214+
if (!host?.packageId) return null;
215+
const pair = customizationPackageId(host.packageId);
216+
return pair.ok ? pair.value : null;
217+
}, [host?.packageId]);
218+
const [onlyCustomized, setOnlyCustomized] = React.useState(false);
198219
// objectstack#27356 — under Setup the header names the page as the sidebar
199220
// entry that opens it does ("Positions"); the type's label otherwise.
200221
const { app } = useExpressionContext();
@@ -548,15 +569,45 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
548569
// Only rows tagged with the active writable base match. Untagged /
549570
// `sys_metadata`-provenance legacy rows have no scope of their own
550571
// (ADR-0070 D5 — the package-less "Local / Custom" scope is removed).
551-
return pkg === activePackage;
572+
// Under the area a base's list also holds its pair's items (ADR-0146 D3).
573+
return pkg === activePackage || (pairId !== null && pkg === pairId);
552574
}),
553-
[items, activePackage, config, envScope],
575+
[items, activePackage, config, envScope, pairId],
576+
);
577+
578+
// The customization mark of each row, from the base's served read only.
579+
const markPrimaryKey = config.primaryKey ?? 'name';
580+
const markMemberField = config.bundleMemberField;
581+
const markOf = React.useCallback(
582+
(row: ItemRow): CustomizationMarkKind | null => {
583+
if (customizations.status !== 'read') return null;
584+
return customizationMarkOf(
585+
{
586+
type,
587+
name: String(row.item[markPrimaryKey] ?? ''),
588+
member: bundleMemberOf(row.item, markMemberField),
589+
packageId: (row.item as { _packageId?: string })._packageId,
590+
},
591+
customizations.index,
592+
);
593+
},
594+
[customizations, markPrimaryKey, markMemberField, type],
554595
);
596+
const markedCount = React.useMemo(
597+
() => scopedItems.filter((row) => markOf(row) !== null).length,
598+
[scopedItems, markOf],
599+
);
600+
// The filter is offered once the read has answered and the base has a
601+
// customization to show; a base with no pair is unchanged.
602+
const showCustomizedFilter =
603+
customizations.status === 'read' && (markedCount > 0 || customizationTotal(customizations.read) > 0);
604+
const filteringCustomized = onlyCustomized && showCustomizedFilter;
555605

556606
// User-driven filters (search query + source provenance) on top of scope.
557607
const filtered = scopedItems.filter((row) => {
558608
if (!matchesQuery(row.item, query, searchableFields)) return false;
559609
if (sourceFilter !== 'all' && row.source !== sourceFilter) return false;
610+
if (filteringCustomized && markOf(row) === null) return false;
560611
if (activation && statusFilter !== 'all') {
561612
// Only a KNOWN state filters: while the ledger is unread or unreadable,
562613
// no item is either active or inactive to this filter.
@@ -719,7 +770,24 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
719770
</SelectContent>
720771
</Select>
721772
)}
773+
{showCustomizedFilter && (
774+
<Button
775+
size="sm"
776+
variant={filteringCustomized ? 'default' : 'outline'}
777+
className={inSetup ? 'h-8 rounded-full px-3 text-xs' : undefined}
778+
aria-pressed={filteringCustomized}
779+
onClick={() => setOnlyCustomized((v) => !v)}
780+
data-testid="metadata-only-customizations"
781+
>
782+
{tFormat('engine.list.customizations.only', locale, { marked: markedCount })}
783+
</Button>
784+
)}
722785
</div>
786+
{customizations.status === 'failed' && (
787+
<div className="text-xs text-muted-foreground" data-testid="metadata-customizations-failed">
788+
{tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })}
789+
</div>
790+
)}
723791

724792
{/* objectui#7611 — the environment scope says WHY a caller cannot
725793
define here, in the posture's own terms (#22621 → A): under
@@ -924,6 +992,22 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
924992
rowEditSuffix,
925993
rowMember ? `${BUNDLE_MEMBER_PARAM}=${encodeURIComponent(rowMember)}` : '',
926994
)}`;
995+
// ADR-0146 D3 — the row's customization mark: under a base,
996+
// from its served read; in the cross-package leg, an item of a
997+
// pair names its base.
998+
const mark = markOf(row);
999+
const pairBase = host && host.packageId === null ? customizationBaseOfRow(rowPkg) : null;
1000+
const markNode =
1001+
mark && customizations.status === 'read' ? (
1002+
<CustomizationMark
1003+
kind={mark}
1004+
baseName={customizations.baseName}
1005+
pairId={customizations.index.pairId}
1006+
locale={locale}
1007+
/>
1008+
) : pairBase ? (
1009+
<CustomizationMark kind="customizationOf" baseName={pairBase} pairId={rowPkg} locale={locale} />
1010+
) : null;
9271011
return (
9281012
<tr
9291013
key={name + i}
@@ -985,6 +1069,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
9851069
{cell}
9861070
</Link>
9871071
)}
1072+
{markNode}
9881073
</span>
9891074
) : inSetup && c.key === 'description' && typeof value === 'string' && value !== '' ? (
9901075
// objectstack#27356 — two lines at most, so a row

0 commit comments

Comments
 (0)