feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)
Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to contribute routes, UI slots, search providers, layout context, session lifecycle, and permissions. Modules are activated via config/modules.php or APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions, slot keys) cause a fail-fast with a clear error message. Extract the address book from hardcoded Core integration points into the first module (modules/addressbook/). The module provides: - Aside icon-bar tab + People panel via UI slot system - Global search resource via AddressBookSearchProvider - Layout context data via AddressBookLayoutProvider - Session lifecycle via AddressBookSessionProvider Core cleanup removes address-book hardcodings from SearchSqlResourceProvider, SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(), and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic (AddressBookService) remain in Core as they gate general user visibility. Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture tests verifying zero hardcoded address-book references in search/templates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,8 @@ $accountName = currentUserDisplayName();
|
||||
$accountTooltip = $accountName !== '' ? $accountName : t('Account');
|
||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$hasAdminPanel = layoutHasAdminPanel($layoutAuth);
|
||||
$canViewAddressBook = (bool) ($layoutAuth['can_view_address_book'] ?? false);
|
||||
$addressBookUrl = lurl('address-book');
|
||||
?>
|
||||
<aside class="aside-icon-bar" data-aside-storage="app-sidebar-panel">
|
||||
<aside class="aside-icon-bar" data-app-component="aside-panels" data-aside-storage="app-sidebar-panel">
|
||||
<nav>
|
||||
<ul class="aside-icon-group" role="tablist" aria-orientation="vertical">
|
||||
<li>
|
||||
@@ -24,16 +22,35 @@ $addressBookUrl = lurl('address-book');
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</li>
|
||||
<?php if ($canViewAddressBook): ?>
|
||||
<?php
|
||||
// ── Module-contributed aside tabs (aside.tab_panel slot) ──
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$moduleTabSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
||||
foreach ($moduleTabSlots as $slot):
|
||||
if (!is_array($slot)) { continue; }
|
||||
$slotKey = $slot['key'] ?? '';
|
||||
$slotLabel = $slot['label'] ?? '';
|
||||
$slotIcon = $slot['icon'] ?? 'bi-puzzle';
|
||||
$slotHref = $slot['href'] ?? '';
|
||||
$slotPermission = $slot['permission'] ?? '';
|
||||
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; }
|
||||
// Resolve relative route keys via lurl()
|
||||
if ($slotHref !== '' && !str_starts_with($slotHref, '/') && !str_starts_with($slotHref, 'http')) {
|
||||
$slotHref = lurl($slotHref);
|
||||
}
|
||||
?>
|
||||
<li>
|
||||
<button type="button" id="aside-tab-people" data-aside-target="people"
|
||||
data-aside-href="<?php e($addressBookUrl); ?>" role="tab" aria-selected="false"
|
||||
aria-controls="aside-panel-people" aria-label="<?php e(t('Address book')); ?>"
|
||||
data-tooltip="<?php e(t('Address book')); ?>" data-tooltip-pos="right">
|
||||
<i class="bi bi-people"></i>
|
||||
<button type="button" id="aside-tab-<?php e($slotKey); ?>"
|
||||
data-aside-target="<?php e($slotKey); ?>"
|
||||
<?php if ($slotHref !== ''): ?>data-aside-href="<?php e($slotHref); ?>"<?php endif; ?>
|
||||
role="tab" aria-selected="false"
|
||||
aria-controls="aside-panel-<?php e($slotKey); ?>"
|
||||
aria-label="<?php e(t($slotLabel)); ?>"
|
||||
data-tooltip="<?php e(t($slotLabel)); ?>" data-tooltip-pos="right">
|
||||
<i class="bi <?php e($slotIcon); ?>"></i>
|
||||
</button>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<li>
|
||||
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
|
||||
aria-selected="false" aria-controls="aside-panel-bookmarks"
|
||||
|
||||
@@ -20,7 +20,6 @@ $canViewSystemAudit = (bool) ($layoutAuth['can_view_system_audit'] ?? false);
|
||||
$canViewImportsAudit = (bool) ($layoutAuth['can_view_imports_audit'] ?? false);
|
||||
$canViewUserLifecycleAudit = (bool) ($layoutAuth['can_view_user_lifecycle_audit'] ?? false);
|
||||
$canViewStats = (bool) ($layoutAuth['can_view_stats'] ?? false);
|
||||
$canViewAddressBook = (bool) ($layoutAuth['can_view_address_book'] ?? false);
|
||||
$docsDefaultSlug = DocsCatalogService::defaultSlug();
|
||||
$docsDefaultPath = 'admin/docs/' . $docsDefaultSlug;
|
||||
$hasOrganization = $canViewTenants || $canViewDepartments || $canViewUsers;
|
||||
@@ -254,18 +253,10 @@ $tenantAvatar = is_array($layoutNav['tenantAvatar'] ?? null) ? $layoutNav['tenan
|
||||
$tenantUuid = trim((string) ($tenantAvatar['uuid'] ?? ''));
|
||||
$tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
|
||||
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
|
||||
$addressBook = is_array($layoutNav['addressBook'] ?? null) ? $layoutNav['addressBook'] : [];
|
||||
$addressBookUrl = trim((string) ($addressBook['url'] ?? lurl('address-book')));
|
||||
$activeAddressSearch = trim((string) ($addressBook['activeSearch'] ?? ''));
|
||||
$activeAddressTenants = is_array($addressBook['activeTenants'] ?? null) ? $addressBook['activeTenants'] : [];
|
||||
$activeAddressDepartments = is_array($addressBook['activeDepartments'] ?? null) ? $addressBook['activeDepartments'] : [];
|
||||
$activeAddressRoles = is_array($addressBook['activeRoles'] ?? null) ? $addressBook['activeRoles'] : [];
|
||||
$activeAddressCustomFilters = is_array($addressBook['activeCustomFilters'] ?? null) ? $addressBook['activeCustomFilters'] : [];
|
||||
$peopleGroups = is_array($addressBook['peopleGroups'] ?? null) ? $addressBook['peopleGroups'] : [];
|
||||
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
|
||||
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
?>
|
||||
<aside class="app-sidebar" id="app-sidebar">
|
||||
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
|
||||
<?php if ($currentTenant): ?>
|
||||
<div class="app-sidebar-tenant-logo">
|
||||
<a href="<?php e(lurl('')); ?>">
|
||||
@@ -309,84 +300,40 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($canViewAddressBook): ?>
|
||||
<?php $addressBookActive = navActive('address-book', true); ?>
|
||||
<nav id="aside-panel-people" class="app-sidebar-panel" data-aside-panel="people"
|
||||
data-aside-details-storage="aside-people-tenant"
|
||||
data-aside-title="<?php e(t('Address book')); ?>" role="tabpanel" aria-labelledby="aside-tab-people" hidden>
|
||||
<ul>
|
||||
<?php if (!$peopleGroups): ?>
|
||||
<li>
|
||||
<a href="<?php e($addressBookUrl); ?>" class="<?php e($addressBookActive['class'] ?? ''); ?>"
|
||||
<?php echo $addressBookActive['aria'] ?? ''; ?>>
|
||||
<?php e(t('Address book')); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php foreach ($peopleGroups as $index => $group): ?>
|
||||
<?php
|
||||
$tenant = $group['tenant'] ?? [];
|
||||
$departments = $group['departments'] ?? [];
|
||||
$tenantUuid = (string) ($tenant['uuid'] ?? '');
|
||||
$tenantName = (string) ($tenant['description'] ?? '');
|
||||
if ($tenantUuid === '') {
|
||||
continue;
|
||||
}
|
||||
$baseHref = $addressBookUrl . '?tenants=' . urlencode($tenantUuid);
|
||||
$isActiveTenant = $addressBookActive['isActive']
|
||||
&& !$activeAddressDepartments
|
||||
&& count($activeAddressTenants) === 1
|
||||
&& $activeAddressTenants[0] === $tenantUuid;
|
||||
?>
|
||||
<li class="app-sidebar-group">
|
||||
<small>
|
||||
<a href="<?php e($baseHref); ?>" class="<?php e($isActiveTenant ? 'active' : 'muted'); ?>"
|
||||
<?php echo $isActiveTenant ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($tenantName); ?>
|
||||
</a>
|
||||
</small>
|
||||
<ul>
|
||||
<?php if (!$departments): ?>
|
||||
<li>
|
||||
<?php
|
||||
$emptyState = [
|
||||
'message' => t('No departments'),
|
||||
'size' => 'small',
|
||||
'align' => 'center',
|
||||
];
|
||||
require templatePath('partials/app-empty-state.phtml');
|
||||
?>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php foreach ($departments as $department): ?>
|
||||
<?php
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
if ($departmentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$departmentName = (string) ($department['description'] ?? '');
|
||||
$href = $baseHref . '&departments=' . $departmentId;
|
||||
$isActive = $addressBookActive['isActive']
|
||||
&& in_array($tenantUuid, $activeAddressTenants, true)
|
||||
&& in_array($departmentId, $activeAddressDepartments, true);
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php e($href); ?>" class="<?php e($isActive ? 'active' : ''); ?>"
|
||||
<?php echo $isActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($departmentName); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php
|
||||
// ── Module-contributed aside panels (aside.tab_panel slot) ──
|
||||
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
|
||||
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
|
||||
foreach ($modulePanelSlots as $panelSlot):
|
||||
if (!is_array($panelSlot)) { continue; }
|
||||
$panelKey = $panelSlot['key'] ?? '';
|
||||
$panelLabel = $panelSlot['label'] ?? '';
|
||||
$panelTemplate = $panelSlot['panel_template'] ?? '';
|
||||
$panelPermission = $panelSlot['permission'] ?? '';
|
||||
if ($panelPermission !== '' && empty($layoutAuth[$panelPermission])) { continue; }
|
||||
if ($panelKey === '' || $panelTemplate === '') { continue; }
|
||||
?>
|
||||
<?php
|
||||
$panelDetailsStorage = $panelSlot['details_storage'] ?? '';
|
||||
$panelDetailsOpenActive = !empty($panelSlot['details_open_active']);
|
||||
?>
|
||||
<nav id="aside-panel-<?php e($panelKey); ?>" class="app-sidebar-panel"
|
||||
data-aside-panel="<?php e($panelKey); ?>"
|
||||
<?php if ($panelDetailsStorage !== ''): ?>data-aside-details-storage="<?php e($panelDetailsStorage); ?>"<?php endif; ?>
|
||||
<?php if ($panelDetailsOpenActive): ?>data-aside-details-open-active="1"<?php endif; ?>
|
||||
data-aside-title="<?php e(t($panelLabel)); ?>"
|
||||
role="tabpanel" aria-labelledby="aside-tab-<?php e($panelKey); ?>" hidden>
|
||||
<?php
|
||||
// Include the module-provided panel template
|
||||
$panelTemplatePath = $panelTemplate;
|
||||
if (is_file($panelTemplatePath)) {
|
||||
include $panelTemplatePath;
|
||||
}
|
||||
?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div id="aside-panel-search" class="app-sidebar-panel" data-aside-panel="search"
|
||||
<div id="aside-panel-search" class="app-sidebar-panel" data-app-component="global-search" data-aside-panel="search"
|
||||
data-aside-title="<?php e(t('Search')); ?>" role="tabpanel" aria-labelledby="aside-tab-search" hidden>
|
||||
<form class="app-search">
|
||||
<input type="search" name="side-search" id="side-search" placeholder="<?php e(t('Search...')); ?>"
|
||||
@@ -545,7 +492,7 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
|
||||
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
||||
?>
|
||||
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-aside-panel="bookmarks"
|
||||
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-app-component="bookmark-panel" data-aside-panel="bookmarks"
|
||||
data-aside-title="<?php e(t('Bookmarks')); ?>"
|
||||
data-aside-details-storage="aside-bookmarks-groups-v1"
|
||||
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
|
||||
|
||||
Reference in New Issue
Block a user