Skip to content

Commit a7af1c1

Browse files
feat(console): report index groups reports by object, with All/Recent views and search (#27622)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27526 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:应用的报表索引页从一张平铺列表改为按报表主对象分组(导航顺序,「其它」垫底,组头带计数可折叠),顶部加「全部 / 最近」两个视图(按用户记住选择)和搜索框;每行显示报表名、所属对象、上次打开时间。 - 为什么改:维护者裁决「索引页按对象分组 + 最近/我的/全部」;报表多了以后平铺列表找不到东西。「我创建的」和行内「所有者」按维护者裁决 B 不做(服务端下发的报表没有创建者;所有权随 North Star 报表那一行再做)。 - 风险与回滚:只动 Console 的索引页和十个语言包,不动 spec、不动服务端、不加元数据键;回滚即 revert 本 PR。视图偏好存在浏览器 localStorage 的 `os:pref:reports.index.view`。 - 先看:`ui/packages/app-shell/src/views/appReportIndex.ts`(主对象推导 `reportPrimaryObject`、分组 `groupReportsByObject`、偏好声明)→ `AppReportIndexView.tsx`(视图与搜索)→ 新测试 `AppReportIndex.groupsViews-27526.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx`,预期 17/17 通过。 - 需要你做的:无。裁决 B 已记在卡上(6509006168),席位 ACCEPT 后落地。 ## What changes The app's report index (`/apps/APP/reports`, `AppReportIndexView`) listed every report the member may open as one flat list. It now follows the card's Target 1–3, except the 「我创建的」 view and the row's owner, which the maintainer's ruling B dropped from the card (see Acceptance notes). - **Grouped by primary object, derived, no new key.** `reportPrimaryObject` in `appReportIndex.ts` takes the object of the first dataset the report binds, in `reportDatasets` order (its own `dataset`, then each `blocks[].dataset`), that the dataset list maps to an object. Rule chosen for a report with several datasets: **the first dataset that resolves to an object**, so a joined report whose first block names an unknown dataset still lands under the object of its next block. A report with none goes to the 「其它」 section. - **Section order.** `groupReportsByObject`: first the objects the app's served navigation names (`type: 'object'` entries, in the order `flattenNavigation` first meets them), then the objects it does not name, by label, then 「其它」. Each section header is a button with `aria-expanded` that shows the count and collapses its section (visit-only state). - **Views.** A view switch with two buttons, **All reports** (全部) and **Recent** (最近). The choice is `REPORT_INDEX_VIEW_PREFERENCE`, declared through `defineWorkspacePreference` in the `user` scope, stored as `{"value":"all"|"recent","v":1}` under `os:pref:reports.index.view`; a reader who never chose, or whose stored value the codec does not read, sees All reports. Recent reads the recent-items store (`useRecentItems`, the `report` entries and their `visitedAt`) and lists the index's reports the reader opened, most recent first, as one flat list: the order is the point of that view, so it is not split by object (my reading of Target 1 + 2; the sections apply to All reports). A recent report the index does not list (another app's, an unreadable one) is not shown. - **Rows.** A row is the report's link (label and description, so the link's accessible name stays the report's label, which the existing index pins rely on), its object's label and when the reader last opened it (`timeAgo` in the display locale), with the link's overlay covering the row. The object and time cells hide below `sm`. - **Search.** A search box filters the current view by report label, description or object label, case-insensitively; a view with no match says so. - **Object labels.** `useReportObjectLabels` labels the sections (plural) and rows (singular) from the object definitions the shell reads for the app: the app's package object scope, the same `readObjectScope` read the navigation gates already make, plus the grouped objects outside the package by name; an app with no package reads those objects by name. The index still never reads the whole object registry (the existing CONTROL pin holds, and a new one beside it). - **i18n.** Nine new `console.reportIndex.*` keys in all ten packs: `viewsLabel`, `viewAll`, `viewRecent`, `searchPlaceholder`, `searchLabel`, `otherSection`, `lastOpened`, `recentEmpty`, `noMatch`. The ⌘K palette reads the same `useAppReportEntries`; its entries gain an optional `object` and are otherwise unchanged. `useReportObjectLabels` reads `getItemsByType('object')` only where the app's object scope cannot be read, so it is a reader the objectui#12811 census counts: `console/__tests__/openPathObjectReaders-12811.test.ts` gains one `scope` row for `appReportIndex.ts`, the same kind as `useServedViewItems` and `useObjectPresence`. ## Why Maintainer ruling on the card, quoted verbatim: 「ui(索引页按对象分组 + 最近/我的/全部)」. A flat list stops working once an app ships more than a handful of reports. ## Tests New `ui/packages/app-shell/src/views/__tests__/AppReportIndex.groupsViews-27526.test.tsx` (17 tests), through the real `MetadataProvider`, `MePermissionsProvider`, `ExpressionProvider`, `RecentItemsProvider` and `I18nProvider`: - grouping: sections in navigation order (Opportunity before Lead although L sorts first), an object the navigation does not name next, 「其它」 last, with counts; a joined report goes under the first of its datasets that resolves; the row's object cell; a header collapses only its own section; labels read from the package scope and never the whole registry; - views: All reports with no stored choice; Recent lists opened reports most recent first, only the index's, with "Opened N hours ago"; the choice is written as `{"value":"recent","v":1}` and read back by a fresh mount; an unreadable stored value is no choice; Recent with nothing opened says so; no 我创建的 button; - search: within All reports by label (empty sections dropped, counts recomputed) and by object label; within Recent only among opened reports; the no-match message. The three existing index files (`AppReportIndex-13020`, `.navContributions-13273`, `.navGates-26892`) pass unchanged. Run at head `82d69466` (after the latest merge of `main`): - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/__tests__/ packages/app-shell/src/__tests__/` plus 56 named files: the ⌘K palette, `AppContent`, `AppHeader`, sidebar and route-chunk files that read `useAppReportEntries` or the console routes, and the objectui#12811 census: Test Files 164 passed (164), Tests 1523 passed (1523). The rest of the app-shell suite is CI's `Test UI`, green on this head. - `pnpm --dir ui exec vitest run packages/i18n/`: Test Files 90 passed, Tests 1380 passed, 13 skipped (the packs' key-parity pins included). - Four ablations, each through `scripts/ablation-replace.mjs` (restored and proved restored): navigation order replaced by label order, 2 tests red; primary object taken from the first dataset only, 3 red; the view held in `useState` instead of the preference, 2 red; Recent listing every report, 3 red. ## Acceptance notes - **Ruled B: 「我创建的」 and the row's owner are dropped.** Ruling-ref: 6509006168. The maintainer ruled B on #27526 (on dev report 6500744179): both leave this card, and report ownership comes back with the North Star reports line, not here. No spec, engine or metadata-protocol change. The measurement behind the question stays on record: the served `/meta/report` item is the stored row's parsed body plus read decorations (`storedOverlayEntries` in `packages/metadata-protocol/src/protocol-stored-overlay-reads.ts`); a report the signed-in admin saved through `PUT /api/v1/meta/report/NAME` on a live showcase has `sys_metadata.created_by` set, yet neither `/meta/report` nor `/meta/report/NAME` serves a creator; `ReportSchema` declares no owner key; a code-authored report (every HotCRM report) has no creator; and a code package refuses report saves (422), so a code app's index never lists a member's console-saved report. The index renders no 我创建的 control, and a test pins its absence. - **Recent is one flat list, not grouped**, and **sections apply to All reports**: my reading of Target 1 with Target 2. - **The primary-object rule** is the first dataset that resolves to an object, not strictly the first dataset (see What changes). - **Last-opened time** comes from the recent-items store, which keeps the reader's last 20 targets of every kind: a report opened long ago shows no time and is not in Recent. - **Docs.** `ui/AGENTS.md` #2 asks a feature to update the package README and its `content/docs/frontend/` page; no docs page describes the report index today and the dispatch's file surface excludes docs, so none is written here. - **Live run.** HotCRM is outside this session's repository scope, so the run booted `examples/app-showcase` (`objectstack dev --ui --fresh`) on the built console. A code package is read-only for report saves, so four env-wide reports over the invoice, account and project datasets and a console app naming those objects were saved. Its index showed Invoices 2, Accounts 1, Projects 1, Tasks 2 in navigation order; Recent listed the two opened reports with their times; the stored choice survived a reload; search narrowed to one section. The run found the last-opened cell cutting its text, fixed in `19b7a1d9`. Screenshots are in the project thread; nothing is uploaded here. ## Verification <details><summary>Typecheck and lint (head `82d69466`)</summary> - `pnpm --filter @object-ui/app-shell type-check`: exit 0, after rebuilding the `@object-ui/app-shell^...` closure the merge moved; the new test file is in `tsconfig.test.json`'s program (`--listFilesOnly` count 1). - `pnpm --filter @object-ui/i18n type-check`: exit 0. - `eslint` on the four touched app-shell source and test files: 0 errors. Its 5 warnings are `no-explicit-any`: 3 on lines of `appReportIndex.ts` that `main` already has, 2 on the `getObjectSchema` test double the new file copies from `AppReportIndex-13020.test.tsx`. - `pnpm --dir ui check:new-line-citations`: 0 new citations. - Declarations re-measured on the final diff: `appReportIndex.ts` is internal (not re-exported by the package entry), the preference is a browser storage key, and the ten packs gain keys only, so `Clause-②: no`; no metadata author can write anything new, so `Metadata-protocol: none`. </details> <details><summary>Derived gates</summary> At head `82d69466`, `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived (the same list as at every earlier head); each run and recorded, reconciled with `--ran`: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". `pnpm --dir ui exec vitest run scripts/` timed out in one tree-wide scan file (`check-vi-mock-inherit.test.ts`, 15 s) inside the gate loop and passed when run alone (Test Files 169 passed, 2 skipped); the line below is the solo run. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYhwxF1tGkRRemmhXK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01eab23 commit a7af1c1

15 files changed

Lines changed: 993 additions & 24 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): the app's report index groups reports by their object and offers All and Recent views with search
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An app's report index (`/apps/APP/reports`) used to list every report the member may open as one flat list. Now:
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
17+
There is no "Created by me" view: the report list the console reads carries no creator.
18+
19+
What to do: nothing. No metadata changes.

