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>
221 lines
9.4 KiB
JavaScript
221 lines
9.4 KiB
JavaScript
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;
|
||
},
|
||
});
|