Skip to content
18 changes: 18 additions & 0 deletions .changeset/28568-all-metadata-customization-marks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(studio): the All metadata area marks a base's customizations, filters to them and counts them

Clause-②: no
Metadata-protocol: none

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.

- **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.
- **Filter.** Each list offers "Only customizations (n)" once the read has answered and the base has a customization.
- **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.
- **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.
- **Across packages.** In `/studio/~all/metadata`, an item of a `BASE.custom` package is labelled "Customization of BASE".
- **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.
38 changes: 38 additions & 0 deletions ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { buildPackageScopeOptions } from './package-scope.js';
import { diagnosticsHeaderTone, type DiagnosticsHeaderTone } from './diagnostics-tone.js';
import { ENVIRONMENT_AREA_TYPES, SETUP_DATASOURCES_PATH, useMetadataAreaHost } from './metadataAreaHost.js';
import { ENVIRONMENT_SCOPE_QUERY } from './catalog-scope.js';
import { usePackageCustomizations } from '../studio-design/CustomizationMark.js';
import { customizationCountsByType, customizationTotal } from '../studio-design/customizationMarks.js';

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

// objectstack ADR-0146 D3 (#28568) — under the area, a base's directory
// counts its customizations: each tile the type's, the header the base's
// total, all from the server's customization read. Setup reads nothing.
const customizations = usePackageCustomizations(host?.packageId ?? null);
const customizedByType = React.useMemo(
() => (customizations.status === 'read' ? customizationCountsByType(customizations.read) : null),
[customizations],
);

// Base set: visible (non-hidden) metadata types contributed by the active
// project package. Fail closed — show nothing until a concrete project
// package is active, so system-only types never surface.
Expand Down Expand Up @@ -248,6 +259,19 @@ export function MetadataDirectoryPage() {
}),
}}
/>
{customizations.status === 'read' && customizationTotal(customizations.read) > 0 && (
<p className="text-sm text-muted-foreground mt-1" data-testid="directory-customizations-total">
{tFormat('engine.directory.customizations.total', locale, {
customized: customizationTotal(customizations.read),
baseName: customizations.baseName,
})}
</p>
)}
{customizations.status === 'failed' && (
<p className="text-xs text-muted-foreground mt-1" data-testid="directory-customizations-failed">
{tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })}
</p>
)}

