From c2241b2034f3619ff77965ebfe1833a6014b301f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:24:57 +0000 Subject: [PATCH] fix(ui): an empty report index points at the apps that have reports Claude-Session: https://claude.ai/code/session_01L9qibwpXbWKjEuA5DF45BL Co-authored-by: Claude --- ...27356-report-index-points-to-other-apps.md | 11 ++ .../src/views/AppReportIndexView.tsx | 38 +++++- .../AppReportIndex.otherApps-27356.test.tsx | 124 ++++++++++++++++++ .../app-shell/src/views/appReportIndex.ts | 26 ++++ ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 14 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 .changeset/27356-report-index-points-to-other-apps.md create mode 100644 ui/packages/app-shell/src/views/__tests__/AppReportIndex.otherApps-27356.test.tsx diff --git a/.changeset/27356-report-index-points-to-other-apps.md b/.changeset/27356-report-index-points-to-other-apps.md new file mode 100644 index 00000000000..ad618e09e99 --- /dev/null +++ b/.changeset/27356-report-index-points-to-other-apps.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(console): an empty report index points at the apps that have reports + +Clause-②: no +Metadata-protocol: none + +An app's report index with nothing to list, such as Setup's All reports, said only "This app has no reports you can open." It now also lists the other apps whose report index has a report for the reader, each linking to that app's index: an app counts when it ships at least one report whose datasets the reader may read, the same served reports and read rule the index uses. The current app, an app that ships no report, and an app whose reports the reader may not read are not listed. The sidebar entry stays static by design. One key is added in all ten locale packs: `console.reportIndex.otherApps`. diff --git a/ui/packages/app-shell/src/views/AppReportIndexView.tsx b/ui/packages/app-shell/src/views/AppReportIndexView.tsx index 9114d876436..d29ab14d20f 100644 --- a/ui/packages/app-shell/src/views/AppReportIndexView.tsx +++ b/ui/packages/app-shell/src/views/AppReportIndexView.tsx @@ -36,7 +36,10 @@ import { useId, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; 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 { useDisplayLocale, useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { useMetadata } from '../providers/MetadataProvider.js'; +import { useLauncherApps } from '../hooks/useLauncherApps.js'; +import { appRouteSegment, resolveKeyedI18nLabel } from '../utils/index.js'; import { useExpressionContext } from '../providers/ExpressionProvider.js'; import { useNavMemberGates } from '../hooks/useNavMemberGates.js'; import { useRecentItems } from '../context/RecentItemsProvider.js'; @@ -50,6 +53,7 @@ import { navigationObjectOrder, reportMatchesQuery, useAppReportEntries, + useOtherAppsWithReports, useReportObjectLabels, type AppReportEntry, type ReportIndexView, @@ -78,9 +82,16 @@ export function AppReportIndexView() { const [view, setView] = useWorkspacePreference(REPORT_INDEX_VIEW_PREFERENCE, 'user', DEFAULT_REPORT_INDEX_VIEW); const [query, setQuery] = useState(''); const [collapsed, setCollapsed] = useState>(() => new Set()); + const otherAppsHeadingId = useId(); const objects = [...new Set(entries.flatMap((entry) => (entry.object ? [entry.object] : [])))]; const labels = useReportObjectLabels(app, objects); const title = t('console.reportIndex.title', { defaultValue: 'Reports' }); + // objectstack#27356 — an index with nothing to list points at the apps + // whose index has something for this reader. + const { apps: metadataApps } = useMetadata(); + const { apps: launcherApps } = useLauncherApps(metadataApps); + const otherApps = useOtherAppsWithReports(launcherApps, app); + const { appLabel } = useObjectLabel(); if (loading) { return ( @@ -172,6 +183,31 @@ export function AppReportIndexView() { defaultValue: 'This app has no reports you can open.', })} + {otherApps.length > 0 && ( + + )} ) : ( diff --git a/ui/packages/app-shell/src/views/__tests__/AppReportIndex.otherApps-27356.test.tsx b/ui/packages/app-shell/src/views/__tests__/AppReportIndex.otherApps-27356.test.tsx new file mode 100644 index 00000000000..ada9a8e7c45 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/AppReportIndex.otherApps-27356.test.tsx @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27356 (row 11) — an app's report index with nothing to list + * (Setup → All reports, "This app has no reports you can open.") points the + * reader at the apps whose index has a report for them, each linking to that + * app's own index. + * + * Pinned through the real `MetadataProvider` (a fake adapter answers + * `/meta/:type`), the real `MePermissionsProvider`, `ExpressionProvider` and + * `I18nProvider` in `en`, the harness of `AppReportIndex-13020`: + * + * - an app that ships a report the member may read is offered; + * - an app whose only report reads an object the member may not read, an app + * that ships no report, and the current app are not; + * - CONTROL: an index that lists reports draws no such links. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, cleanup, 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_admin' }, activeOrganization: null }), +})); + +import { MetadataProvider } from '../../providers/MetadataProvider'; +import { ExpressionProvider } from '../../providers/ExpressionProvider'; +import { AppReportIndexView } from '../AppReportIndexView'; + +const SETUP_APP = { name: 'setup', label: 'Setup', active: true, _packageId: 'com.objectstack.setup', navigation: [] }; +const CRM_APP = { name: 'crm', label: 'CRM', active: true, _packageId: 'com.example.crm', navigation: [] }; +const HR_APP = { name: 'hr', label: 'People', active: true, _packageId: 'com.example.hr', navigation: [] }; +const EMPTY_APP = { name: 'notes', label: 'Notes', active: true, _packageId: 'com.example.notes', navigation: [] }; + +const REPORTS = [ + { name: 'crm_win_rate', label: 'Win Rate', dataset: 'ds_accounts', _packageId: 'com.example.crm' }, + { name: 'hr_salaries', label: 'Salaries', dataset: 'ds_salaries', _packageId: 'com.example.hr' }, +]; + +const DATASETS = [ + { name: 'ds_accounts', object: 'crm_account' }, + { name: 'ds_salaries', object: 'hr_salary' }, +]; + +const ANSWER: MePermissionsResponse = { + authenticated: true, + userId: 'u_admin', + tenantId: null, + roles: [], + permissionSets: ['admin'], + systemPermissions: [], + objects: { + crm_account: { allowRead: true }, + hr_salary: { allowRead: false }, + }, + fields: {}, +}; + +function adapterFor(apps: unknown[]) { + const items: Record = { app: apps, report: REPORTS, dataset: DATASETS }; + return { + clearCache: vi.fn(), + getObjectSchema: () => Promise.resolve(null), + getClient: () => ({ + meta: { + getItems: (type: string) => Promise.resolve({ type, items: items[type] ?? [] }), + getItem: () => Promise.resolve({ item: null }), + }, + }), + } as unknown as Parameters[0]['adapter']; +} + +function renderIndex(current: any) { + const fetcher = vi.fn(async () => ({ ok: true, status: 200, json: async () => ANSWER }) as unknown as Response); + render( + + + + + + + } /> + + + + + + , + ); +} + +beforeEach(() => { + cleanup(); + localStorage.clear(); + sessionStorage.clear(); +}); + +describe('an empty report index points at the apps that have reports (objectstack#27356 row 11)', () => { + it('offers the apps whose index has a report for this reader, each linking to its index', async () => { + renderIndex(SETUP_APP); + expect(await screen.findByText('This app has no reports you can open.')).toBeInTheDocument(); + const nav = await screen.findByTestId('report-index-other-apps'); + expect(within(nav).getByText('Reports in your other apps:')).toBeInTheDocument(); + const links = within(nav).getAllByRole('link'); + expect(links.map((a) => a.textContent)).toEqual(['CRM']); + expect(links[0]).toHaveAttribute('href', '/apps/com.example.crm/reports'); + // Not an app whose only report the reader may not read, one with none, or this one. + expect(within(nav).queryByText('People')).toBeNull(); + expect(within(nav).queryByText('Notes')).toBeNull(); + expect(within(nav).queryByText('Setup')).toBeNull(); + }); + + it('CONTROL: an index that lists reports draws no links to other apps', async () => { + renderIndex(CRM_APP); + expect(await screen.findByRole('link', { name: /Win Rate/ })).toBeInTheDocument(); + expect(screen.queryByTestId('report-index-other-apps')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/appReportIndex.ts b/ui/packages/app-shell/src/views/appReportIndex.ts index 4dd9373370a..d4e7c01cfe4 100644 --- a/ui/packages/app-shell/src/views/appReportIndex.ts +++ b/ui/packages/app-shell/src/views/appReportIndex.ts @@ -335,6 +335,32 @@ export function useAppReportEntries( return { entries, loading: false }; } +/** + * The apps in `apps` other than `current` that ship at least one report the + * member may read (objectstack#27356): where an app's empty report index sends + * the reader. The list reads the same served reports and read rule as + * {@link useAppReportEntries}; each app's own index still applies that app's + * navigation gates when it opens. Empty while either list is loading. + */ +export function useOtherAppsWithReports(apps: readonly T[], current: unknown): T[] { + const metadata = useMetadata(); + const { can } = usePermissions(); + const reports = metadata.reports; + const datasets = metadata.getItemsByType('dataset'); + const pending = (type: string) => { + const status = metadata.getTypeStatus?.(type); + return status === 'idle' || status === 'loading'; + }; + if (pending('report') || pending('dataset')) return []; + const currentName = current && typeof current === 'object' ? (current as { name?: unknown }).name : undefined; + const datasetObjects = datasetObjectIndex(datasets); + return apps.filter( + (app) => + app.name !== currentName && + appShippedReports(reports, app).some((report) => mayOpenReport(report, datasetObjects, can)), + ); +} + /** * 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 diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index d1454ad5d6a..a068258072e 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2381,6 +2381,7 @@ const ar = { title: "التقارير", emptyTitle: "لا توجد تقارير", emptyDescription: "لا يحتوي هذا التطبيق على تقارير يمكنك فتحها.", + otherApps: "التقارير في تطبيقاتك الأخرى:", viewsLabel: "طرق عرض التقارير", viewAll: "كل التقارير", viewRecent: "الأخيرة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index fddf61a9b36..eb084140277 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2158,6 +2158,7 @@ const de = { title: "Berichte", emptyTitle: "Keine Berichte", emptyDescription: "Diese App hat keine Berichte, die Sie öffnen können.", + otherApps: "Berichte in Ihren anderen Apps:", viewsLabel: "Berichtsansichten", viewAll: "Alle Berichte", viewRecent: "Zuletzt geöffnet", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 76c296d4ee3..8959cbb4d97 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2326,6 +2326,7 @@ const en = { title: 'Reports', emptyTitle: 'No reports', emptyDescription: 'This app has no reports you can open.', + otherApps: 'Reports in your other apps:', viewsLabel: 'Report views', viewAll: 'All reports', viewRecent: 'Recent', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 08831d2f649..e9241a4716e 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2216,6 +2216,7 @@ const es = { title: "Informes", emptyTitle: "No hay informes", emptyDescription: "Esta aplicación no tiene informes que puedas abrir.", + otherApps: "Informes en tus otras aplicaciones:", viewsLabel: "Vistas de informes", viewAll: "Todos los informes", viewRecent: "Recientes", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 6d4364c382c..eb892fa9e89 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2212,6 +2212,7 @@ const fr = { title: "Rapports", emptyTitle: "Aucun rapport", emptyDescription: "Cette application n’a aucun rapport que vous pouvez ouvrir.", + otherApps: "Rapports dans vos autres applications :", viewsLabel: "Vues des rapports", viewAll: "Tous les rapports", viewRecent: "Récents", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 0cb90298a75..541e31c97ba 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2160,6 +2160,7 @@ const ja = { title: "レポート", emptyTitle: "レポートはありません", emptyDescription: "このアプリには開けるレポートがありません。", + otherApps: "ほかのアプリのレポート:", viewsLabel: "レポートビュー", viewAll: "すべて", viewRecent: "最近", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 0d535b02beb..b0310156daa 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2158,6 +2158,7 @@ const ko = { title: "보고서", emptyTitle: "보고서 없음", emptyDescription: "이 앱에는 열 수 있는 보고서가 없습니다.", + otherApps: "다른 앱의 보고서:", viewsLabel: "보고서 보기", viewAll: "전체", viewRecent: "최근", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06ad6b4b40b..c7d67532882 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2211,6 +2211,7 @@ const pt = { title: "Relatórios", emptyTitle: "Nenhum relatório", emptyDescription: "Este aplicativo não tem relatórios que você possa abrir.", + otherApps: "Relatórios nos seus outros aplicativos:", viewsLabel: "Visualizações de relatórios", viewAll: "Todos os relatórios", viewRecent: "Recentes", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index c6971a12436..61b7636502a 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2276,6 +2276,7 @@ const ru = { title: "Отчёты", emptyTitle: "Нет отчётов", emptyDescription: "В этом приложении нет отчётов, которые вы можете открыть.", + otherApps: "Отчёты в других ваших приложениях:", viewsLabel: "Представления отчётов", viewAll: "Все отчёты", viewRecent: "Недавние", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index ace362120e2..1a27c48ac22 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1946,6 +1946,7 @@ const zh = { title: '报表', emptyTitle: '暂无报表', emptyDescription: '此应用没有你可以打开的报表。', + otherApps: '你的其它应用中的报表:', viewsLabel: '报表视图', viewAll: '全部', viewRecent: '最近',