diff --git a/.changeset/28568-all-metadata-customization-marks.md b/.changeset/28568-all-metadata-customization-marks.md new file mode 100644 index 00000000000..5335b2d026e --- /dev/null +++ b/.changeset/28568-all-metadata-customization-marks.md @@ -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. diff --git a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx index 232799bd9c6..536f2c6a9cc 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx @@ -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 @@ -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. @@ -248,6 +259,19 @@ export function MetadataDirectoryPage() { }), }} /> + {customizations.status === 'read' && customizationTotal(customizations.read) > 0 && ( +

+ {tFormat('engine.directory.customizations.total', locale, { + customized: customizationTotal(customizations.read), + baseName: customizations.baseName, + })} +

+ )} + {customizations.status === 'failed' && ( +

+ {tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })} +

+ )} {/* Filter row */}
@@ -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 @@ -409,6 +434,7 @@ function TypeTile({ packageFilter, environmentScope = false, href: hrefOverride, + customizedCount, }: { entry: RichMetadataTypeEntry; locale?: string; @@ -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); @@ -467,6 +495,16 @@ function TypeTile({ > {itemCount} + {customizedCount !== undefined && customizedCount > 0 && ( + + {tFormat('engine.directory.customizations.tile', locale ?? 'en', { customized: customizedCount })} + + )} {invalidCount > 0 && ( .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(); @@ -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. @@ -719,7 +770,24 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string )} + {showCustomizedFilter && ( + + )}
+ {customizations.status === 'failed' && ( +
+ {tFormat('engine.customizationMark.readFailed', locale, { message: customizations.message })} +
+ )} {/* objectui#7611 — the environment scope says WHY a caller cannot define here, in the posture's own terms (#22621 → A): under @@ -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' ? ( + + ) : pairBase ? ( + + ) : null; return ( )} + {markNode} ) : inSetup && c.key === 'description' && typeof value === 'string' && value !== '' ? ( // objectstack#27356 — two lines at most, so a row diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx new file mode 100644 index 00000000000..163fb224171 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx @@ -0,0 +1,276 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28568 — the All metadata area's lists and directory mark a + * base's customizations (ADR-0146 D3, "On top, one surface"), from the + * server's customization read alone (`GET /api/v1/packages/:id/customizations`). + * + * The fixture is a read-only base, HotCRM (`hotcrm`), and its paired + * `hotcrm.custom` package carrying one overridden translation, one added email + * template and one extension field. The real pages and the real reader run; + * only `fetch` and the metadata client are stubbed. Pinned, each against a + * control: + * + * - the translation list marks the served override "Customized (HotCRM)" and + * the "only customizations" filter shows it alone; + * - the email-template list holds the pair's item beside the base's, marked + * "Added", and an override of ANOTHER member of a name marks no row; + * - the directory's object tile counts the extension, and the header the base's total; + * - a failed read draws no mark, no filter and no number, with the server's sentence; + * - a base without a pair is unchanged, and Setup (no area) never reads; + * - in the cross-package leg an item of a pair is labelled with its base. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, within, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact +// specifier (see `ResourceListPage.environmentScope-7611.test.tsx`). +import '@objectstack/spec/identity'; +import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost'; + +const BASE = 'hotcrm'; +const PAIR = 'hotcrm.custom'; +const PLAIN = 'com.example.crm'; + +const LISTS: Record[]> = { + // A served override is served as the base's item, under the base's id. + translation: [ + { name: 'crm_en', label: 'CRM English', _packageId: BASE }, + { name: 'crm_de', label: 'CRM German', _packageId: BASE }, + { name: 'plain_en', label: 'Plain English', _packageId: PLAIN }, + ], + email_template: [ + { name: 'welcome', label: 'Welcome', locale: 'en', _packageId: BASE }, + { name: 'renewal_notice', label: 'Renewal notice', locale: 'en', _packageId: PAIR }, + ], + permission: [ + { name: 'crm_rep', label: 'CRM Rep', _packageId: BASE }, + { name: 'crm_rep_plus', label: 'CRM Rep Plus', _packageId: PAIR }, + ], +}; + +const CLIENT = { + list: async (type: string) => + type === 'package' ? [{ manifest: { id: BASE, name: 'HotCRM', scope: 'installed' } }] : (LISTS[type] ?? []), +}; + +const entry = (type: string, domain: string) => ({ + type, + label: type, + domain, + allowEnvironmentOverlay: true, + allowRuntimeCreate: true, +}); +const ENTRIES = [ + entry('object', 'data'), + entry('translation', 'ui'), + entry('email_template', 'system'), + entry('permission', 'security'), +]; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }), + useGlobalDiagnostics: () => ({ + loading: false, + error: null, + summary: { entries: [], total: 0, scannedTypes: 0, scannedItems: 0 }, + byType: {}, + warnByType: {}, + countsByType: { object: 3, translation: 2, email_template: 1 }, + lockedByType: {}, + packagesByType: Object.fromEntries(ENTRIES.map((e) => [e.type, [BASE, PLAIN]])), + allPackages: [BASE, PLAIN], + reload: () => {}, + }), +})); +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useCanAuthorMetadata: () => true, +})); + +import { MetadataDirectoryPage } from './DirectoryPage'; +import { MetadataResourceListPage } from './ResourceListPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { t, tFormat } from './i18n'; + +registerDefaultMetadataSchemas(); + +/** HotCRM's read: the overridden translation, an override of another member, the extension. */ +const HOTCRM_READ = { + packageId: BASE, + overrides: [ + { type: 'email_template', name: 'welcome', member: 'zh-CN' }, + { type: 'translation', name: 'crm_en', basedOn: '2.1.0' }, + ], + extensions: [{ object: 'crm_account', kind: 'field', name: 'tier__c' }], + switchedOff: [], + counts: { overrides: 2, extensions: 1, switchedOff: 0 }, +}; +const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], counts: { overrides: 0, extensions: 0, switchedOff: 0 } }; + +let customizationsFail = false; +const fetchMock = vi.fn(async (url: string) => { + const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + const u = String(url); + if (u === '/api/v1/packages') { + return json(200, { success: true, data: { packages: [{ manifest: { id: BASE, name: 'HotCRM', scope: 'installed' }, writable: false }] } }); + } + const m = u.match(/^\/api\/v1\/packages\/([^/]+)\/customizations$/); + if (m) { + if (customizationsFail) { + return json(503, { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The customization ledger is unavailable.' } }); + } + return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? HOTCRM_READ : EMPTY_READ }); + } + if (u.startsWith('/api/v1/data/sys_metadata_activation')) { + return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false }); + } + return json(404, { success: false }); +}); + +beforeEach(() => { + customizationsFail = false; + fetchMock.mockClear(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const area = (packageId: string | null): MetadataAreaHost => ({ + packageId, + readOnly: packageId !== null, + basePath: `/studio/${packageId ?? '~all'}/metadata`, +}); + +function mountList(host: MetadataAreaHost | null, type: string, search: string) { + const base = host?.basePath ?? '/apps/setup/metadata'; + const tree = ( + + + + ); + return render(host ? {tree} : tree); +} + +function mountDirectory(host: MetadataAreaHost) { + return render( + + + + + , + ); +} + +const marksOf = (rowName: string) => + within(screen.getByTestId(`metadata-row-${rowName}`)) + .queryAllByTestId('customization-mark') + .map((m) => `${m.getAttribute('data-kind')}:${m.textContent}`); +const customizationReads = () => + fetchMock.mock.calls.filter(([u]) => String(u).endsWith('/customizations')).map(([u]) => String(u)); + +describe('the All metadata lists mark a base\'s customizations (objectstack#28568)', () => { + it('the translation list marks the served override "Customized (HotCRM)", and the filter shows it alone', async () => { + mountList(area(BASE), 'translation', `?package=${BASE}`); + const only = await screen.findByTestId('metadata-only-customizations'); + expect(marksOf('crm_en')).toEqual([`customized:${tFormat('engine.customizationMark.customized', 'en-US', { baseName: 'HotCRM' })}`]); + expect(marksOf('crm_en')).toEqual(['customized:Customized (HotCRM)']); + // CONTROL: the base's item the read does not list carries no mark. + expect(marksOf('crm_de')).toEqual([]); + // Another package's row stays out of the base's list, as before. + expect(screen.queryByTestId('metadata-row-plain_en')).toBeNull(); + + expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en-US', { marked: 1 })); + fireEvent.click(only); + expect(screen.getByTestId('metadata-row-crm_en')).toBeInTheDocument(); + expect(screen.queryByTestId('metadata-row-crm_de')).toBeNull(); + fireEvent.click(only); + expect(screen.getByTestId('metadata-row-crm_de')).toBeInTheDocument(); + }); + + it('the email-template list holds the pair\'s item beside the base\'s, marked "Added"', async () => { + mountList(area(BASE), 'email_template', `?package=${BASE}`); + await screen.findByTestId('metadata-only-customizations'); + expect(marksOf('renewal_notice')).toEqual([`added:${t('engine.customizationMark.added', 'en-US')}`]); + // The read overrides `welcome` in zh-CN only: the en member is the base's own. + expect(marksOf('welcome')).toEqual([]); + }); + + it('a failed read draws no mark and no filter, and says the server\'s sentence', async () => { + customizationsFail = true; + mountList(area(BASE), 'email_template', `?package=${BASE}`); + const failed = await screen.findByTestId('metadata-customizations-failed'); + expect(failed).toHaveTextContent('The customization ledger is unavailable.'); + // The pair's item is still listed (the list read is its own), unmarked. + expect(marksOf('renewal_notice')).toEqual([]); + expect(screen.queryByTestId('metadata-only-customizations')).toBeNull(); + }); + + it('a base without a pair is unchanged: no mark, no filter', async () => { + mountList(area(PLAIN), 'translation', `?package=${PLAIN}`); + await screen.findByTestId('metadata-row-plain_en'); + await vi.waitFor(() => expect(customizationReads()).toEqual([`/api/v1/packages/${PLAIN}/customizations`])); + // Let the read's answer render, then look. + await new Promise((r) => setTimeout(r, 0)); + expect(marksOf('plain_en')).toEqual([]); + expect(screen.queryByTestId('metadata-only-customizations')).toBeNull(); + expect(screen.queryByTestId('metadata-customizations-failed')).toBeNull(); + }); + + it('CONTROL: Setup (no area) reads no customizations and marks nothing', async () => { + mountList(null, 'translation', `?package=${BASE}`); + await screen.findByTestId('metadata-row-crm_en'); + expect(marksOf('crm_en')).toEqual([]); + expect(customizationReads()).toEqual([]); + }); + + it('the cross-package leg labels an item of a pair with its base', async () => { + mountList(area(null), 'permission', '?scope=environment'); + await screen.findByTestId('metadata-row-crm_rep_plus'); + expect(marksOf('crm_rep_plus')).toEqual([ + `customizationOf:${tFormat('engine.customizationMark.customizationOf', 'en-US', { baseName: BASE })}`, + ]); + // CONTROL: the base's own item names no base. + expect(marksOf('crm_rep')).toEqual([]); + expect(customizationReads()).toEqual([]); + }); +}); + +describe('the All metadata directory counts a base\'s customizations (objectstack#28568)', () => { + it('the object tile counts the extension, the translation tile its override, and the header the total', async () => { + mountDirectory(area(BASE)); + const total = await screen.findByTestId('directory-customizations-total'); + expect(total).toHaveTextContent( + tFormat('engine.directory.customizations.total', 'en-US', { customized: 3, baseName: 'HotCRM' }), + ); + expect(screen.getByTestId('directory-tile-customized-object')).toHaveTextContent( + tFormat('engine.directory.customizations.tile', 'en-US', { customized: 1 }), + ); + expect(screen.getByTestId('directory-tile-customized-translation')).toHaveTextContent('1 customized'); + expect(screen.getByTestId('directory-tile-customized-email_template')).toHaveTextContent('1 customized'); + // CONTROL: a type with no served customization draws no number, not "0". + expect(screen.queryByTestId('directory-tile-customized-permission')).toBeNull(); + }); + + it('a failed read draws no number, never "0", and says the server\'s sentence', async () => { + customizationsFail = true; + mountDirectory(area(BASE)); + expect(await screen.findByTestId('directory-customizations-failed')).toHaveTextContent( + 'The customization ledger is unavailable.', + ); + expect(screen.queryByTestId('directory-customizations-total')).toBeNull(); + expect(screen.queryAllByTestId(/^directory-tile-customized-/)).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx new file mode 100644 index 00000000000..8de2e3436db --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The console's one customization mark (objectstack ADR-0146 D3, "On top, one + * surface"): the chip that says an item of an installed base is customized, + * naming the base, or added by the base's paired `.custom` package. The + * All metadata area's lists draw it; the pillar rails (N-19) reuse it, so a + * customization reads the same wherever it is listed. + * + * Which row carries which mark is `customizationMarks.ts`'s rule, from the + * base's served customization read only. {@link usePackageCustomizations} is + * that read for one base, in the four states the pages draw: off (no base to + * read), loading, failed (with the server's sentence) and read. ⛔ A loading + * or failed read draws no mark and no number, never a "0". + */ + +import * as React from 'react'; +import { cn } from '@object-ui/components'; +import { customizationPackageId } from '@objectstack/spec/kernel'; +import { t, tFormat } from '../metadata-admin/i18n.js'; +import { fetchListedPackageManifests } from './packages-io.js'; +import { fetchPackageCustomizations, type PackageCustomizations } from './packageCustomizations-io.js'; +import { indexCustomizations, type CustomizationMarkIndex, type CustomizationMarkKind } from './customizationMarks.js'; + +/** + * The marks the component draws: a list's two ({@link CustomizationMarkKind}), + * and `customizationOf`, which labels an item of a pair with its base where + * no base is open (the cross-package leg). + */ +export type CustomizationMarkChipKind = CustomizationMarkKind | 'customizationOf'; + +const KIND_CLASS: Record = { + customized: 'bg-violet-100 text-violet-800 dark:bg-violet-500/15 dark:text-violet-300', + added: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300', + customizationOf: 'bg-muted text-muted-foreground', +}; + +export function CustomizationMark({ + kind, + baseName, + pairId, + locale, + className, +}: { + kind: CustomizationMarkChipKind; + /** The base's display name ("customized (HotCRM)"); needed by `customized` and `customizationOf`. */ + baseName?: string; + /** The pair's id, named in the chip's title. */ + pairId?: string; + locale: string; + className?: string; +}): React.ReactElement { + const base = baseName ?? ''; + const label = + kind === 'added' + ? t('engine.customizationMark.added', locale) + : tFormat(`engine.customizationMark.${kind}`, locale, { baseName: base }); + const title = pairId + ? tFormat(`engine.customizationMark.${kind}Title`, locale, { baseName: base, pair: pairId }) + : undefined; + return ( + + {label} + + ); +} + +/** One base's customization read, as the pages draw it. */ +export type PackageCustomizationsState = + | { status: 'off' } + | { status: 'loading' } + | { status: 'failed'; message: string } + | { + status: 'read'; + read: PackageCustomizations; + index: CustomizationMarkIndex; + /** The base's listed name, else its id. */ + baseName: string; + }; + +/** + * Read `baseId`'s customizations, or nothing (`off`) when `baseId` is `null` or + * derives no pair: a customization package itself has none to read, and the + * route refuses its id. + * + * The base's name comes from the package list the area already reads (one + * request, `packages-io.ts`); when that list cannot name it, the id stands in. + */ +export function usePackageCustomizations(baseId: string | null): PackageCustomizationsState { + const readable = baseId !== null && customizationPackageId(baseId).ok; + const [answer, setAnswer] = React.useState<{ id: string; state: PackageCustomizationsState } | null>(null); + React.useEffect(() => { + if (!readable || baseId === null) return; + let cancelled = false; + const name = fetchListedPackageManifests().then( + (manifests) => manifests.get(baseId)?.name ?? baseId, + () => baseId, + ); + Promise.all([fetchPackageCustomizations(baseId), name]).then( + ([read, baseName]) => { + if (cancelled) return; + const index = indexCustomizations(baseId, read); + setAnswer({ id: baseId, state: index ? { status: 'read', read, index, baseName } : { status: 'off' } }); + }, + (err: unknown) => { + if (cancelled) return; + setAnswer({ id: baseId, state: { status: 'failed', message: err instanceof Error ? err.message : String(err) } }); + }, + ); + return () => { + cancelled = true; + }; + }, [baseId, readable]); + if (!readable) return { status: 'off' }; + return answer?.id === baseId ? answer.state : { status: 'loading' }; +} diff --git a/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts b/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts new file mode 100644 index 00000000000..ab962c99e88 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28568 — which mark a row of a base's lists carries, and the + * per-type numbers of the directory, from the served customization read only + * (ADR-0146 D3; no diff, no `basedOn` verdict, D2/D6). + */ + +import { describe, it, expect } from 'vitest'; +import { + customizationBaseOfRow, + customizationCountsByType, + customizationMarkOf, + customizationTotal, + indexCustomizations, +} from './customizationMarks'; +import type { PackageCustomizations } from './packageCustomizations-io'; + +const READ: PackageCustomizations = { + packageId: 'hotcrm', + overrides: [ + { type: 'translation', name: 'crm_en' }, + { type: 'email_template', name: 'welcome', member: 'zh-CN' }, + { type: 'object', name: 'crm_account' }, + ], + extensions: [ + { object: 'crm_account', kind: 'field', name: 'tier__c' }, + { object: 'crm_lead', kind: 'index', fields: ['email'] }, + ], + switchedOff: [{ type: 'flow', name: 'nightly_sync' }], + counts: { overrides: 3, extensions: 2, switchedOff: 1 }, +}; +const index = indexCustomizations('hotcrm', READ)!; + +describe('customizationMarkOf (objectstack#28568)', () => { + it('a served override is "customized", whether served under the base or the pair', () => { + expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'hotcrm' }, index)).toBe('customized'); + expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'hotcrm.custom' }, index)).toBe('customized'); + }); + + it('an override of a pair-identified type marks its own member only', () => { + expect(customizationMarkOf({ type: 'email_template', name: 'welcome', member: 'zh-CN', packageId: 'hotcrm' }, index)).toBe('customized'); + expect(customizationMarkOf({ type: 'email_template', name: 'welcome', member: 'en', packageId: 'hotcrm' }, index)).toBeNull(); + expect(customizationMarkOf({ type: 'email_template', name: 'welcome', packageId: 'hotcrm' }, index)).toBeNull(); + }); + + it('any other item of the pair is "added"; the base\'s own items and other packages carry nothing', () => { + expect(customizationMarkOf({ type: 'translation', name: 'crm_fr', packageId: 'hotcrm.custom' }, index)).toBe('added'); + expect(customizationMarkOf({ type: 'translation', name: 'crm_de', packageId: 'hotcrm' }, index)).toBeNull(); + // The type is part of the identity: a same-named item of another type is not the override. + expect(customizationMarkOf({ type: 'view', name: 'crm_en', packageId: 'hotcrm' }, index)).toBeNull(); + expect(customizationMarkOf({ type: 'translation', name: 'crm_en', packageId: 'com.example.other' }, index)).toBeNull(); + expect(customizationMarkOf({ type: 'translation', name: 'crm_en' }, index)).toBeNull(); + }); + + it('a customization package has no pair to index', () => { + expect(indexCustomizations('hotcrm.custom', { ...READ, packageId: 'hotcrm.custom' })).toBeNull(); + }); +}); + +describe('the directory\'s numbers (objectstack#28568)', () => { + it('per type: the served overrides, and the extensions (members) on object', () => { + expect(customizationCountsByType(READ)).toEqual({ translation: 1, email_template: 1, object: 3 }); + }); + + it('the total is the served overrides plus the served extensions, the landing\'s "customized"', () => { + expect(customizationTotal(READ)).toBe(5); + }); + + it('an empty read counts nothing', () => { + const empty = { ...READ, overrides: [], extensions: [], counts: { overrides: 0, extensions: 0, switchedOff: 1 } }; + expect(customizationCountsByType(empty)).toEqual({}); + expect(customizationTotal(empty)).toBe(0); + }); +}); + +describe('customizationBaseOfRow (objectstack#28568)', () => { + it('names the base of a pair id, and nothing else', () => { + expect(customizationBaseOfRow('hotcrm.custom')).toBe('hotcrm'); + expect(customizationBaseOfRow('hotcrm')).toBeNull(); + expect(customizationBaseOfRow(undefined)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/customizationMarks.ts b/ui/packages/app-shell/src/views/studio-design/customizationMarks.ts new file mode 100644 index 00000000000..397da492f51 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/customizationMarks.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which customization mark an item of a base's lists carries, and the + * per-type numbers the All metadata directory draws (objectstack ADR-0146 D3, + * "On top, one surface"). Pure, so the rules are pinned without a render; the + * one component that draws a mark is `CustomizationMark.tsx`. + * + * ## The facts it reads, and only those + * + * - The base's customization read (`GET /api/v1/packages/:id/customizations`, + * ADR-0146 D8 item 4): the overrides its paired `.custom` package + * declares and the server serves, and the members that pair adds to the + * base's objects. + * - The `_packageId` a list row is served under. A served override is served + * as the base's item, 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. + * + * ⛔ Nothing is computed from the documents: no diff against the base and no + * `basedOn` verdict (ADR-0146 D2, D6). An item the read lists as an override + * is "customized"; any other item of the pair is "added". A row of the base + * the read does not list carries no mark. + */ + +import { customizationBasePackageId, customizationPackageId } from '@objectstack/spec/kernel'; +import type { PackageCustomizations } from './packageCustomizations-io.js'; + +/** The marks an item of a base's lists can carry. */ +export type CustomizationMarkKind = 'customized' | 'added'; + +/** One list row, as the marks read it. */ +export interface CustomizationMarkRow { + type: string; + name: string; + /** The bundle member of a type identified by a pair (`email_template`'s locale). */ + member?: string | null; + /** The package the row is served under (`_packageId`). */ + packageId?: string | null; +} + +const overrideKey = (type: string, name: string, member: string | null | undefined) => + JSON.stringify([type, name, member ?? null]); + +/** A base's read, indexed for the list's marks. */ +export interface CustomizationMarkIndex { + baseId: string; + /** The base's paired `.custom` id. */ + pairId: string; + overrides: ReadonlySet; +} + +/** + * Index a base's served read. `null` when the base derives no pair id (a + * customization package itself, or an id the pair grammar refuses): such a + * package has no customizations to mark. + */ +export function indexCustomizations(baseId: string, read: PackageCustomizations): CustomizationMarkIndex | null { + const pair = customizationPackageId(baseId); + if (!pair.ok) return null; + return { + baseId, + pairId: pair.value, + overrides: new Set(read.overrides.map((o) => overrideKey(o.type, o.name, o.member))), + }; +} + +/** + * The mark one row carries, or `null`: + * - "customized" — the read lists it as an override (type, name and, for a + * pair-identified type, its member), served under the base or the pair; + * - "added" — any other item served under the pair; + * - nothing — every other row, the base's own items among them. + */ +export function customizationMarkOf( + row: CustomizationMarkRow, + index: CustomizationMarkIndex, +): CustomizationMarkKind | null { + const pkg = row.packageId ?? null; + if (pkg !== index.baseId && pkg !== index.pairId) return null; + if (index.overrides.has(overrideKey(row.type, row.name, row.member))) return 'customized'; + return pkg === index.pairId ? 'added' : null; +} + +/** + * The customization count each directory tile draws, by type: the read's + * overrides of the type, and on `object` the read's extensions (members, not + * objects — the read's own count). A type with none is absent. + */ +export function customizationCountsByType(read: PackageCustomizations): Record { + const out: Record = {}; + for (const o of read.overrides) out[o.type] = (out[o.type] ?? 0) + 1; + if (read.counts.extensions > 0) out.object = (out.object ?? 0) + read.counts.extensions; + return out; +} + +/** The base's total the directory header draws: the served overrides plus the served extensions. */ +export function customizationTotal(read: PackageCustomizations): number { + return read.counts.overrides + read.counts.extensions; +} + +/** + * The base a row of the cross-package leg is a customization of, or `null`: + * the row is served under a paired `.custom` id (`customizationBasePackageId`). + */ +export function customizationBaseOfRow(packageId: unknown): string | null { + return customizationBasePackageId(packageId); +} 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 index c7714e1860c..35f1c596a11 100644 --- a/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts +++ b/ui/packages/app-shell/src/views/studio-design/packageCustomizations-io.ts @@ -25,7 +25,10 @@ * without a request. */ -import { GetPackageCustomizationsResponseSchema } from '@objectstack/spec/api'; +import { + GetPackageCustomizationsResponseSchema, + type GetPackageCustomizationsResponseParsed, +} from '@objectstack/spec/api'; import { isCustomizationPackage } from '@objectstack/spec/kernel'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; @@ -41,14 +44,19 @@ export interface PackageCustomizationCounts { export const packageCustomizationsUrl = (baseId: string) => `/api/v1/packages/${encodeURIComponent(baseId)}/customizations`; +/** One installed base's customization read, as the route served it. */ +export type PackageCustomizations = GetPackageCustomizationsResponseParsed; + /** - * Read one installed base's customization counts. + * Read one installed base's customizations: the served overrides, extensions, + * switched-off items and their counts (the All metadata area's marks and + * counts, objectstack ADR-0146 D3). * * 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. + * the answer the server served, parsed by that declaration. */ -export async function fetchPackageCustomizationCounts(baseId: string): Promise { +export async function fetchPackageCustomizations(baseId: string): Promise { if (isCustomizationPackage(baseId)) { throw new Error(`Package '${baseId}' is a customization package: customizations are read for its base.`); } @@ -66,6 +74,16 @@ export async function fetchPackageCustomizationCounts(baseId: string): Promise

{ + const { counts } = await fetchPackageCustomizations(baseId); 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 05d68eb5f3c..c73cd6b5fa1 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3497,6 +3497,19 @@ const STUDIO_EN: Record = { 'engine.directory.invalidTooltip': '{count} invalid item(s) — click to fix', 'engine.directory.warnTooltip': '{count} item(s) with warnings', 'engine.directory.itemCountTooltip': '{count} item(s) of this type', + // objectstack ADR-0146 D3 (#28568) — the customization marks, counts and + // filter of the All metadata area, from a base's customization read. + 'engine.customizationMark.customized': 'Customized ({baseName})', + 'engine.customizationMark.customizedTitle': 'The customization package {pair} overrides this item of {baseName}', + 'engine.customizationMark.added': 'Added', + 'engine.customizationMark.addedTitle': 'Added to {baseName} by its customization package {pair}', + 'engine.customizationMark.customizationOf': 'Customization of {baseName}', + 'engine.customizationMark.customizationOfTitle': 'An item of {pair}, the customization package of {baseName}', + 'engine.customizationMark.readFailed': 'Couldn’t read customizations: {message}', + 'engine.directory.customizations.total': '{customized} customized on {baseName}', + 'engine.directory.customizations.tile': '{customized} customized', + 'engine.directory.customizations.tileTitle': 'Customized items of this type: {customized}', + 'engine.list.customizations.only': 'Only customizations ({marked})', 'engine.home.recent': 'Recently viewed', 'engine.diagnostics.title': 'Metadata Diagnostics', 'engine.diagnostics.description': diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index b7a315d4036..803448ee6f4 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -3112,6 +3112,19 @@ const STUDIO_ZH: Record = { 'engine.directory.invalidTooltip': '{count} 个无效项 — 点击修复', 'engine.directory.warnTooltip': '{count} 个条目带有警告', 'engine.directory.itemCountTooltip': '此类型共 {count} 项', + // objectstack ADR-0146 D3 (#28568) — the customization marks, counts and + // filter of the All metadata area, from a base's customization read. + 'engine.customizationMark.customized': '已定制({baseName})', + 'engine.customizationMark.customizedTitle': '定制包 {pair} 覆盖了 {baseName} 的此项', + 'engine.customizationMark.added': '新增', + 'engine.customizationMark.addedTitle': '由 {baseName} 的定制包 {pair} 新增', + 'engine.customizationMark.customizationOf': '{baseName} 的定制', + 'engine.customizationMark.customizationOfTitle': '{pair} 中的项,即 {baseName} 的定制包', + 'engine.customizationMark.readFailed': '无法读取定制:{message}', + 'engine.directory.customizations.total': '{baseName} 已定制 {customized} 项', + 'engine.directory.customizations.tile': '已定制 {customized}', + 'engine.directory.customizations.tileTitle': '此类型已定制的项:{customized}', + 'engine.list.customizations.only': '仅看定制({marked})', 'engine.home.recent': '最近访问', 'engine.diagnostics.title': '元数据诊断', 'engine.diagnostics.description':