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
38 changes: 36 additions & 2 deletions apps/api/app/Http/Controllers/ReportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,31 @@ public function previewPdf(Request $request)
return $this->generatePdfResponse($reportName, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout);
}

/**
* Same document as the PDF export, served as HTML so the print preview can show
* and print exactly what the PDF contains.
*/
public function previewHtml(Request $request)
{
$validated = $request->validate([
'table_id' => 'required|uuid|exists:tables,id',
'name' => 'sometimes|required|string|max:255',
'query' => 'nullable|array',
'layout' => 'nullable|array',
]);

$table = Table::findOrFail($validated['table_id']);
$reportName = $validated['name'] ?? 'Rapport temporaire';
$ast = $validated['query'] ?? [];
$layout = $validated['layout'] ?? null;

$result = $this->queryService->execute($table, $ast, null, null, $layout);

$html = $this->renderReportHtml($reportName, $result['columns'], $result['groups'], $ast['group_by'] ?? null, $layout);

return response($html, 200, ['Content-Type' => 'text/html; charset=UTF-8']);
}

public function previewCsv(Request $request)
{
$validated = $request->validate([
Expand All @@ -209,21 +234,30 @@ public function previewCsv(Request $request)
return $this->generateCsvResponse($result['columns'], $result['groups'], $ast['group_by'] ?? null, $filename, $layout);
}

private function generatePdfResponse(string $reportName, array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null)
/**
* The single rendering of a report document. The PDF export and the on-screen
* print preview both go through here, so what is previewed is what is printed.
*/
private function renderReportHtml(string $reportName, array $columns, array $groups, ?string $groupBy, ?array $layout = null): string
{
$view = null;
if ($layout && isset($layout['view_id'])) {
$view = View::with('table.fields')->find($layout['view_id']);
}

$html = view('reports.pdf', [
return view('reports.pdf', [
'reportName' => $reportName,
'columns' => $columns,
'groups' => $groups,
'groupBy' => $groupBy,
'layout' => $layout,
'view' => $view,
])->render();
}

private function generatePdfResponse(string $reportName, array $columns, array $groups, ?string $groupBy, string $filename, ?array $layout = null)
{
$html = $this->renderReportHtml($reportName, $columns, $groups, $groupBy, $layout);

$dompdf = new Dompdf([
'isHtml5ParserEnabled' => true,
Expand Down
1 change: 1 addition & 0 deletions apps/api/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
Route::post('reports/preview', [ReportController::class, 'preview']);
Route::post('reports/preview/pdf', [ReportController::class, 'previewPdf']);
Route::post('reports/preview/csv', [ReportController::class, 'previewCsv']);
Route::post('reports/preview/html', [ReportController::class, 'previewHtml']);
Route::get('reports/{report}/export/pdf', [ReportController::class, 'exportPdf']);
Route::get('reports/{report}/export/csv', [ReportController::class, 'exportCsv']);
Route::match(['get', 'post'], 'reports/{report}/execute', [ReportController::class, 'execute']);
Expand Down
84 changes: 84 additions & 0 deletions apps/api/tests/Feature/ReportFeatureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,87 @@ function createAuthenticatedUser()
expect($headerLine)->toBe(['Ville', 'Nom']);
expect(array_count_values($headerLine)['Ville'])->toBe(1);
});

test('print preview html is the same document the pdf export renders', function () {
$setup = createAuthenticatedUser();
$user = $setup['user'];
$table = $setup['table'];

Field::factory()->create(['table_id' => $table->id, 'name' => 'Ville', 'type' => 'text']);
Field::factory()->create(['table_id' => $table->id, 'name' => 'Nom', 'type' => 'text']);

// More records than a preview page holds, so pagination would be visible.
foreach (range(1, 25) as $i) {
Record::create([
'table_id' => $table->id,
'data' => ['Ville' => 'Québec', 'Nom' => "Tremblay {$i}"],
'version' => 1,
]);
}

$payload = [
'table_id' => $table->id,
'name' => 'Rapport imprimable',
'query' => ['select' => ['Ville', 'Nom'], 'group_by' => 'Ville'],
'layout' => ['fields' => [
['name' => 'Ville', 'visible' => true, 'order' => 1],
['name' => 'Nom', 'visible' => true, 'order' => 2],
]],
];

$html = $this->actingAs($user)->postJson('/api/v1/reports/preview/html', $payload);
$html->assertStatus(200);
$html->assertHeader('Content-Type', 'text/html; charset=UTF-8');

$body = $html->getContent();
expect($body)->toContain('Rapport imprimable');
// The document carries its own styles, so the iframe renders it standalone.
expect($body)->toContain('report-table');

// Not paginated: the preview shows every record the PDF would contain. The old
// JSX preview rendered only the current preview page, so the two disagreed.
expect($body)->toContain('Tremblay 1');
expect($body)->toContain('Tremblay 25');

$pdf = $this->actingAs($user)->postJson('/api/v1/reports/preview/pdf', $payload);
$pdf->assertStatus(200);
expect($pdf->headers->get('Content-Type'))->toBe('application/pdf');
});

test('print preview html honours the selected card view', function () {
$setup = createAuthenticatedUser();
$user = $setup['user'];
$table = $setup['table'];

$ville = Field::factory()->create(['table_id' => $table->id, 'name' => 'Ville', 'type' => 'text']);
$nom = Field::factory()->create(['table_id' => $table->id, 'name' => 'Nom', 'type' => 'text']);

$view = View::factory()->create([
'table_id' => $table->id,
'type' => 'card',
'config' => ['columns' => [[$ville->id], [$nom->id]]],
]);

Record::create([
'table_id' => $table->id,
'data' => ['Ville' => 'Québec', 'Nom' => 'Tremblay'],
'version' => 1,
]);

$response = $this->actingAs($user)->postJson('/api/v1/reports/preview/html', [
'table_id' => $table->id,
'name' => 'Rapport carte',
'query' => ['select' => ['Ville', 'Nom']],
'layout' => [
'fields' => [
['name' => 'Ville', 'visible' => true, 'order' => 1],
['name' => 'Nom', 'visible' => true, 'order' => 2],
],
'view_id' => $view->id,
],
]);

$response->assertStatus(200);
// Card markup, not the fallback table.
expect($response->getContent())->toContain('report-card');
});
105 changes: 105 additions & 0 deletions apps/client/src/components/ReportPrintPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n } from '../contexts/I18nContext';
import { LoadingSpinner } from './LoadingSpinner';

interface ReportPrintPreviewProps {
tableId: string;
reportName: string;
query: unknown;
layout: unknown;
}

/**
* Shows the report document produced by the API — the very same HTML the PDF export
* is rendered from — inside an iframe, and prints that document rather than the
* surrounding page. Previously this screen re-implemented the layout in JSX, so the
* printed result and the exported PDF drifted apart (different markup, and the
* preview was paginated while the export was not).
*/
export function ReportPrintPreview({
tableId,
reportName,
query,
layout,
}: ReportPrintPreviewProps) {
const { t } = useI18n();
const frameRef = useRef<HTMLIFrameElement>(null);
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(false);

fetch('/api/v1/reports/preview/html', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ table_id: tableId, name: reportName, query, layout }),
})
.then((response) => {
if (!response.ok) throw new Error('Preview failed');
return response.text();
})
.then((text) => {
if (!cancelled) {
setHtml(text);
setIsLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setError(true);
setIsLoading(false);
}
});

return () => {
cancelled = true;
};
}, [tableId, reportName, query, layout]);

