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/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", diff --git a/ui/packages/app-shell/src/views/AppReportIndexView.tsx b/ui/packages/app-shell/src/views/AppReportIndexView.tsx index 27953d04796..9114d876436 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,160 @@ 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; +}) { + const openedText = opened ? t('console.reportIndex.lastOpened', { defaultValue: 'Opened {{time}}', time: opened }) : ''; + return ( +
  • + + + {entry.label} + {entry.description && {entry.description}} + + {objectLabel && ( + + {objectLabel} + + )} + + {openedText} + + +
  • + ); +} + +/** 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/__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(); + }); +}); diff --git a/ui/packages/app-shell/src/views/appReportIndex.ts b/ui/packages/app-shell/src/views/appReportIndex.ts index 41159b26597..4dd9373370a 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 + * (`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({ + 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 e799b70a4f4..d9897b4fd06 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2352,6 +2352,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 f71a6be992b..4d9eacfe3f8 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2137,6 +2137,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 a37a51bded2..ca3a3ccf5af 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2305,6 +2305,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 162f92088d8..48a6487f7fe 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2193,6 +2193,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 fa3500b61cb..478a1b3a452 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2189,6 +2189,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 9d0dd4ec853..9a92d962bfe 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2139,6 +2139,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 9e52a430ded..76c18ff832e 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2137,6 +2137,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 432ee9b2cb9..8a1c84aa7f9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2188,6 +2188,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 367ee649d55..90bb054cea5 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2251,6 +2251,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 4e6a27fe587..875385d9684 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1925,6 +1925,15 @@ const zh = { title: '报表', emptyTitle: '暂无报表', emptyDescription: '此应用没有你可以打开的报表。', + viewsLabel: '报表视图', + viewAll: '全部', + viewRecent: '最近', + searchPlaceholder: '搜索报表…', + searchLabel: '搜索报表', + otherSection: '其它', + lastOpened: '{{time}}打开', + recentEmpty: '暂无最近打开的报表', + noMatch: '没有与搜索匹配的报表。', }, commandPalette: { title: '命令面板',