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:
@@ -175,6 +175,12 @@ docker/ # Dockerfiles, Nginx configs, PHP configs
|
||||
- **Server:** endpoint calls `exportRequireGetRequest()` + `exportCapLimit()` + `exportSendCsv()` from `core/Support/helpers/export.php`, builds columns as `ExportColumn[]` (`core/Service/Export/CsvExportService.php`), and reuses the SAME `filter-schema.php` via `gridParseFiltersFromSchemaFile()` as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (see `DomainListService` / `SystemAuditRowPresenter`).
|
||||
- **View:** `require templatePath('partials/app-list-export-dropdown.phtml');` inside `$listTitleActionsHtml`. Pass the URL via `'exportUrl' => endpointUrl('<route-path>')` in the page config — **always `endpointUrl()`, never plain `lurl()`**. The helper resolves module routes to their target path so query strings survive.
|
||||
- **Client:** `initListExport({ gridConfig, exportUrl })` from `/js/core/app-list-export.js`. No hand-rolled click listener, no inline URL construction.
|
||||
- **Detail drawer**: Row-level detail views that slide in from the right use the core primitive — no per-module drawer implementation.
|
||||
- **Server:** Action `<route>/<name>-fragment($id).php` + view `<route>/<name>-fragment(none).phtml`. The `(none)` template renders without layout/topbar/breadcrumbs. Reuse the same partial that the full-page view uses (extract a `modules/<id>/templates/<name>-profile.phtml` if needed). Must enforce auth + scope exactly like the full-page view.
|
||||
- **Route:** Declare `['path' => '<route>/<name>-fragment', 'target' => '<route>/<name>-fragment']` in the module manifest.
|
||||
- **Client:** `initDetailDrawer({ gridConfig, fetchUrl: (uuid) => new URL('<route>/<name>-fragment/' + uuid, appBase).toString(), fullUrl, hashPrefix, rowProvider? })` from `/js/components/app-detail-drawer.js`. The drawer handles focus-trap, body-scroll-lock, session-expiry reload, and auto-initializes tabs/tooltips/fslightbox in the fragment via `initFragmentContent()`. Consumers do NOT need to call `initTabs`, `refreshFsLightbox`, etc. manually.
|
||||
- **Architecture test** `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer({ fetchUrl })` has matching `*-fragment($id).php` + `*-fragment(none).phtml`.
|
||||
- **Filename rule:** URL parameters go only into the `.php` action filename (`*-fragment($id).php`). The `(none).phtml` view filename must NOT contain `($id)` — MintyPHP's router parses paren groups and breaks silently on nested ones.
|
||||
|
||||
### Never Do This
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"About": "Über",
|
||||
"Address book": "Adressbuch",
|
||||
"Call": "Anrufen",
|
||||
"Call mobile": "Mobil anrufen",
|
||||
"Call phone": "Telefon anrufen",
|
||||
"Failed to load": "Laden fehlgeschlagen",
|
||||
"From": "Von",
|
||||
"Loading": "Lädt",
|
||||
"More": "Mehr",
|
||||
"No departments": "Keine Abteilungen",
|
||||
"Open details": "Details öffnen",
|
||||
"Open full page": "Vollseite öffnen",
|
||||
"Quick actions": "Schnellaktionen",
|
||||
"Send email": "E-Mail senden",
|
||||
"Tenant / Department": "Mandant / Abteilung",
|
||||
"To": "Bis",
|
||||
"View in address book": "Im Adressbuch ansehen"
|
||||
"View in address book": "Im Adressbuch ansehen",
|
||||
"further assignments": "weitere Zuordnungen"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"About": "About",
|
||||
"Address book": "Address book",
|
||||
"Call": "Call",
|
||||
"Call mobile": "Call mobile",
|
||||
"Call phone": "Call phone",
|
||||
"Failed to load": "Failed to load",
|
||||
"From": "From",
|
||||
"Loading": "Loading",
|
||||
"More": "More",
|
||||
"No departments": "No departments",
|
||||
"Open details": "Open details",
|
||||
"Open full page": "Open full page",
|
||||
"Quick actions": "Quick actions",
|
||||
"Send email": "Send email",
|
||||
"Tenant / Department": "Tenant / Department",
|
||||
"To": "To",
|
||||
"View in address book": "View in address book"
|
||||
"View in address book": "View in address book",
|
||||
"further assignments": "further assignments"
|
||||
}
|
||||
|
||||
@@ -135,11 +135,29 @@ class AddressBookService
|
||||
);
|
||||
}
|
||||
|
||||
$activeTenant = trim((string) ($query['tenant'] ?? ''));
|
||||
|
||||
$tenantTabs = array_values(array_filter(array_map(
|
||||
static function (array $tenant): array {
|
||||
return [
|
||||
'uuid' => (string) ($tenant['uuid'] ?? ''),
|
||||
'description' => (string) ($tenant['description'] ?? ''),
|
||||
];
|
||||
},
|
||||
$tenants
|
||||
), static fn (array $tab): bool => $tab['uuid'] !== ''));
|
||||
usort($tenantTabs, static fn (array $a, array $b): int => strnatcasecmp(
|
||||
(string) $a['description'],
|
||||
(string) $b['description']
|
||||
));
|
||||
|
||||
return [
|
||||
'activeTenants' => $activeTenants,
|
||||
'activeRoles' => $activeRoles,
|
||||
'activeDepartments' => $activeDepartments,
|
||||
'activeTenant' => $activeTenant,
|
||||
'tenantItems' => $tenantItems,
|
||||
'tenantTabs' => $tenantTabs,
|
||||
'departments' => $departments,
|
||||
'roles' => $roles,
|
||||
'customFieldFilterDefinitions' => $customFieldFilterDefinitions,
|
||||
@@ -191,6 +209,10 @@ class AddressBookService
|
||||
$displayName = (string) ($row['email'] ?? '');
|
||||
}
|
||||
|
||||
$primaryTenantLabel = $tenantList[0] ?? '';
|
||||
$primaryDepartmentLabel = $departmentList[0] ?? '';
|
||||
$extraAssignmentsCount = max(0, count($tenantList) - 1) + max(0, count($departmentList) - 1);
|
||||
|
||||
$rows[] = [
|
||||
'uuid' => $uuid,
|
||||
'display_name' => $displayName,
|
||||
@@ -203,6 +225,9 @@ class AddressBookService
|
||||
'tenants' => $tenantList,
|
||||
'departments' => $departmentList,
|
||||
'roles' => $roleList,
|
||||
'primary_tenant_label' => $primaryTenantLabel,
|
||||
'primary_department_label' => $primaryDepartmentLabel,
|
||||
'extra_assignments_count' => $extraAssignmentsCount,
|
||||
'has_avatar' => $uuid !== '' && $this->avatarService->hasAvatar($uuid),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ return [
|
||||
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops.
|
||||
['path' => 'address-book', 'target' => 'address-book'],
|
||||
['path' => 'address-book/data', 'target' => 'address-book/data'],
|
||||
['path' => 'address-book/view-fragment', 'target' => 'address-book/view-fragment'],
|
||||
// Legacy aliases for existing bookmarks/links.
|
||||
['path' => 'addressbook', 'target' => 'address-book'],
|
||||
['path' => 'adressbook', 'target' => 'address-book'],
|
||||
@@ -70,6 +71,9 @@ return [
|
||||
'modules/addressbook/css/pages/address-book-view.css',
|
||||
'modules/addressbook/css/pages/address-book-banner.css',
|
||||
],
|
||||
'address-book-index' => [
|
||||
'modules/addressbook/css/pages/address-book-index.css',
|
||||
],
|
||||
],
|
||||
'scheduler_jobs' => [],
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ return gridFilterSchema([
|
||||
'roles' => ['type' => 'csv_strings', 'max' => 200],
|
||||
],
|
||||
'toolbar' => [
|
||||
[
|
||||
'key' => 'tenant',
|
||||
'type' => 'hidden',
|
||||
'input_id' => 'address-book-tenant-filter-single',
|
||||
'default' => '',
|
||||
],
|
||||
[
|
||||
'key' => 'search',
|
||||
'type' => 'text',
|
||||
|
||||
@@ -19,6 +19,8 @@ $activeTenants = $context['activeTenants'] ?? [];
|
||||
$activeRoles = $context['activeRoles'] ?? [];
|
||||
$activeDepartments = $context['activeDepartments'] ?? [];
|
||||
$tenantItems = $context['tenantItems'] ?? [];
|
||||
$tenantTabs = $context['tenantTabs'] ?? [];
|
||||
$activeTenant = (string) ($context['activeTenant'] ?? '');
|
||||
$departments = $context['departments'] ?? [];
|
||||
$roles = $context['roles'] ?? [];
|
||||
$customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? [];
|
||||
@@ -30,6 +32,7 @@ $toolbarOptionSets = [
|
||||
'role_items' => (array) $roles,
|
||||
];
|
||||
$toolbarFilterStateOverrides = [
|
||||
'tenant' => $activeTenant,
|
||||
'search' => (string) gridQueryString($query, 'search', ''),
|
||||
'tenants' => array_map('strval', (array) $activeTenants),
|
||||
'departments' => array_map('strval', (array) $activeDepartments),
|
||||
@@ -81,6 +84,7 @@ $breadcrumbs = [
|
||||
['label' => t('Address book')],
|
||||
];
|
||||
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
Buffer::set('style_groups', json_encode(['address-book-index']));
|
||||
Buffer::set(
|
||||
'grid_csrf',
|
||||
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
|
||||
@@ -14,12 +14,39 @@ $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"
|
||||
@@ -220,13 +247,20 @@ $pageConfig = [
|
||||
'gridLang' => $gridLang,
|
||||
'labels' => [
|
||||
'name' => t('Name'),
|
||||
'email' => t('Email'),
|
||||
'context' => t('Tenant / Department'),
|
||||
'phone' => t('Phone'),
|
||||
'mobile' => t('Mobile'),
|
||||
'shortDial' => t('Short dial'),
|
||||
'tenants' => t('Tenants'),
|
||||
'departments' => t('Departments'),
|
||||
'roles' => t('Roles'),
|
||||
'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'),
|
||||
],
|
||||
];
|
||||
?>
|
||||
|
||||
@@ -1,239 +1,2 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
$values = $user ?? [];
|
||||
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
|
||||
$displayName = $name !== '' ? $name : ($values['email'] ?? t('Address book'));
|
||||
$email = trim((string) ($values['email'] ?? ''));
|
||||
$phone = trim((string) ($values['phone'] ?? ''));
|
||||
$mobile = trim((string) ($values['mobile'] ?? ''));
|
||||
$shortDial = trim((string) ($values['short_dial'] ?? ''));
|
||||
$address = trim((string) ($values['address'] ?? ''));
|
||||
$postalCode = trim((string) ($values['postal_code'] ?? ''));
|
||||
$city = trim((string) ($values['city'] ?? ''));
|
||||
$region = trim((string) ($values['region'] ?? ''));
|
||||
$country = trim((string) ($values['country'] ?? ''));
|
||||
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
|
||||
$jobTitle = trim((string) ($values['job_title'] ?? ''));
|
||||
$hireDate = trim((string) ($values['hire_date'] ?? ''));
|
||||
$hireDateLabel = '';
|
||||
if ($hireDate !== '') {
|
||||
$format = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
|
||||
try {
|
||||
$hireDateLabel = (new \DateTimeImmutable($hireDate))->format($format);
|
||||
} catch (\Exception $e) {
|
||||
$hireDateLabel = $hireDate;
|
||||
}
|
||||
}
|
||||
$avatarUuid = (string) ($values['uuid'] ?? '');
|
||||
$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== '';
|
||||
|
||||
$initials = '';
|
||||
if ($displayName !== '') {
|
||||
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
|
||||
$chars = '';
|
||||
foreach ($parts as $part) {
|
||||
if (function_exists('mb_substr')) {
|
||||
$chars .= mb_substr($part, 0, 1);
|
||||
} else {
|
||||
$chars .= substr($part, 0, 1);
|
||||
}
|
||||
}
|
||||
$initials = strtoupper($chars !== '' ? $chars : '?');
|
||||
}
|
||||
$tenantGroups = $values['tenant_groups'] ?? [];
|
||||
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
|
||||
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
|
||||
$hasOrganization = (!empty($tenantGroups));
|
||||
$aboutSummary = '';
|
||||
if ($profileDescription !== '') {
|
||||
foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line !== '') {
|
||||
$aboutSummary = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-profile-card-container">
|
||||
<div class="app-profile-card">
|
||||
<div class="app-profile-banner"></div>
|
||||
<div class="app-profile-header">
|
||||
<div class="user-avatar-block avatar-round app-profile-avatar">
|
||||
<?php if ($hasAvatar): ?>
|
||||
<a data-fslightbox="address-book-avatar"
|
||||
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
|
||||
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160"
|
||||
alt="">
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="app-profile-meta">
|
||||
<hgroup>
|
||||
<h2><?php e($displayName); ?></h2>
|
||||
<?php if ($jobTitle !== ''): ?>
|
||||
<p><small><?php e($jobTitle); ?></small></p>
|
||||
<?php else: ?>
|
||||
<p><a href="mailto:<?php e($email); ?>"><small><?php e($email); ?></small></a></p>
|
||||
<?php endif; ?>
|
||||
</hgroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-profile-body-container">
|
||||
<div class="app-profile-body">
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
|
||||
<div class="app-tabs-nav">
|
||||
<?php if ($hasContact): ?>
|
||||
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<button type="button" data-tab="address"><small><?php e(t('Address')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
|
||||
</div>
|
||||
<?php if ($hasContact): ?>
|
||||
<div data-tab-panel="contact">
|
||||
<div class="grid">
|
||||
<?php if ($email !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Email')); ?></small>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Phone')); ?></small>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Mobile')); ?></small>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($shortDial !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Short dial')); ?></small>
|
||||
<p><?php e($shortDial); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<div data-tab-panel="address">
|
||||
<div>
|
||||
<?php if ($address !== ''): ?>
|
||||
<p><?php e($address); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php $line2 = trim($postalCode . ' ' . $city); ?>
|
||||
<?php if ($line2 !== ''): ?>
|
||||
<p><?php e($line2); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($region !== ''): ?>
|
||||
<p><?php e($region); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($country !== ''): ?>
|
||||
<p><?php e($country); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<div data-tab-panel="organization">
|
||||
<?php if (!empty($tenantGroups)): ?>
|
||||
<table class="app-profile-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Tenant')); ?></th>
|
||||
<th><?php e(t('Departments')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tenantGroups as $group): ?>
|
||||
<?php
|
||||
$departments = $group['departments'] ?? [];
|
||||
$departments = is_array($departments) ? $departments : [];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php e($group['label'] ?? ''); ?>
|
||||
<?php if (!empty($group['is_primary'])): ?>
|
||||
<small>(<?php e(t('Primary tenant')); ?>)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($departments)): ?>
|
||||
<?php e(implode(', ', $departments)); ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div data-tab-panel="about">
|
||||
<?php if ($hireDateLabel !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Hire date')); ?></small>
|
||||
<p><?php e($hireDateLabel); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($profileDescription !== ''): ?>
|
||||
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
|
||||
<?php if (trim($line) !== ''): ?>
|
||||
<p><?php e($line); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($hireDateLabel === ''): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<h2><?php e(t('Contact')); ?></h2>
|
||||
<p><?php e(t('Quick actions')); ?></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<?php if ($email !== ''): ?>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e(t('Send email')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e(t('Call phone')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e(t('Call mobile')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!$hasContact): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
require __DIR__ . '/../../templates/address-book-profile.phtml';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW);
|
||||
|
||||
$currentUserId = (int) ($session['user']['id'] ?? 0);
|
||||
$uuid = trim((string) ($id ?? ''));
|
||||
if ($uuid === '') {
|
||||
http_response_code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class);
|
||||
$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid);
|
||||
$status = (string) ($viewContext['status'] ?? '');
|
||||
if ($status === 'not_found') {
|
||||
http_response_code(404);
|
||||
$user = null;
|
||||
return;
|
||||
}
|
||||
if ($status === 'forbidden') {
|
||||
http_response_code(403);
|
||||
$user = null;
|
||||
return;
|
||||
}
|
||||
$user = $viewContext['user'] ?? null;
|
||||
if (!is_array($user)) {
|
||||
http_response_code(404);
|
||||
$user = null;
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
if (!isset($user) || !is_array($user)) {
|
||||
return;
|
||||
}
|
||||
require __DIR__ . '/../../templates/address-book-profile.phtml';
|
||||
239
modules/addressbook/templates/address-book-profile.phtml
Normal file
239
modules/addressbook/templates/address-book-profile.phtml
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\I18n;
|
||||
|
||||
$values = $user ?? [];
|
||||
$name = trim(($values['first_name'] ?? '') . ' ' . ($values['last_name'] ?? ''));
|
||||
$displayName = $name !== '' ? $name : ($values['email'] ?? t('Address book'));
|
||||
$email = trim((string) ($values['email'] ?? ''));
|
||||
$phone = trim((string) ($values['phone'] ?? ''));
|
||||
$mobile = trim((string) ($values['mobile'] ?? ''));
|
||||
$shortDial = trim((string) ($values['short_dial'] ?? ''));
|
||||
$address = trim((string) ($values['address'] ?? ''));
|
||||
$postalCode = trim((string) ($values['postal_code'] ?? ''));
|
||||
$city = trim((string) ($values['city'] ?? ''));
|
||||
$region = trim((string) ($values['region'] ?? ''));
|
||||
$country = trim((string) ($values['country'] ?? ''));
|
||||
$profileDescription = trim((string) ($values['profile_description'] ?? ''));
|
||||
$jobTitle = trim((string) ($values['job_title'] ?? ''));
|
||||
$hireDate = trim((string) ($values['hire_date'] ?? ''));
|
||||
$hireDateLabel = '';
|
||||
if ($hireDate !== '') {
|
||||
$format = (strpos((string) (I18n::$locale ?? ''), 'de') === 0) ? 'd.m.Y' : 'Y-m-d';
|
||||
try {
|
||||
$hireDateLabel = (new \DateTimeImmutable($hireDate))->format($format);
|
||||
} catch (\Exception $e) {
|
||||
$hireDateLabel = $hireDate;
|
||||
}
|
||||
}
|
||||
$avatarUuid = (string) ($values['uuid'] ?? '');
|
||||
$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== '';
|
||||
|
||||
$initials = '';
|
||||
if ($displayName !== '') {
|
||||
$parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: []));
|
||||
$chars = '';
|
||||
foreach ($parts as $part) {
|
||||
if (function_exists('mb_substr')) {
|
||||
$chars .= mb_substr($part, 0, 1);
|
||||
} else {
|
||||
$chars .= substr($part, 0, 1);
|
||||
}
|
||||
}
|
||||
$initials = strtoupper($chars !== '' ? $chars : '?');
|
||||
}
|
||||
$tenantGroups = $values['tenant_groups'] ?? [];
|
||||
$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== '');
|
||||
$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== '');
|
||||
$hasOrganization = (!empty($tenantGroups));
|
||||
$aboutSummary = '';
|
||||
if ($profileDescription !== '') {
|
||||
foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line !== '') {
|
||||
$aboutSummary = $line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="app-details-container">
|
||||
<section>
|
||||
<div class="app-profile-card-container">
|
||||
<div class="app-profile-card">
|
||||
<div class="app-profile-banner"></div>
|
||||
<div class="app-profile-header">
|
||||
<div class="user-avatar-block avatar-round app-profile-avatar">
|
||||
<?php if ($hasAvatar): ?>
|
||||
<a data-fslightbox="address-book-avatar"
|
||||
href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
|
||||
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=160"
|
||||
alt="">
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="app-profile-meta">
|
||||
<hgroup>
|
||||
<h2><?php e($displayName); ?></h2>
|
||||
<?php if ($jobTitle !== ''): ?>
|
||||
<p><small><?php e($jobTitle); ?></small></p>
|
||||
<?php else: ?>
|
||||
<p><a href="mailto:<?php e($email); ?>"><small><?php e($email); ?></small></a></p>
|
||||
<?php endif; ?>
|
||||
</hgroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-profile-body-container">
|
||||
<div class="app-profile-body">
|
||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
|
||||
<div class="app-tabs-nav">
|
||||
<?php if ($hasContact): ?>
|
||||
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<button type="button" data-tab="address"><small><?php e(t('Address')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<button type="button" data-tab="organization"><small><?php e(t('Organization')); ?></small></button>
|
||||
<?php endif; ?>
|
||||
<button type="button" data-tab="about"><small><?php e(t('About')); ?></small></button>
|
||||
</div>
|
||||
<?php if ($hasContact): ?>
|
||||
<div data-tab-panel="contact">
|
||||
<div class="grid">
|
||||
<?php if ($email !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Email')); ?></small>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Phone')); ?></small>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Mobile')); ?></small>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e($mobile); ?></a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($shortDial !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Short dial')); ?></small>
|
||||
<p><?php e($shortDial); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasAddress): ?>
|
||||
<div data-tab-panel="address">
|
||||
<div>
|
||||
<?php if ($address !== ''): ?>
|
||||
<p><?php e($address); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php $line2 = trim($postalCode . ' ' . $city); ?>
|
||||
<?php if ($line2 !== ''): ?>
|
||||
<p><?php e($line2); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($region !== ''): ?>
|
||||
<p><?php e($region); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($country !== ''): ?>
|
||||
<p><?php e($country); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasOrganization): ?>
|
||||
<div data-tab-panel="organization">
|
||||
<?php if (!empty($tenantGroups)): ?>
|
||||
<table class="app-profile-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php e(t('Tenant')); ?></th>
|
||||
<th><?php e(t('Departments')); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tenantGroups as $group): ?>
|
||||
<?php
|
||||
$departments = $group['departments'] ?? [];
|
||||
$departments = is_array($departments) ? $departments : [];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php e($group['label'] ?? ''); ?>
|
||||
<?php if (!empty($group['is_primary'])): ?>
|
||||
<small>(<?php e(t('Primary tenant')); ?>)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($departments)): ?>
|
||||
<?php e(implode(', ', $departments)); ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div data-tab-panel="about">
|
||||
<?php if ($hireDateLabel !== ''): ?>
|
||||
<div>
|
||||
<small><?php e(t('Hire date')); ?></small>
|
||||
<p><?php e($hireDateLabel); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($profileDescription !== ''): ?>
|
||||
<?php foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line): ?>
|
||||
<?php if (trim($line) !== ''): ?>
|
||||
<p><?php e($line); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php elseif ($hireDateLabel === ''): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside id="app-details-aside-section">
|
||||
<div class="app-details-aside-section">
|
||||
<hgroup>
|
||||
<h2><?php e(t('Contact')); ?></h2>
|
||||
<p><?php e(t('Quick actions')); ?></p>
|
||||
</hgroup>
|
||||
<hr>
|
||||
<?php if ($email !== ''): ?>
|
||||
<p><a href="mailto:<?php e($email); ?>"><?php e(t('Send email')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($phone !== ''): ?>
|
||||
<p><a href="tel:<?php e($phone); ?>"><?php e(t('Call phone')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($mobile !== ''): ?>
|
||||
<p><a href="tel:<?php e($mobile); ?>"><?php e(t('Call mobile')); ?></a></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!$hasContact): ?>
|
||||
<p>-</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
143
modules/addressbook/web/css/pages/address-book-index.css
Normal file
143
modules/addressbook/web/css/pages/address-book-index.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* Address book list — dense identity layout */
|
||||
|
||||
#address-book-grid .gridjs-tr {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.addr-identity-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.addr-identity-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addr-identity-text {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.addr-identity-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--app-color-text, inherit);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
.addr-identity-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.addr-identity-email {
|
||||
color: var(--app-color-muted, #6b7280);
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
.addr-context-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
color: var(--app-color-text, inherit);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.addr-context-primary {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
.addr-context-secondary {
|
||||
color: var(--app-color-muted, #6b7280);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
.addr-context-separator {
|
||||
color: var(--app-color-muted, #9ca3af);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addr-context-extra {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--app-color-subtle, #f3f4f6);
|
||||
color: var(--app-color-muted, #6b7280);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addr-context-empty,
|
||||
.addr-phone-empty {
|
||||
color: var(--app-color-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.addr-phone-cell {
|
||||
color: var(--app-color-text, inherit);
|
||||
text-decoration: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.addr-phone-cell:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.addr-action-cell {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.addr-action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--app-color-muted, #6b7280);
|
||||
text-decoration: none;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.addr-action-button:hover {
|
||||
background: var(--app-color-subtle, #f3f4f6);
|
||||
color: var(--app-color-text, #111827);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.addr-identity-name,
|
||||
.addr-identity-email,
|
||||
.addr-context-primary,
|
||||
.addr-context-secondary {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
|
||||
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
|
||||
import { initDetailDrawer } from '/js/components/app-detail-drawer.js';
|
||||
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
||||
import { escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
import { escapeHtml, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||
|
||||
createListPageModule({
|
||||
configId: 'address-book-index',
|
||||
@@ -53,52 +54,111 @@ createListPageModule({
|
||||
name: labels.name || 'Name',
|
||||
sort: true,
|
||||
formatter: (cell, row) => {
|
||||
const nameValue = typeof cell === 'object' && cell !== null
|
||||
? (cell.name ?? '')
|
||||
: cell;
|
||||
const avatarUuid = typeof cell === 'object' && cell !== null
|
||||
? (cell.avatar_uuid ?? '')
|
||||
: '';
|
||||
const nameValue = cell?.name ?? '';
|
||||
const email = cell?.email ?? '';
|
||||
const avatarUuid = cell?.avatar_uuid ?? '';
|
||||
const name = escapeHtml(nameValue || '-');
|
||||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||||
const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : '';
|
||||
const nameHtml = editUrl
|
||||
? `<a class="app-grid-link-cell" href="${escapeHtml(editUrl)}">${name}</a>`
|
||||
: `<span>${name}</span>`;
|
||||
const nameNode = editUrl
|
||||
? `<a class="app-grid-link-cell addr-identity-name" href="${escapeHtml(editUrl)}" data-drawer-trigger>${name}</a>`
|
||||
: `<span class="addr-identity-name">${name}</span>`;
|
||||
const emailNode = email
|
||||
? `<span class="addr-identity-email">${escapeHtml(email)}</span>`
|
||||
: '';
|
||||
let avatarNode;
|
||||
if (!avatarUuid) {
|
||||
const initials = escapeHtml(initialsForName(nameValue));
|
||||
return gridjs.html(`<span class="grid-name-cell"><span class="grid-avatar grid-avatar-placeholder">${initials}</span>${nameHtml}</span>`);
|
||||
avatarNode = `<span class="grid-avatar grid-avatar-placeholder addr-identity-avatar">${initials}</span>`;
|
||||
} else {
|
||||
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
|
||||
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
|
||||
avatarNode = `<a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar addr-identity-avatar" src="${src}" alt="" loading="lazy"></a>`;
|
||||
}
|
||||
const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString();
|
||||
const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString();
|
||||
return gridjs.html(`<span class="grid-name-cell"><a data-fslightbox="address-book-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>${nameHtml}</span>`);
|
||||
return gridjs.html(`<span class="addr-identity-cell">${avatarNode}<span class="addr-identity-text">${nameNode}${emailNode}</span></span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.context || 'Tenant / Department',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const tenant = escapeHtml(String(cell?.tenant || ''));
|
||||
const department = escapeHtml(String(cell?.department || ''));
|
||||
const extra = Number(cell?.extra || 0);
|
||||
if (!tenant && !department && !extra) {
|
||||
return gridjs.html('<span class="addr-context-cell addr-context-empty">–</span>');
|
||||
}
|
||||
const parts = [];
|
||||
if (tenant) {
|
||||
parts.push(`<span class="addr-context-primary">${tenant}</span>`);
|
||||
}
|
||||
if (department) {
|
||||
parts.push(`<span class="addr-context-separator" aria-hidden="true">·</span><span class="addr-context-secondary">${department}</span>`);
|
||||
}
|
||||
if (extra > 0) {
|
||||
const extraLabel = escapeHtml(labels.extraAssignments || 'further assignments');
|
||||
parts.push(`<span class="addr-context-extra" title="+${extra} ${extraLabel}">+${extra}</span>`);
|
||||
}
|
||||
return gridjs.html(`<span class="addr-context-cell">${parts.join('')}</span>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.phone || 'Phone',
|
||||
sort: false,
|
||||
formatter: (cell) => {
|
||||
const number = String(cell || '').trim();
|
||||
if (!number) {
|
||||
return gridjs.html('<span class="addr-phone-empty">–</span>');
|
||||
}
|
||||
const tel = number.replace(/[^+0-9]/g, '');
|
||||
const safe = escapeHtml(number);
|
||||
return gridjs.html(`<a class="addr-phone-cell" href="tel:${escapeHtml(tel)}">${safe}</a>`);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: labels.actions || 'Actions',
|
||||
sort: false,
|
||||
formatter: (cell, row) => {
|
||||
const uuid = row?.cells?.[uuidIndex]?.data;
|
||||
if (!uuid) {
|
||||
return gridjs.html('');
|
||||
}
|
||||
const viewUrl = escapeHtml(withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()));
|
||||
const email = String(cell?.email || '').trim();
|
||||
const openLabel = escapeHtml(labels.openDetails || 'Open details');
|
||||
const mailLabel = escapeHtml(labels.sendEmail || 'Send email');
|
||||
const actions = [
|
||||
`<a class="addr-action-button" href="${viewUrl}" data-tooltip="${openLabel}" data-tooltip-pos="left" aria-label="${openLabel}"><i class="bi bi-box-arrow-up-right"></i></a>`,
|
||||
];
|
||||
if (email) {
|
||||
const safeMail = escapeHtml(email);
|
||||
actions.push(`<a class="addr-action-button" href="mailto:${safeMail}" data-tooltip="${mailLabel}" data-tooltip-pos="left" aria-label="${mailLabel}"><i class="bi bi-envelope"></i></a>`);
|
||||
}
|
||||
return gridjs.html(`<span class="addr-action-cell">${actions.join('')}</span>`);
|
||||
},
|
||||
},
|
||||
{ name: labels.email || 'Email', sort: true },
|
||||
{ name: labels.phone || 'Phone', sort: false },
|
||||
{ name: labels.mobile || 'Mobile', sort: false },
|
||||
{ name: labels.shortDial || 'Short dial', sort: false },
|
||||
{ name: labels.tenants || 'Tenants', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
{ name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
|
||||
],
|
||||
sortColumns: [null, 'display_name', 'email', null, null, null, null, null, null],
|
||||
sortColumns: [null, 'display_name', null, null, null],
|
||||
paginationLimit: 10,
|
||||
language: config.gridLang ?? {},
|
||||
mapData: (data) => data.data.map((row) => [
|
||||
row.uuid,
|
||||
{
|
||||
name: row.display_name,
|
||||
avatar_uuid: row.has_avatar ? row.uuid : '',
|
||||
},
|
||||
row.email,
|
||||
row.phone,
|
||||
row.mobile,
|
||||
row.short_dial,
|
||||
row.tenants,
|
||||
row.departments,
|
||||
row.roles,
|
||||
]),
|
||||
mapData: (data) => data.data.map((row) => {
|
||||
const phone = (row.phone && String(row.phone).trim()) || (row.mobile && String(row.mobile).trim()) || '';
|
||||
return [
|
||||
row.uuid,
|
||||
{
|
||||
name: row.display_name,
|
||||
email: row.email || '',
|
||||
avatar_uuid: row.has_avatar ? row.uuid : '',
|
||||
},
|
||||
{
|
||||
tenant: row.primary_tenant_label || '',
|
||||
department: row.primary_department_label || '',
|
||||
extra: row.extra_assignments_count || 0,
|
||||
},
|
||||
phone,
|
||||
{ email: row.email || '' },
|
||||
];
|
||||
}),
|
||||
search: gridSearch,
|
||||
filterSchema,
|
||||
extraFilters: customFilters,
|
||||
@@ -118,6 +178,13 @@ createListPageModule({
|
||||
mode: 'drawer',
|
||||
chipMeta: filterChipMeta,
|
||||
watchInputs: ['#address-book-search'],
|
||||
preserveFilterParamsOnReset: ['tenant'],
|
||||
drawer: {
|
||||
countExcludeParams: ['tenant'],
|
||||
},
|
||||
clearMetaOptions: {
|
||||
preserveFilterParams: ['tenant'],
|
||||
},
|
||||
},
|
||||
}) || {};
|
||||
|
||||
@@ -131,6 +198,23 @@ createListPageModule({
|
||||
});
|
||||
});
|
||||
|
||||
initDetailDrawer({
|
||||
gridConfig,
|
||||
triggerSelector: '[data-drawer-trigger]',
|
||||
rowUuidAttr: 'uuid',
|
||||
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
|
||||
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()),
|
||||
hashPrefix: 'user',
|
||||
labels: {
|
||||
close: labels.drawerClose || 'Close',
|
||||
previous: labels.drawerPrev || 'Previous',
|
||||
next: labels.drawerNext || 'Next',
|
||||
openFull: labels.drawerOpenFull || 'Open full page',
|
||||
loading: labels.drawerLoading || 'Loading',
|
||||
error: labels.drawerError || 'Failed to load',
|
||||
},
|
||||
});
|
||||
|
||||
return gridConfig;
|
||||
},
|
||||
});
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,14 @@ class FilterDrawerRuntimeContractTest extends TestCase
|
||||
$drawerJs = $this->readProjectFile('web/js/components/app-filter-drawer.js');
|
||||
$this->assertStringContainsString("drawer.setAttribute('role', 'dialog')", $drawerJs);
|
||||
$this->assertStringContainsString("drawer.setAttribute('aria-modal', 'true')", $drawerJs);
|
||||
$this->assertStringContainsString("if (event.key !== 'Tab') {return;}", $drawerJs);
|
||||
$this->assertStringContainsString('if (lastTrigger && typeof lastTrigger.focus === \'function\'', $drawerJs);
|
||||
// Focus-trap + focus-return implementation moved to shared utility.
|
||||
$this->assertStringContainsString("from '../core/app-focus-trap.js'", $drawerJs);
|
||||
$this->assertStringContainsString('focusTrap.activate(', $drawerJs);
|
||||
$this->assertStringContainsString('focusTrap.deactivate()', $drawerJs);
|
||||
|
||||
$trapJs = $this->readProjectFile('web/js/core/app-focus-trap.js');
|
||||
$this->assertStringContainsString("if (event.key !== 'Tab')", $trapJs);
|
||||
$this->assertStringContainsString("lastTrigger && typeof lastTrigger.focus === 'function'", $trapJs);
|
||||
|
||||
$experienceJs = $this->readProjectFile('web/js/pages/app-list-filter-experience.js');
|
||||
$this->assertStringContainsString('countDraftChanges(', $experienceJs);
|
||||
|
||||
@@ -41,7 +41,7 @@ class FrontendRuntimeHostContractTest extends TestCase
|
||||
public function testTabsHostsUseDataAppComponentContract(): void
|
||||
{
|
||||
$files = [
|
||||
'modules/addressbook/pages/address-book/view(default).phtml',
|
||||
'modules/addressbook/templates/address-book-profile.phtml',
|
||||
'pages/admin/departments/_form.phtml',
|
||||
'pages/admin/permissions/_form.phtml',
|
||||
'pages/admin/roles/_form.phtml',
|
||||
|
||||
132
web/css/components/app-detail-drawer.css
Normal file
132
web/css/components/app-detail-drawer.css
Normal file
@@ -0,0 +1,132 @@
|
||||
@layer components {
|
||||
/* Detail drawer — slide-in overlay panel for row detail view. */
|
||||
|
||||
html.app-detail-drawer-open {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.app-detail-drawer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2050;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-detail-drawer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-detail-drawer-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-detail-drawer.is-open .app-detail-drawer-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-detail-drawer-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(720px, 100vw);
|
||||
height: 100%;
|
||||
background: var(--app-card-background-color, #fff);
|
||||
border-left: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.35));
|
||||
box-shadow: -12px 0 30px rgba(15, 23, 42, 0.18);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.22s ease;
|
||||
pointer-events: auto;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.app-detail-drawer.is-open .app-detail-drawer-panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.app-detail-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.25));
|
||||
background: var(--app-card-background-color, #fff);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-detail-drawer-stepper,
|
||||
.app-detail-drawer-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-detail-drawer-stepper[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-detail-drawer-header button,
|
||||
.app-detail-drawer-header a {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-detail-drawer-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem 2rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-detail-drawer-loading,
|
||||
.app-detail-drawer-error {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--app-color-muted, #6b7280);
|
||||
}
|
||||
|
||||
.app-detail-drawer-error {
|
||||
color: var(--app-color-danger, #b91c1c);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-detail-drawer-panel {
|
||||
width: 100vw;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inside the drawer, force single-column layout for any .app-details-container
|
||||
regardless of viewport width (the grid rule in app-details.css triggers on
|
||||
viewport ≥968px, which is wrong for a 720px drawer). Any page that uses the
|
||||
standard details container gets stacked main + aside automatically. */
|
||||
.app-detail-drawer .app-details-container,
|
||||
.app-detail-drawer .app-details-container:has(> aside) {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-detail-drawer .app-details-container > section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-detail-drawer .app-details-container > aside {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--app-border);
|
||||
min-height: 0;
|
||||
padding: calc(var(--app-spacing) * 1.5) 0 0;
|
||||
margin-top: calc(var(--app-spacing) * 1.5);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
@import url("components/app-tabs.css");
|
||||
@import url("components/app-list-toolbar.css");
|
||||
@import url("components/app-filter-drawer.css");
|
||||
@import url("components/app-detail-drawer.css");
|
||||
@import url("components/app-active-filter-chips.css");
|
||||
@import url("components/app-confirm-dialog.css");
|
||||
@import url("components/app-breadcrumb.css");
|
||||
|
||||
312
web/js/components/app-detail-drawer.js
Normal file
312
web/js/components/app-detail-drawer.js
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Reusable detail drawer — slides in from the right, loads a fragment URL,
|
||||
* supports deep-link via URL hash, and can step through rows on the current
|
||||
* grid page.
|
||||
*/
|
||||
|
||||
import { getHtml, SessionExpiredError } from '../core/app-http.js';
|
||||
import { initFragmentContent } from '../core/app-fragment-init.js';
|
||||
import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js';
|
||||
|
||||
const STATE_KEY = Symbol('detail-drawer-state');
|
||||
|
||||
function resolveLabels(labels = {}) {
|
||||
return {
|
||||
close: labels.close || 'Close',
|
||||
previous: labels.previous || 'Previous',
|
||||
next: labels.next || 'Next',
|
||||
openFull: labels.openFull || 'Open full page',
|
||||
loading: labels.loading || 'Loading',
|
||||
error: labels.error || 'Failed to load',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDrawerElement(labels) {
|
||||
let root = document.querySelector('[data-detail-drawer]');
|
||||
if (root instanceof HTMLElement) {
|
||||
return root;
|
||||
}
|
||||
root = document.createElement('div');
|
||||
root.className = 'app-detail-drawer';
|
||||
root.setAttribute('data-detail-drawer', '');
|
||||
root.setAttribute('role', 'dialog');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="app-detail-drawer-backdrop" data-detail-drawer-backdrop></div>
|
||||
<aside class="app-detail-drawer-panel" data-detail-drawer-panel tabindex="-1">
|
||||
<header class="app-detail-drawer-header">
|
||||
<div class="app-detail-drawer-stepper">
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-prev
|
||||
aria-label="${labels.previous}" data-tooltip="${labels.previous}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-next
|
||||
aria-label="${labels.next}" data-tooltip="${labels.next}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-detail-drawer-actions">
|
||||
<a class="transparent icon-button" data-detail-drawer-full
|
||||
target="_blank" rel="noopener"
|
||||
aria-label="${labels.openFull}" data-tooltip="${labels.openFull}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
<button type="button" class="transparent icon-button" data-detail-drawer-close
|
||||
aria-label="${labels.close}" data-tooltip="${labels.close}" data-tooltip-pos="bottom">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="app-detail-drawer-body" data-detail-drawer-body>
|
||||
<div class="app-detail-drawer-loading" data-detail-drawer-loading hidden>${labels.loading}</div>
|
||||
<div class="app-detail-drawer-error" data-detail-drawer-error hidden>${labels.error}</div>
|
||||
<div class="app-detail-drawer-content" data-detail-drawer-content></div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Default row-uuid discovery: scrapes the visible Grid.js rows on the page. */
|
||||
function makeGridRowProvider(gridConfig, rowUuidAttr) {
|
||||
if (!gridConfig) { return null; }
|
||||
return () => {
|
||||
const wrapper = gridConfig?.wrapper || document;
|
||||
const rows = wrapper.querySelectorAll(`.gridjs-tr[data-${rowUuidAttr}]`);
|
||||
const uuids = [];
|
||||
rows.forEach((row) => {
|
||||
const uuid = row.getAttribute(`data-${rowUuidAttr}`);
|
||||
if (uuid) { uuids.push(uuid); }
|
||||
});
|
||||
return uuids;
|
||||
};
|
||||
}
|
||||
|
||||
export function initDetailDrawer(options = {}) {
|
||||
const {
|
||||
gridConfig = null,
|
||||
triggerSelector = '[data-drawer-trigger]',
|
||||
rowSelector = '.gridjs-tr',
|
||||
rowUuidAttr = 'uuid',
|
||||
rowProvider = null,
|
||||
fetchUrl,
|
||||
fullUrl,
|
||||
hashPrefix = 'detail',
|
||||
onContentLoaded = null,
|
||||
labels: labelOverrides = {},
|
||||
} = options;
|
||||
|
||||
if (typeof fetchUrl !== 'function') {
|
||||
console.warn('[detail-drawer] fetchUrl required');
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveRowProvider = typeof rowProvider === 'function'
|
||||
? rowProvider
|
||||
: makeGridRowProvider(gridConfig, rowUuidAttr);
|
||||
const stepperEnabled = typeof effectiveRowProvider === 'function';
|
||||
|
||||
const labels = resolveLabels(labelOverrides);
|
||||
const root = ensureDrawerElement(labels);
|
||||
if (root[STATE_KEY]) { return root[STATE_KEY]; }
|
||||
|
||||
const panel = root.querySelector('[data-detail-drawer-panel]');
|
||||
const backdrop = root.querySelector('[data-detail-drawer-backdrop]');
|
||||
const closeButton = root.querySelector('[data-detail-drawer-close]');
|
||||
const prevButton = root.querySelector('[data-detail-drawer-prev]');
|
||||
const nextButton = root.querySelector('[data-detail-drawer-next]');
|
||||
const stepperContainer = root.querySelector('.app-detail-drawer-stepper');
|
||||
const fullLink = root.querySelector('[data-detail-drawer-full]');
|
||||
const contentEl = root.querySelector('[data-detail-drawer-content]');
|
||||
const loadingEl = root.querySelector('[data-detail-drawer-loading]');
|
||||
const errorEl = root.querySelector('[data-detail-drawer-error]');
|
||||
|
||||
if (stepperContainer instanceof HTMLElement) {
|
||||
stepperContainer.hidden = !stepperEnabled;
|
||||
}
|
||||
|
||||
const hashPattern = new RegExp(`^#${hashPrefix}/([^/?#]+)`);
|
||||
const focusTrap = createFocusTrap(panel);
|
||||
const scrollLock = createScrollLock();
|
||||
let lastTrigger = null;
|
||||
|
||||
const api = {
|
||||
currentUuid: null,
|
||||
isOpen: false,
|
||||
open(uuid, opts = {}) {
|
||||
const pushHash = opts.pushHash !== false;
|
||||
if (!uuid) { return; }
|
||||
const wasOpen = api.isOpen;
|
||||
api.currentUuid = String(uuid);
|
||||
root.hidden = false;
|
||||
root.setAttribute('aria-hidden', 'false');
|
||||
root.classList.add('is-open');
|
||||
document.documentElement.classList.add('app-detail-drawer-open');
|
||||
api.isOpen = true;
|
||||
if (!wasOpen) {
|
||||
lastTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
scrollLock.lock();
|
||||
focusTrap.activate(lastTrigger);
|
||||
}
|
||||
if (typeof fullUrl === 'function') {
|
||||
fullLink.href = fullUrl(uuid);
|
||||
}
|
||||
if (pushHash) {
|
||||
const targetHash = `#${hashPrefix}/${uuid}`;
|
||||
if (window.location.hash !== targetHash) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search + targetHash);
|
||||
}
|
||||
}
|
||||
updateStepper();
|
||||
loadContent(uuid);
|
||||
},
|
||||
close(opts = {}) {
|
||||
const clearHash = opts.clearHash !== false;
|
||||
if (!api.isOpen) { return; }
|
||||
api.isOpen = false;
|
||||
api.currentUuid = null;
|
||||
root.classList.remove('is-open');
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.hidden = true;
|
||||
document.documentElement.classList.remove('app-detail-drawer-open');
|
||||
focusTrap.deactivate();
|
||||
scrollLock.unlock();
|
||||
lastTrigger = null;
|
||||
contentEl.innerHTML = '';
|
||||
if (clearHash && hashPattern.test(window.location.hash)) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
},
|
||||
openNext() { step(+1); },
|
||||
openPrev() { step(-1); },
|
||||
};
|
||||
|
||||
function getRowUuids() {
|
||||
if (!stepperEnabled) { return []; }
|
||||
try {
|
||||
const result = effectiveRowProvider();
|
||||
return Array.isArray(result) ? result.filter(Boolean).map(String) : [];
|
||||
} catch (err) {
|
||||
console.warn('[detail-drawer] rowProvider failed', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function step(direction) {
|
||||
if (!api.currentUuid || !stepperEnabled) { return; }
|
||||
const uuids = getRowUuids();
|
||||
const idx = uuids.indexOf(api.currentUuid);
|
||||
if (idx < 0) { return; }
|
||||
const nextIdx = idx + direction;
|
||||
if (nextIdx < 0 || nextIdx >= uuids.length) { return; }
|
||||
api.open(uuids[nextIdx]);
|
||||
}
|
||||
|
||||
function updateStepper() {
|
||||
if (!stepperEnabled) {
|
||||
prevButton.disabled = true;
|
||||
nextButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
const uuids = getRowUuids();
|
||||
const idx = uuids.indexOf(api.currentUuid);
|
||||
prevButton.disabled = idx <= 0;
|
||||
nextButton.disabled = idx < 0 || idx >= uuids.length - 1;
|
||||
}
|
||||
|
||||
async function loadContent(uuid) {
|
||||
contentEl.innerHTML = '';
|
||||
errorEl.hidden = true;
|
||||
loadingEl.hidden = false;
|
||||
const url = fetchUrl(uuid);
|
||||
try {
|
||||
const html = await getHtml(url);
|
||||
if (!api.isOpen || api.currentUuid !== String(uuid)) { return; }
|
||||
contentEl.innerHTML = html;
|
||||
contentEl.querySelectorAll('script').forEach((script) => {
|
||||
const replacement = document.createElement('script');
|
||||
[...script.attributes].forEach((attr) => replacement.setAttribute(attr.name, attr.value));
|
||||
replacement.textContent = script.textContent;
|
||||
script.parentNode.replaceChild(replacement, script);
|
||||
});
|
||||
loadingEl.hidden = true;
|
||||
initFragmentContent(contentEl);
|
||||
if (typeof onContentLoaded === 'function') {
|
||||
try {
|
||||
onContentLoaded(contentEl, uuid);
|
||||
} catch (hookErr) {
|
||||
console.warn('[detail-drawer] onContentLoaded failed', hookErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) {
|
||||
api.close({ clearHash: true });
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
loadingEl.hidden = true;
|
||||
errorEl.hidden = false;
|
||||
console.warn('[detail-drawer] load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest(triggerSelector);
|
||||
if (!trigger) { return; }
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) {
|
||||
return;
|
||||
}
|
||||
const row = trigger.closest(rowSelector);
|
||||
const uuid = row?.getAttribute(`data-${rowUuidAttr}`) || trigger.getAttribute(`data-${rowUuidAttr}`);
|
||||
if (!uuid) { return; }
|
||||
event.preventDefault();
|
||||
api.open(uuid);
|
||||
});
|
||||
|
||||
closeButton.addEventListener('click', () => api.close());
|
||||
backdrop.addEventListener('click', () => api.close());
|
||||
prevButton.addEventListener('click', () => api.openPrev());
|
||||
nextButton.addEventListener('click', () => api.openNext());
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (!api.isOpen) { return; }
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
api.close();
|
||||
return;
|
||||
}
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const tag = event.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'j' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
api.openNext();
|
||||
} else if (event.key === 'k' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
api.openPrev();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const match = hashPattern.exec(window.location.hash);
|
||||
if (match) {
|
||||
api.open(match[1], { pushHash: false });
|
||||
} else if (api.isOpen) {
|
||||
api.close({ clearHash: false });
|
||||
}
|
||||
});
|
||||
|
||||
const initialMatch = hashPattern.exec(window.location.hash);
|
||||
if (initialMatch) {
|
||||
requestAnimationFrame(() => api.open(initialMatch[1], { pushHash: false }));
|
||||
}
|
||||
|
||||
root[STATE_KEY] = api;
|
||||
return api;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* Slide-in filter drawer with focus trap, scroll lock, and apply/reset/discard lifecycle.
|
||||
*/
|
||||
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
||||
import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js';
|
||||
|
||||
export function initFilterDrawer(options = {}) {
|
||||
const {
|
||||
@@ -46,8 +47,9 @@ export function initFilterDrawer(options = {}) {
|
||||
};
|
||||
|
||||
let isOpen = false;
|
||||
let lockedScrollY = 0;
|
||||
let lastTrigger = null;
|
||||
const focusTrap = createFocusTrap(panel);
|
||||
const scrollLock = createScrollLock();
|
||||
|
||||
const ensureDrawerInBody = () => {
|
||||
if (!document.body || !drawer.isConnected || drawer.parentNode === document.body) {
|
||||
@@ -83,61 +85,9 @@ export function initFilterDrawer(options = {}) {
|
||||
panel.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
const lockPageScroll = () => {
|
||||
const body = document.body;
|
||||
if (body.dataset.filterDrawerLock === '1') {
|
||||
return;
|
||||
}
|
||||
lockedScrollY = window.scrollY || window.pageYOffset || 0;
|
||||
body.dataset.filterDrawerLock = '1';
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${lockedScrollY}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
};
|
||||
|
||||
const unlockPageScroll = () => {
|
||||
const body = document.body;
|
||||
if (body.dataset.filterDrawerLock !== '1') {
|
||||
return;
|
||||
}
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.width = '';
|
||||
delete body.dataset.filterDrawerLock;
|
||||
window.scrollTo(0, lockedScrollY);
|
||||
};
|
||||
|
||||
const getFocusableElements = () => {
|
||||
const selectors = [
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
];
|
||||
return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => {
|
||||
if (element.getAttribute('aria-hidden') === 'true') {
|
||||
return false;
|
||||
}
|
||||
if (element instanceof HTMLElement && element.hidden) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const focusFirstField = () => {
|
||||
const focusable = getFocusableElements()[0] || panel;
|
||||
if (focusable && typeof focusable.focus === 'function') {
|
||||
focusable.focus();
|
||||
}
|
||||
};
|
||||
const lockPageScroll = () => scrollLock.lock();
|
||||
const unlockPageScroll = () => scrollLock.unlock();
|
||||
cleanupFns.push(() => focusTrap.destroy());
|
||||
|
||||
const setOpen = (nextOpen) => {
|
||||
if (nextOpen) {
|
||||
@@ -151,7 +101,9 @@ export function initFilterDrawer(options = {}) {
|
||||
|
||||
if (nextOpen) {
|
||||
lockPageScroll();
|
||||
focusTrap.activate(lastTrigger);
|
||||
} else {
|
||||
focusTrap.deactivate();
|
||||
unlockPageScroll();
|
||||
}
|
||||
|
||||
@@ -160,9 +112,6 @@ export function initFilterDrawer(options = {}) {
|
||||
});
|
||||
|
||||
if (!nextOpen) {
|
||||
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
|
||||
lastTrigger.focus();
|
||||
}
|
||||
lastTrigger = null;
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
@@ -173,7 +122,6 @@ export function initFilterDrawer(options = {}) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen();
|
||||
}
|
||||
focusFirstField();
|
||||
};
|
||||
|
||||
const normalizeForcedClose = () => {
|
||||
@@ -181,6 +129,7 @@ export function initFilterDrawer(options = {}) {
|
||||
return;
|
||||
}
|
||||
isOpen = false;
|
||||
focusTrap.deactivate();
|
||||
unlockPageScroll();
|
||||
document.body.classList.remove('filter-drawer-open');
|
||||
openButtons.forEach((button) => {
|
||||
@@ -267,32 +216,6 @@ export function initFilterDrawer(options = {}) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close('discard');
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {return;}
|
||||
|
||||
const focusable = getFocusableElements();
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (active === first || !panel.contains(active)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
bind(document, 'keydown', onKeyDown);
|
||||
|
||||
170
web/js/core/app-focus-trap.js
Normal file
170
web/js/core/app-focus-trap.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Focus-trap + body-scroll-lock utilities for modal-style overlays
|
||||
* (filter drawer, detail drawer, dialogs).
|
||||
*
|
||||
* Usage:
|
||||
* const trap = createFocusTrap(panelEl);
|
||||
* trap.activate(triggerEl); // remembers trigger for focus-return
|
||||
* trap.deactivate(); // restores focus to trigger
|
||||
*
|
||||
* const lock = createScrollLock();
|
||||
* lock.lock();
|
||||
* lock.unlock();
|
||||
*/
|
||||
|
||||
const FOCUSABLE_SELECTORS = [
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([type="hidden"]):not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
const getFocusable = (panel) => {
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)).filter((el) => {
|
||||
if (el.getAttribute('aria-hidden') === 'true') {
|
||||
return false;
|
||||
}
|
||||
if (el instanceof HTMLElement && el.hidden) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export function createFocusTrap(panel) {
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
return {
|
||||
activate() {},
|
||||
deactivate() {},
|
||||
focusFirst() {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!panel.hasAttribute('tabindex')) {
|
||||
panel.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
let active = false;
|
||||
let lastTrigger = null;
|
||||
|
||||
const focusFirst = () => {
|
||||
const target = getFocusable(panel)[0] || panel;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
const focusable = getFocusable(panel);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
if (event.shiftKey) {
|
||||
if (current === first || !panel.contains(current)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return {
|
||||
activate(trigger = null) {
|
||||
if (active) { return; }
|
||||
active = true;
|
||||
lastTrigger = trigger && typeof trigger.focus === 'function'
|
||||
? trigger
|
||||
: (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
requestAnimationFrame(() => focusFirst());
|
||||
},
|
||||
deactivate() {
|
||||
if (!active) { return; }
|
||||
active = false;
|
||||
if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) {
|
||||
lastTrigger.focus();
|
||||
}
|
||||
lastTrigger = null;
|
||||
},
|
||||
focusFirst,
|
||||
destroy() {
|
||||
active = false;
|
||||
lastTrigger = null;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Body scroll-lock with position:fixed strategy. Reference-counted so multiple
|
||||
* overlays cooperate: the lock is only released when the last caller unlocks.
|
||||
*/
|
||||
const SCROLL_LOCK_ATTR = 'data-app-scroll-lock-count';
|
||||
|
||||
export function createScrollLock() {
|
||||
let locked = false;
|
||||
|
||||
return {
|
||||
lock() {
|
||||
if (locked) { return; }
|
||||
locked = true;
|
||||
const body = document.body;
|
||||
if (!body) { return; }
|
||||
const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0;
|
||||
const next = current + 1;
|
||||
body.setAttribute(SCROLL_LOCK_ATTR, String(next));
|
||||
if (current === 0) {
|
||||
const y = window.scrollY || window.pageYOffset || 0;
|
||||
body.dataset.appScrollLockY = String(y);
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${y}px`;
|
||||
body.style.left = '0';
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
}
|
||||
},
|
||||
unlock() {
|
||||
if (!locked) { return; }
|
||||
locked = false;
|
||||
const body = document.body;
|
||||
if (!body) { return; }
|
||||
const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0;
|
||||
const next = Math.max(0, current - 1);
|
||||
if (next === 0) {
|
||||
body.removeAttribute(SCROLL_LOCK_ATTR);
|
||||
const y = Number(body.dataset.appScrollLockY || '0') || 0;
|
||||
body.style.position = '';
|
||||
body.style.top = '';
|
||||
body.style.left = '';
|
||||
body.style.right = '';
|
||||
body.style.width = '';
|
||||
delete body.dataset.appScrollLockY;
|
||||
window.scrollTo(0, y);
|
||||
} else {
|
||||
body.setAttribute(SCROLL_LOCK_ATTR, String(next));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
82
web/js/core/app-fragment-init.js
Normal file
82
web/js/core/app-fragment-init.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Initializes standard UI components inside a dynamically-loaded HTML fragment
|
||||
* (e.g. detail drawer body). Mirrors the subset of app-init.js components that
|
||||
* make sense inside content fragments — layout/topbar/global components are
|
||||
* intentionally excluded since those live in the shell, not the fragment.
|
||||
*
|
||||
* Consumers:
|
||||
* import { initFragmentContent } from '/js/core/app-fragment-init.js';
|
||||
* initFragmentContent(contentEl);
|
||||
*
|
||||
* Each initializer is best-effort: failures are logged but do not abort the
|
||||
* remaining initializers so one broken component cannot silently break all
|
||||
* interactive elements in the fragment.
|
||||
*/
|
||||
|
||||
import { initTabs } from '../components/app-tabs.js';
|
||||
import { initConfirmActions } from '../components/app-confirm-actions.js';
|
||||
import { initLookupFields } from '../components/app-lookup-field.js';
|
||||
import { initAutoSubmit } from '../components/app-auto-submit.js';
|
||||
import { initFileUpload } from '../components/app-file-upload.js';
|
||||
|
||||
const INITIALIZERS = [
|
||||
{
|
||||
name: 'tabs',
|
||||
selector: '[data-app-component="tabs"]',
|
||||
run: (root) => initTabs(root),
|
||||
},
|
||||
{
|
||||
name: 'confirm-actions',
|
||||
selector: '[data-confirm]',
|
||||
run: (root) => initConfirmActions(root),
|
||||
},
|
||||
{
|
||||
name: 'lookup-field',
|
||||
selector: '[data-lookup-field]',
|
||||
run: (root) => initLookupFields(root),
|
||||
},
|
||||
{
|
||||
name: 'auto-submit',
|
||||
selector: '[data-auto-submit]',
|
||||
run: (root) => initAutoSubmit(root),
|
||||
},
|
||||
{
|
||||
name: 'file-upload',
|
||||
selector: '[data-app-component="file-upload"]',
|
||||
run: (root) => initFileUpload(root),
|
||||
},
|
||||
];
|
||||
|
||||
const refreshFsLightboxSafe = () => {
|
||||
if (typeof window !== 'undefined' && typeof window.refreshFsLightbox === 'function') {
|
||||
try {
|
||||
window.refreshFsLightbox();
|
||||
} catch (err) {
|
||||
console.warn('[fragment-init] refreshFsLightbox failed', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize all known components within the given fragment root.
|
||||
*
|
||||
* @param {HTMLElement} contentEl — the container holding freshly-injected HTML
|
||||
*/
|
||||
export function initFragmentContent(contentEl) {
|
||||
if (!(contentEl instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { name, selector, run } of INITIALIZERS) {
|
||||
if (!contentEl.querySelector(selector)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
run(contentEl);
|
||||
} catch (err) {
|
||||
console.warn(`[fragment-init] ${name} failed`, err);
|
||||
}
|
||||
}
|
||||
|
||||
refreshFsLightboxSafe();
|
||||
}
|
||||
@@ -124,6 +124,14 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor(redirectUrl = '') {
|
||||
super('Session expired');
|
||||
this.name = 'SessionExpiredError';
|
||||
this.redirectUrl = redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const emitHttpTelemetry = (error, source) => {
|
||||
if (!(error instanceof HttpError)) {
|
||||
return;
|
||||
@@ -251,6 +259,62 @@ export const postForm = (url, data, options = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getHtml = async (url, options = {}) => {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
const headers = mergeHeaders({
|
||||
'Accept': 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}, options.headers || null);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(normalizedUrl, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
credentials: options.credentials || 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') { throw error; }
|
||||
const httpError = new HttpError({
|
||||
status: 0,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: 'Network error',
|
||||
cause: error,
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.network');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const httpError = new HttpError({
|
||||
status: response.status,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: response.statusText || 'Request failed',
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.response');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// Session-expiry detection: fetch follows 302 redirects transparently, so we
|
||||
// land on the login page with a 200 response. The final URL path diverges
|
||||
// from the requested one — that's the signal.
|
||||
try {
|
||||
const requested = new URL(normalizedUrl).pathname;
|
||||
const landed = new URL(response.url || normalizedUrl).pathname;
|
||||
if (landed !== requested && /\/login(\/|$)/.test(landed)) {
|
||||
throw new SessionExpiredError(response.url || '');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) { throw err; }
|
||||
// URL parsing error — ignore, let caller process the body
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const postJson = (url, payload, options = {}) => {
|
||||
const headers = mergeHeaders({
|
||||
...DEFAULT_HEADERS,
|
||||
|
||||
Reference in New Issue
Block a user