const handlePrint = () => {
const frame = frameRef.current?.contentWindow;
if (!frame) return;
frame.focus();
frame.print();
};

return (
<div>
<div className="d-flex justify-content-end mb-3">
<button
type="button"
className="btn btn-primary"
onClick={handlePrint}
disabled={!html || isLoading}
>
<i className="ti ti-printer me-1" aria-hidden="true" />
{t('reports.print')}
</button>
</div>

{isLoading && (
<div className="d-flex justify-content-center py-5">
<LoadingSpinner size="lg" />
</div>
)}

{error && <div className="alert alert-danger">{t('reports.printPreviewError')}</div>}

{html && !error && (
<iframe
ref={frameRef}
title={t('reports.preview.print')}
srcDoc={html}
data-testid="report-print-frame"
className="w-100 border rounded bg-white"
style={{ height: '75vh' }}
/>
)}
</div>
);
}
74 changes: 74 additions & 0 deletions apps/client/src/components/__tests__/ReportPrintPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReportPrintPreview } from '../ReportPrintPreview';
import { render } from '../../test/render';

const documentHtml =
'<html><head><style>.report-table{}</style></head><body><h1>Rapport</h1><table class="report-table"><tbody><tr><td>Tremblay</td></tr></tbody></table></body></html>';

