Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/27356-report-index-points-to-other-apps.md
Original file line number Diff line number Diff line change
@@ -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`.
38 changes: 37 additions & 1 deletion ui/packages/app-shell/src/views/AppReportIndexView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -50,6 +53,7 @@ import {
navigationObjectOrder,
reportMatchesQuery,
useAppReportEntries,
useOtherAppsWithReports,
useReportObjectLabels,
type AppReportEntry,
type ReportIndexView,
Expand Down Expand Up @@ -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<ReadonlySet<string>>(() => 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 (
Expand Down Expand Up @@ -172,6 +183,31 @@ export function AppReportIndexView() {
defaultValue: 'This app has no reports you can open.',
})}
</EmptyDescription>
{otherApps.length > 0 && (
<nav
aria-labelledby={otherAppsHeadingId}
className="mt-4 flex flex-col items-center gap-2"
data-testid="report-index-other-apps"
>
<p id={otherAppsHeadingId} className="text-sm text-muted-foreground">
{t('console.reportIndex.otherApps', { defaultValue: 'Reports in your other apps:' })}
</p>
<ul className="flex flex-wrap justify-center gap-2">
{otherApps.map((other) => (
<li key={String(other.name)}>
<Link
to={`/apps/${appRouteSegment(other) ?? String(other.name)}/reports`}
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`report-index-other-app-${String(other.name)}`}
>
<BarChart3 className="h-4 w-4 text-muted-foreground" aria-hidden />
{appLabel({ name: String(other.name), label: resolveKeyedI18nLabel(other.label, t) })}
</Link>
</li>
))}
</ul>
</nav>
)}
</Empty>
</div>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<string, unknown[]> = { 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<typeof MetadataProvider>[0]['adapter'];
}

function renderIndex(current: any) {
const fetcher = vi.fn(async () => ({ ok: true, status: 200, json: async () => ANSWER }) as unknown as Response);
render(
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
<MePermissionsProvider endpoint="/api/v1/auth/me/permissions" fetcher={fetcher as unknown as typeof fetch}>
<MetadataProvider adapter={adapterFor([SETUP_APP, CRM_APP, HR_APP, EMPTY_APP])}>
<ExpressionProvider app={current}>
<MemoryRouter initialEntries={[`/apps/${current.name}/reports`]}>
<Routes>
<Route path="/apps/:appName/reports" element={<AppReportIndexView />} />
</Routes>
</MemoryRouter>
</ExpressionProvider>
</MetadataProvider>
</MePermissionsProvider>
</I18nProvider>,
);
}

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();
});
});
26 changes: 26 additions & 0 deletions ui/packages/app-shell/src/views/appReportIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { name?: unknown }>(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
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2381,6 +2381,7 @@ const ar = {
title: "التقارير",
emptyTitle: "لا توجد تقارير",
emptyDescription: "لا يحتوي هذا التطبيق على تقارير يمكنك فتحها.",
otherApps: "التقارير في تطبيقاتك الأخرى:",
viewsLabel: "طرق عرض التقارير",
viewAll: "كل التقارير",
viewRecent: "الأخيرة",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2160,6 +2160,7 @@ const ja = {
title: "レポート",
emptyTitle: "レポートはありません",
emptyDescription: "このアプリには開けるレポートがありません。",
otherApps: "ほかのアプリのレポート:",
viewsLabel: "レポートビュー",
viewAll: "すべて",
viewRecent: "最近",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2158,6 +2158,7 @@ const ko = {
title: "보고서",
emptyTitle: "보고서 없음",
emptyDescription: "이 앱에는 열 수 있는 보고서가 없습니다.",
otherApps: "다른 앱의 보고서:",
viewsLabel: "보고서 보기",
viewAll: "전체",
viewRecent: "최근",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2276,6 +2276,7 @@ const ru = {
title: "Отчёты",
emptyTitle: "Нет отчётов",
emptyDescription: "В этом приложении нет отчётов, которые вы можете открыть.",
otherApps: "Отчёты в других ваших приложениях:",
viewsLabel: "Представления отчётов",
viewAll: "Все отчёты",
viewRecent: "Недавние",
Expand Down
1 change: 1 addition & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1946,6 +1946,7 @@ const zh = {
title: '报表',
emptyTitle: '暂无报表',
emptyDescription: '此应用没有你可以打开的报表。',
otherApps: '你的其它应用中的报表:',
viewsLabel: '报表视图',
viewAll: '全部',
viewRecent: '最近',
Expand Down
Loading