feat(js): add app-http contracts and migrate helpdesk runtime layer
This commit is contained in:
@@ -25,6 +25,7 @@ import { initTabs } from './components/app-tabs.js';
|
||||
import { initSessionWarning } from './components/app-session-warning.js';
|
||||
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
|
||||
import { initFileUpload } from './components/app-file-upload.js';
|
||||
import { initLookupFields } from './components/app-lookup-field.js';
|
||||
|
||||
const runtime = createComponentRuntime({
|
||||
root: document,
|
||||
@@ -193,6 +194,13 @@ const bootRuntime = async () => {
|
||||
selector: '[data-app-component="file-upload"]',
|
||||
configPath: 'components.fileUpload',
|
||||
});
|
||||
runtime.register('lookup-field', initLookupFields, {
|
||||
scope: 'global',
|
||||
configPath: 'components.lookupField',
|
||||
defaultConfig: {
|
||||
selector: '[data-app-lookup]',
|
||||
},
|
||||
});
|
||||
await mountModuleComponentsByPhase('early');
|
||||
|
||||
// Phase B: structure-defining components.
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
/**
|
||||
* Generic autocomplete lookup field.
|
||||
*
|
||||
* Usage:
|
||||
* <div data-app-lookup
|
||||
* data-lookup-url="/helpdesk/debitor-lookup-data"
|
||||
* data-lookup-min-chars="2"
|
||||
* data-lookup-debounce="300"
|
||||
* data-lookup-value-key="value"
|
||||
* data-lookup-display-key="label"
|
||||
* data-lookup-placeholder="Search...">
|
||||
* <input type="hidden" name="debitor_no" data-lookup-value>
|
||||
* <input type="hidden" name="debitor_name" data-lookup-display-value>
|
||||
* </div>
|
||||
*
|
||||
* The component creates a visible text input, a dropdown, and indicator icons.
|
||||
* On selection, it sets the hidden input values and fires a 'lookup:select' CustomEvent.
|
||||
*/
|
||||
import { getJson } from '../core/app-http.js';
|
||||
import { resolveHost } from '../core/app-dom.js';
|
||||
|
||||
/** @param {HTMLElement} container */
|
||||
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function initLookupField(container) {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const url = container.dataset.lookupUrl || '';
|
||||
const minChars = parseInt(container.dataset.lookupMinChars || '2', 10);
|
||||
const debounceMs = parseInt(container.dataset.lookupDebounce || '300', 10);
|
||||
@@ -28,10 +23,28 @@ export function initLookupField(container) {
|
||||
const initialDisplay = container.dataset.lookupInitialDisplay || '';
|
||||
const initialValue = container.dataset.lookupInitialValue || '';
|
||||
|
||||
if (!url) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
const hiddenValue = container.querySelector('[data-lookup-value]');
|
||||
const hiddenDisplay = container.querySelector('[data-lookup-display-value]');
|
||||
|
||||
// Build DOM
|
||||
const listenerController = new AbortController();
|
||||
const on = (target, type, handler, options = {}) => {
|
||||
if (!target || typeof target.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.addEventListener(type, handler, { ...options, signal: listenerController.signal });
|
||||
};
|
||||
|
||||
const cleanupNodes = [];
|
||||
const clearNode = (el) => {
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
container.classList.add('app-lookup-field');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
@@ -59,15 +72,15 @@ export function initLookupField(container) {
|
||||
|
||||
container.appendChild(wrap);
|
||||
container.appendChild(dropdown);
|
||||
cleanupNodes.push(wrap, dropdown);
|
||||
|
||||
// State
|
||||
let results = [];
|
||||
let activeIndex = -1;
|
||||
let debounceTimer = null;
|
||||
let abortController = null;
|
||||
let blurTimer = null;
|
||||
let selected = false;
|
||||
let activeRequestController = null;
|
||||
|
||||
// Restore initial state
|
||||
if (initialValue && initialDisplay) {
|
||||
input.value = initialDisplay;
|
||||
selected = true;
|
||||
@@ -75,34 +88,30 @@ export function initLookupField(container) {
|
||||
showClear();
|
||||
}
|
||||
|
||||
// ── Indicator helpers ──────────────────────────────────────────────
|
||||
|
||||
function showSpinner() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
const spinner = document.createElement('div');
|
||||
spinner.className = 'app-lookup-field-spinner';
|
||||
indicator.appendChild(spinner);
|
||||
}
|
||||
|
||||
function showClear() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'app-lookup-field-clear';
|
||||
button.setAttribute('aria-label', 'Clear');
|
||||
button.addEventListener('click', clearSelection);
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bi bi-x-lg';
|
||||
button.appendChild(icon);
|
||||
on(button, 'click', clearSelection);
|
||||
indicator.appendChild(button);
|
||||
}
|
||||
|
||||
function hideIndicator() {
|
||||
clearElement(indicator);
|
||||
clearNode(indicator);
|
||||
}
|
||||
|
||||
// ── Dropdown helpers ───────────────────────────────────────────────
|
||||
|
||||
function openDropdown() {
|
||||
dropdown.hidden = false;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
@@ -114,67 +123,6 @@ export function initLookupField(container) {
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function clearElement(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function renderResults(items) {
|
||||
results = items;
|
||||
activeIndex = -1;
|
||||
clearElement(dropdown);
|
||||
|
||||
if (items.length === 0) {
|
||||
const msg = document.createElement('li');
|
||||
msg.className = 'app-lookup-field-message';
|
||||
msg.textContent = container.dataset.lookupEmptyText || 'No results';
|
||||
dropdown.appendChild(msg);
|
||||
openDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'app-lookup-field-option';
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', 'false');
|
||||
li.dataset.index = index;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'app-lookup-field-option-label';
|
||||
label.textContent = item[displayKey] || '';
|
||||
li.appendChild(label);
|
||||
|
||||
if (item.meta) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'app-lookup-field-option-meta';
|
||||
meta.textContent = item.meta;
|
||||
li.appendChild(meta);
|
||||
}
|
||||
|
||||
li.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
selectItem(index);
|
||||
});
|
||||
|
||||
li.addEventListener('mouseenter', () => {
|
||||
setActive(index);
|
||||
});
|
||||
|
||||
dropdown.appendChild(li);
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
clearElement(dropdown);
|
||||
const msg = document.createElement('li');
|
||||
msg.className = 'app-lookup-field-message';
|
||||
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
|
||||
dropdown.appendChild(msg);
|
||||
openDropdown();
|
||||
}
|
||||
|
||||
function setActive(index) {
|
||||
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
||||
options.forEach((opt, i) => {
|
||||
@@ -182,18 +130,17 @@ export function initLookupField(container) {
|
||||
});
|
||||
activeIndex = index;
|
||||
|
||||
// Scroll active into view
|
||||
const active = options[index];
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection ──────────────────────────────────────────────────────
|
||||
|
||||
function selectItem(index) {
|
||||
const item = results[index];
|
||||
if (!item) return;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = item[displayKey] || '';
|
||||
input.dataset.lookupSelected = '';
|
||||
@@ -225,38 +172,88 @@ export function initLookupField(container) {
|
||||
container.dispatchEvent(new CustomEvent('lookup:clear', { bubbles: true }));
|
||||
}
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────
|
||||
function renderResults(items) {
|
||||
results = items;
|
||||
activeIndex = -1;
|
||||
clearNode(dropdown);
|
||||
|
||||
function fetchResults(query) {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
if (items.length === 0) {
|
||||
const msg = document.createElement('li');
|
||||
msg.className = 'app-lookup-field-message';
|
||||
msg.textContent = container.dataset.lookupEmptyText || 'No results';
|
||||
dropdown.appendChild(msg);
|
||||
openDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'app-lookup-field-option';
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', 'false');
|
||||
li.dataset.index = String(index);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'app-lookup-field-option-label';
|
||||
label.textContent = item[displayKey] || '';
|
||||
li.appendChild(label);
|
||||
|
||||
if (item.meta) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'app-lookup-field-option-meta';
|
||||
meta.textContent = item.meta;
|
||||
li.appendChild(meta);
|
||||
}
|
||||
|
||||
on(li, 'mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
selectItem(index);
|
||||
});
|
||||
on(li, 'mouseenter', () => {
|
||||
setActive(index);
|
||||
});
|
||||
|
||||
dropdown.appendChild(li);
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
clearNode(dropdown);
|
||||
const msg = document.createElement('li');
|
||||
msg.className = 'app-lookup-field-message';
|
||||
msg.textContent = container.dataset.lookupErrorText || 'Search failed';
|
||||
dropdown.appendChild(msg);
|
||||
openDropdown();
|
||||
}
|
||||
|
||||
async function fetchResults(query) {
|
||||
if (activeRequestController) {
|
||||
activeRequestController.abort();
|
||||
}
|
||||
activeRequestController = new AbortController();
|
||||
|
||||
showSpinner();
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
fetch(url + separator + 'q=' + encodeURIComponent(query), {
|
||||
signal: abortController.signal,
|
||||
headers: { 'Accept': 'application/json' },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
hideIndicator();
|
||||
const items = Array.isArray(data) ? data : (data.data || []);
|
||||
renderResults(items);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
hideIndicator();
|
||||
renderError();
|
||||
try {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
const data = await getJson(`${url}${separator}q=${encodeURIComponent(query)}`, {
|
||||
signal: activeRequestController.signal,
|
||||
});
|
||||
hideIndicator();
|
||||
const items = Array.isArray(data) ? data : (data?.data || []);
|
||||
renderResults(items);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
hideIndicator();
|
||||
renderError();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Input events ───────────────────────────────────────────────────
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
on(input, 'input', () => {
|
||||
if (selected) {
|
||||
selected = false;
|
||||
delete input.dataset.lookupSelected;
|
||||
@@ -271,59 +268,91 @@ export function initLookupField(container) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => fetchResults(query), debounceMs);
|
||||
window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
void fetchResults(query);
|
||||
}, debounceMs);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
on(input, 'keydown', (event) => {
|
||||
if (dropdown.hidden) return;
|
||||
|
||||
const options = dropdown.querySelectorAll('.app-lookup-field-option');
|
||||
if (!options.length) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex < options.length - 1 ? activeIndex + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActive(activeIndex > 0 ? activeIndex - 1 : options.length - 1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0) {
|
||||
selectItem(activeIndex);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
on(input, 'focus', () => {
|
||||
if (input.value.trim().length >= minChars && !selected && results.length > 0) {
|
||||
openDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
// Delay to allow mousedown on option to fire first
|
||||
setTimeout(() => closeDropdown(), 150);
|
||||
on(input, 'blur', () => {
|
||||
window.clearTimeout(blurTimer);
|
||||
blurTimer = window.setTimeout(() => closeDropdown(), 150);
|
||||
});
|
||||
|
||||
return { input, clearSelection };
|
||||
return {
|
||||
input,
|
||||
clearSelection,
|
||||
destroy: () => {
|
||||
listenerController.abort();
|
||||
if (activeRequestController) {
|
||||
activeRequestController.abort();
|
||||
}
|
||||
window.clearTimeout(debounceTimer);
|
||||
window.clearTimeout(blurTimer);
|
||||
cleanupNodes.forEach((node) => {
|
||||
if (node.parentNode === container) {
|
||||
container.removeChild(node);
|
||||
}
|
||||
});
|
||||
container.classList.remove('app-lookup-field');
|
||||
delete container.dataset.lookupInitialized;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auto-init ──────────────────────────────────────────────────────
|
||||
export function initLookupFields(root = document, options = {}) {
|
||||
const host = resolveHost(root);
|
||||
const selector = String(options.selector || '[data-app-lookup]').trim() || '[data-app-lookup]';
|
||||
const elements = Array.from(host.querySelectorAll(selector));
|
||||
if (elements.length === 0) {
|
||||
return EMPTY_API;
|
||||
}
|
||||
|
||||
function initAll() {
|
||||
document.querySelectorAll('[data-app-lookup]').forEach((el) => {
|
||||
if (el.dataset.lookupInitialized) return;
|
||||
const instances = [];
|
||||
elements.forEach((el) => {
|
||||
if (el.dataset.lookupInitialized) {
|
||||
return;
|
||||
}
|
||||
el.dataset.lookupInitialized = '1';
|
||||
initLookupField(el);
|
||||
instances.push(initLookupField(el));
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
} else {
|
||||
initAll();
|
||||
return {
|
||||
destroy: () => {
|
||||
instances.forEach((instance) => {
|
||||
if (instance && typeof instance.destroy === 'function') {
|
||||
instance.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
266
web/js/core/app-http.js
Normal file
266
web/js/core/app-http.js
Normal file
@@ -0,0 +1,266 @@
|
||||
import { telemetry } from './app-telemetry.js';
|
||||
|
||||
const JSON_ACCEPT = 'application/json';
|
||||
const DEFAULT_HEADERS = Object.freeze({
|
||||
'Accept': JSON_ACCEPT,
|
||||
'X-Requested-With': 'fetch',
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 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 ?? {}),
|
||||
});
|
||||
};
|
||||
76
web/js/core/app-list-page-module.js
Normal file
76
web/js/core/app-list-page-module.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { initStandardListPage } from './app-grid-factory.js';
|
||||
import { warnOnce } from './app-dom.js';
|
||||
import { readPageConfig } from './app-page-config.js';
|
||||
import { getAppBase } from '../pages/app-list-utils.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const defaultErrorMessage = (moduleId) => `${moduleId} grid init failed`;
|
||||
|
||||
export function createListPageModule(options = {}) {
|
||||
const {
|
||||
configId,
|
||||
moduleId,
|
||||
setup,
|
||||
missingGridMessage = '',
|
||||
initErrorMessage = '',
|
||||
} = options;
|
||||
|
||||
const safeModuleId = String(moduleId || '').trim() || 'list-page';
|
||||
const safeConfigId = String(configId || '').trim();
|
||||
if (safeConfigId === '' || typeof setup !== 'function') {
|
||||
warnOnce('UI_CONFIG_INVALID', 'createListPageModule requires configId and setup()', {
|
||||
module: safeModuleId,
|
||||
component: 'list-page-module',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = readPageConfig(safeConfigId);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridjs = window.gridjs || null;
|
||||
if (!gridjs) {
|
||||
const fallbackMessage = missingGridMessage || `${safeModuleId} grid init failed: Grid.js missing`;
|
||||
warnOnce('UI_INIT_FAIL', fallbackMessage, { module: safeModuleId, component: 'grid' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const failInit = (error = null) => {
|
||||
const message = initErrorMessage || defaultErrorMessage(safeModuleId);
|
||||
warnOnce('UI_INIT_FAIL', message, {
|
||||
module: safeModuleId,
|
||||
component: 'grid',
|
||||
error,
|
||||
});
|
||||
};
|
||||
|
||||
const initListPage = (pageOptions = {}) => {
|
||||
const result = initStandardListPage(pageOptions);
|
||||
const gridConfig = result?.gridConfig || null;
|
||||
if (!gridConfig || !gridConfig.grid) {
|
||||
failInit();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gridConfig,
|
||||
filterPanel: result?.filterPanel || null,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
return setup({
|
||||
config,
|
||||
gridjs,
|
||||
appBase: getAppBase(),
|
||||
initListPage,
|
||||
failInit,
|
||||
noop,
|
||||
});
|
||||
} catch (error) {
|
||||
failInit(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { postForm as postFormRequest } from '../core/app-http.js';
|
||||
|
||||
export const buildUrl = (appBase, path) => new URL(path, appBase).toString();
|
||||
|
||||
export const getAppBase = () => {
|
||||
@@ -125,15 +127,7 @@ export const buildBadgeList = (items, options = {}) => {
|
||||
|
||||
export const postAction = async (url, csrf, data = {}) => {
|
||||
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
return postFormRequest(url, body);
|
||||
};
|
||||
|
||||
export const badgeHtml = (gridjs, value = '') => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { confirmDialog } from '../core/app-confirm-dialog.js';
|
||||
import { warnOnce } from '../core/app-dom.js';
|
||||
import { postForm } from '../core/app-http.js';
|
||||
|
||||
export function initUsersListPage(options = {}) {
|
||||
const {
|
||||
@@ -29,15 +30,7 @@ export function initUsersListPage(options = {}) {
|
||||
|
||||
const postAction = async (url, data = {}) => {
|
||||
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
return postForm(url, body);
|
||||
};
|
||||
|
||||
const exportButton = document.querySelector(exportSelector);
|
||||
@@ -118,9 +111,11 @@ export function initUsersListPage(options = {}) {
|
||||
|
||||
const executeBulk = async () => {
|
||||
const url = resolveBulkUrl(action);
|
||||
const response = await postAction(url, { uuids: ids.join(',') });
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.ok && data && data.ok) {
|
||||
try {
|
||||
const data = await postAction(url, { uuids: ids.join(',') });
|
||||
if (!data || data.ok !== true) {
|
||||
throw new Error('Bulk action failed');
|
||||
}
|
||||
gridConfig.selection?.clear?.();
|
||||
gridConfig.grid?.forceRender();
|
||||
|
||||
@@ -136,7 +131,7 @@ export function initUsersListPage(options = {}) {
|
||||
showFlash('success', text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} catch {
|
||||
if (showFlash && labels.bulkMessages) {
|
||||
const msg = labels.bulkMessages[action];
|
||||
if (msg?.error) {
|
||||
|
||||
Reference in New Issue
Block a user