feat(core): detail drawer + address book list redesign
Introduces a reusable core detail-drawer primitive that slides in from the right and loads any view via a `*-fragment(none).phtml` endpoint. Bundles the address book list overhaul that is its first consumer. Core additions: - `app-detail-drawer.js` — generic drawer with stepper, focus trap, body scroll-lock, URL-hash deep-linking, session-expiry detection - `app-fragment-init.js` — auto-wires tabs/lookups/confirm/file-upload/ fslightbox inside injected HTML; consumers do not re-initialize components - `app-focus-trap.js` — shared focus-trap + refcounted scroll-lock, used by both filter-drawer and detail-drawer - `getHtml()` in `app-http.js` + `SessionExpiredError`; drawer reloads the page on auth redirect instead of rendering the login form in the panel - `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer` consumer ships matching `*-fragment($id).php` + `*-fragment(none).phtml` Address book list: - Grid collapses from 9 columns to 4 (identity / context / phone / actions) with a two-line identity cell (avatar + name + email) - Tenant register tabs above the grid using the `app-list-tabs` partial; tenant filter wired via hidden toolbar field so grid.js forwards it on every data fetch - Profile body extracted to a shared partial so the full-page view and the new drawer fragment share the same markup - New i18n keys for the drawer/list labels Also refactors `app-filter-drawer` to reuse the shared focus-trap and scroll-lock instead of maintaining its own copy, and documents the detail-drawer convention in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
tests/Architecture/DetailDrawerFragmentContractTest.php
Normal file
148
tests/Architecture/DetailDrawerFragmentContractTest.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Every `initDetailDrawer({ fetchUrl })` consumer must expose a matching
|
||||
* fragment endpoint following the convention:
|
||||
* <route>-fragment($id).php ← action
|
||||
* <route>-fragment(none).phtml ← view (no-layout)
|
||||
*
|
||||
* This test scans JS files for `initDetailDrawer` calls, extracts the fragment
|
||||
* path from the fetchUrl template literal, and verifies both files exist.
|
||||
*/
|
||||
class DetailDrawerFragmentContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
public function testEveryDrawerConsumerHasMatchingFragmentEndpoint(): void
|
||||
{
|
||||
$root = $this->projectRootPath();
|
||||
$scanRoots = ['web/js', 'modules'];
|
||||
|
||||
$consumers = [];
|
||||
|
||||
foreach ($scanRoots as $scanRoot) {
|
||||
$basePath = $root . '/' . $scanRoot;
|
||||
if (!is_dir($basePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS));
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'js') {
|
||||
continue;
|
||||
}
|
||||
// Exclude the drawer implementation itself.
|
||||
$relativePath = str_replace($root . '/', '', $file->getPathname());
|
||||
if ($relativePath === 'web/js/components/app-detail-drawer.js') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file->getPathname());
|
||||
if ($content === false || !str_contains($content, 'initDetailDrawer(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find `fetchUrl: (uuid) => new URL(`<path>/${uuid}`, ...)` — extract <path>.
|
||||
// The path must contain "-fragment" per the convention.
|
||||
if (preg_match_all(
|
||||
'/fetchUrl\s*:\s*\([^)]*\)\s*=>\s*new\s+URL\s*\(\s*`([^`${]+)\$\{[^}]+\}[^`]*`/',
|
||||
$content,
|
||||
$matches
|
||||
)) {
|
||||
foreach ($matches[1] as $prefix) {
|
||||
$prefix = rtrim($prefix, '/');
|
||||
$consumers[$prefix] = $relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotEmpty(
|
||||
$consumers,
|
||||
'No initDetailDrawer fetchUrl patterns found. Either the convention changed or the regex is stale.'
|
||||
);
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($consumers as $fragmentPath => $consumerFile) {
|
||||
if (!str_contains($fragmentPath, '-fragment')) {
|
||||
$violations[] = sprintf(
|
||||
"Fragment path does not follow *-fragment convention: %s (in %s)",
|
||||
$fragmentPath,
|
||||
$consumerFile
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
[$actionFound, $viewFound, $searchedDirs] = $this->locateFragmentFiles($fragmentPath);
|
||||
|
||||
if (!$actionFound) {
|
||||
$violations[] = sprintf(
|
||||
"Missing action file for fragment path '%s'. Expected somewhere like:\n pages/%s(\$id).php\n modules/*/pages/%s(\$id).php\nSearched in: %s\n(Consumer: %s)",
|
||||
$fragmentPath,
|
||||
$fragmentPath,
|
||||
$fragmentPath,
|
||||
implode(', ', $searchedDirs),
|
||||
$consumerFile
|
||||
);
|
||||
}
|
||||
if (!$viewFound) {
|
||||
$violations[] = sprintf(
|
||||
"Missing (none) view file for fragment path '%s'. Expected:\n %s(none).phtml\nSearched in: %s\n(Consumer: %s)",
|
||||
$fragmentPath,
|
||||
$fragmentPath,
|
||||
implode(', ', $searchedDirs),
|
||||
$consumerFile
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$violations,
|
||||
"Detail-drawer fragment-contract violations:\n" . implode("\n", $violations)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool, 1: bool, 2: list<string>} [actionFound, viewFound, searchedDirs]
|
||||
*/
|
||||
private function locateFragmentFiles(string $fragmentPath): array
|
||||
{
|
||||
$root = $this->projectRootPath();
|
||||
$searchBases = [$root . '/pages'];
|
||||
$modulesDir = $root . '/modules';
|
||||
if (is_dir($modulesDir)) {
|
||||
foreach (scandir($modulesDir) ?: [] as $moduleEntry) {
|
||||
if ($moduleEntry === '.' || $moduleEntry === '..') {
|
||||
continue;
|
||||
}
|
||||
$modulePages = $modulesDir . '/' . $moduleEntry . '/pages';
|
||||
if (is_dir($modulePages)) {
|
||||
$searchBases[] = $modulePages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$actionFound = false;
|
||||
$viewFound = false;
|
||||
|
||||
foreach ($searchBases as $base) {
|
||||
$actionCandidate = $base . '/' . $fragmentPath . '($id).php';
|
||||
$viewCandidate = $base . '/' . $fragmentPath . '(none).phtml';
|
||||
if (is_file($actionCandidate)) {
|
||||
$actionFound = true;
|
||||
}
|
||||
if (is_file($viewCandidate)) {
|
||||
$viewFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [$actionFound, $viewFound, $searchBases];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user