Files
breadcrumb-the-shire/modules/addressbook/web/js/pages/address-book-index.js
fs 4d1bd13a3b refactor(ui): move detail drawer chrome to a server-rendered partial
The drawer chrome used to be built in JS via a 50-line innerHTML string
and needed every caller to plumb through a labels object sourced from
per-page PHP config. That diverged from the codebase convention — the
confirm dialog, search dialog and session warning are all PHP partials
mounted once in templates/default.phtml. The drawer now follows the
same pattern: templates/partials/app-detail-drawer.phtml renders the
markup with t() labels, default.phtml mounts it inside the logged-in
shell, and the JS only attaches behavior via the [data-detail-drawer]
selector.

Knock-on cleanup: ensureDrawerElement and resolveLabels disappear from
the JS, all three initDetailDrawer callers (admin/users, address book,
helpdesk debitor) drop their labels parameter, and the matching dead
'drawerClose'/'drawerPrev'/… entries leave the per-page PHP grid configs.
The drawer also gains a clean fallback when the partial is missing
(console warn + null return), so logged-out edges can't crash.

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

213 lines
9.1 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',
});
return gridConfig;
},
});