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>
331 lines
8.2 KiB
JavaScript
331 lines
8.2 KiB
JavaScript
import { telemetry } from './app-telemetry.js';
|
|
|
|
const JSON_ACCEPT = 'application/json';
|
|
const DEFAULT_HEADERS = Object.freeze({
|
|
'Accept': JSON_ACCEPT,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
});
|
|
|
|
const toObject = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const normalizeUrl = (value) => {
|
|
try {
|
|
return new URL(String(value || ''), window.location.href).toString();
|
|
} catch {
|
|
return String(value || '');
|
|
}
|
|
};
|
|
|
|
const normalizeRoute = (url) => {
|
|
try {
|
|
return new URL(url, window.location.href).pathname || '/';
|
|
} catch {
|
|
return '/';
|
|
}
|
|
};
|
|
|
|
const mergeHeaders = (baseHeaders, inputHeaders) => {
|
|
const headers = new Headers();
|
|
Object.entries(baseHeaders).forEach(([key, value]) => {
|
|
headers.set(key, value);
|
|
});
|
|
|
|
if (!inputHeaders) {
|
|
return headers;
|
|
}
|
|
|
|
if (inputHeaders instanceof Headers) {
|
|
inputHeaders.forEach((value, key) => headers.set(key, value));
|
|
return headers;
|
|
}
|
|
|
|
if (Array.isArray(inputHeaders)) {
|
|
inputHeaders.forEach((entry) => {
|
|
if (Array.isArray(entry) && entry.length >= 2) {
|
|
headers.set(String(entry[0]), String(entry[1]));
|
|
}
|
|
});
|
|
return headers;
|
|
}
|
|
|
|
if (typeof inputHeaders === 'object') {
|
|
Object.entries(inputHeaders).forEach(([key, value]) => {
|
|
if (value === null || value === undefined) {
|
|
return;
|
|
}
|
|
headers.set(String(key), String(value));
|
|
});
|
|
}
|
|
|
|
return headers;
|
|
};
|
|
|
|
const readJsonSafe = async (response) => {
|
|
const raw = await response.text();
|
|
if (raw.trim() === '') {
|
|
return null;
|
|
}
|
|
|
|
return JSON.parse(raw);
|
|
};
|
|
|
|
const messageFromPayload = (payload, fallback) => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return fallback;
|
|
}
|
|
|
|
if (typeof payload.message === 'string' && payload.message.trim() !== '') {
|
|
return payload.message.trim();
|
|
}
|
|
|
|
if (typeof payload.error === 'string' && payload.error.trim() !== '') {
|
|
return payload.error.trim();
|
|
}
|
|
|
|
if (payload.errors && typeof payload.errors === 'object') {
|
|
const general = payload.errors.general;
|
|
if (typeof general === 'string' && general.trim() !== '') {
|
|
return general.trim();
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
};
|
|
|
|
const normalizeMethod = (value, fallback = 'GET') => {
|
|
const method = String(value || fallback).trim().toUpperCase();
|
|
return method === '' ? fallback : method;
|
|
};
|
|
|
|
const buildHttpErrorMessage = (method, status, message) => {
|
|
if (status > 0) {
|
|
return `[${method}] HTTP ${status}: ${message}`;
|
|
}
|
|
return `[${method}] ${message}`;
|
|
};
|
|
|
|
export class HttpError extends Error {
|
|
constructor(details = {}) {
|
|
const method = normalizeMethod(details.method || 'GET');
|
|
const status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
|
|
const message = String(details.message || 'Request failed').trim() || 'Request failed';
|
|
super(buildHttpErrorMessage(method, status, message));
|
|
this.name = 'HttpError';
|
|
this.status = status;
|
|
this.method = method;
|
|
this.url = normalizeUrl(details.url || '');
|
|
this.payload = details.payload ?? null;
|
|
this.cause = details.cause ?? null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
telemetry.capture('ajax_error', {
|
|
severity: error.status >= 500 || error.status === 0 ? 'error' : 'warning',
|
|
message: error.message,
|
|
meta: {
|
|
source,
|
|
module: 'app-http',
|
|
http_method: error.method,
|
|
http_status: error.status,
|
|
request_path: normalizeRoute(error.url),
|
|
error_code: error.status === 0 ? 'http_error_status_0' : `http_${error.status}`,
|
|
},
|
|
});
|
|
};
|
|
|
|
const toFormBody = (data) => {
|
|
if (data instanceof URLSearchParams) {
|
|
return data;
|
|
}
|
|
|
|
if (typeof FormData !== 'undefined' && data instanceof FormData) {
|
|
return new URLSearchParams(data);
|
|
}
|
|
|
|
if (data && typeof data === 'object') {
|
|
const body = new URLSearchParams();
|
|
Object.entries(data).forEach(([key, value]) => {
|
|
if (value === null || value === undefined) {
|
|
body.append(key, '');
|
|
} else {
|
|
body.append(key, String(value));
|
|
}
|
|
});
|
|
return body;
|
|
}
|
|
|
|
return new URLSearchParams();
|
|
};
|
|
|
|
const performRequest = async (url, options = {}) => {
|
|
const normalizedUrl = normalizeUrl(url);
|
|
const method = normalizeMethod(options.method || 'GET');
|
|
const headers = mergeHeaders(DEFAULT_HEADERS, options.headers || null);
|
|
|
|
const requestInit = {
|
|
...options,
|
|
method,
|
|
credentials: options.credentials || 'same-origin',
|
|
headers,
|
|
};
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(normalizedUrl, requestInit);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw error;
|
|
}
|
|
|
|
const httpError = new HttpError({
|
|
status: 0,
|
|
method,
|
|
url: normalizedUrl,
|
|
message: 'Network error',
|
|
payload: null,
|
|
cause: error,
|
|
});
|
|
emitHttpTelemetry(httpError, 'http.network');
|
|
throw httpError;
|
|
}
|
|
|
|
let payload = null;
|
|
try {
|
|
payload = await readJsonSafe(response);
|
|
} catch (error) {
|
|
const httpError = new HttpError({
|
|
status: 0,
|
|
method,
|
|
url: normalizedUrl,
|
|
message: 'Invalid JSON response',
|
|
payload: null,
|
|
cause: error,
|
|
});
|
|
emitHttpTelemetry(httpError, 'http.parse');
|
|
throw httpError;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = messageFromPayload(payload, response.statusText || 'Request failed');
|
|
const httpError = new HttpError({
|
|
status: response.status,
|
|
method,
|
|
url: normalizedUrl,
|
|
message,
|
|
payload,
|
|
});
|
|
emitHttpTelemetry(httpError, 'http.response');
|
|
throw httpError;
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
export const getJson = (url, options = {}) => performRequest(url, {
|
|
...options,
|
|
method: 'GET',
|
|
});
|
|
|
|
export const postForm = (url, data, options = {}) => {
|
|
const body = toFormBody(data);
|
|
const headers = mergeHeaders({
|
|
...DEFAULT_HEADERS,
|
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
}, options.headers || null);
|
|
|
|
return performRequest(url, {
|
|
...options,
|
|
method: 'POST',
|
|
headers,
|
|
body,
|
|
});
|
|
};
|
|
|
|
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,
|
|
'Content-Type': 'application/json;charset=UTF-8',
|
|
}, options.headers || null);
|
|
|
|
return performRequest(url, {
|
|
...options,
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(payload ?? {}),
|
|
});
|
|
};
|