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:
@@ -124,6 +124,14 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionExpiredError extends Error {
|
||||
constructor(redirectUrl = '') {
|
||||
super('Session expired');
|
||||
this.name = 'SessionExpiredError';
|
||||
this.redirectUrl = redirectUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const emitHttpTelemetry = (error, source) => {
|
||||
if (!(error instanceof HttpError)) {
|
||||
return;
|
||||
@@ -251,6 +259,62 @@ export const postForm = (url, data, options = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getHtml = async (url, options = {}) => {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
const headers = mergeHeaders({
|
||||
'Accept': 'text/html',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}, options.headers || null);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(normalizedUrl, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
credentials: options.credentials || 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') { throw error; }
|
||||
const httpError = new HttpError({
|
||||
status: 0,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: 'Network error',
|
||||
cause: error,
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.network');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const httpError = new HttpError({
|
||||
status: response.status,
|
||||
method: 'GET',
|
||||
url: normalizedUrl,
|
||||
message: response.statusText || 'Request failed',
|
||||
});
|
||||
emitHttpTelemetry(httpError, 'http.response');
|
||||
throw httpError;
|
||||
}
|
||||
|
||||
// Session-expiry detection: fetch follows 302 redirects transparently, so we
|
||||
// land on the login page with a 200 response. The final URL path diverges
|
||||
// from the requested one — that's the signal.
|
||||
try {
|
||||
const requested = new URL(normalizedUrl).pathname;
|
||||
const landed = new URL(response.url || normalizedUrl).pathname;
|
||||
if (landed !== requested && /\/login(\/|$)/.test(landed)) {
|
||||
throw new SessionExpiredError(response.url || '');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionExpiredError) { throw err; }
|
||||
// URL parsing error — ignore, let caller process the body
|
||||
}
|
||||
|
||||
return response.text();
|
||||
};
|
||||
|
||||
export const postJson = (url, payload, options = {}) => {
|
||||
const headers = mergeHeaders({
|
||||
...DEFAULT_HEADERS,
|
||||
|
||||
Reference in New Issue
Block a user