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

@@ -28,35 +28,24 @@ if (!$decision->isAllowed()) {
$request = requestInput();
$currentUserId = (int) ($session['user']['id'] ?? 0);
$search = $request->queryString('search');
$order = (string) $request->query('order', 'id');
$dir = strtolower((string) $request->query('dir', 'desc')) === 'asc' ? 'asc' : 'desc';
$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);
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
$filters['limit'] = exportCapLimit($request->query('limit'), 5000);
$filters['offset'] = 0;
$result = app(\MintyPHP\Service\User\UserAccountService::class)->listPaged([
'limit' => $limit,
'offset' => 0,
'search' => $search,
'order' => $order,
'dir' => $dir,
'active' => $active,
'created_from' => $createdFrom,
'created_to' => $createdTo,
'tenant' => $tenant,
'roles' => $roles,
'departments' => $departments,
'limit' => $filters['limit'],
'offset' => $filters['offset'],
'search' => $filters['search'],
'order' => $filters['order'],
'dir' => $filters['dir'],
'active' => $filters['active'],
'created_from' => $filters['created_from'],
'created_to' => $filters['created_to'],
'tenant' => $filters['tenant'],
'roles' => $filters['roles'],
'departments' => $filters['departments'],
'email_verified' => $filters['email_verified'],
'login_status' => $filters['login_status'],
'tenantUserId' => $currentUserId,
]);