docs(agents): encode list-export convention as GR-UI-EXPORT guard

Make the core-primitive-end-to-end rule for Grid.js list exports binding
instead of advisory — so future consumers (and reviewers) cannot quietly
drift back into inline fputcsv / hand-rolled headers / bespoke click
listeners / lurl() for query-carrying URLs.

- CLAUDE.md: new UI Patterns bullet + two "Never Do This" entries
  spelling out the server/view/client contract in one place.
- .agents/checks/guard-catalog.json: new GR-UI-EXPORT entry describing
  the end-to-end requirement (exportRequireGetRequest + exportCapLimit +
  exportSendCsv + ExportColumn + shared filter-schema + dropdown partial
  + endpointUrl() + initListExport).
- .agents/checks/guard-enforcement-map.json: wire the new guard to
  tests/Architecture/ListExportContractTest.php as the automated
  evidence source.
- .agents/checks/guard-checklist.md & .agents/prompts/reviewer-code.md:
  add GR-UI-EXPORT so the Code Reviewer prompt and the checklist flag
  violations alongside GR-UI-LIST.
- tests/Architecture/ListExportContractFiles: registry of every list
  export (currently helpdesk-domains, admin/users, audit/system-audit).
  Adding a new list = one entry, contract test covers the rest.
- tests/Architecture/ListExportContractTest: six checks per registered
  entry — endpoint uses the primitive, no hand-rolled output, data and
  export share the same filter-schema, template includes the dropdown
  partial and uses endpointUrl(), page module imports and invokes
  initListExport, and the shared dropdown partial stays zero-config.
- pages/admin/users/export().php: align with the new contract by
  switching from ad-hoc requestInput()->queryAll() reads to
  gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'), same
  as the data endpoint — eliminates the last place Grid and export
  could disagree on what "the current filter" means.

Gates: PHPUnit 1900 OK (+6 contract checks), PHPStan 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 22:50:43 +02:00
parent 0e82e00495
commit 53ccd212d5
8 changed files with 256 additions and 27 deletions

View File

