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:
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user