Files
breadcrumb-the-shire/modules/addressbook/web/js/pages/address-book-index.js
fs 811588290c feat(core): detail drawer + address book list redesign
Introduces a reusable core detail-drawer primitive that slides in from the
right and loads any view via a `*-fragment(none).phtml` endpoint. Bundles the
address book list overhaul that is its first consumer.

Core additions:
- `app-detail-drawer.js` — generic drawer with stepper, focus trap, body
  scroll-lock, URL-hash deep-linking, session-expiry detection
- `app-fragment-init.js` — auto-wires tabs/lookups/confirm/file-upload/
  fslightbox inside injected HTML; consumers do not re-initialize components
- `app-focus-trap.js` — shared focus-trap + refcounted scroll-lock, used by
  both filter-drawer and detail-drawer
- `getHtml()` in `app-http.js` + `SessionExpiredError`; drawer reloads the
  page on auth redirect instead of rendering the login form in the panel
- `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer`
  consumer ships matching `*-fragment($id).php` + `*-fragment(none).phtml`

Address book list:
- Grid collapses from 9 columns to 4 (identity / context / phone / actions)
  with a two-line identity cell (avatar + name + email)
- Tenant register tabs above the grid using the `app-list-tabs` partial;
  tenant filter wired via hidden toolbar field so grid.js forwards it on
  every data fetch
- Profile body extracted to a shared partial so the full-page view and the
  new drawer fragment share the same markup
- New i18n keys for the drawer/list labels

Also refactors `app-filter-drawer` to reuse the shared focus-trap and
scroll-lock instead of maintaining its own copy, and documents the
detail-drawer convention in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:22:26 +02:00

221 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js';
createListPageModule({
configId: 'address-book-index',
moduleId: 'address-book-index',
missingGridMessage: 'Address book grid init failed: Grid.js missing',
initErrorMessage: 'Address book grid init failed',
setup: ({ config, gridjs, appBase, initListPage }) => {
const labels = config.labels ?? {};
const gridSearch = config.gridSearch ?? null;
const filterSchema = config.filterSchema ?? [];
const filterChipMeta = config.filterChipMeta ?? [];
const toolbar = document.querySelector('#address-book-drawer-toolbar');
const customFilterInputs = Array.from(document.querySelectorAll('[data-address-book-custom-filter]'));
const tenantDepartmentMap = toolbar?.dataset?.tenantDeptMap
? JSON.parse(toolbar.dataset.tenantDeptMap)
: {};
const uuidIndex = 0;
const initialsForName = (name) => {
const parts = String(name || '')
.trim()
.split(/\s+/)
.filter(Boolean);
const chars = parts.slice(0, 2).map((value) => value[0] || '').join('').toUpperCase();
return chars || '?';
};
const customFilters = customFilterInputs.map((input) => {
const param = String(input?.dataset?.addressBookCustomFilterParam || '').trim();
if (!param) { return null; }
if (input.dataset.addressBookCustomFilterType === 'multiselect') {
const targetSelector = input.dataset.addressBookCustomFilterInput || '';
return gridFilterMultiCsv(targetSelector, param, { default: [] });
}
return gridFilterText(input, param, { default: '' });
}).filter(Boolean);
const { gridConfig } = initListPage({
grid: {
gridjs,
container: '#address-book-grid',
dataUrl: 'address-book/data',
appBase,
columns: [
{ name: 'UUID', hidden: true },
{
name: labels.name || 'Name',
sort: true,
formatter: (cell, row) => {
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 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));
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>`;
}
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>`);
},
},
],
sortColumns: [null, 'display_name', null, null, null],
paginationLimit: 10,
language: config.gridLang ?? {},
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,
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data,
}),
rowInteraction: { linkColumn: false },
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[uuidIndex]?.data;
return uuid ? new URL(`address-book/view/${uuid}`, appBase).toString() : '';
},
},
},
filters: {
mode: 'drawer',
chipMeta: filterChipMeta,
watchInputs: ['#address-book-search'],
preserveFilterParamsOnReset: ['tenant'],
drawer: {
countExcludeParams: ['tenant'],
},
clearMetaOptions: {
preserveFilterParams: ['tenant'],
},
},
}) || {};
initMultiSelect().then(() => {
initMultiSelectCascade({
parent: '#address-book-tenant-filter',
child: '#address-book-department-filter',
map: tenantDepartmentMap,
mode: 'disable',
clearInvalid: true,
});
});
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;
},
});