@@ -92,6 +92,13 @@
"title": "Reuse-first (JS + CSS)", "title": "Reuse-first (JS + CSS)",
"requirement": "MUST check web/js/core/ and web/js/components/ for existing exports before adding new functions. MUST check existing CSS layers and app-* classes before writing new rules. No duplication of logic from core modules." "requirement": "MUST check web/js/core/ and web/js/components/ for existing exports before adding new functions. MUST check existing CSS layers and app-* classes before writing new rules. No duplication of logic from core modules."
}, },
{
"id": "GR-UI-EXPORT",
"area": "ui",
"reviewer": "code",
"title": "List export via core primitive",
"requirement": "Grid.js list pages that offer CSV/Excel downloads MUST use the core export primitive end-to-end. Server endpoints MUST call exportRequireGetRequest(), cap rows via exportCapLimit(), stream with exportSendCsv() from core/Support/helpers/export.php, and declare columns as MintyPHP\\Service\\Export\\ExportColumn. The SAME filter-schema.php as the list's data endpoint MUST be parsed via gridParseFiltersFromSchemaFile() so exported rows match the grid under identical filters. Views MUST include templates/partials/app-list-export-dropdown.phtml in $listTitleActionsHtml and pass 'exportUrl' => endpointUrl('<route-path>') in the page config (never plain lurl() — query-string survival on module routes where path != target depends on endpointUrl). Page JS MUST call initListExport({ gridConfig, exportUrl }) from /js/core/app-list-export.js. No inline fputcsv, no hand-rolled headers, no bespoke click listener."
},
{ {
"id": "GR-SEC-001", "id": "GR-SEC-001",
"area": "security", "area": "security",

View File

@@ -17,6 +17,7 @@ Guard IDs live in `.agents/checks/guard-catalog.json` (guards where `reviewer` =
## UI (skip if backend-only) ## UI (skip if backend-only)
- `GR-UI-014` new views/components handle loading, empty, error, success states - `GR-UI-014` new views/components handle loading, empty, error, success states
- `GR-UI-LIST` list/grid standards preserved (row actions, page size, initStandardListPage) - `GR-UI-LIST` list/grid standards preserved (row actions, page size, initStandardListPage)
- `GR-UI-EXPORT` list exports go through the core primitive: server uses exportRequireGetRequest + exportCapLimit + exportSendCsv + ExportColumn, shares filter-schema.php with the data endpoint; view uses `app-list-export-dropdown.phtml` partial and `endpointUrl()` (never `lurl()`) for the exportUrl; JS calls `initListExport` from `/js/core/app-list-export.js`. Add the new list to `tests/Architecture/ListExportContractFiles.php`.
- `GR-UI-DETAIL` detail page contracts, shared partials, no inline confirm, layout system - `GR-UI-DETAIL` detail page contracts, shared partials, no inline confirm, layout system
- `GR-UI-A11Y` no a11y regression; new components use semantic HTML, keyboard-operable - `GR-UI-A11Y` no a11y regression; new components use semantic HTML, keyboard-operable
- `GR-UI-I18N` all visible text uses t(); keys present in all language files - `GR-UI-I18N` all visible text uses t(); keys present in all language files

View File

@@ -127,6 +127,13 @@
".agents/runs/<TASK>/review-code.json" ".agents/runs/<TASK>/review-code.json"
] ]
}, },
{
"guard_id": "GR-UI-EXPORT",
"enforcement_mode": "automated",
"evidence_source": [
"tests/Architecture/ListExportContractTest.php"
]
},
{ {
"guard_id": "GR-SEC-001", "guard_id": "GR-SEC-001",
"enforcement_mode": "automated", "enforcement_mode": "automated",

View File

@@ -25,6 +25,7 @@ Scope — verify these guards:
- GR-TEST-002: code is testable - GR-TEST-002: code is testable
- GR-UI-014: UI state completeness (if UI touched) - GR-UI-014: UI state completeness (if UI touched)
- GR-UI-LIST: list/grid standards (if lists touched) - GR-UI-LIST: list/grid standards (if lists touched)
- GR-UI-EXPORT: list export via core primitive (if list exports touched)
- GR-UI-DETAIL: detail page standards (if detail pages touched) - GR-UI-DETAIL: detail page standards (if detail pages touched)
- GR-UI-A11Y: accessibility (if UI touched) - GR-UI-A11Y: accessibility (if UI touched)
- GR-UI-I18N: i18n completeness (if visible text touched) - GR-UI-I18N: i18n completeness (if visible text touched)

View File

@@ -171,6 +171,10 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
- **Components inside forms**: Reset core `margin-bottom` on buttons/inputs/selects/labels at component scope. Checkboxes in flex need explicit `1.25em` sizing + `flex-shrink: 0`. - **Components inside forms**: Reset core `margin-bottom` on buttons/inputs/selects/labels at component scope. Checkboxes in flex need explicit `1.25em` sizing + `flex-shrink: 0`.
- **Sticky elements**: Always use `top: calc(var(--app-topbar-height) + ...)` — never `top: 0`. Multi-column grids must have a responsive single-column `@media` fallback. - **Sticky elements**: Always use `top: calc(var(--app-topbar-height) + ...)` — never `top: 0`. Multi-column grids must have a responsive single-column `@media` fallback.
- **File uploads** use `app-file-upload.phtml` partial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). See `templates/partials/app-file-upload.phtml` for the `$fileUpload` array contract. Never write file-upload markup inline; always use the partial. - **File uploads** use `app-file-upload.phtml` partial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). See `templates/partials/app-file-upload.phtml` for the `$fileUpload` array contract. Never write file-upload markup inline; always use the partial.
- **List exports**: every Grid.js list that offers a download MUST go through the core primitive — no inline `fputcsv`, no hand-rolled headers, no bespoke dropdown. (GR-UI-EXPORT)
- **Server:** endpoint calls `exportRequireGetRequest()` + `exportCapLimit()` + `exportSendCsv()` from `core/Support/helpers/export.php`, builds columns as `ExportColumn[]` (`core/Service/Export/CsvExportService.php`), and reuses the SAME `filter-schema.php` via `gridParseFiltersFromSchemaFile()` as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (see `DomainListService` / `SystemAuditRowPresenter`).
- **View:** `require templatePath('partials/app-list-export-dropdown.phtml');` inside `$listTitleActionsHtml`. Pass the URL via `'exportUrl' => endpointUrl('<route-path>')` in the page config — **always `endpointUrl()`, never plain `lurl()`**. The helper resolves module routes to their target path so query strings survive.
- **Client:** `initListExport({ gridConfig, exportUrl })` from `/js/core/app-list-export.js`. No hand-rolled click listener, no inline URL construction.
### Never Do This ### Never Do This
@@ -186,6 +190,8 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
- Inline SQL or string-concatenated queries (GR-SEC-003) - Inline SQL or string-concatenated queries (GR-SEC-003)
- Raw `echo` in views without `// raw-html-ok` marker — use `e()` (GR-SEC-010) - Raw `echo` in views without `// raw-html-ok` marker — use `e()` (GR-SEC-010)
- OpenAPI changes without updating the spec (GR-CORE-007) - OpenAPI changes without updating the spec (GR-CORE-007)
- Hand-rolled list exports (inline `fputcsv`, own header setup, bespoke click-to-download JS) — always use `core/Service/Export/*` + `exportSendCsv()` + `app-list-export-dropdown.phtml` + `initListExport()` (GR-UI-EXPORT)
- Building an export/data endpoint URL with `lurl()` when it will carry query params — use `endpointUrl()` so module routes where `path ≠ target` still resolve (GR-UI-EXPORT)
## Database ## Database

