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>
This commit is contained in:
@@ -255,12 +255,6 @@ $pageConfig = [
|
|||||||
'sendEmail' => t('Send email'),
|
'sendEmail' => t('Send email'),
|
||||||
'call' => t('Call'),
|
'call' => t('Call'),
|
||||||
'extraAssignments' => t('further assignments'),
|
'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'),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -205,14 +205,6 @@ createListPageModule({
|
|||||||
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
|
fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(),
|
||||||
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()),
|
fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()),
|
||||||
hashPrefix: 'user',
|
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;
|
return gridConfig;
|
||||||
|
|||||||
@@ -351,12 +351,6 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
|
|||||||
'support' => t('Support'),
|
'support' => t('Support'),
|
||||||
'created' => t('Created'),
|
'created' => t('Created'),
|
||||||
'lastActivity' => t('Last activity'),
|
'lastActivity' => t('Last activity'),
|
||||||
'drawerClose' => t('Close'),
|
|
||||||
'drawerPrev' => t('Previous'),
|
|
||||||
'drawerNext' => t('Next'),
|
|
||||||
'drawerOpenFull' => t('Open full page'),
|
|
||||||
'drawerLoading' => t('Loading'),
|
|
||||||
'drawerError' => t('Failed to load'),
|
|
||||||
],
|
],
|
||||||
'gridSearch' => $searchConfig,
|
'gridSearch' => $searchConfig,
|
||||||
'filterSchema' => $clientFilterSchema,
|
'filterSchema' => $clientFilterSchema,
|
||||||
|
|||||||
@@ -848,14 +848,6 @@ function init(container) {
|
|||||||
onContentLoaded: (contentEl) => {
|
onContentLoaded: (contentEl) => {
|
||||||
try { initHelpdeskTicket(contentEl); } catch (err) { console.warn('[helpdesk-detail] initHelpdeskTicket failed', err); }
|
try { initHelpdeskTicket(contentEl); } catch (err) { console.warn('[helpdesk-detail] initHelpdeskTicket failed', err); }
|
||||||
},
|
},
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
ticketsGridInitializing = false;
|
ticketsGridInitializing = false;
|
||||||
|
|||||||
@@ -131,12 +131,6 @@ $pageConfig = [
|
|||||||
'bulkSendAccessConfirm' => t('Send access emails to selected users?'),
|
'bulkSendAccessConfirm' => t('Send access emails to selected users?'),
|
||||||
'bulkAccessPdfConfirm' => t('Generate access PDFs for selected users?'),
|
'bulkAccessPdfConfirm' => t('Generate access PDFs for selected users?'),
|
||||||
'primaryTenant' => t('Primary tenant'),
|
'primaryTenant' => t('Primary tenant'),
|
||||||
'drawerClose' => t('Close'),
|
|
||||||
'drawerPrev' => t('Previous'),
|
|
||||||
'drawerNext' => t('Next'),
|
|
||||||
'drawerOpenFull' => t('Open full page'),
|
|
||||||
'drawerLoading' => t('Loading'),
|
|
||||||
'drawerError' => t('Failed to load'),
|
|
||||||
'bulkMessages' => [
|
'bulkMessages' => [
|
||||||
'activate' => [
|
'activate' => [
|
||||||
'success' => t('%d users activated'),
|
'success' => t('%d users activated'),
|
||||||
|
|||||||
@@ -340,6 +340,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
|
|||||||
<?php if ($isLoggedIn): ?>
|
<?php if ($isLoggedIn): ?>
|
||||||
<?php require __DIR__ . '/partials/app-search-dialog.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-search-dialog.phtml'; ?>
|
||||||
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
||||||
|
<?php require __DIR__ . '/partials/app-detail-drawer.phtml'; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>
|
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
57
templates/partials/app-detail-drawer.phtml
Normal file
57
templates/partials/app-detail-drawer.phtml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared right-edge detail drawer.
|
||||||
|
*
|
||||||
|
* Mounted once at the bottom of the logged-in shell so admin list pages
|
||||||
|
* can attach behavior via [data-detail-drawer]. Markup mirrors what
|
||||||
|
* web/js/components/app-detail-drawer.js drives — JS only queries
|
||||||
|
* data-attributes, never injects chrome.
|
||||||
|
*/
|
||||||
|
|
||||||
|
?>
|
||||||
|
<div class="app-detail-drawer"
|
||||||
|
data-detail-drawer
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
hidden>
|
||||||
|
<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="<?php e(t('Previous')); ?>"
|
||||||
|
data-tooltip="<?php e(t('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="<?php e(t('Next')); ?>"
|
||||||
|
data-tooltip="<?php e(t('Next')); ?>"
|
||||||
|
data-tooltip-pos="bottom">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="app-detail-drawer-actions">
|
||||||
|
<a role="button" class="secondary outline small app-detail-drawer-full"
|
||||||
|
data-detail-drawer-full
|
||||||
|
aria-label="<?php e(t('Open full page')); ?>">
|
||||||
|
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i>
|
||||||
|
<span><?php e(t('Open full page')); ?></span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="transparent icon-button" data-detail-drawer-close
|
||||||
|
aria-label="<?php e(t('Close')); ?>"
|
||||||
|
data-tooltip="<?php e(t('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><?php e(t('Loading')); ?></div>
|
||||||
|
<div class="app-detail-drawer-error" data-detail-drawer-error hidden><?php e(t('Failed to load')); ?></div>
|
||||||
|
<div class="app-detail-drawer-content" data-detail-drawer-content></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
* Reusable detail drawer — slides in from the right, loads a fragment URL,
|
* 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
|
* supports deep-link via URL hash, and can step through rows on the current
|
||||||
* grid page.
|
* grid page.
|
||||||
|
*
|
||||||
|
* The drawer chrome is rendered server-side by templates/partials/app-detail-drawer.phtml
|
||||||
|
* (mounted once in the logged-in shell). This module only attaches behavior
|
||||||
|
* via the [data-detail-drawer] selector and never injects markup.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getHtml, SessionExpiredError } from '../core/app-http.js';
|
import { getHtml, SessionExpiredError } from '../core/app-http.js';
|
||||||
@@ -10,66 +14,6 @@ import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js';
|
|||||||
|
|
||||||
const STATE_KEY = Symbol('detail-drawer-state');
|
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 role="button" class="secondary outline small app-detail-drawer-full" data-detail-drawer-full
|
|
||||||
aria-label="${labels.openFull}">
|
|
||||||
<i class="bi bi-box-arrow-up-right" aria-hidden="true"></i>
|
|
||||||
<span>${labels.openFull}</span>
|
|
||||||
</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. */
|
/** Default row-uuid discovery: scrapes the visible Grid.js rows on the page. */
|
||||||
function makeGridRowProvider(gridConfig, rowUuidAttr) {
|
function makeGridRowProvider(gridConfig, rowUuidAttr) {
|
||||||
if (!gridConfig) { return null; }
|
if (!gridConfig) { return null; }
|
||||||
@@ -96,7 +40,6 @@ export function initDetailDrawer(options = {}) {
|
|||||||
fullUrl,
|
fullUrl,
|
||||||
hashPrefix = 'detail',
|
hashPrefix = 'detail',
|
||||||
onContentLoaded = null,
|
onContentLoaded = null,
|
||||||
labels: labelOverrides = {},
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (typeof fetchUrl !== 'function') {
|
if (typeof fetchUrl !== 'function') {
|
||||||
@@ -104,15 +47,18 @@ export function initDetailDrawer(options = {}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root = document.querySelector('[data-detail-drawer]');
|
||||||
|
if (!(root instanceof HTMLElement)) {
|
||||||
|
console.warn('[detail-drawer] [data-detail-drawer] not in DOM — partial not mounted?');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (root[STATE_KEY]) { return root[STATE_KEY]; }
|
||||||
|
|
||||||
const effectiveRowProvider = typeof rowProvider === 'function'
|
const effectiveRowProvider = typeof rowProvider === 'function'
|
||||||
? rowProvider
|
? rowProvider
|
||||||
: makeGridRowProvider(gridConfig, rowUuidAttr);
|
: makeGridRowProvider(gridConfig, rowUuidAttr);
|
||||||
const stepperEnabled = typeof effectiveRowProvider === 'function';
|
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 panel = root.querySelector('[data-detail-drawer-panel]');
|
||||||
const backdrop = root.querySelector('[data-detail-drawer-backdrop]');
|
const backdrop = root.querySelector('[data-detail-drawer-backdrop]');
|
||||||
const closeButton = root.querySelector('[data-detail-drawer-close]');
|
const closeButton = root.querySelector('[data-detail-drawer-close]');
|
||||||
|
|||||||
@@ -199,14 +199,6 @@ createListPageModule({
|
|||||||
fetchUrl: (uuid) => new URL(`admin/users/view-fragment/${uuid}`, appBase).toString(),
|
fetchUrl: (uuid) => new URL(`admin/users/view-fragment/${uuid}`, appBase).toString(),
|
||||||
fullUrl: (uuid) => withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()),
|
fullUrl: (uuid) => withCurrentListReturn(new URL(`admin/users/edit/${uuid}`, appBase).toString()),
|
||||||
hashPrefix: 'user',
|
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 usersGridConfig;
|
return usersGridConfig;
|
||||||
|
|||||||
Reference in New Issue
Block a user