From aaac8dac6cd2e42901f142226c4fb981da23b705 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:28:36 +0000 Subject: [PATCH 1/5] wip(app-shell): report index groups by primary object, recent/all views, search Claude-Session: https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK Co-authored-by: Claude --- .../src/views/AppReportIndexView.tsx | 291 ++++++++++++++++-- .../app-shell/src/views/appReportIndex.ts | 210 ++++++++++++- ui/packages/i18n/src/locales/ar.ts | 9 + ui/packages/i18n/src/locales/de.ts | 9 + ui/packages/i18n/src/locales/en.ts | 9 + ui/packages/i18n/src/locales/es.ts | 9 + ui/packages/i18n/src/locales/fr.ts | 9 + ui/packages/i18n/src/locales/ja.ts | 9 + ui/packages/i18n/src/locales/ko.ts | 9 + ui/packages/i18n/src/locales/pt.ts | 9 + ui/packages/i18n/src/locales/ru.ts | 9 + ui/packages/i18n/src/locales/zh.ts | 9 + 12 files changed, 567 insertions(+), 24 deletions(-) diff --git a/ui/packages/app-shell/src/views/AppReportIndexView.tsx b/ui/packages/app-shell/src/views/AppReportIndexView.tsx index 27953d04796..9fdcef04373 100644 --- a/ui/packages/app-shell/src/views/AppReportIndexView.tsx +++ b/ui/packages/app-shell/src/views/AppReportIndexView.tsx @@ -15,22 +15,71 @@ * app's navigation does not name still has a UI path. A report it names is * listed only when the sidebar's gates show the member an entry naming it * (objectstack#26892). + * + * ## Views, sections and search (objectstack#27526) + * + * Two views across the top, the reader's choice kept per user + * (`REPORT_INDEX_VIEW_PREFERENCE`): 全部 lists every report in one section + * per primary object, in the app's navigation order, with the 「其它」 section + * last (`groupReportsByObject`); each section header shows its count and + * collapses its section. 最近 lists the reports the reader opened last, most + * recent first, from the recent-items store, in one list: its order is the + * point of the view, so it is not split by object. A row shows the report's + * label, its object and when the reader last opened it; the search box + * filters the current view. There is no 「我创建的」 view: the served report + * list carries no creator. + * + * Which sections are collapsed is this visit's state only. */ +import { useId, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; -import { BarChart3, ChevronRight, Loader2 } from 'lucide-react'; -import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { BarChart3, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react'; +import { Empty, EmptyTitle, EmptyDescription, Input, cn } from '@object-ui/components'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { useNavMemberGates } from '../hooks/useNavMemberGates.js'; -import { useAppReportEntries } from './appReportIndex.js'; +import { useRecentItems } from '../context/RecentItemsProvider.js'; +import { useWorkspacePreference } from '../utils/workspacePreference.js'; +import { timeAgo } from '../utils/relativeTime.js'; +import { + DEFAULT_REPORT_INDEX_VIEW, + REPORT_INDEX_VIEW_PREFERENCE, + groupReportsByObject, + lastOpenedReports, + navigationObjectOrder, + reportMatchesQuery, + useAppReportEntries, + useReportObjectLabels, + type AppReportEntry, + type ReportIndexView, + type ReportObjectLabels, + type ReportSection, +} from './appReportIndex.js'; + +const VIEWS: readonly ReportIndexView[] = ['all', 'recent']; + +type Translate = ReturnType['t']; + +function viewLabel(t: Translate, view: ReportIndexView): string { + return view === 'recent' + ? t('console.reportIndex.viewRecent', { defaultValue: 'Recent' }) + : t('console.reportIndex.viewAll', { defaultValue: 'All reports' }); +} export function AppReportIndexView() { - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); + const displayLocale = useDisplayLocale(); const { appName } = useParams<{ appName: string }>(); const { app } = useExpressionContext(); const gates = useNavMemberGates(); const { entries, loading } = useAppReportEntries(app, true, gates); + const { recentItems } = useRecentItems(); + const [view, setView] = useWorkspacePreference(REPORT_INDEX_VIEW_PREFERENCE, 'user', DEFAULT_REPORT_INDEX_VIEW); + const [query, setQuery] = useState(''); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const objects = [...new Set(entries.flatMap((entry) => (entry.object ? [entry.object] : [])))]; + const labels = useReportObjectLabels(app, objects); const title = t('console.reportIndex.title', { defaultValue: 'Reports' }); if (loading) { @@ -41,6 +90,71 @@ export function AppReportIndexView() { ); } + const lastOpened = lastOpenedReports(recentItems); + const matches = (entry: AppReportEntry) => + reportMatchesQuery(entry, entry.object ? labels.singular(entry.object) : undefined, query); + const row = (entry: AppReportEntry) => ( + + ); + + let body: ReactNode; + if (view === 'recent') { + const byName = new Map(entries.map((entry) => [entry.name, entry])); + const recent = [...lastOpened.keys()].flatMap((name) => byName.get(name) ?? []); + const shown = recent.filter(matches); + body = + shown.length > 0 ? ( +
    + {shown.map(row)} +
+ ) : ( + + ); + } else { + const sections = groupReportsByObject(entries.filter(matches), navigationObjectOrder(app), labels.plural, language); + body = + sections.length > 0 ? ( +
+ {sections.map((section) => { + const key = section.object ?? ''; + return ( + + setCollapsed((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }) + } + t={t} + > + {section.entries.map(row)} + + ); + })} +
+ ) : ( + + ); + } + return (
@@ -61,27 +175,158 @@ export function AppReportIndexView() {
) : ( -
    - {entries.map((entry) => ( -
  • - - - - {entry.label} - {entry.description && ( - {entry.description} + <> +
    +
    + {VIEWS.map((option) => ( +
  • - ))} + data-testid={`report-index-view-${option}`} + > + {viewLabel(t, option)} + + ))} +
+
+ + setQuery(event.target.value)} + placeholder={t('console.reportIndex.searchPlaceholder', { defaultValue: 'Search reports…' })} + aria-label={t('console.reportIndex.searchLabel', { defaultValue: 'Search reports' })} + className="pl-8" + data-testid="report-index-search" + /> +
+ + {body} + + )} + + ); +} + +/** One collapsible section of the 全部 view: its object's label and count, then its reports. */ +function ReportSectionView({ + section, + labels, + collapsed, + onToggle, + t, + children, +}: { + section: ReportSection; + labels: ReportObjectLabels; + collapsed: boolean; + onToggle: () => void; + t: Translate; + children: ReactNode; +}) { + const headerId = useId(); + const listId = useId(); + const label = + section.object === null + ? t('console.reportIndex.otherSection', { defaultValue: 'Other' }) + : labels.plural(section.object); + const Chevron = collapsed ? ChevronRight : ChevronDown; + return ( +
+

+ +

+ {!collapsed && ( +
    + {children}
)} +
+ ); +} + +/** + * One report: its link carries the label and description only, so the link's + * name is the report's; the object and last-opened cells sit beside it, and + * the link's overlay makes the whole row the click target. + */ +function ReportRow({ + entry, + href, + objectLabel, + opened, + t, +}: { + entry: AppReportEntry; + href: string; + objectLabel: string | undefined; + opened: string; + t: Translate; +}) { + return ( +
  • + + + {entry.label} + {entry.description && {entry.description}} + + {objectLabel && ( + + {objectLabel} + + )} + + {opened ? t('console.reportIndex.lastOpened', { defaultValue: 'Opened {{time}}', time: opened }) : ''} + + +
  • + ); +} + +/** What a view shows when it lists no report. */ +function NoneShown({ title }: { title: string }) { + return ( +
    +

    {title}

    ); } diff --git a/ui/packages/app-shell/src/views/appReportIndex.ts b/ui/packages/app-shell/src/views/appReportIndex.ts index 41159b26597..9bd2d61b089 100644 --- a/ui/packages/app-shell/src/views/appReportIndex.ts +++ b/ui/packages/app-shell/src/views/appReportIndex.ts @@ -65,6 +65,23 @@ * A report no entry names keeps the read rule alone: the index is its only * door (objectui#13020), and no navigation gate speaks about it. * + * ## How the index groups them (objectstack#27526) + * + * By the report's primary object, derived and never declared: the report + * schema carries no grouping key. The primary object is the object of the + * first dataset the report binds (its own `dataset`, then each block's, the + * order {@link reportDatasets} answers) that the dataset list maps to one; a + * report with none is in the 「其它」 section. The sections follow the order + * the app's navigation first names each object in (`type: 'object'` entries, + * {@link flattenNavigation}'s walk), then the objects it names nowhere by + * label, then 「其它」 ({@link groupReportsByObject}). + * + * Which reports the index shows is the reader's choice of view, kept in the + * console's preference store ({@link REPORT_INDEX_VIEW_PREFERENCE}, scope + * `user`): every report, or the ones the reader opened last, read from the + * recent-items store ({@link lastOpenedReports}). The served report list + * carries no creator, so there is no 「我创建的」 view. + * * ## What it reads * * The report list and the dataset list (`MetadataProvider`'s cache, one @@ -73,7 +90,11 @@ * are lazy getters, and a closed palette reads neither. The navigation gates * are the caller's `useNavMemberGates()`, the sidebar's: the reads they make * (the app's object scope, each entry's API binding) are the sidebar's reads, - * through the same readers, and this module makes none of its own. + * through the same readers, and this module makes none of its own. The + * index's object labels ({@link useReportObjectLabels}) read that same object + * scope: the app's package scope the gates already hold, and by name the + * grouped objects it does not hold (for an app with no package, by name + * alone), never the whole registry while nothing else has loaded it. * * Internal module: not re-exported by the package entry. */ @@ -84,6 +105,8 @@ import { useMetadata } from '../providers/MetadataProvider.js'; import { metadataText } from '../hooks/useNavTargetLabel.js'; import { mayReadObject, type ObjectReadCheck } from '../utils/navObjectReadGate.js'; import { navEntriesShownToMember, type NavMemberGates } from '../hooks/useNavMemberGates.js'; +import { defineWorkspacePreference, versionedPreferenceCodec } from '../utils/workspacePreference.js'; +import type { RecentItem } from '../context/RecentItemsProvider.js'; import type { NavigationItem } from '@object-ui/types'; /** The members this module reads off a served report document. */ @@ -104,6 +127,8 @@ export interface AppReportEntry { label: string; /** Its localized description, when it has one. */ description?: string; + /** Its primary object ({@link reportPrimaryObject}), when it has one. */ + object?: string; } const nonEmptyString = (value: unknown): value is string => typeof value === 'string' && value !== ''; @@ -236,6 +261,23 @@ export function mayOpenReport( }); } +/** + * The report's primary object, the one the index groups it under + * (objectstack#27526): the object of the first dataset it binds, in + * {@link reportDatasets}'s order, that `datasetObjects` maps to one. A report + * whose datasets name none, or are unknown, has no primary object. + */ +export function reportPrimaryObject( + report: ServedReport, + datasetObjects: ReadonlyMap, +): string | undefined { + for (const dataset of reportDatasets(report)) { + const object = datasetObjects.get(dataset); + if (object !== undefined) return object; + } + return undefined; +} + /** What {@link useAppReportEntries} answers. */ export interface AppReportEntries { /** The reports the member may open, sorted by label. Empty while loading. */ @@ -281,12 +323,178 @@ export function useAppReportEntries( const text = metadataText(report.label, language); const label = reportLabel({ name, label: text ?? '' }); const description = metadataText(report.description, language); + const object = reportPrimaryObject(report, datasetObjects); return { name, label: nonEmptyString(label) && label.trim() !== '' ? label : name, ...(description ? { description } : {}), + ...(object ? { object } : {}), }; }) .sort((a, b) => a.label.localeCompare(b.label, language)); return { entries, loading: false }; } + +/** + * The object names the `type: 'object'` entries of the app's served + * navigation name, in the order each is first named, at any depth: the order + * of the index's sections (objectstack#27526). + */ +export function navigationObjectOrder(app: unknown): string[] { + const out: string[] = []; + for (const item of flattenNavigation(navigationOf(app) ?? [])) { + if (!item || typeof item !== 'object' || item.type !== 'object') continue; + if (nonEmptyString(item.objectName) && !out.includes(item.objectName)) out.push(item.objectName); + } + return out; +} + +/** One section of the index: the reports of one primary object, or of none. */ +export interface ReportSection { + /** The reports' primary object, or `null` for the 「其它」 section. */ + object: string | null; + /** Its reports, in the order they were handed in. */ + entries: AppReportEntry[]; +} + +/** + * `entries` in sections by primary object (objectstack#27526), each section + * keeping the entries' order: first the objects `order` names, in its order + * (the app's navigation, {@link navigationObjectOrder}), then the other + * objects by `labelOf`, then the 「其它」 section of the reports with no + * primary object. An object with no entry has no section. + */ +export function groupReportsByObject( + entries: readonly AppReportEntry[], + order: readonly string[], + labelOf: (object: string) => string, + language?: string, +): ReportSection[] { + const byObject = new Map(); + const other: AppReportEntry[] = []; + for (const entry of entries) { + if (entry.object === undefined) { + other.push(entry); + continue; + } + const section = byObject.get(entry.object); + if (section) section.push(entry); + else byObject.set(entry.object, [entry]); + } + const rank = (object: string) => { + const at = order.indexOf(object); + return at === -1 ? order.length : at; + }; + const objects = [...byObject.keys()].sort( + (a, b) => rank(a) - rank(b) || labelOf(a).localeCompare(labelOf(b), language) || a.localeCompare(b), + ); + const sections: ReportSection[] = objects.map((object) => ({ object, entries: byObject.get(object)! })); + if (other.length > 0) sections.push({ object: null, entries: other }); + return sections; +} + +/** + * The index's views (objectstack#27526): every report the member may open, + * or the ones they opened last. A reader's choice, never metadata. + */ +export type ReportIndexView = 'all' | 'recent'; + +/** What a reader who never chose sees: every report. */ +export const DEFAULT_REPORT_INDEX_VIEW: ReportIndexView = 'all'; + +/** + * The reader's index view, in the console's preference store + * (`workspacePreference.ts`), scope `user`: one choice across apps, stored as + * `{"value":"all"|"recent","v":1}`. Any other stored value is no choice. + */ +export const REPORT_INDEX_VIEW_PREFERENCE = defineWorkspacePreference({ + name: 'reports.index.view', + codec: versionedPreferenceCodec({ + version: 1, + toRecord: (value) => ({ value }), + fromRecord: (record) => (record.value === 'all' || record.value === 'recent' ? record.value : undefined), + }), +}); + +/** + * When the reader last opened each report, by name, most recent first: the + * `report` entries of the recent-items store and their `visitedAt`. The store + * keeps the last few targets of every kind, so a report opened long ago, or + * only in another browser before the store synced, is not in it. + */ +export function lastOpenedReports(items: readonly RecentItem[]): Map { + const out = new Map(); + for (const item of items) { + if (item.type === 'report' && !out.has(item.name)) out.set(item.name, item.visitedAt); + } + return out; +} + +/** + * Whether `entry` holds the search `query`, ignoring case: in its label, its + * description or its object's label. An empty query matches every entry. + */ +export function reportMatchesQuery(entry: AppReportEntry, objectLabel: string | undefined, query: string): boolean { + const needle = query.trim().toLocaleLowerCase(); + if (needle === '') return true; + return [entry.label, entry.description, objectLabel].some( + (text) => typeof text === 'string' && text.toLocaleLowerCase().includes(needle), + ); +} + +/** How the index names an object (objectstack#27526). */ +export interface ReportObjectLabels { + /** A section header: the object's plural label, else its label, else its name. */ + plural(object: string): string; + /** A row's object cell: the object's label, else its name. */ + singular(object: string): string; +} + +const NO_OBJECTS: readonly unknown[] = []; + +const nonBlank = (text: string | undefined): string | undefined => + typeof text === 'string' && text.trim() !== '' ? text : undefined; + +/** + * The labels of `objects`, the primary objects the index groups by, from the + * object definitions the shell reads for the app: while nothing has loaded + * the whole registry, the app's object scope (its package scope, the one the + * navigation gates read, plus by name the objects it does not hold; for an + * app with no package, the objects by name), else the registry as loaded. + * A label passes through the bundle lookup the console applies to object + * names (`useObjectLabel`). + */ +export function useReportObjectLabels(app: unknown, objects: readonly string[]): ReportObjectLabels { + const metadata = useMetadata(); + const { objectLabel, objectPluralLabel } = useObjectLabel(); + const { language } = useObjectTranslation(); + const packageId = packageOf(app); + const scoped = !!metadata.readObjectScope && metadata.getTypeStatus?.('object') === 'idle'; + let served: unknown; + if (!scoped) served = metadata.getItemsByType('object'); + else if (objects.length === 0) served = NO_OBJECTS; + else served = metadata.readObjectScope!(packageId ? { packageId, names: objects } : { names: objects }).served; + + const byName = new Map(); + for (const item of Array.isArray(served) ? served : []) { + if (!item || typeof item !== 'object') continue; + const name = (item as { name?: unknown }).name; + if (nonEmptyString(name) && !byName.has(name)) byName.set(name, item as { label?: unknown; pluralLabel?: unknown }); + } + const labelText = (object: string) => metadataText(byName.get(object)?.label, language) ?? ''; + return { + plural: (object) => { + const pluralLabel = byName.get(object)?.pluralLabel; + return ( + nonBlank( + objectPluralLabel({ + name: object, + label: labelText(object), + ...(typeof pluralLabel === 'string' ? { pluralLabel } : {}), + }), + ) ?? object + ); + }, + singular: (object) => nonBlank(objectLabel({ name: object, label: labelText(object) })) ?? object, + }; +} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 1e575af4cdb..6a51a5ec857 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2344,6 +2344,15 @@ const ar = { title: "التقارير", emptyTitle: "لا توجد تقارير", emptyDescription: "لا يحتوي هذا التطبيق على تقارير يمكنك فتحها.", + viewsLabel: "طرق عرض التقارير", + viewAll: "كل التقارير", + viewRecent: "الأخيرة", + searchPlaceholder: "ابحث في التقارير…", + searchLabel: "البحث في التقارير", + otherSection: "أخرى", + lastOpened: "فُتح {{time}}", + recentEmpty: "لا توجد تقارير مفتوحة مؤخرًا", + noMatch: "لا توجد تقارير تطابق بحثك.", }, commandPalette: { title: "لوحة الأوامر", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index e0a23f5d052..5356c6615bd 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2129,6 +2129,15 @@ const de = { title: "Berichte", emptyTitle: "Keine Berichte", emptyDescription: "Diese App hat keine Berichte, die Sie öffnen können.", + viewsLabel: "Berichtsansichten", + viewAll: "Alle Berichte", + viewRecent: "Zuletzt geöffnet", + searchPlaceholder: "Berichte suchen…", + searchLabel: "Berichte suchen", + otherSection: "Sonstige", + lastOpened: "Geöffnet {{time}}", + recentEmpty: "Keine zuletzt geöffneten Berichte", + noMatch: "Keine Berichte entsprechen Ihrer Suche.", }, commandPalette: { title: "Befehlspalette", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 1fe4c7407b8..c3cab5f2600 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2297,6 +2297,15 @@ const en = { title: 'Reports', emptyTitle: 'No reports', emptyDescription: 'This app has no reports you can open.', + viewsLabel: 'Report views', + viewAll: 'All reports', + viewRecent: 'Recent', + searchPlaceholder: 'Search reports…', + searchLabel: 'Search reports', + otherSection: 'Other', + lastOpened: 'Opened {{time}}', + recentEmpty: 'No recently opened reports', + noMatch: 'No reports match your search.', }, commandPalette: { title: 'Command palette', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 12e38350c92..63ac4c24ec3 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2185,6 +2185,15 @@ const es = { title: "Informes", emptyTitle: "No hay informes", emptyDescription: "Esta aplicación no tiene informes que puedas abrir.", + viewsLabel: "Vistas de informes", + viewAll: "Todos los informes", + viewRecent: "Recientes", + searchPlaceholder: "Buscar informes…", + searchLabel: "Buscar informes", + otherSection: "Otros", + lastOpened: "Abierto {{time}}", + recentEmpty: "No hay informes abiertos recientemente", + noMatch: "Ningún informe coincide con tu búsqueda.", }, commandPalette: { title: "Paleta de comandos", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 552f517f250..d8088524bfd 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2181,6 +2181,15 @@ const fr = { title: "Rapports", emptyTitle: "Aucun rapport", emptyDescription: "Cette application n’a aucun rapport que vous pouvez ouvrir.", + viewsLabel: "Vues des rapports", + viewAll: "Tous les rapports", + viewRecent: "Récents", + searchPlaceholder: "Rechercher des rapports…", + searchLabel: "Rechercher des rapports", + otherSection: "Autres", + lastOpened: "Ouvert {{time}}", + recentEmpty: "Aucun rapport ouvert récemment", + noMatch: "Aucun rapport ne correspond à votre recherche.", }, commandPalette: { title: "Palette de commandes", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f741aa3a704..b0c75607937 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2131,6 +2131,15 @@ const ja = { title: "レポート", emptyTitle: "レポートはありません", emptyDescription: "このアプリには開けるレポートがありません。", + viewsLabel: "レポートビュー", + viewAll: "すべて", + viewRecent: "最近", + searchPlaceholder: "レポートを検索…", + searchLabel: "レポートを検索", + otherSection: "その他", + lastOpened: "{{time}}に開きました", + recentEmpty: "最近開いたレポートはありません", + noMatch: "検索に一致するレポートはありません。", }, commandPalette: { title: "コマンドパレット", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e1102bc3870..c37385f8f91 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2129,6 +2129,15 @@ const ko = { title: "보고서", emptyTitle: "보고서 없음", emptyDescription: "이 앱에는 열 수 있는 보고서가 없습니다.", + viewsLabel: "보고서 보기", + viewAll: "전체", + viewRecent: "최근", + searchPlaceholder: "보고서 검색…", + searchLabel: "보고서 검색", + otherSection: "기타", + lastOpened: "{{time}} 열람", + recentEmpty: "최근에 연 보고서가 없습니다", + noMatch: "검색과 일치하는 보고서가 없습니다.", }, commandPalette: { title: "명령 팔레트", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index fef9cfe5736..e1c71fcfb90 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2180,6 +2180,15 @@ const pt = { title: "Relatórios", emptyTitle: "Nenhum relatório", emptyDescription: "Este aplicativo não tem relatórios que você possa abrir.", + viewsLabel: "Visualizações de relatórios", + viewAll: "Todos os relatórios", + viewRecent: "Recentes", + searchPlaceholder: "Pesquisar relatórios…", + searchLabel: "Pesquisar relatórios", + otherSection: "Outros", + lastOpened: "Aberto {{time}}", + recentEmpty: "Nenhum relatório aberto recentemente", + noMatch: "Nenhum relatório corresponde à sua pesquisa.", }, commandPalette: { title: "Paleta de comandos", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 6b562f493c2..856b54bef99 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2243,6 +2243,15 @@ const ru = { title: "Отчёты", emptyTitle: "Нет отчётов", emptyDescription: "В этом приложении нет отчётов, которые вы можете открыть.", + viewsLabel: "Представления отчётов", + viewAll: "Все отчёты", + viewRecent: "Недавние", + searchPlaceholder: "Поиск отчётов…", + searchLabel: "Поиск отчётов", + otherSection: "Прочее", + lastOpened: "Открыт {{time}}", + recentEmpty: "Нет недавно открытых отчётов", + noMatch: "Нет отчётов, соответствующих поиску.", }, commandPalette: { title: "Палитра команд", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 185ea43baa2..ebbdc54bf59 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1917,6 +1917,15 @@ const zh = { title: '报表', emptyTitle: '暂无报表', emptyDescription: '此应用没有你可以打开的报表。', + viewsLabel: '报表视图', + viewAll: '全部', + viewRecent: '最近', + searchPlaceholder: '搜索报表…', + searchLabel: '搜索报表', + otherSection: '其它', + lastOpened: '{{time}}打开', + recentEmpty: '暂无最近打开的报表', + noMatch: '没有与搜索匹配的报表。', }, commandPalette: { title: '命令面板', From b8f73b968f9fff7ffad591f39d21a01ef6f6b6b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:42:16 +0000 Subject: [PATCH 2/5] test(app-shell): pin report index grouping, views, persistence and search; changeset Claude-Session: https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK Co-authored-by: Claude --- .changeset/27526-report-index-groups-views.md | 19 + .../AppReportIndex.groupsViews-27526.test.tsx | 401 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 .changeset/27526-report-index-groups-views.md create mode 100644 ui/packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx diff --git a/.changeset/27526-report-index-groups-views.md b/.changeset/27526-report-index-groups-views.md new file mode 100644 index 00000000000..ca10284105b --- /dev/null +++ b/.changeset/27526-report-index-groups-views.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(console): the app's report index groups reports by their object and offers All and Recent views with search + +Clause-②: no +Metadata-protocol: none + +An app's report index (`/apps/APP/reports`) used to list every report the member may open as one flat list. Now: + +- **Grouped by object.** The index has one section per object, in the order the app's navigation names them; objects the navigation does not name come next, by label, and a report that reads no object is in an **Other** section at the end. Each section header shows how many reports it holds and collapses the section. A report's object is derived, not declared: it is the object of the first dataset the report binds that names one (for a joined report, its own dataset first, then its blocks'). Nothing is added to the report schema. +- **All and Recent.** Two views across the top: **All reports**, grouped as above, and **Recent**, the reports the reader opened last, most recent first, from the console's recent items. The choice is the reader's own and is kept in the browser under `os:pref:reports.index.view`, across apps; a reader who never chose sees All reports. +- **Rows and search.** A row shows the report's label and description, its object, and when the reader last opened it. The search box filters the current view by report label, description or object. + +There is no "Created by me" view: the report list the console reads carries no creator. + +What to do: nothing. No metadata changes. diff --git a/ui/packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx b/ui/packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx new file mode 100644 index 00000000000..bfb43e7d3aa --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx @@ -0,0 +1,401 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27526 — the report index groups its reports by primary object + * and offers the reader two views, 全部 and 最近, kept per user. + * + * The report schema carries no grouping key, so the object is derived: the + * object of the first dataset a report binds that the dataset list maps to + * one. Sections follow the app's navigation order, the objects it does not + * name come next by label, and the reports with no object close the list in + * 「其它」. The view is a `workspacePreference` (scope `user`); 最近 reads the + * recent-items store. The served report list carries no creator, so there is + * no 「我创建的」 view. + * + * Pinned through the real `MetadataProvider` (a fake adapter answers + * `/meta/:type`), the real `MePermissionsProvider`, `ExpressionProvider`, + * `RecentItemsProvider` (seeded through its localStorage key) and + * `I18nProvider` in `en`: + * + * (a) grouping: one section per primary object in navigation order, then an + * object the navigation does not name, then 「其它」; a joined report goes + * under the first of its datasets that names an object; each header + * shows its count and collapses its section; + * (b) views: 全部 with no stored choice; picking 最近 stores it in the + * preference store and a new mount reads it back; 最近 lists the opened + * reports most recent first, only those the index lists, with when each + * was opened; an unreadable stored value is no choice; + * (c) search: filters within the current view only, by label or object, and + * says so when nothing matches. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u_agent' }, activeOrganization: null }), +})); + +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; +import { RecentItemsProvider } from '../../context/RecentItemsProvider'; +import { AppReportIndexView } from '../AppReportIndexView'; +import { + REPORT_INDEX_VIEW_PREFERENCE, + groupReportsByObject, + reportPrimaryObject, + type AppReportEntry, +} from '../appReportIndex'; +import { workspacePreferenceStorageKey } from '../../utils/workspacePreference'; + +const PKG = 'com.example.crm'; + +const CRM_APP = { + name: 'crm', + label: 'CRM', + active: true, + _packageId: PKG, + // Opportunity is named before Lead: the sections follow this order, not the labels'. + navigation: [ + { id: 'nav_opps', type: 'object', objectName: 'crm_opportunity', label: 'Deals' }, + { + id: 'nav_sales', + type: 'group', + label: 'Sales', + children: [{ id: 'nav_leads', type: 'object', objectName: 'crm_lead', label: 'Leads' }], + }, + ], +}; + +const REPORTS = [ + { name: 'lead_sources', label: 'Lead Sources', dataset: 'ds_leads', _packageId: PKG }, + { name: 'lead_conversion', label: 'Lead Conversion', dataset: 'ds_leads', _packageId: PKG }, + { name: 'pipeline', label: 'Pipeline', dataset: 'ds_opps', _packageId: PKG }, + // An object the navigation does not name. + { name: 'case_aging', label: 'Case Aging', dataset: 'ds_cases', _packageId: PKG }, + // A joined report: its first dataset is unknown, its second reads leads. + { + name: 'mixed_funnel', + label: 'Mixed Funnel', + type: 'joined', + blocks: [{ dataset: 'ds_gone' }, { dataset: 'ds_leads' }, { dataset: 'ds_opps' }], + _packageId: PKG, + }, + // No dataset at all: 「其它」. + { name: 'scratch', label: 'Scratch Pad', _packageId: PKG }, +]; + +const DATASETS = [ + { name: 'ds_leads', object: 'crm_lead' }, + { name: 'ds_opps', object: 'crm_opportunity' }, + { name: 'ds_cases', object: 'crm_case' }, +]; + +const OBJECTS = [ + { name: 'crm_lead', label: 'Lead', pluralLabel: 'Leads', _packageId: PKG }, + { name: 'crm_opportunity', label: 'Opportunity', pluralLabel: 'Opportunities', _packageId: PKG }, + { name: 'crm_case', label: 'Case', pluralLabel: 'Cases', _packageId: PKG }, +]; + +const ANSWER: MePermissionsResponse = { + authenticated: true, + userId: 'u_agent', + tenantId: null, + roles: [], + permissionSets: ['sales'], + systemPermissions: [], + objects: { + crm_lead: { allowRead: true }, + crm_opportunity: { allowRead: true }, + crm_case: { allowRead: true }, + }, + fields: {}, +}; + +const RECENT_KEY = 'objectui-recent-items:u:u_agent'; +const VIEW_KEY = workspacePreferenceStorageKey(REPORT_INDEX_VIEW_PREFERENCE, 'user'); + +const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString(); + +/** Seed the recent-items store, most recent first, as the provider stores it. */ +function seedRecent(entries: Array<{ name: string; type?: string; visitedAt: string }>) { + localStorage.setItem( + RECENT_KEY, + JSON.stringify( + entries.map(({ name, type = 'report', visitedAt }) => ({ + id: `${type}:${name}`, + type, + name, + href: `/apps/crm/${type}/${name}`, + visitedAt, + })), + ), + ); +} + +function adapterFor(asked: string[]) { + const items: Record = { app: [CRM_APP], report: REPORTS, dataset: DATASETS, object: OBJECTS }; + return { + clearCache: vi.fn(), + getObjectSchema(this: { getClient(): any }, name: string) { + return this.getClient() + .meta.getItem('object', name) + .then((res: any) => (res && typeof res === 'object' && 'item' in res ? res.item : res)); + }, + getClient: () => ({ + meta: { + getItems: (type: string, params?: { packageId?: string }) => { + asked.push(params?.packageId ? `${type}?packageId=${params.packageId}` : type); + return Promise.resolve({ type, items: items[type] ?? [] }); + }, + getItem: () => Promise.resolve({ item: null }), + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function renderIndex() { + const asked: string[] = []; + const fetcher = vi.fn(async () => ({ ok: true, status: 200, json: async () => ANSWER }) as unknown as Response); + const view = render( + + + + + + + + } /> + + + + + + + , + ); + return { asked, unmount: view.unmount }; +} + +/** Each section's header label and its links' text, in DOM order. */ +function sections(): Array<{ header: string; count: string; links: string[] }> { + return screen.queryAllByTestId('report-index-section').map((section) => { + const button = within(section).getByRole('button'); + return { + header: button.querySelector('.truncate')?.textContent ?? '', + count: within(section).getByTestId('report-index-section-count').textContent ?? '', + links: within(section) + .queryAllByRole('link') + .map((a) => a.textContent ?? ''), + }; + }); +} + +/** Wait until the lists have answered and the index has drawn its rows. */ +async function settled() { + await screen.findByRole('link', { name: 'Pipeline' }); +} + +/** Wait until the object scope has answered and the section headers carry the objects' labels. */ +async function labelled() { + await screen.findByRole('button', { name: /Opportunities/ }); +} + +beforeEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); +}); + +describe('the report index groups by primary object (objectstack#27526)', () => { + it('(a) one section per object in navigation order, an unnamed object next, 「其它」 last', async () => { + renderIndex(); + await settled(); + await labelled(); + expect(sections()).toEqual([ + { header: 'Opportunities', count: '1', links: ['Pipeline'] }, + { header: 'Leads', count: '3', links: ['Lead Conversion', 'Lead Sources', 'Mixed Funnel'] }, + { header: 'Cases', count: '1', links: ['Case Aging'] }, + { header: 'Other', count: '1', links: ['Scratch Pad'] }, + ]); + }); + + it('(a) a row shows its report\'s object', async () => { + renderIndex(); + await settled(); + await labelled(); + expect(screen.getByTestId('report-index-object-mixed_funnel')).toHaveTextContent('Lead'); + expect(screen.getByTestId('report-index-object-pipeline')).toHaveTextContent('Opportunity'); + expect(screen.queryByTestId('report-index-object-scratch')).not.toBeInTheDocument(); + }); + + it('(a) a section header collapses and expands its own section only', async () => { + renderIndex(); + await settled(); + await labelled(); + const leads = screen.getByRole('button', { name: /Leads/ }); + expect(leads).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(leads); + expect(leads).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('link', { name: 'Lead Sources' })).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Pipeline' })).toBeInTheDocument(); + fireEvent.click(leads); + expect(screen.getByRole('link', { name: 'Lead Sources' })).toBeInTheDocument(); + }); + + it('CONTROL: the labels come from the app\'s package object scope, never the whole registry', async () => { + const { asked } = renderIndex(); + await settled(); + await labelled(); + expect(asked).toContain(`object?packageId=${PKG}`); + expect(asked).not.toContain('object'); + }); + + it('the primary object is the first dataset that maps to one', () => { + const datasetObjects = new Map([ + ['ds_leads', 'crm_lead'], + ['ds_opps', 'crm_opportunity'], + ]); + expect(reportPrimaryObject({ dataset: 'ds_opps', blocks: [{ dataset: 'ds_leads' }] }, datasetObjects)).toBe( + 'crm_opportunity', + ); + expect(reportPrimaryObject({ blocks: [{ dataset: 'ds_gone' }, { dataset: 'ds_leads' }] }, datasetObjects)).toBe( + 'crm_lead', + ); + expect(reportPrimaryObject({ dataset: 'ds_gone' }, datasetObjects)).toBeUndefined(); + expect(reportPrimaryObject({}, datasetObjects)).toBeUndefined(); + }); + + it('sections: navigation order first, then by label, then 「其它」; an object with no report has none', () => { + const entry = (name: string, object?: string): AppReportEntry => ({ name, label: name, ...(object ? { object } : {}) }); + const grouped = groupReportsByObject( + [entry('a', 'zeta'), entry('b'), entry('c', 'alpha'), entry('d', 'nav_second'), entry('e', 'nav_first')], + ['nav_first', 'nav_unused', 'nav_second'], + (object) => object, + ); + expect(grouped.map((section) => section.object)).toEqual(['nav_first', 'nav_second', 'alpha', 'zeta', null]); + expect(grouped[4].entries.map((e) => e.name)).toEqual(['b']); + }); +}); + +describe('the report index views and their persistence (objectstack#27526)', () => { + it('(b) shows 全部 when the reader never chose', async () => { + renderIndex(); + await settled(); + expect(screen.getByRole('button', { name: 'All reports' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Recent' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getAllByTestId('report-index-section')).toHaveLength(4); + }); + + it('(b) 最近 lists the opened reports the index lists, most recent first, with when each was opened', async () => { + seedRecent([ + { name: 'case_aging', visitedAt: hoursAgo(1) }, + { name: 'crm_lead', type: 'object', visitedAt: hoursAgo(2) }, + { name: 'other_pkg_report', visitedAt: hoursAgo(3) }, + { name: 'pipeline', visitedAt: hoursAgo(5) }, + ]); + renderIndex(); + await settled(); + fireEvent.click(screen.getByRole('button', { name: 'Recent' })); + const list = screen.getByTestId('report-index-recent'); + expect(within(list).getAllByRole('link').map((a) => a.textContent)).toEqual(['Case Aging', 'Pipeline']); + expect(screen.queryByTestId('report-index-section')).not.toBeInTheDocument(); + expect(screen.getByTestId('report-index-opened-case_aging')).toHaveTextContent('Opened 1 hour ago'); + expect(screen.getByTestId('report-index-opened-pipeline')).toHaveTextContent('Opened 5 hours ago'); + }); + + it('(b) the 全部 rows show when the reader last opened a report, and nothing for one never opened', async () => { + seedRecent([{ name: 'pipeline', visitedAt: hoursAgo(5) }]); + renderIndex(); + await settled(); + expect(screen.getByTestId('report-index-opened-pipeline')).toHaveTextContent('Opened 5 hours ago'); + expect(screen.getByTestId('report-index-opened-case_aging')).toHaveTextContent(''); + }); + + it('(b) the choice is stored per user in the preference store and read back on the next mount', async () => { + seedRecent([{ name: 'pipeline', visitedAt: hoursAgo(5) }]); + const first = renderIndex(); + await settled(); + fireEvent.click(screen.getByRole('button', { name: 'Recent' })); + expect(VIEW_KEY).toBe('os:pref:reports.index.view'); + expect(JSON.parse(localStorage.getItem(VIEW_KEY) ?? 'null')).toEqual({ value: 'recent', v: 1 }); + first.unmount(); + + renderIndex(); + await settled(); + expect(screen.getByRole('button', { name: 'Recent' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('report-index-recent')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'All reports' })); + expect(JSON.parse(localStorage.getItem(VIEW_KEY) ?? 'null')).toEqual({ value: 'all', v: 1 }); + expect(screen.getAllByTestId('report-index-section')).toHaveLength(4); + }); + + it('(b) an unreadable stored view is no choice: 全部', async () => { + localStorage.setItem(VIEW_KEY, JSON.stringify({ value: 'mine', v: 1 })); + renderIndex(); + await settled(); + expect(screen.getByRole('button', { name: 'All reports' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('(b) 最近 with nothing opened says so', async () => { + localStorage.setItem(VIEW_KEY, JSON.stringify({ value: 'recent', v: 1 })); + renderIndex(); + await screen.findByText('No recently opened reports'); + expect(screen.queryAllByRole('link')).toHaveLength(0); + }); + + it('CONTROL: there is no 我创建的 view', async () => { + renderIndex(); + await settled(); + const views = screen.getByRole('group', { name: 'Report views' }); + expect(within(views).getAllByRole('button').map((b) => b.textContent)).toEqual(['All reports', 'Recent']); + }); +}); + +describe('the report index search filters the current view (objectstack#27526)', () => { + it('(c) in 全部: by label, dropping the sections left empty and recounting the rest', async () => { + renderIndex(); + await settled(); + await labelled(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search reports' }), { target: { value: 'lead s' } }); + expect(sections()).toEqual([{ header: 'Leads', count: '1', links: ['Lead Sources'] }]); + }); + + it('(c) in 全部: by object label', async () => { + renderIndex(); + await settled(); + await labelled(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search reports' }), { target: { value: 'CASE' } }); + expect(sections()).toEqual([{ header: 'Cases', count: '1', links: ['Case Aging'] }]); + }); + + it('(c) in 最近: only the opened reports, never another view\'s', async () => { + seedRecent([ + { name: 'lead_sources', visitedAt: hoursAgo(1) }, + { name: 'pipeline', visitedAt: hoursAgo(2) }, + ]); + renderIndex(); + await settled(); + fireEvent.click(screen.getByRole('button', { name: 'Recent' })); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search reports' }), { target: { value: 'lead' } }); + // Lead Conversion and Mixed Funnel match but were never opened. + expect(within(screen.getByTestId('report-index-recent')).getAllByRole('link').map((a) => a.textContent)).toEqual([ + 'Lead Sources', + ]); + }); + + it('(c) says so when nothing in the view matches', async () => { + renderIndex(); + await settled(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search reports' }), { target: { value: 'zzz' } }); + expect(screen.getByText('No reports match your search.')).toBeInTheDocument(); + expect(screen.queryByTestId('report-index-section')).not.toBeInTheDocument(); + }); +}); From ac7be1f1788f2c75577ac2e29cb0064bd1aa2375 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:46:17 +0000 Subject: [PATCH 3/5] docs(app-shell): cite the report index preference store by symbol The view preference's docblock named the store by file name, which the new-line-citations check reads as a cross-file address. Name the declaring helper instead. Claude-Session: https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK Co-authored-by: Claude --- ui/packages/app-shell/src/views/appReportIndex.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/appReportIndex.ts b/ui/packages/app-shell/src/views/appReportIndex.ts index 9bd2d61b089..4dd9373370a 100644 --- a/ui/packages/app-shell/src/views/appReportIndex.ts +++ b/ui/packages/app-shell/src/views/appReportIndex.ts @@ -404,7 +404,7 @@ export const DEFAULT_REPORT_INDEX_VIEW: ReportIndexView = 'all'; /** * The reader's index view, in the console's preference store - * (`workspacePreference.ts`), scope `user`: one choice across apps, stored as + * (`defineWorkspacePreference`), scope `user`: one choice across apps, stored as * `{"value":"all"|"recent","v":1}`. Any other stored value is no choice. */ export const REPORT_INDEX_VIEW_PREFERENCE = defineWorkspacePreference({ From 19b7a1d96c1f96341a085adb8d21a08fba32df14 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:11:59 +0000 Subject: [PATCH 4/5] fix(app-shell): give the report index's last-opened cell room for its text On a running console the cell cut "Opened 4 seconds ago" to "Opened 4 seconds a...": eight rem is narrower than the English string at the row's text size. Widen it to twelve rem, keep the truncation for longer translations, and carry the whole text as the cell's title. Claude-Session: https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK Co-authored-by: Claude --- ui/packages/app-shell/src/views/AppReportIndexView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/views/AppReportIndexView.tsx b/ui/packages/app-shell/src/views/AppReportIndexView.tsx index 9fdcef04373..9114d876436 100644 --- a/ui/packages/app-shell/src/views/AppReportIndexView.tsx +++ b/ui/packages/app-shell/src/views/AppReportIndexView.tsx @@ -292,6 +292,7 @@ function ReportRow({ opened: string; t: Translate; }) { + const openedText = opened ? t('console.reportIndex.lastOpened', { defaultValue: 'Opened {{time}}', time: opened }) : ''; return (
  • @@ -312,10 +313,11 @@ function ReportRow({ )} - {opened ? t('console.reportIndex.lastOpened', { defaultValue: 'Opened {{time}}', time: opened }) : ''} + {openedText}
  • From 87651030981137d5a8f333380a97aa795445b8df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:23:53 +0000 Subject: [PATCH 5/5] test(app-shell): classify the report index as an object-scope reader useReportObjectLabels reads getItemsByType('object') when the app's object scope cannot be read, so the objectui#12811 census counts it as a reader of the whole object list and failed on the unclassified file (Test UI 5/16 on ac7be1f1). It reads readObjectScope on the open path, like useServedViewItems and useObjectPresence: add it as a scope row. Claude-Session: https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK Co-authored-by: Claude --- .../src/console/__tests__/openPathObjectReaders-12811.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts index 0214b8c763d..6a61ea5adef 100644 --- a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts +++ b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -63,6 +63,10 @@ const OBJECTS_READERS: Record = { kind: 'scope', why: "an object's presence (the bell's sys_activity feed) is asked by name through the app scope, until something loads the whole registry", }, + 'packages/app-shell/src/views/appReportIndex.ts': { + kind: 'scope', + why: "the report index's section and row labels: the app's package scope plus the grouped objects by name, until something loads the whole registry", + }, 'packages/app-shell/src/hooks/useActionModal.tsx': { kind: 'action', why: "an object's form modal: the open waits for the whole list, whose subforms come from every object",