View File

@@ -28,35 +28,24 @@ if (!$decision->isAllowed()) {
$request = requestInput(); $request = requestInput();
$currentUserId = (int) ($session['user']['id'] ?? 0); $currentUserId = (int) ($session['user']['id'] ?? 0);
$search = $request->queryString('search'); $filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$order = (string) $request->query('order', 'id'); $filters['limit'] = exportCapLimit($request->query('limit'), 5000);
$dir = strtolower((string) $request->query('dir', 'desc')) === 'asc' ? 'asc' : 'desc'; $filters['offset'] = 0;
$active = (string) $request->query('active', 'all');
$createdFrom = $request->queryString('created_from');
$createdTo = $request->queryString('created_to');
$tenant = $request->queryString('tenant');
$roles = $request->query('roles', '');
$departments = $request->query('departments', '');
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'locale', 'theme', 'created', 'modified', 'active'];
if (!in_array($order, $allowedOrder, true)) {
$order = 'id';
}
$limit = exportCapLimit($request->query('limit'), 5000);
$result = app(\MintyPHP\Service\User\UserAccountService::class)->listPaged([ $result = app(\MintyPHP\Service\User\UserAccountService::class)->listPaged([
'limit' => $limit, 'limit' => $filters['limit'],
'offset' => 0, 'offset' => $filters['offset'],
'search' => $search, 'search' => $filters['search'],
'order' => $order, 'order' => $filters['order'],
'dir' => $dir, 'dir' => $filters['dir'],
'active' => $active, 'active' => $filters['active'],
'created_from' => $createdFrom, 'created_from' => $filters['created_from'],
'created_to' => $createdTo, 'created_to' => $filters['created_to'],
'tenant' => $tenant, 'tenant' => $filters['tenant'],
'roles' => $roles, 'roles' => $filters['roles'],
'departments' => $departments, 'departments' => $filters['departments'],
'email_verified' => $filters['email_verified'],
'login_status' => $filters['login_status'],
'tenantUserId' => $currentUserId, 'tenantUserId' => $currentUserId,
]); ]);

View File

