Skip to content

Commit 862c27a

Browse files
committed
feat(studio): mark a base's customizations in the All metadata lists (wip)
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jSKpVAotd33JtFuXkP
1 parent 3694d3b commit 862c27a

7 files changed

Lines changed: 405 additions & 7 deletions

File tree

‎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+
base: 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: 86 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,44 @@ 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 markOf = React.useCallback(
580+
(row: ItemRow): CustomizationMarkKind | null => {
581+
if (customizations.status !== 'read') return null;
582+
const pk = config.primaryKey ?? 'name';
583+
return customizationMarkOf(
584+
{
585+
type,
586+
name: String(row.item[pk] ?? ''),
587+
member: bundleMemberOf(row.item, config.bundleMemberField),
588+
packageId: (row.item as { _packageId?: string })._packageId,
589+
},
590+
customizations.index,
591+
);
592+
},
593+
[customizations, config.primaryKey, config.bundleMemberField, type],
554594
);
595+
const markedCount = React.useMemo(
596+
() => scopedItems.filter((row) => markOf(row) !== null).length,
597+
[scopedItems, markOf],
598+
);
599+
// The filter is offered once the read has answered and the base has a
600+
// customization to show; a base with no pair is unchanged.
601+
const showCustomizedFilter =
602+
customizations.status === 'read' && (markedCount > 0 || customizationTotal(customizations.read) > 0);
603+
const filteringCustomized = onlyCustomized && showCustomizedFilter;
555604

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