{/* Filter row */}
<div className="flex items-center gap-3 mt-4 flex-wrap">
Expand Down Expand Up @@ -348,6 +372,7 @@ export function MetadataDirectoryPage() {
warnCount={warnByType[e.type] ?? 0}
lockedCount={lockedByType[e.type] ?? 0}
itemCount={totalCount}
customizedCount={customizedByType?.[e.type]}
packageFilter={activePackage ?? undefined}
environmentScope={!!host && host.packageId === null}
// ADR-0146 D1 — under the area, the datasource tile
Expand Down Expand Up @@ -409,6 +434,7 @@ function TypeTile({
packageFilter,
environmentScope = false,
href: hrefOverride,
customizedCount,
}: {
entry: RichMetadataTypeEntry;
locale?: string;
Expand All @@ -423,6 +449,8 @@ function TypeTile({
environmentScope?: boolean;
/** Where the tile leads instead of the type's list (the Studio area's datasource tile). */
href?: string;
/** The type's served customization count (ADR-0146 D3); absent while unread, failed, or none. */
customizedCount?: number;
}) {
// Prefer the locale-table translation; fall back to server's `label` (typically English).
const label = translateMetadataType(entry.type, locale, entry.label);
Expand Down Expand Up @@ -467,6 +495,16 @@ function TypeTile({
>
{itemCount}
</Badge>
{customizedCount !== undefined && customizedCount > 0 && (
<Badge
variant="outline"
className="text-[10px] tabular-nums border-violet-500/40 text-violet-700 dark:text-violet-300 bg-violet-500/[0.06]"
title={tFormat('engine.directory.customizations.tileTitle', locale ?? 'en', { customized: customizedCount })}
data-testid={`directory-tile-customized-${entry.type}`}
>
{tFormat('engine.directory.customizations.tile', locale ?? 'en', { customized: customizedCount })}
</Badge>
)}
{invalidCount > 0 && (
<Badge
variant="outline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'
import { buildPackageScopeOptions } from './package-scope.js';
import { ENVIRONMENT_SCOPE_QUERY, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js';
import { useMetadataAreaHost } from './metadataAreaHost.js';
import { customizationPackageId } from '@objectstack/spec/kernel';
import { CustomizationMark, usePackageCustomizations } from '../studio-design/CustomizationMark.js';
import {
customizationBaseOfRow,
customizationMarkOf,
customizationTotal,
type CustomizationMarkKind,
} from '../studio-design/customizationMarks.js';
import {
audienceAnchorsOf,
hasCatalogActivation,
Expand Down Expand Up @@ -195,6 +203,19 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
// package scope is the area's, never picked or repaired here, and a
// read-only package offers no create.
const host = useMetadataAreaHost();
// objectstack ADR-0146 D3 (#28568) — "On top, one surface": with a base open
// in the area, its list holds the base's items and its paired
// `<base>.custom` package's items together, each item of the pair marked
// from the server's customization read ("customized (base)" for a served
// override, "added" for the rest), filterable to those alone. A pending or
// failed read draws no mark; Setup (no host) reads nothing.
const customizations = usePackageCustomizations(host?.packageId ?? null);
const pairId = React.useMemo(() => {
if (!host?.packageId) return null;
const pair = customizationPackageId(host.packageId);
return pair.ok ? pair.value : null;
}, [host?.packageId]);
const [onlyCustomized, setOnlyCustomized] = React.useState(false);
// objectstack#27356 — under Setup the header names the page as the sidebar
// entry that opens it does ("Positions"); the type's label otherwise.
const { app } = useExpressionContext();
Expand Down Expand Up @@ -548,15 +569,45 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
// Only rows tagged with the active writable base match. Untagged /
// `sys_metadata`-provenance legacy rows have no scope of their own
// (ADR-0070 D5 — the package-less "Local / Custom" scope is removed).
return pkg === activePackage;
// Under the area a base's list also holds its pair's items (ADR-0146 D3).
return pkg === activePackage || (pairId !== null && pkg === pairId);
}),
[items, activePackage, config, envScope],
[items, activePackage, config, envScope, pairId],
);

// The customization mark of each row, from the base's served read only.
const markPrimaryKey = config.primaryKey ?? 'name';
const markMemberField = config.bundleMemberField;
const markOf = React.useCallback(
(row: ItemRow): CustomizationMarkKind | null => {
if (customizations.status !== 'read') return null;
return customizationMarkOf(
{
type,
name: String(row.item[markPrimaryKey] ?? ''),
member: bundleMemberOf(row.item, markMemberField),
packageId: (row.item as { _packageId?: string })._packageId,
},
customizations.index,
);
},
[customizations, markPrimaryKey, markMemberField, type],
);
const markedCount = React.useMemo(
() => scopedItems.filter((row) => markOf(row) !== null).length,
[scopedItems, markOf],
);
// The filter is offered once the read has answered and the base has a
// customization to show; a base with no pair is unchanged.
const showCustomizedFilter =
customizations.status === 'read' && (markedCount > 0 || customizationTotal(customizations.read) > 0);
const filteringCustomized = onlyCustomized && showCustomizedFilter;

// User-driven filters (search query + source provenance) on top of scope.
const filtered = scopedItems.filter((row) => {
if (!matchesQuery(row.item, query, searchableFields)) return false;
if (sourceFilter !== 'all' && row.source !== sourceFilter) return false;
if (filteringCustomized && markOf(row) === null) return false;
if (activation && statusFilter !== 'all') {
// Only a KNOWN state filters: while the ledger is unread or unreadable,
// no item is either active or inactive to this filter.
Expand Down Expand Up @@ -719,7 +770,24 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
</SelectContent>
</Select>
)}
{showCustomizedFilter && (
<Button
size="sm"
variant={filteringCustomized ? 'default' : 'outline'}
className={inSetup ? 'h-8 rounded-full px-3 text-xs' : undefined}
aria-pressed={filteringCustomized}
onClick={() => setOnlyCustomized((v) => !v)}
data-testid="metadata-only-customizations"
>
{tFormat('engine.list.customizations.only', locale, { marked: markedCount })}
</Button>
)}
</div>
{customizations.status === 'failed' && (
<div className="text-xs text-muted-foreground" data-testid="metadata-customizations-failed">
{tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })}
</div>
)}

{/* objectui#7611 — the environment scope says WHY a caller cannot
define here, in the posture's own terms (#22621 → A): under
Expand Down Expand Up @@ -924,6 +992,22 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
rowEditSuffix,
rowMember ? `${BUNDLE_MEMBER_PARAM}=${encodeURIComponent(rowMember)}` : '',
)}`;
// ADR-0146 D3 — the row's customization mark: under a base,
// from its served read; in the cross-package leg, an item of a
// pair names its base.
const mark = markOf(row);
const pairBase = host && host.packageId === null ? customizationBaseOfRow(rowPkg) : null;
const markNode =
mark && customizations.status === 'read' ? (
<CustomizationMark
kind={mark}
baseName={customizations.baseName}
pairId={customizations.index.pairId}
locale={locale}
/>
) : pairBase ? (
<CustomizationMark kind="customizationOf" baseName={pairBase} pairId={rowPkg} locale={locale} />
) : null;
return (
<tr
key={name + i}
Expand Down Expand Up @@ -985,6 +1069,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
{cell}
</Link>
)}
{markNode}
</span>
) : inSetup && c.key === 'description' && typeof value === 'string' && value !== '' ? (
// objectstack#27356 — two lines at most, so a row
Expand Down
Loading
Loading