@@ -0,0 +1,60 @@
<?php
namespace MintyPHP\Tests\Architecture;
/**
* Registry of Grid.js list pages that expose a CSV/Excel export. Every
* entry carries enough context for ListExportContractTest to verify:
*
* - endpoint → the export action file (must use exportSendCsv + co.)
* - data_endpoint → the matching data() file (shares filter-schema.php)
* - filter_schema → the schema file both endpoints must call
* - index_template → the list page view (must include the dropdown partial)
* - page_module → the front-end module (must call initListExport)
* - route_path → the source path used by endpointUrl() / lurl
*
* Adding a new list export = add one entry here. The test will then
* enforce the full contract without any per-list bespoke code.
*/
trait ListExportContractFiles
{
/**
* @return list<array{
* endpoint: string,
* data_endpoint: string,
* filter_schema: string,
* index_template: string,
* page_module: string,
* route_path: string
* }>
*/
private function listExportRegistry(): array
{
return [
[
'endpoint' => 'modules/helpdesk/pages/helpdesk/domains/export().php',
'data_endpoint' => 'modules/helpdesk/pages/helpdesk/domains-data().php',
'filter_schema' => 'modules/helpdesk/pages/helpdesk/domains/filter-schema.php',
'index_template' => 'modules/helpdesk/pages/helpdesk/domains/index(default).phtml',
'page_module' => 'modules/helpdesk/web/js/pages/helpdesk-domains-index.js',
'route_path' => 'helpdesk/domains/export',
],
[
'endpoint' => 'pages/admin/users/export().php',
'data_endpoint' => 'pages/admin/users/data().php',
'filter_schema' => 'pages/admin/users/filter-schema.php',
'index_template' => 'pages/admin/users/index(default).phtml',
'page_module' => 'web/js/pages/app-users-list.js',
'route_path' => 'admin/users/export',
],
[
'endpoint' => 'modules/audit/pages/audit/system-audit/export().php',
'data_endpoint' => 'modules/audit/pages/audit/system-audit/data().php',
'filter_schema' => 'modules/audit/pages/audit/system-audit/filter-schema.php',
'index_template' => 'modules/audit/pages/audit/system-audit/index(default).phtml',
'page_module' => 'modules/audit/web/js/pages/admin-system-audit-index.js',
'route_path' => 'admin/system-audit/export',
],
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Enforces GR-UI-LIST-EXPORT: every Grid.js list export must go through
* the core primitive (no inline fputcsv, no hand-rolled headers, no
* bespoke URL-building), and the export endpoint must share the data
* endpoint's filter-schema so the download matches what the grid shows.
*
* Add new list exports to ListExportContractFiles::listExportRegistry().
*/
class ListExportContractTest extends TestCase
{
use ListExportContractFiles;
use ProjectFileAssertionSupport;
public function testEveryRegisteredExportEndpointUsesTheCorePrimitive(): void
{
foreach ($this->listExportRegistry() as $entry) {
$content = $this->readProjectFile($entry['endpoint']);
$this->assertStringContainsString('Guard::requireLogin();', $content, $entry['endpoint']);
$this->assertStringContainsString('exportRequireGetRequest();', $content, $entry['endpoint']);
$this->assertStringContainsString('exportCapLimit(', $content, $entry['endpoint']);
$this->assertStringContainsString('exportSendCsv(', $content, $entry['endpoint']);
$this->assertStringContainsString('exportResolveFlavor(', $content, $entry['endpoint']);
$this->assertStringContainsString('new ExportColumn(', $content, $entry['endpoint']);
$this->assertStringContainsString(
'MintyPHP\\Service\\Export\\ExportColumn',
$content,
$entry['endpoint']
);
}
}
public function testExportEndpointsBanHandRolledOutputPrimitives(): void
{
foreach ($this->listExportRegistry() as $entry) {
$content = $this->readProjectFile($entry['endpoint']);
$this->assertStringNotContainsString('fputcsv(', $content, $entry['endpoint']);
$this->assertStringNotContainsString(
"header('Content-Type: text/csv",
$content,
$entry['endpoint']
);
$this->assertStringNotContainsString(
"header('Content-Disposition:",
$content,
$entry['endpoint']
);
$this->assertStringNotContainsString('php://output', $content, $entry['endpoint']);
}
}
public function testExportAndDataEndpointsShareTheSameFilterSchema(): void
{
foreach ($this->listExportRegistry() as $entry) {
$export = $this->readProjectFile($entry['endpoint']);
$data = $this->readProjectFile($entry['data_endpoint']);
$schemaDir = dirname($entry['filter_schema']);
$endpointDir = dirname($entry['endpoint']);
$dataEndpointDir = dirname($entry['data_endpoint']);
$schemaRefFromExport = $this->relativeSchemaRef($endpointDir, $schemaDir);
$schemaRefFromData = $this->relativeSchemaRef($dataEndpointDir, $schemaDir);
$this->assertStringContainsString(
"gridParseFiltersFromSchemaFile(__DIR__ . '" . $schemaRefFromExport . "/filter-schema.php'",
$export,
$entry['endpoint'] . ' must parse the shared filter-schema.php'
);
$this->assertStringContainsString(
"gridParseFiltersFromSchemaFile(__DIR__ . '" . $schemaRefFromData . "/filter-schema.php'",
$data,
$entry['data_endpoint'] . ' must parse the same filter-schema.php as the export endpoint'
);
$this->assertFileExists(
$this->projectRootPath() . '/' . $entry['filter_schema'],
$entry['filter_schema']
);
}
}
public function testIndexTemplateIncludesExportDropdownAndUsesEndpointUrl(): void
{
foreach ($this->listExportRegistry() as $entry) {
$template = $this->readProjectFile($entry['index_template']);
$this->assertStringContainsString(
"templatePath('partials/app-list-export-dropdown.phtml')",
$template,
$entry['index_template'] . ' must require the shared export dropdown partial'
);
$this->assertStringContainsString(
"endpointUrl('" . $entry['route_path'] . "')",
$template,
$entry['index_template'] . ' must build exportUrl via endpointUrl(), not lurl()'
);
$this->assertStringNotContainsString(
"'exportUrl' => lurl(",
$template,
$entry['index_template'] . ' must not use lurl() for exportUrl (use endpointUrl())'
);
}
}
public function testPageModuleInvokesInitListExportFromCore(): void
{
foreach ($this->listExportRegistry() as $entry) {
$module = $this->readProjectFile($entry['page_module']);
$this->assertMatchesRegularExpression(
"#\\bimport\\s*\\{[^}]*\\binitListExport\\b[^}]*\\}\\s*from\\s*['\"](?:\\.\\./core|/js/core)/app-list-export\\.js['\"]#s",
$module,
$entry['page_module'] . ' must import initListExport from the core module'
);
$this->assertStringContainsString(
'initListExport(',
$module,
$entry['page_module'] . ' must invoke initListExport({ gridConfig, exportUrl })'
);
}
}
public function testExportDropdownPartialRemainsZeroConfigAndReusesCoreClasses(): void
{
$partial = $this->readProjectFile('templates/partials/app-list-export-dropdown.phtml');
$this->assertStringContainsString('data-list-export="csv"', $partial);
$this->assertStringContainsString('data-list-export="excel"', $partial);
$this->assertStringContainsString("<details class=\"dropdown app-list-export-dropdown\"", $partial);
$this->assertStringNotContainsString('<script', $partial, 'partial must not ship inline JS');
$this->assertStringNotContainsString('<style', $partial, 'partial must not ship inline CSS');
}
private function relativeSchemaRef(string $endpointDir, string $schemaDir): string
{
if ($endpointDir === $schemaDir) {
return '';
}
// Schema lives inside the endpoint dir (typical: endpoint sits one level up).
if (str_starts_with($schemaDir, $endpointDir . '/')) {
return '/' . substr($schemaDir, strlen($endpointDir) + 1);
}
// Schema lives in a sibling directory (endpoint sits in same dir parent).
$commonParent = dirname($endpointDir);
if ($schemaDir === $commonParent) {
return '/..';
}
if (str_starts_with($schemaDir, $commonParent . '/')) {
return '/../' . substr($schemaDir, strlen($commonParent) + 1);
}
return '/' . $schemaDir;
}
}