724791
{/* objectui#7611 — the environment scope says WHY a caller cannot
725792
define here, in the posture's own terms (#22621 → A): under
@@ -924,6 +991,22 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
924991
rowEditSuffix,
925992
rowMember ? `${BUNDLE_MEMBER_PARAM}=${encodeURIComponent(rowMember)}` : '',
926993
)}`;
994+
// ADR-0146 D3 — the row's customization mark: under a base,
995+
// from its served read; in the cross-package leg, an item of a
996+
// pair names its base.
997+
const mark = markOf(row);
998+
const pairBase = host && host.packageId === null ? customizationBaseOfRow(rowPkg) : null;
999+
const markNode =
1000+
mark && customizations.status === 'read' ? (
1001+
<CustomizationMark
1002+
kind={mark}
1003+
baseName={customizations.baseName}
1004+
pairId={customizations.index.pairId}
1005+
locale={locale}
1006+
/>
1007+
) : pairBase ? (
1008+
<CustomizationMark kind="customizationOf" baseName={pairBase} pairId={rowPkg} locale={locale} />
1009+
) : null;
9271010
return (
9281011
<tr
9291012
key={name + i}
@@ -985,6 +1068,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
9851068
{cell}
9861069
</Link>
9871070
)}
1071+
{markNode}
9881072
</span>
9891073
) : inSetup && c.key === 'description' && typeof value === 'string' && value !== '' ? (
9901074
// objectstack#27356 — two lines at most, so a row
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The console's one customization mark (objectstack ADR-0146 D3, "On top, one
5+
* surface"): the chip that says an item of an installed base is customized,
6+
* naming the base, or added by the base's paired `<base>.custom` package. The
7+
* All metadata area's lists draw it; the pillar rails (N-19) reuse it, so a
8+
* customization reads the same wherever it is listed.
9+
*
10+
* Which row carries which mark is `customizationMarks.ts`'s rule, from the
11+
* base's served customization read only. {@link usePackageCustomizations} is
12+
* that read for one base, in the four states the pages draw: off (no base to
13+
* read), loading, failed (with the server's sentence) and read. ⛔ A loading
14+
* or failed read draws no mark and no number, never a "0".
15+
*/
16+
17+
import * as React from 'react';
18+
import { cn } from '@object-ui/components';
19+
import { customizationPackageId } from '@objectstack/spec/kernel';
20+
import { t, tFormat } from '../metadata-admin/i18n.js';
21+
import { fetchListedPackageManifests } from './packages-io.js';
22+
import { fetchPackageCustomizations, type PackageCustomizations } from './packageCustomizations-io.js';
23+
import { indexCustomizations, type CustomizationMarkIndex, type CustomizationMarkKind } from './customizationMarks.js';
24+
25+
/**
26+
* The marks the component draws: a list's two ({@link CustomizationMarkKind}),
27+
* and `customizationOf`, which labels an item of a pair with its base where
28+
* no base is open (the cross-package leg).
29+
*/
30+
export type CustomizationMarkChipKind = CustomizationMarkKind | 'customizationOf';
31+
32+
const KIND_CLASS: Record<CustomizationMarkChipKind, string> = {
33+
customized: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-300',
34+
added: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
35+
customizationOf: 'bg-muted text-muted-foreground',
36+
};
37+
38+
export function CustomizationMark({
39+
kind,
40+
baseName,
41+
pairId,
42+
locale,
43+
className,
44+
}: {
45+
kind: CustomizationMarkChipKind;
46+
/** The base's display name ("customized (HotCRM)"); needed by `customized` and `customizationOf`. */
47+
baseName?: string;
48+
/** The pair's id, named in the chip's title. */
49+
pairId?: string;
50+
locale: string;
51+
className?: string;
52+
}): React.ReactElement {
53+
const base = baseName ?? '';
54+
const label =
55+
kind === 'added'
56+
? t('engine.customizationMark.added', locale)
57+
: tFormat(`engine.customizationMark.${kind}`, locale, { base });
58+
const title = pairId
59+
? tFormat(`engine.customizationMark.${kind}Title`, locale, { base, pair: pairId })
60+
: undefined;
61+
return (
62+
<span
63+
data-testid="customization-mark"
64+
data-kind={kind}
65+
title={title}
66+
className={cn(
67+
'inline-flex h-[18px] shrink-0 items-center whitespace-nowrap rounded-[5px] px-1.5 text-[11px] font-medium',
68+
KIND_CLASS[kind],
69+
className,
70+
)}
71+
>
72+
{label}
73+
</span>
74+
);
75+
}
76+
77+
/** One base's customization read, as the pages draw it. */
78+
export type PackageCustomizationsState =
79+
| { status: 'off' }
80+
| { status: 'loading' }
81+
| { status: 'failed'; message: string }
82+
| {
83+
status: 'read';
84+
read: PackageCustomizations;
85+
index: CustomizationMarkIndex;
86+
/** The base's listed name, else its id. */
87+
baseName: string;
88+
};
89+
90+
/**
91+
* Read `baseId`'s customizations, or nothing (`off`) when `baseId` is `null` or
92+
* derives no pair: a customization package itself has none to read, and the
93+
* route refuses its id.
94+
*
95+
* The base's name comes from the package list the area already reads (one
96+
* request, `packages-io.ts`); when that list cannot name it, the id stands in.
97+
*/
98+
export function usePackageCustomizations(baseId: string | null): PackageCustomizationsState {
99+
const readable = baseId !== null && customizationPackageId(baseId).ok;
100+
const [answer, setAnswer] = React.useState<{ id: string; state: PackageCustomizationsState } | null>(null);
101+
React.useEffect(() => {
102+
if (!readable || baseId === null) return;
103+
let cancelled = false;
104+
const name = fetchListedPackageManifests().then(
105+
(manifests) => manifests.get(baseId)?.name ?? baseId,
106+
() => baseId,
107+
);
108+
Promise.all([fetchPackageCustomizations(baseId), name]).then(
109+
([read, baseName]) => {
110+
if (cancelled) return;
111+
const index = indexCustomizations(baseId, read);
112+
setAnswer({ id: baseId, state: index ? { status: 'read', read, index, baseName } : { status: 'off' } });
113+
},
114+
(err: unknown) => {
115+
if (cancelled) return;
116+
setAnswer({ id: baseId, state: { status: 'failed', message: err instanceof Error ? err.message : String(err) } });
117+
},
118+
);
119+
return () => {
120+
cancelled = true;
121+
};
122+
}, [baseId, readable]);
123+
if (!readable) return { status: 'off' };
124+
return answer?.id === baseId ? answer.state : { status: 'loading' };
125+
}

0 commit comments

Comments
 (0)