refactor(addressbook): move from aside icon-bar to explorer nav link
Introduce generic explorer.nav_item slot type in app-main-aside.phtml so modules can contribute links to the explorer nav panel. Switch addressbook module from aside.tab_panel to explorer.nav_item. Remove aside department-filter panel, AddressBookLayoutProvider, and AddressBookSessionProvider (only served the now-removed aside panel). Fix grid Name column overflow into Email column: CSS selector .grid-name-cell > span:last-child never matched the <a> name link; changed to :last-child and scoped flex-shrink:0 to :first-child only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\AddressBook\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||
|
||||
/**
|
||||
* Provides address book data for the layout navigation context.
|
||||
*
|
||||
* Contributes the 'addressbook.nav' key to $layoutNav with URL, active filters,
|
||||
* and people groups from the session.
|
||||
*/
|
||||
final class AddressBookLayoutProvider implements LayoutContextProvider
|
||||
{
|
||||
public function provide(array $session, AppContainer $container): array
|
||||
{
|
||||
// Read query params for active filter state
|
||||
$query = [];
|
||||
try {
|
||||
$query = requestInput()->queryAll();
|
||||
} catch (\Throwable) {
|
||||
// fail-open: may not be available in CLI context
|
||||
}
|
||||
|
||||
return [
|
||||
'addressbook.nav' => [
|
||||
'url' => lurl('address-book'),
|
||||
'activeSearch' => trim((string) ($query['search'] ?? '')),
|
||||
'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''),
|
||||
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
|
||||
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
|
||||
'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
|
||||
'peopleGroups' => is_array($session['module.addressbook.departments_by_tenant'] ?? null)
|
||||
? $session['module.addressbook.departments_by_tenant']
|
||||
: [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize address-book custom field query keys/values.
|
||||
*
|
||||
* @param array<string, mixed> $rawQuery
|
||||
* @return array<string, string|list<int>>
|
||||
*/
|
||||
public static function normalizeCustomFilterQuery(array $rawQuery): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($rawQuery as $rawKey => $rawValue) {
|
||||
$key = strtolower(trim((string) $rawKey));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '') {
|
||||
$normalized[$key] = $value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
|
||||
$ids = appNormalizePositiveIntList($rawValue);
|
||||
if ($ids) {
|
||||
$normalized[$key] = $ids;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
if ($dt && $dt->format('Y-m-d') === $value) {
|
||||
$normalized[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($normalized, SORT_STRING);
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\AddressBook\Providers;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
/**
|
||||
* Populates session with department-by-tenant hierarchy data used by the
|
||||
* address book aside panel.
|
||||
*/
|
||||
final class AddressBookSessionProvider implements SessionProvider
|
||||
{
|
||||
public function populate(array $user, AppContainer $container): void
|
||||
{
|
||||
$sessionStore = $container->get(SessionStoreInterface::class);
|
||||
if (!$sessionStore instanceof SessionStoreInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
$sessionStore->remove('module.addressbook.departments_by_tenant');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$tenantContext = $container->get(UserTenantContextService::class);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
if (!$tenantContext instanceof UserTenantContextService) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore->set(
|
||||
'module.addressbook.departments_by_tenant',
|
||||
$tenantContext->getAvailableDepartmentsByTenant($userId)
|
||||
);
|
||||
}
|
||||
|
||||
public function clear(AppContainer $container): void
|
||||
{
|
||||
$sessionStore = $container->get(SessionStoreInterface::class);
|
||||
if ($sessionStore instanceof SessionStoreInterface) {
|
||||
$sessionStore->remove('module.addressbook.departments_by_tenant');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
/**
|
||||
* Address Book module manifest.
|
||||
*
|
||||
* Contributes routes, sidebar slots, search entry, user-edit aside action,
|
||||
* layout context data, and session lifecycle.
|
||||
* Contributes routes, explorer nav link, search entry, user-edit aside action,
|
||||
* and authorization policy.
|
||||
*/
|
||||
return [
|
||||
'id' => 'addressbook',
|
||||
@@ -27,16 +27,12 @@ return [
|
||||
],
|
||||
|
||||
'ui_slots' => [
|
||||
'aside.tab_panel' => [
|
||||
'explorer.nav_item' => [
|
||||
[
|
||||
'key' => 'people',
|
||||
'key' => 'address-book',
|
||||
'label' => 'Address book',
|
||||
'icon' => 'bi-people',
|
||||
'href' => 'address-book',
|
||||
'permission' => 'addressbook.view',
|
||||
'panel_template' => 'templates/aside-people-panel.phtml',
|
||||
'details_storage' => 'aside-people-tenant',
|
||||
'details_open_active' => true,
|
||||
'order' => 110,
|
||||
],
|
||||
],
|
||||
@@ -77,13 +73,9 @@ return [
|
||||
],
|
||||
'scheduler_jobs' => [],
|
||||
|
||||
'layout_context_providers' => [
|
||||
\MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider::class,
|
||||
],
|
||||
'layout_context_providers' => [],
|
||||
|
||||
'session_providers' => [
|
||||
\MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class,
|
||||
],
|
||||
'session_providers' => [],
|
||||
|
||||
'permissions' => [
|
||||
[
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Address book aside panel — people browser with tenant/department hierarchy.
|
||||
*
|
||||
* Rendered by the module slot system. The outer <nav> with id, role, aria
|
||||
* attributes is created by the slot renderer in app-main-aside.phtml.
|
||||
* This template provides only the inner content.
|
||||
*
|
||||
* Available in scope via include: $layoutNav, $layoutAuth, $viewAuth
|
||||
*/
|
||||
$addressBook = is_array($layoutNav['addressbook.nav'] ?? null) ? $layoutNav['addressbook.nav'] : [];
|
||||
$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'] : [];
|
||||
$addressBookActive = navActive('address-book', true);
|
||||
?>
|
||||
<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>
|
||||
@@ -6,6 +6,9 @@ $layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$moduleNavItemSlots = is_array(($layoutNav ?? [])['moduleSlots']['sidebar.admin_nav_item'] ?? null)
|
||||
? $layoutNav['moduleSlots']['sidebar.admin_nav_item']
|
||||
: [];
|
||||
$moduleExplorerNavSlots = is_array(($layoutNav ?? [])['moduleSlots']['explorer.nav_item'] ?? null)
|
||||
? $layoutNav['moduleSlots']['explorer.nav_item']
|
||||
: [];
|
||||
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
|
||||
$canViewDepartments = (bool) ($layoutAuth['can_view_departments'] ?? false);
|
||||
$canViewUsers = (bool) ($layoutAuth['can_view_users'] ?? false);
|
||||
@@ -308,6 +311,26 @@ $moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $m
|
||||
<?php e(t('Home')); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php foreach ($moduleExplorerNavSlots as $explorerSlot):
|
||||
if (!is_array($explorerSlot)) { continue; }
|
||||
$explorerKey = $explorerSlot['key'] ?? '';
|
||||
$explorerLabel = $explorerSlot['label'] ?? '';
|
||||
$explorerHref = $explorerSlot['href'] ?? '';
|
||||
$explorerPermission = $explorerSlot['permission'] ?? '';
|
||||
if ($explorerKey === '' || $explorerHref === '') { continue; }
|
||||
if ($explorerPermission !== '' && empty($layoutAuth[$explorerPermission])) { continue; }
|
||||
if (!str_starts_with($explorerHref, '/') && !str_starts_with($explorerHref, 'http')) {
|
||||
$explorerHref = lurl($explorerHref);
|
||||
}
|
||||
$explorerActive = navActive($explorerSlot['href'], true);
|
||||
?>
|
||||
<li>
|
||||
<a href="<?php e($explorerHref); ?>" class="<?php e($explorerActive['class'] ?? ''); ?>"
|
||||
<?php echo $explorerActive['aria'] ?? ''; ?>>
|
||||
<?php e(t($explorerLabel)); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</nav>
|
||||
<?php if ($hasAdminPanel): ?>
|
||||
|
||||
@@ -22,9 +22,9 @@ final class ModuleRegistryBootstrapContractTest extends TestCase
|
||||
self::assertTrue($registry->hasModule('addressbook'));
|
||||
self::assertNotEmpty($registry->getPermissions());
|
||||
self::assertNotEmpty($registry->getSearchResources());
|
||||
self::assertNotEmpty($registry->getLayoutContextProviders());
|
||||
self::assertNotEmpty($registry->getSessionProviders());
|
||||
self::assertNotEmpty($registry->getSlotContributions('aside.tab_panel'));
|
||||
self::assertEmpty($registry->getLayoutContextProviders());
|
||||
self::assertEmpty($registry->getSessionProviders());
|
||||
self::assertNotEmpty($registry->getSlotContributions('explorer.nav_item'));
|
||||
}
|
||||
|
||||
public function testEmptyEnvDisablesAllConfiguredModules(): void
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Unit\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider;
|
||||
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Verifies that the AddressBookLayoutProvider correctly contributes
|
||||
* the 'addressbook.nav' key to the layout context.
|
||||
*/
|
||||
final class LayoutContextProviderTest extends TestCase
|
||||
{
|
||||
use AppContainerIsolationTrait;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Guard against other tests overwriting the global container
|
||||
if (!($GLOBALS['minty_app_container'] ?? null) instanceof AppContainer
|
||||
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)
|
||||
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\Service\Access\UiAccessService::class)) {
|
||||
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
|
||||
$this->pushAppContainer($container);
|
||||
setAppContainer($container);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->restoreAppContainer();
|
||||
}
|
||||
|
||||
public function testProviderReturnsAddressBookKey(): void
|
||||
{
|
||||
$provider = new AddressBookLayoutProvider();
|
||||
$container = new AppContainer();
|
||||
|
||||
$session = [
|
||||
'module.addressbook.departments_by_tenant' => [
|
||||
[
|
||||
'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'],
|
||||
'departments' => [['id' => 1, 'description' => 'Dept 1']],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $provider->provide($session, $container);
|
||||
|
||||
self::assertArrayHasKey('addressbook.nav', $result);
|
||||
$ab = $result['addressbook.nav'];
|
||||
self::assertArrayHasKey('url', $ab);
|
||||
self::assertArrayHasKey('activeSearch', $ab);
|
||||
self::assertArrayHasKey('activeTenants', $ab);
|
||||
self::assertArrayHasKey('activeDepartments', $ab);
|
||||
self::assertArrayHasKey('activeRoles', $ab);
|
||||
self::assertArrayHasKey('activeCustomFilters', $ab);
|
||||
self::assertArrayHasKey('peopleGroups', $ab);
|
||||
self::assertCount(1, $ab['peopleGroups']);
|
||||
}
|
||||
|
||||
public function testProviderReturnsEmptyPeopleGroupsWhenSessionEmpty(): void
|
||||
{
|
||||
$provider = new AddressBookLayoutProvider();
|
||||
$container = new AppContainer();
|
||||
|
||||
$result = $provider->provide([], $container);
|
||||
|
||||
self::assertArrayHasKey('addressbook.nav', $result);
|
||||
self::assertSame([], $result['addressbook.nav']['peopleGroups']);
|
||||
}
|
||||
|
||||
public function testProviderIsIncludedInLayoutNavWhenModuleActive(): void
|
||||
{
|
||||
// When the module is active, appBuildLayoutNavContext() should include
|
||||
// addressbook.nav via the provider loop
|
||||
$layoutNav = appBuildLayoutNavContext([], [], []);
|
||||
|
||||
self::assertArrayHasKey(
|
||||
'addressbook.nav',
|
||||
$layoutNav,
|
||||
'addressbook.nav key must be present in layoutNav when module is active'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Unit\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\Http\SessionStore;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider
|
||||
*/
|
||||
final class SessionProviderTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
private function containerWithSessionStore(): AppContainer
|
||||
{
|
||||
$container = new AppContainer();
|
||||
$container->set(SessionStoreInterface::class, static fn (): SessionStore => new SessionStore());
|
||||
return $container;
|
||||
}
|
||||
|
||||
public function testPopulateWithInvalidUserClearsSessionKey(): void
|
||||
{
|
||||
$_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
|
||||
|
||||
$provider = new AddressBookSessionProvider();
|
||||
$provider->populate(['id' => 0], $this->containerWithSessionStore());
|
||||
|
||||
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||
}
|
||||
|
||||
public function testPopulateWithMissingUserIdClearsSessionKey(): void
|
||||
{
|
||||
$_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
|
||||
|
||||
$provider = new AddressBookSessionProvider();
|
||||
$provider->populate([], $this->containerWithSessionStore());
|
||||
|
||||
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||
}
|
||||
|
||||
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
|
||||
{
|
||||
$container = $this->containerWithSessionStore();
|
||||
$provider = new AddressBookSessionProvider();
|
||||
// Container has SessionStore but no UserTenantContextService → provider returns early
|
||||
$provider->populate(['id' => 42], $container);
|
||||
|
||||
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||
}
|
||||
|
||||
public function testClearRemovesSessionKey(): void
|
||||
{
|
||||
$_SESSION['module.addressbook.departments_by_tenant'] = [
|
||||
['tenant' => ['uuid' => 'abc'], 'departments' => []],
|
||||
];
|
||||
|
||||
$provider = new AddressBookSessionProvider();
|
||||
$provider->clear($this->containerWithSessionStore());
|
||||
|
||||
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||
}
|
||||
|
||||
public function testClearIsIdempotent(): void
|
||||
{
|
||||
// Key doesn't exist — clear() should not throw
|
||||
$provider = new AddressBookSessionProvider();
|
||||
$provider->clear($this->containerWithSessionStore());
|
||||
|
||||
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||
}
|
||||
}
|
||||
@@ -191,12 +191,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-name-cell > a {
|
||||
.grid-name-cell > a:first-child,
|
||||
.grid-name-cell > span:first-child {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-name-cell > span:last-child {
|
||||
.grid-name-cell > :last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
Reference in New Issue
Block a user