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:
60
tests/Architecture/ListExportContractFiles.php
Normal file
60
tests/Architecture/ListExportContractFiles.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
158
tests/Architecture/ListExportContractTest.php
Normal file
158
tests/Architecture/ListExportContractTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user