describe('ReportPrintPreview', () => {
let fetchMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => documentHtml });
vi.stubGlobal('fetch', fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('renders the document the API produced for the PDF, not a local re-implementation', async () => {
render(
<ReportPrintPreview
tableId="tbl-1"
reportName="Rapport"
query={{ select: ['Nom'] }}
layout={{ view_id: 'view-1' }}
/>
);

const frame = (await screen.findByTestId('report-print-frame')) as HTMLIFrameElement;
expect(frame.getAttribute('srcdoc')).toBe(documentHtml);

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/v1/reports/preview/html');
expect(init.method).toBe('POST');

const body = JSON.parse(init.body);
expect(body.table_id).toBe('tbl-1');
expect(body.layout).toEqual({ view_id: 'view-1' });
});

it('prints the embedded document rather than the surrounding page', async () => {
const user = userEvent.setup();
render(<ReportPrintPreview tableId="tbl-1" reportName="Rapport" query={{}} layout={{}} />);

const frame = (await screen.findByTestId('report-print-frame')) as HTMLIFrameElement;
const framePrint = vi.fn();
Object.defineProperty(frame, 'contentWindow', {
value: { focus: vi.fn(), print: framePrint },
configurable: true,
});

const pagePrint = vi.fn();
window.print = pagePrint;

await user.click(screen.getByRole('button', { name: /Print/i }));

expect(framePrint).toHaveBeenCalledTimes(1);
expect(pagePrint).not.toHaveBeenCalled();
});

it('reports a failure instead of showing a blank frame', async () => {
fetchMock.mockResolvedValue({ ok: false });
render(<ReportPrintPreview tableId="tbl-1" reportName="Rapport" query={{}} layout={{}} />);

await waitFor(() => {
expect(screen.getByText(/could not be loaded/i)).toBeInTheDocument();
});
expect(screen.queryByTestId('report-print-frame')).not.toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions apps/client/src/contexts/I18nContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ const resources = {
'reports.preview.live': 'Prévisualisation en direct',
'reports.preview.print': 'Rendu imprimé',
'reports.print': 'Imprimer',
'reports.printPreviewError': 'L’aperçu avant impression n’a pas pu être chargé.',
'reports.save': 'Enregistrer le rapport',
'reports.delete': 'Supprimer le rapport',
'reports.noReports': 'Aucun rapport enregistré pour le moment.',
Expand Down Expand Up @@ -426,6 +427,7 @@ const resources = {
'reports.preview.live': 'Live Preview',
'reports.preview.print': 'Print Layout',
'reports.print': 'Print',
'reports.printPreviewError': 'The print preview could not be loaded.',
'reports.save': 'Save Report',
'reports.delete': 'Delete Report',
'reports.noReports': 'No saved reports yet.',
Expand Down
Loading
Loading