Development — csv-export-reports-listing
Working through the 6 units in order. Foundation → Logic → Interface.
## Layer 1 — Foundation
### Unit 1: `CsvView` class
**File:** `src/View/CsvView.php` (new, 28 lines)
```php
setLayout(false);
$this->response = $this->response
->withType('csv')
->withDownload($this->buildFilename());
}
private function buildFilename(): string
{
$tz = Configure::read('App.defaultTimezone') ?? 'UTC';
return sprintf(
'reports-%s.csv',
FrozenTime::now($tz)->format('Y-m-d-Hi')
);
}
}
```
**Success signal:** Hitting `/reports.csv` returns `Content-Type: text/csv` and `Content-Disposition: attachment; filename="reports-{stamp}.csv"`. (Confirmed via curl in next layer's smoke check.)
### Unit 2: `ReportsTable::findForIndex` cap extension
**File:** `src/Model/Table/ReportsTable.php` (edited, +6 lines)
```php
public function findForIndex(Query $query, array $opts): Query
{
// ...existing filter logic (status, owner_id, created_from, created_to)...
// ...existing sort handling...
if (!empty($opts['cap'])) {
$query->limit(\App\Domain\BoundedRowLimit::DEFAULT);
}
return $query;
}
```
**Success signal:** Finder-level unit test confirms `cap => true` produces a `Query` whose compiled SQL contains `LIMIT 10000`. (Driver-gated; see testing phase.)
---
## Layer 2 — Logic
### Unit 3: Filter parsing extraction
**File:** `src/Controller/ReportsController.php` (edited, +14 / -52 lines net)
```php
private function parseListingFilters(): array
{
$q = $this->request->getQueryParams();
return [
'status' => $q['status'] ?? null,
'owner_id' => $q['owner_id'] ?? $this->Authentication->getIdentityData('id'),
'created_from' => $q['created_from'] ?? null,
'created_to' => $q['created_to'] ?? null,
'sort' => $q['sort'] ?? 'created_at',
'direction' => $q['direction'] ?? 'desc',
];
}
public function index(): void
{
$filters = $this->parseListingFilters();
$cap = $this->request->getParam('_ext') === 'csv';
$query = $this->Reports->find('forIndex', $filters + ['cap' => $cap]);
$query = $this->Authorization->applyScope($query);
if ($cap) {
$rows = $query->all()->toArray();
$this->set(compact('rows'));
$this->viewBuilder()->setClassName('Csv');
return;
}
// ...existing pagination + cap-warning flash, HTML path...
$reports = $this->paginate($query);
$this->set(compact('reports', 'filters'));
}
```
**Success signal:** `/reports?status=open` (HTML) renders identically to pre-change. `/reports.csv?status=open` returns the same matched rows in CSV form. No duplicate filter-parsing code.
### Unit 4: Cap-warning flash (HTML path only)
**File:** `src/Controller/ReportsController.php` (added inside `index()`, +9 lines)
```php
// After paginate(), HTML path only:
$capLimit = \App\Domain\BoundedRowLimit::DEFAULT;
if (count($reports) >= $capLimit) {
$overshoot = $this->Reports
->find('forIndex', $filters)
->limit(1)
->offset($capLimit)
->count();
if ($overshoot > 0) {
$this->Flash->warning(__(
'CSV export is capped at {0} rows; refine filters to export more.',
$capLimit
));
}
}
```
**Success signal:** Page with 10,001 matching rows shows the flash on the HTML view. Page with 10,000 exactly does not.
---
## Layer 3 — Interface
### Unit 5: CSV template
**File:** `templates/Reports/csv/index.php` (new, 35 lines)
```php
$rows
*/
$out = fopen('php://output', 'wb');
// Header row
fputcsv($out, ['id', 'title', 'status', 'owner_email', 'created_at', 'updated_at']);
// Data rows
foreach ($rows as $row) {
fputcsv($out, [
$row->id,
$this->csvSafe($row->title),
$row->status,
$row->owner?->email,
$row->created_at?->format('Y-m-d H:i:s'),
$row->updated_at?->format('Y-m-d H:i:s'),
]);
}
fclose($out);
```
And the `csvSafe` helper, added to `AppView`:
```php
public function csvSafe(?string $value): string
{
if ($value === null || $value === '') {
return '';
}
// Prefix with apostrophe to neutralize Excel formula injection.
return preg_match('/^[=+\-@]/', $value) ? "'" . $value : $value;
}
```
**Success signal:** A report with title `=cmd|' /C calc'!A0` exports as `'=cmd|' /C calc'!A0` in the CSV cell — Excel opens the cell as text, not a formula.
### Unit 6: Listing page "Download CSV" link
**File:** `templates/Reports/index.php` (edited, +5 lines after the filter form)
```php
request->getQueryParams();
$exportUrl = $this->Url->build(['_ext' => 'csv'] + $exportQuery);
?>
= __('Download CSV') ?>
```
**Success signal:** Visiting `/reports?status=open&sort=title&direction=asc`, the "Download CSV" link target is `/reports.csv?status=open&sort=title&direction=asc`.
---
## Completion checklist (development)
1. [x] All 6 units built and named — evidence above
2. [x] `BoundedRowLimit::DEFAULT` referenced by name (Unit 2, Unit 4) — no magic 10000 literal
3. [x] `$this->Authorization->applyScope($query)` applied in `index()` for both HTML and CSV paths (Unit 3) — HC1 satisfied
4. [x] CSV-injection apostrophe-prefix lives in template via `csvSafe()` (Unit 5) — HC3 satisfied
5. [x] Filename uses server-configured timezone (Unit 1) — HC4 satisfied
6. [x] No `Content-Length` header added (CsvView relies on streaming)
7. [x] No background job enqueued, no Excel export, no column selection — out-of-scope items not introduced
8. [x] Empty-set path tested manually — `/reports.csv?status=does_not_exist` returns header-only CSV, HTTP 200
9. [x] HTML path verified unchanged via curl diff against pre-change `git stash`
10. [x] One WIP item at a time — each unit completed before next started
11. [x] No silent assumption-fills — filter param shape matches existing index() exactly
12. [N/A] BLOCKED-state items — none raised in this task