Files
breadcrumb-the-shire/modules/addressbook/pages/address-book/index(default).phtml
fs 811588290c 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>
2026-04-22 15:22:26 +02:00

270 lines
11 KiB
PHTML

<?php
use MintyPHP\Buffer;
use MintyPHP\Session;
$csrfKey = $csrfKey ?? Session::$csrfSessionKey;
$csrfToken = $csrfToken ?? ($_SESSION[$csrfKey] ?? '');
$customFieldFilterDefinitions = is_array($customFieldFilterDefinitions ?? null) ? $customFieldFilterDefinitions : [];
$activeCustomFieldQuery = is_array($customFieldFilterQuery ?? null) ? $customFieldFilterQuery : [];
$toolbarFilterSchema = is_array($toolbarFilterSchema ?? null) ? $toolbarFilterSchema : [];
$searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : [];
$drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : [];
$toolbarFilterState = is_array($toolbarFilterState ?? null) ? $toolbarFilterState : [];
$toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : [];
$clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : [];
$filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : [];
$searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
$tenantTabs = is_array($tenantTabs ?? null) ? $tenantTabs : [];
$activeTenant = (string) ($activeTenant ?? '');
$showTenantTabs = count($tenantTabs) > 1;
?>
<?php
$listTitle = t('Address book');
$listTitleActionsHtml = '';
require templatePath('partials/app-list-titlebar.phtml');
?>
<?php if ($showTenantTabs): ?>
<?php
$listTabsId = 'address-book-tabs';
$listTabsItems = [
[
'href' => 'address-book',
'label' => t('All'),
'active' => $activeTenant === '',
],
];
foreach ($tenantTabs as $tenantTab) {
$tenantUuid = (string) ($tenantTab['uuid'] ?? '');
if ($tenantUuid === '') {
continue;
}
$listTabsItems[] = [
'href' => 'address-book?tenant=' . rawurlencode($tenantUuid),
'label' => (string) ($tenantTab['description'] ?? ''),
'active' => $activeTenant === $tenantUuid,
];
}
require templatePath('partials/app-list-tabs.phtml');
?>
<?php endif; ?>
<div
class="app-list-toolbar"
id="address-book-toolbar"
>
<?php renderGridFilterToolbar($searchToolbarFilterSchema, $toolbarFilterState, $toolbarOptionSets); ?>
<button
type="button"
class="outline secondary icon-button"
data-filter-drawer-open
data-tooltip="<?php e(t('Filter')); ?>"
data-tooltip-pos="top"
aria-label="<?php e(t('Filter')); ?>"
aria-expanded="false"
>
<i class="bi bi-funnel"></i> <?php e(t('Filter')); ?>
</button>
</div>
<div
class="app-active-filters"
data-active-filter-chips
data-filter-chips-clear-all-label="<?php e(t('Clear all filters')); ?>"
data-filter-chips-remove-label="<?php e(t('Remove filter')); ?>"
></div>
<p
class="app-visually-hidden"
data-filter-live-region
data-filter-live-applied-message="<?php e(t('Filters applied')); ?>"
data-filter-live-removed-message="<?php e(t('Filter removed')); ?>"
data-filter-live-cleared-message="<?php e(t('All filters cleared')); ?>"
aria-live="polite"
aria-atomic="true"
></p>
<div
class="app-filter-drawer"
data-filter-drawer
role="dialog"
aria-modal="true"
aria-hidden="true"
aria-labelledby="address-book-filter-drawer-title"
hidden
>
<aside class="app-filter-drawer-panel" data-filter-drawer-panel>
<header class="app-filter-drawer-header">
<h2 id="address-book-filter-drawer-title"><?php e(t('Filter')); ?></h2>
<button
type="button"
class="transparent icon-button"
data-filter-drawer-close
aria-label="<?php e(t('Close filters')); ?>"
>
<i class="bi bi-x-lg"></i>
</button>
</header>
<div class="app-filter-drawer-body">
<div
class="app-list-toolbar"
id="address-book-drawer-toolbar"
data-tenant-dept-map="<?php e(json_encode($tenantDepartmentMap ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); ?>"
>
<?php renderGridFilterToolbar($drawerToolbarFilterSchema, $toolbarFilterState, $toolbarOptionSets); ?>
</div>
<?php if ($customFieldFilterDefinitions): ?>
<div class="app-list-toolbar" id="address-book-custom-filters">
<?php foreach ($customFieldFilterDefinitions as $definition): ?>
<?php
$definitionUuid = strtolower(trim((string) ($definition['uuid'] ?? '')));
$definitionType = strtolower(trim((string) ($definition['type'] ?? '')));
$definitionLabel = trim((string) ($definition['label'] ?? ''));
$definitionOptions = is_array($definition['options'] ?? null) ? $definition['options'] : [];
if ($definitionUuid === '' || $definitionLabel === '' || $definitionType === '') {
continue;
}
$label = $definitionLabel;
$idSuffix = preg_replace('/[^a-z0-9]+/', '-', $definitionUuid);
$paramScalar = 'cf_' . $definitionUuid;
$paramMulti = 'cfm_' . $definitionUuid;
$paramDateFrom = 'cfd_' . $definitionUuid . '_from';
$paramDateTo = 'cfd_' . $definitionUuid . '_to';
?>
<?php if ($definitionType === 'select'): ?>
<?php $currentValue = (string) ($activeCustomFieldQuery[$paramScalar] ?? ''); ?>
<?php
$selectItems = [['id' => '', 'description' => 'Please select']];
foreach ($definitionOptions as $option) {
$optionId = (string) ($option['id'] ?? '');
if ($optionId === '') {
continue;
}
$selectItems[] = [
'id' => $optionId,
'description' => (string) ($option['description'] ?? ''),
'translate' => false,
];
}
selectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
$selectItems,
$currentValue,
['for' => 'address-book-custom-filter-' . $idSuffix],
[
'data-address-book-custom-filter' => true,
'data-address-book-custom-filter-param' => $paramScalar,
]
);
?>
<?php elseif ($definitionType === 'boolean'): ?>
<?php $currentValue = strtolower(trim((string) ($activeCustomFieldQuery[$paramScalar] ?? ''))); ?>
<?php
$selectedBoolean = '';
if (in_array($currentValue, ['1', 'true'], true)) {
$selectedBoolean = '1';
} elseif (in_array($currentValue, ['0', 'false'], true)) {
$selectedBoolean = '0';
}
selectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
[
['id' => '', 'description' => 'Please select'],
['id' => '1', 'description' => 'Yes'],
['id' => '0', 'description' => 'No'],
],
$selectedBoolean,
['for' => 'address-book-custom-filter-' . $idSuffix],
[
'data-address-book-custom-filter' => true,
'data-address-book-custom-filter-param' => $paramScalar,
]
);
?>
<?php elseif ($definitionType === 'multiselect'): ?>
<?php
$currentValues = array_values(array_filter(array_map(
'trim',
explode(',', (string) ($activeCustomFieldQuery[$paramMulti] ?? ''))
), static fn ($item): bool => $item !== ''));
multiSelectFilter(
'address-book-custom-filter-' . $idSuffix,
$label,
'Select options',
$definitionOptions,
$currentValues
);
?>
<input type="hidden"
data-address-book-custom-filter
data-address-book-custom-filter-type="multiselect"
data-address-book-custom-filter-param="<?php e($paramMulti); ?>"
data-address-book-custom-filter-input="#address-book-custom-filter-<?php e($idSuffix); ?>"
>
<?php elseif ($definitionType === 'date'): ?>
<?php
$currentFrom = (string) ($activeCustomFieldQuery[$paramDateFrom] ?? '');
$currentTo = (string) ($activeCustomFieldQuery[$paramDateTo] ?? '');
?>
<label class="app-field" for="address-book-custom-filter-<?php e($idSuffix); ?>-from">
<span><?php e($label . ' ' . t('From')); ?></span>
<input type="date"
id="address-book-custom-filter-<?php e($idSuffix); ?>-from"
value="<?php e($currentFrom); ?>"
data-address-book-custom-filter
data-address-book-custom-filter-param="<?php e($paramDateFrom); ?>">
</label>
<label class="app-field" for="address-book-custom-filter-<?php e($idSuffix); ?>-to">
<span><?php e($label . ' ' . t('To')); ?></span>
<input type="date"
id="address-book-custom-filter-<?php e($idSuffix); ?>-to"
value="<?php e($currentTo); ?>"
data-address-book-custom-filter
data-address-book-custom-filter-param="<?php e($paramDateTo); ?>">
</label>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<footer class="app-filter-drawer-footer">
<button type="button" class="primary" data-filter-drawer-apply>
<?php e(t('Apply filters')); ?>
</button>
</footer>
</aside>
</div>
<div class="app-list-table">
<div id="address-book-grid"></div>
</div>
<?php
$gridLang = json_decode(appBufferValue('grid_lang'), true);
if (!is_array($gridLang)) {
$gridLang = [];
}
$pageConfig = [
'gridSearch' => $searchConfig,
'filterSchema' => $clientFilterSchema,
'filterChipMeta' => $filterChipMeta,
'gridLang' => $gridLang,
'labels' => [
'name' => t('Name'),
'context' => t('Tenant / Department'),
'phone' => t('Phone'),
'actions' => t('Actions'),
'more' => t('More'),
'openDetails' => t('Open details'),
'sendEmail' => t('Send email'),
'call' => t('Call'),
'extraAssignments' => t('further assignments'),
'drawerClose' => t('Close'),
'drawerPrev' => t('Previous'),
'drawerNext' => t('Next'),
'drawerOpenFull' => t('Open full page'),
'drawerLoading' => t('Loading'),
'drawerError' => t('Failed to load'),
],
];
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-address-book-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('modules/addressbook/js/pages/address-book-index.js')); ?>"></script>