‎ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
6363
kind: 'scope',
6464
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",
6565
},
66+
'packages/app-shell/src/views/appReportIndex.ts': {
67+
kind: 'scope',
68+
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",
69+
},
6670
'packages/app-shell/src/hooks/useActionModal.tsx': {
6771
kind: 'action',
6872
why: "an object's form modal: the open waits for the whole list, whose subforms come from every object",

‎ui/packages/app-shell/src/views/AppReportIndexView.tsx‎

Lines changed: 270 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,71 @@
1515
* app's navigation does not name still has a UI path. A report it names is
1616
* listed only when the sidebar's gates show the member an entry naming it
1717
* (objectstack#26892).
18+
*
19+
* ## Views, sections and search (objectstack#27526)
20+
*
21+
* Two views across the top, the reader's choice kept per user
22+
* (`REPORT_INDEX_VIEW_PREFERENCE`): 全部 lists every report in one section
23+
* per primary object, in the app's navigation order, with the 「其它」 section
24+
* last (`groupReportsByObject`); each section header shows its count and
25+
* collapses its section. 最近 lists the reports the reader opened last, most
26+
* recent first, from the recent-items store, in one list: its order is the
27+
* point of the view, so it is not split by object. A row shows the report's
28+
* label, its object and when the reader last opened it; the search box
29+
* filters the current view. There is no 「我创建的」 view: the served report
30+
* list carries no creator.
31+
*
32+
* Which sections are collapsed is this visit's state only.
1833
*/
1934

35+
import { useId, useState, type ReactNode } from 'react';
2036
import { Link, useParams } from 'react-router-dom';
21-
import { BarChart3, ChevronRight, Loader2 } from 'lucide-react';
22-
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
23-
import { useObjectTranslation } from '@object-ui/i18n';
37+
import { BarChart3, ChevronDown, ChevronRight, Loader2, Search } from 'lucide-react';
38+
import { Empty, EmptyTitle, EmptyDescription, Input, cn } from '@object-ui/components';
39+
import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
2440
import { useExpressionContext } from '../providers/ExpressionProvider.js';
2541
import { useNavMemberGates } from '../hooks/useNavMemberGates.js';
26-
import { useAppReportEntries } from './appReportIndex.js';
42+
import { useRecentItems } from '../context/RecentItemsProvider.js';
43+
import { useWorkspacePreference } from '../utils/workspacePreference.js';
44+
import { timeAgo } from '../utils/relativeTime.js';
45+
import {
46+
DEFAULT_REPORT_INDEX_VIEW,
47+
REPORT_INDEX_VIEW_PREFERENCE,
48+
groupReportsByObject,
49+
lastOpenedReports,
50+
navigationObjectOrder,
51+
reportMatchesQuery,
52+
useAppReportEntries,
53+
useReportObjectLabels,
54+
type AppReportEntry,
55+
type ReportIndexView,
56+
type ReportObjectLabels,
57+
type ReportSection,
58+
} from './appReportIndex.js';
59+
60+
const VIEWS: readonly ReportIndexView[] = ['all', 'recent'];
61+
62+
type Translate = ReturnType<typeof useObjectTranslation>['t'];
63+
64+
function viewLabel(t: Translate, view: ReportIndexView): string {
65+
return view === 'recent'
66+
? t('console.reportIndex.viewRecent', { defaultValue: 'Recent' })
67+
: t('console.reportIndex.viewAll', { defaultValue: 'All reports' });
68+
}
2769

2870
export function AppReportIndexView() {
29-
const { t } = useObjectTranslation();
71+
const { t, language } = useObjectTranslation();
72+
const displayLocale = useDisplayLocale();
3073
const { appName } = useParams<{ appName: string }>();
3174
const { app } = useExpressionContext();
3275
const gates = useNavMemberGates();
3376
const { entries, loading } = useAppReportEntries(app, true, gates);
77+
const { recentItems } = useRecentItems();
78+
const [view, setView] = useWorkspacePreference(REPORT_INDEX_VIEW_PREFERENCE, 'user', DEFAULT_REPORT_INDEX_VIEW);
79+
const [query, setQuery] = useState('');
80+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
81+
const objects = [...new Set(entries.flatMap((entry) => (entry.object ? [entry.object] : [])))];
82+
const labels = useReportObjectLabels(app, objects);
3483
const title = t('console.reportIndex.title', { defaultValue: 'Reports' });
3584

3685
if (loading) {
@@ -41,6 +90,71 @@ export function AppReportIndexView() {
4190
);
4291
}
4392

93+
const lastOpened = lastOpenedReports(recentItems);
94+
const matches = (entry: AppReportEntry) =>
95+
reportMatchesQuery(entry, entry.object ? labels.singular(entry.object) : undefined, query);
96+
const row = (entry: AppReportEntry) => (
97+
<ReportRow
98+
key={entry.name}
99+
entry={entry}
100+
href={`/apps/${appName}/report/${encodeURIComponent(entry.name)}`}
101+
objectLabel={entry.object ? labels.singular(entry.object) : undefined}
102+
opened={timeAgo(lastOpened.get(entry.name), displayLocale)}
103+
t={t}
104+
/>
105+
);
106+
107+
let body: ReactNode;
108+
if (view === 'recent') {
109+
const byName = new Map(entries.map((entry) => [entry.name, entry]));
110+
const recent = [...lastOpened.keys()].flatMap((name) => byName.get(name) ?? []);
111+
const shown = recent.filter(matches);
112+
body =
113+
shown.length > 0 ? (
114+
<ul className="divide-y" aria-label={viewLabel(t, 'recent')} data-testid="report-index-recent">
115+
{shown.map(row)}
116+
</ul>
117+
) : (
118+
<NoneShown
119+
title={
120+
recent.length === 0
121+
? t('console.reportIndex.recentEmpty', { defaultValue: 'No recently opened reports' })
122+
: t('console.reportIndex.noMatch', { defaultValue: 'No reports match your search.' })
123+
}
124+
/>
125+
);
126+
} else {
127+
const sections = groupReportsByObject(entries.filter(matches), navigationObjectOrder(app), labels.plural, language);
128+
body =
129+
sections.length > 0 ? (
130+
<div className="divide-y" data-testid="report-index-sections">
131+
{sections.map((section) => {
132+
const key = section.object ?? '';
133+
return (
134+
<ReportSectionView
135+
key={key}
136+
section={section}
137+
labels={labels}
138+
collapsed={collapsed.has(key)}
139+
onToggle={() =>
140+
setCollapsed((prev) => {
141+
const next = new Set(prev);
142+
if (!next.delete(key)) next.add(key);
143+
return next;
144+
})
145+
}
146+
t={t}
147+
>
148+
{section.entries.map(row)}
149+
</ReportSectionView>
150+
);
151+
})}
152+
</div>
153+
) : (
154+
<NoneShown title={t('console.reportIndex.noMatch', { defaultValue: 'No reports match your search.' })} />
155+
);
156+
}
157+
44158
return (
45159
<div className="flex flex-col h-full overflow-auto bg-background" data-testid="report-index">
46160
<div className="p-4 sm:p-6 border-b shrink-0">
@@ -61,27 +175,160 @@ export function AppReportIndexView() {
61175
</Empty>
62176
</div>
63177
) : (
64-
<ul className="divide-y" aria-label={title}>
65-
{entries.map((entry) => (
66-
<li key={entry.name}>
67-
<Link
68-
to={`/apps/${appName}/report/${encodeURIComponent(entry.name)}`}
69-
className="flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-accent focus-visible:outline-none focus-visible:bg-accent"
70-
data-testid={`report-index-entry-${entry.name}`}
71-
>
72-
<BarChart3 className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
73-
<span className="min-w-0 flex-1">
74-
<span className="block truncate text-sm font-medium">{entry.label}</span>
75-
{entry.description && (
76-
<span className="block truncate text-xs text-muted-foreground">{entry.description}</span>
178+
<>
179+
<div className="flex flex-col gap-3 px-4 sm:px-6 py-3 border-b sm:flex-row sm:items-center sm:justify-between">
180+
<div
181+
role="group"
182+
aria-label={t('console.reportIndex.viewsLabel', { defaultValue: 'Report views' })}
183+
className="inline-flex shrink-0 rounded-md border p-0.5"
184+
>
185+
{VIEWS.map((option) => (
186+
<button
187+
key={option}
188+
type="button"
189+
aria-pressed={view === option}
190+
onClick={() => setView(option)}
191+
className={cn(
192+
'rounded px-3 py-1 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
193+
view === option ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground',
77194
)}
78-
</span>
79-
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
80-
</Link>
81-
</li>
82-
))}
195+
data-testid={`report-index-view-${option}`}
196+
>
197+
{viewLabel(t, option)}
198+
</button>
199+
))}
200+
</div>
201+
<div className="relative w-full sm:max-w-xs">
202+
<Search
203+
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
204+
aria-hidden
205+
/>
206+
<Input
207+
type="search"
208+
value={query}
209+
onChange={(event) => setQuery(event.target.value)}
210+
placeholder={t('console.reportIndex.searchPlaceholder', { defaultValue: 'Search reports…' })}
211+
aria-label={t('console.reportIndex.searchLabel', { defaultValue: 'Search reports' })}
212+
className="pl-8"
213+
data-testid="report-index-search"
214+
/>
215+
</div>
216+
</div>
217+
{body}
218+
</>
219+
)}
220+
</div>
221+
);
222+
}
223+
224+
/** One collapsible section of the 全部 view: its object's label and count, then its reports. */
225+
function ReportSectionView({
226+
section,
227+
labels,
228+
collapsed,
229+
onToggle,
230+
t,
231+
children,
232+
}: {
233+
section: ReportSection;
234+
labels: ReportObjectLabels;
235+
collapsed: boolean;
236+
onToggle: () => void;
237+
t: Translate;
238+
children: ReactNode;
239+
}) {
240+
const headerId = useId();
241+
const listId = useId();
242+
const label =
243+
section.object === null
244+
? t('console.reportIndex.otherSection', { defaultValue: 'Other' })
245+
: labels.plural(section.object);
246+
const Chevron = collapsed ? ChevronRight : ChevronDown;
247+
return (
248+
<section aria-labelledby={headerId} data-testid="report-index-section" data-object={section.object ?? ''}>
249+
<h2 className="m-0">
250+
<button
251+
id={headerId}
252+
type="button"
253+
aria-expanded={!collapsed}
254+
aria-controls={listId}
255+
onClick={onToggle}
256+
className="flex w-full items-center gap-2 bg-muted/40 px-4 sm:px-6 py-2 text-left text-sm font-semibold hover:bg-muted focus-visible:outline-none focus-visible:bg-muted"
257+
>
258+
<Chevron className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
259+
<span className="min-w-0 truncate">{label}</span>
260+
<span
261+
className="rounded-full bg-muted px-2 text-xs font-medium text-muted-foreground"
262+
data-testid="report-index-section-count"
263+
>
264+
{section.entries.length}
265+
</span>
266+
</button>
267+
</h2>
268+
{!collapsed && (
269+
<ul id={listId} className="divide-y" aria-label={label}>
270+
{children}
83271
</ul>
84272
)}
273+
</section>
274+
);
275+
}
276+
277+
/**
278+
* One report: its link carries the label and description only, so the link's
279+
* name is the report's; the object and last-opened cells sit beside it, and
280+
* the link's overlay makes the whole row the click target.
281+
*/
282+
function ReportRow({
283+
entry,
284+
href,
285+
objectLabel,
286+
opened,
287+
t,
288+
}: {
289+
entry: AppReportEntry;
290+
href: string;
291+
objectLabel: string | undefined;
292+
opened: string;
293+
t: Translate;
294+
}) {
295+
const openedText = opened ? t('console.reportIndex.lastOpened', { defaultValue: 'Opened {{time}}', time: opened }) : '';
296+
return (
297+
<li className="relative flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-accent focus-within:bg-accent">
298+
<BarChart3 className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
299+
<Link
300+
to={href}
301+
className="min-w-0 flex-1 after:absolute after:inset-0 focus-visible:outline-none"
302+
data-testid={`report-index-entry-${entry.name}`}
303+
>
304+
<span className="block truncate text-sm font-medium">{entry.label}</span>
305+
{entry.description && <span className="block truncate text-xs text-muted-foreground">{entry.description}</span>}
306+
</Link>
307+
{objectLabel && (
308+
<span
309+
className="hidden sm:block w-40 shrink-0 truncate text-xs text-muted-foreground"
310+
data-testid={`report-index-object-${entry.name}`}
311+
>
312+
{objectLabel}
313+
</span>
314+
)}
315+
<span
316+
className="hidden sm:block w-48 shrink-0 truncate text-right text-xs text-muted-foreground"
317+
data-testid={`report-index-opened-${entry.name}`}
318+
title={openedText || undefined}
319+
>
320+
{openedText}
321+
</span>
322+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
323+
</li>
324+
);
325+
}
326+
327+
/** What a view shows when it lists no report. */
328+
function NoneShown({ title }: { title: string }) {
329+
return (
330+
<div className="flex-1 flex items-center justify-center p-8" data-testid="report-index-none">
331+
<p className="text-sm text-muted-foreground">{title}</p>
85332
</div>
86333
);
87334
}

0 commit comments

Comments
 (0)