diff --git a/apps/client/src/routes/__tests__/reports-live-exports.test.tsx b/apps/client/src/routes/__tests__/reports-live-exports.test.tsx new file mode 100644 index 0000000..ba6c8eb --- /dev/null +++ b/apps/client/src/routes/__tests__/reports-live-exports.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { apiClient } from '../../lib/apiClient'; +import { render } from '../../test/render'; + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => () => ({}), + useParams: () => ({ databaseId: 'db-1', tableId: 'tbl-1' }), + useNavigate: () => vi.fn(), + Link: ({ children, to, ...props }: any) => { + delete props.params; + delete props.search; + return ( + + {children} + + ); + }, +})); + +import { ReportBuilderPage } from '../reports.$databaseId.$tableId'; + +const savedReport = { + id: 'rep-1', + table_id: 'tbl-1', + name: 'Rapport sauvegarde', + query: { select: ['Titre'], group_by: 'Ville', sort: [] }, + layout: { + fields: [{ name: 'Titre', visible: true, order: 1 }], + view_id: 'view-1', + per_page: 10, + }, +}; + +const mockFields = [ + { id: 'f1', name: 'Titre', type: 'title', position: 0, options: {}, validation: {} }, + { id: 'f2', name: 'Ville', type: 'text', position: 1, options: {}, validation: {} }, +]; + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + ); +} + +describe('report exports follow the on-screen configuration', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + vi.spyOn(apiClient, 'get').mockImplementation((url: string) => { + if (url.startsWith('/databases/')) + return Promise.resolve({ id: 'db-1', name: 'Base' }) as any; + if (url.startsWith('/tables/')) return Promise.resolve({ id: 'tbl-1', name: 'Table' }) as any; + if (url.startsWith('/fields')) return Promise.resolve(mockFields) as any; + if (url.startsWith('/reports')) return Promise.resolve([savedReport]) as any; + if (url.startsWith('/views')) return Promise.resolve([]) as any; + return Promise.resolve([]) as any; + }); + vi.spyOn(apiClient, 'post').mockResolvedValue({ columns: ['Titre'], groups: [] } as any); + + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: { get: () => 'attachment; filename="rapport_apercu.csv"' }, + blob: async () => new Blob(['col']), + }); + vi.stubGlobal('fetch', fetchMock); + // The print tab queries this during render; jsdom does not provide it. + window.matchMedia = vi.fn().mockReturnValue({ matches: false }) as any; + vi.stubGlobal('URL', { + ...window.URL, + createObjectURL: vi.fn(() => 'blob:mock'), + revokeObjectURL: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('posts the live configuration instead of re-exporting the saved report', async () => { + const user = userEvent.setup(); + renderPage(); + + // Select the saved report: this is the case that used to bypass the live state. + await user.click(await screen.findByRole('button', { name: /Rapport sauvegarde/ })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /CSV/ })).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', { name: /CSV/ })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const [url, init] = fetchMock.mock.calls[0]; + // Previously this was GET /reports/rep-1/export/csv, which re-ran the *stored* + // report and ignored anything edited since the last save. + expect(url).toContain('/reports/preview/csv'); + expect(init.method).toBe('POST'); + + const body = JSON.parse(init.body); + expect(body.table_id).toBe('tbl-1'); + expect(body.query.select).toEqual(['Titre']); + expect(body.layout.view_id).toBe('view-1'); + }); + + it('sends the PDF export through the same live path', async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole('button', { name: /Rapport sauvegarde/ })); + await waitFor(() => { + expect(screen.getByRole('button', { name: /PDF/ })).toBeEnabled(); + }); + + await user.click(screen.getByRole('button', { name: /PDF/ })); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/reports/preview/pdf'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body).name).toBe('Rapport sauvegarde'); + }); +}); diff --git a/apps/client/src/routes/reports.$databaseId.$tableId.tsx b/apps/client/src/routes/reports.$databaseId.$tableId.tsx index 207dcdc..fb67e10 100644 --- a/apps/client/src/routes/reports.$databaseId.$tableId.tsx +++ b/apps/client/src/routes/reports.$databaseId.$tableId.tsx @@ -67,7 +67,7 @@ interface SortRule { direction: 'asc' | 'desc'; } -function ReportBuilderPage() { +export function ReportBuilderPage() { const { databaseId, tableId } = useParams({ from: '/reports/$databaseId/$tableId' }); const { t } = useI18n(); const queryClient = useQueryClient(); @@ -300,42 +300,44 @@ function ReportBuilderPage() { } }, [selectedReportId, reportsQuery.data, fieldsQuery.data]); + // Single description of the configuration on screen, shared by the preview, the + // save payload and both exports so they cannot drift apart. + const buildQueryAST = () => ({ + select: selectedColumns, + group_by: groupField || undefined, + sort: sorts.map((s) => ({ field: s.field, direction: s.direction })), + where: + conditions.length > 0 + ? { + logic, + conditions: conditions.map((c) => ({ + field: c.field, + operator: c.operator, + value: c.value, + })), + } + : undefined, + }); + + const buildLayout = () => ({ + fields: selectedColumns.map((col, idx) => ({ + name: col, + visible: true, + order: idx + 1, + })), + show_headers_only: showHeadersOnly, + view_id: selectedViewId || undefined, + per_page: perPage, + }); + // Mutators for Saving, Updating and Deleting Reports const saveReportMutation = useMutation({ mutationFn: async () => { - const queryAST = { - select: selectedColumns, - group_by: groupField || undefined, - sort: sorts.map((s) => ({ field: s.field, direction: s.direction })), - where: - conditions.length > 0 - ? { - logic, - conditions: conditions.map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - })), - } - : undefined, - }; - - const layoutObj = { - fields: selectedColumns.map((col, idx) => ({ - name: col, - visible: true, - order: idx + 1, - })), - show_headers_only: showHeadersOnly, - view_id: selectedViewId || undefined, - per_page: perPage, - }; - const payload = { table_id: tableId, name: reportName || t('reports.newReport'), - query: queryAST, - layout: layoutObj, + query: buildQueryAST(), + layout: buildLayout(), }; if (selectedReportId) { @@ -378,43 +380,14 @@ function ReportBuilderPage() { currentPage, perPage, ], - queryFn: () => { - const queryAST = { - select: selectedColumns, - group_by: groupField || undefined, - sort: sorts.map((s) => ({ field: s.field, direction: s.direction })), - where: - conditions.length > 0 - ? { - logic, - conditions: conditions.map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - })), - } - : undefined, - }; - - const layoutObj = { - fields: selectedColumns.map((col, idx) => ({ - name: col, - visible: true, - order: idx + 1, - })), - show_headers_only: showHeadersOnly, - view_id: selectedViewId || undefined, - per_page: perPage, - }; - - return apiClient.post('/reports/preview', { + queryFn: () => + apiClient.post('/reports/preview', { table_id: tableId, - query: queryAST, - layout: layoutObj, + query: buildQueryAST(), + layout: buildLayout(), per_page: showHeadersOnly ? undefined : perPage, page: showHeadersOnly ? undefined : currentPage, - }); - }, + }), enabled: selectedColumns.length > 0, }); @@ -468,7 +441,10 @@ function ReportBuilderPage() { url: string, method: 'GET' | 'POST', body?: any, - defaultFilename: string = 'export' + defaultFilename: string = 'export', + // The preview endpoints always answer with a generic filename; when the report + // has a name, prefer it over what the server suggests. + forceFilename = false ) => { try { const response = await fetch(url.startsWith('/api') ? url : `/api/v1${url}`, { @@ -486,7 +462,7 @@ function ReportBuilderPage() { const disposition = response.headers.get('Content-Disposition'); let filename = defaultFilename; - if (disposition && disposition.indexOf('attachment') !== -1) { + if (!forceFilename && disposition && disposition.indexOf('attachment') !== -1) { const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; const matches = filenameRegex.exec(disposition); if (matches != null && matches[1]) { @@ -509,105 +485,38 @@ function ReportBuilderPage() { } }; + /** + * Exports go through the preview endpoints with the configuration currently on + * screen. Routing a selected report to /reports/{id}/export instead would export + * the last *saved* state, so exports would silently ignore unsaved edits. + */ const handleExportCsv = () => { - const queryAST = { - select: selectedColumns, - group_by: groupField || undefined, - sort: sorts.map((s) => ({ field: s.field, direction: s.direction })), - where: - conditions.length > 0 - ? { - logic, - conditions: conditions.map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - })), - } - : undefined, - }; - - const layoutObj = { - fields: selectedColumns.map((col, idx) => ({ - name: col, - visible: true, - order: idx + 1, - })), - show_headers_only: showHeadersOnly, - view_id: selectedViewId || undefined, - per_page: perPage, - }; - - if (selectedReportId) { - void downloadFile( - `/reports/${selectedReportId}/export/csv`, - 'GET', - undefined, - `${reportName || 'rapport'}.csv` - ); - } else { - void downloadFile( - '/reports/preview/csv', - 'POST', - { - table_id: tableId, - query: queryAST, - layout: layoutObj, - }, - 'rapport_apercu.csv' - ); - } + void downloadFile( + '/reports/preview/csv', + 'POST', + { + table_id: tableId, + query: buildQueryAST(), + layout: buildLayout(), + }, + `${reportName || 'rapport'}.csv`, + true + ); }; const handleExportPdf = () => { - const queryAST = { - select: selectedColumns, - group_by: groupField || undefined, - sort: sorts.map((s) => ({ field: s.field, direction: s.direction })), - where: - conditions.length > 0 - ? { - logic, - conditions: conditions.map((c) => ({ - field: c.field, - operator: c.operator, - value: c.value, - })), - } - : undefined, - }; - - const layoutObj = { - fields: selectedColumns.map((col, idx) => ({ - name: col, - visible: true, - order: idx + 1, - })), - show_headers_only: showHeadersOnly, - view_id: selectedViewId || undefined, - per_page: perPage, - }; - - if (selectedReportId) { - void downloadFile( - `/reports/${selectedReportId}/export/pdf`, - 'GET', - undefined, - `${reportName || 'rapport'}.pdf` - ); - } else { - void downloadFile( - '/reports/preview/pdf', - 'POST', - { - table_id: tableId, - name: reportName || 'Rapport temporaire', - query: queryAST, - layout: layoutObj, - }, - 'rapport_apercu.pdf' - ); - } + void downloadFile( + '/reports/preview/pdf', + 'POST', + { + table_id: tableId, + name: reportName || t('reports.newReport'), + query: buildQueryAST(), + layout: buildLayout(), + }, + `${reportName || 'rapport'}.pdf`, + true + ); }; const handleDeleteReport = () => {