From f189ef9df6b2d85d325e0e01a7564e753675f09e Mon Sep 17 00:00:00 2001 From: fs Date: Mon, 20 Apr 2026 22:31:13 +0200 Subject: [PATCH] feat(js): add app-http contracts and migrate helpdesk runtime layer --- bin/js-contract-check.sh | 101 ++++ bin/qa-required.sh | 3 + modules/helpdesk/module.php | 90 +++ .../pages/helpdesk/debitor(default).phtml | 2 +- .../pages/helpdesk/domain(default).phtml | 2 +- .../helpdesk/handovers/create(default).phtml | 5 +- .../helpdesk/risk-radar/index(default).phtml | 3 +- .../helpdesk/settings/index(default).phtml | 4 +- .../helpdesk/software-products/_form.phtml | 1 + .../software-products/edit(default).phtml | 1 - .../pages/helpdesk/team/index(default).phtml | 3 +- .../pages/helpdesk/ticket(default).phtml | 3 +- .../helpdesk/web/js/handover-domain-select.js | 113 ++-- .../helpdesk/web/js/handover-schema-editor.js | 68 ++- modules/helpdesk/web/js/helpdesk-detail.js | 136 +++-- .../helpdesk/web/js/helpdesk-domain-detail.js | 41 +- .../helpdesk/web/js/helpdesk-risk-radar.js | 253 ++++---- modules/helpdesk/web/js/helpdesk-settings.js | 219 ++++--- modules/helpdesk/web/js/helpdesk-team.js | 72 ++- modules/helpdesk/web/js/helpdesk-ticket.js | 115 ++-- .../web/js/pages/helpdesk-domains-index.js | 170 +++--- .../web/js/pages/helpdesk-handovers-index.js | 111 ++-- .../web/js/pages/helpdesk-search-index.js | 115 ++-- .../pages/helpdesk-software-products-index.js | 126 ++-- .../web/js/pages/helpdesk-updates-index.js | 569 +++++++++--------- .../Architecture/FrontendJsContractsTest.php | 116 ++++ .../FrontendRuntimeContractSupport.php | 9 + .../Architecture/ListTemplateContractTest.php | 6 +- web/js/app-init.js | 8 + web/js/components/app-lookup-field.js | 317 +++++----- web/js/core/app-http.js | 266 ++++++++ web/js/core/app-list-page-module.js | 76 +++ web/js/pages/app-list-utils.js | 12 +- web/js/pages/app-users-list.js | 21 +- 34 files changed, 2023 insertions(+), 1134 deletions(-) create mode 100755 bin/js-contract-check.sh create mode 100644 tests/Architecture/FrontendJsContractsTest.php create mode 100644 web/js/core/app-http.js create mode 100644 web/js/core/app-list-page-module.js diff --git a/bin/js-contract-check.sh b/bin/js-contract-check.sh new file mode 100755 index 0000000..3affc04 --- /dev/null +++ b/bin/js-contract-check.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "${script_dir}/.." && pwd)" +cd "${repo_root}" + +if ! command -v rg >/dev/null 2>&1; then + echo "[ERROR] rg is required for js-contract-check." >&2 + exit 2 +fi + +failures=0 + +ok() { + echo "[OK] $*" +} + +fail() { + echo "[FAIL] $*" >&2 + failures=$((failures + 1)) +} + +alert_hits="$(rg -n "window\\.alert\\s*\\(" web/js modules/helpdesk/web/js -g '*.js' || true)" +if [[ -n "${alert_hits}" ]]; then + fail "window.alert usage detected:" + echo "${alert_hits}" >&2 +else + ok "No window.alert usage in web/js and modules/helpdesk/web/js" +fi + +fetch_hits="$(rg -n "\\bfetch\\s*\\(" modules/helpdesk/web/js -g '*.js' || true)" +if [[ -n "${fetch_hits}" ]]; then + fail "Direct fetch(...) usage detected in helpdesk JS:" + echo "${fetch_hits}" >&2 +else + ok "Helpdesk JS uses centralized app-http (no direct fetch)" +fi + +runtime_files=( + "modules/helpdesk/web/js/helpdesk-detail.js" + "modules/helpdesk/web/js/helpdesk-domain-detail.js" + "modules/helpdesk/web/js/helpdesk-ticket.js" + "modules/helpdesk/web/js/helpdesk-team.js" + "modules/helpdesk/web/js/helpdesk-risk-radar.js" + "modules/helpdesk/web/js/helpdesk-settings.js" + "modules/helpdesk/web/js/handover-domain-select.js" + "modules/helpdesk/web/js/handover-schema-editor.js" + "web/js/components/app-lookup-field.js" +) + +for file in "${runtime_files[@]}"; do + if [[ ! -f "${file}" ]]; then + fail "Missing runtime file: ${file}" + continue + fi + + if rg -q "DOMContentLoaded|document\\.readyState" "${file}"; then + fail "${file}: lifecycle auto-init pattern found" + fi + + if rg -q "const container = document\\.querySelector" "${file}"; then + fail "${file}: top-level container auto-init pattern found" + fi + +done +ok "Runtime files are free of sideeffect auto-init patterns" + +import_violations=0 +while IFS= read -r line; do + file="${line%%:*}" + import_path="$(printf '%s' "${line}" | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")" + + if [[ "${import_path}" == /js/* ]]; then + continue + fi + + if [[ "${import_path}" == ./* || "${import_path}" == ../* ]]; then + if printf '%s' "${import_path}" | rg -q "(^|/)js/(core|components|pages)/"; then + echo "${file}: relative core import forbidden -> ${import_path}" >&2 + import_violations=$((import_violations + 1)) + fi + continue + fi + + echo "${file}: invalid import path -> ${import_path}" >&2 + import_violations=$((import_violations + 1)) +done < <(rg -n "^\\s*import\\s+(?:[^'\\\"]+\\s+from\\s+)?['\\\"][^'\\\"]+['\\\"]" modules/helpdesk/web/js -g '*.js') + +if [[ "${import_violations}" -gt 0 ]]; then + fail "Helpdesk import policy violations: ${import_violations}" +else + ok "Helpdesk import policy valid (core imports absolute /js/...; local imports relative)" +fi + +if [[ "${failures}" -gt 0 ]]; then + echo "[FAIL] JS contract check failed with ${failures} issue(s)." >&2 + exit 1 +fi + +echo "[OK] JS contract check passed." diff --git a/bin/qa-required.sh b/bin/qa-required.sh index fab5510..f983dba 100755 --- a/bin/qa-required.sh +++ b/bin/qa-required.sh @@ -44,6 +44,9 @@ run_step "QG-008 Docs drift contract" \ run_step "CSS contract check" \ bin/css-contract-check.sh +run_step "JS contract check" \ + bin/js-contract-check.sh + run_step "QG-009 Codex skills sync" \ bin/codex-skills-sync.sh --check diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index a216947..825eb1e 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -77,6 +77,96 @@ return [ 'order' => 800, ], ], + 'runtime.component' => [ + [ + 'key' => 'helpdesk-detail', + 'script' => 'modules/helpdesk/js/helpdesk-detail.js', + 'export' => 'initHelpdeskDetail', + 'selector' => '[data-app-component="helpdesk-detail"]', + 'config_path' => 'components.helpdesk.detail', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.access', + 'order' => 801, + ], + [ + 'key' => 'helpdesk-domain-detail', + 'script' => 'modules/helpdesk/js/helpdesk-domain-detail.js', + 'export' => 'initHelpdeskDomainDetail', + 'selector' => '[data-app-component="helpdesk-domain-detail"]', + 'config_path' => 'components.helpdesk.domainDetail', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.access', + 'order' => 802, + ], + [ + 'key' => 'helpdesk-ticket', + 'script' => 'modules/helpdesk/js/helpdesk-ticket.js', + 'export' => 'initHelpdeskTicket', + 'selector' => '[data-app-component="helpdesk-ticket"]', + 'config_path' => 'components.helpdesk.ticket', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.access', + 'order' => 803, + ], + [ + 'key' => 'helpdesk-team', + 'script' => 'modules/helpdesk/js/helpdesk-team.js', + 'export' => 'initHelpdeskTeam', + 'selector' => '[data-app-component="helpdesk-team"]', + 'config_path' => 'components.helpdesk.team', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.team-workload.view', + 'order' => 804, + ], + [ + 'key' => 'helpdesk-risk-radar', + 'script' => 'modules/helpdesk/js/helpdesk-risk-radar.js', + 'export' => 'initHelpdeskRiskRadar', + 'selector' => '[data-app-component="helpdesk-risk-radar"]', + 'config_path' => 'components.helpdesk.riskRadar', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.risk-radar.view', + 'order' => 805, + ], + [ + 'key' => 'helpdesk-settings', + 'script' => 'modules/helpdesk/js/helpdesk-settings.js', + 'export' => 'initHelpdeskSettings', + 'selector' => '[data-app-component="helpdesk-settings"]', + 'config_path' => 'components.helpdesk.settings', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.settings.manage', + 'order' => 806, + ], + [ + 'key' => 'helpdesk-handover-domain-select', + 'script' => 'modules/helpdesk/js/handover-domain-select.js', + 'export' => 'initHandoverDomainSelect', + 'selector' => '#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]', + 'config_path' => 'components.helpdesk.handoverDomainSelect', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.handovers.create', + 'order' => 807, + ], + [ + 'key' => 'helpdesk-handover-schema-editor', + 'script' => 'modules/helpdesk/js/handover-schema-editor.js', + 'export' => 'initHandoverSchemaEditor', + 'selector' => '#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]', + 'config_path' => 'components.helpdesk.handoverSchemaEditor', + 'default_config' => [], + 'phase' => 'default', + 'permission' => 'helpdesk.software-products.manage', + 'order' => 808, + ], + ], ], 'authorization_policies' => [ diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index 180e419..25b2109 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -26,6 +26,7 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; ?>
- diff --git a/modules/helpdesk/pages/helpdesk/domain(default).phtml b/modules/helpdesk/pages/helpdesk/domain(default).phtml index 4e61bef..07f6f37 100644 --- a/modules/helpdesk/pages/helpdesk/domain(default).phtml +++ b/modules/helpdesk/pages/helpdesk/domain(default).phtml @@ -32,6 +32,7 @@ $stateVariant = $stateVariantMap[$domainState] ?? 'neutral'; ?>
t('Hotfix'), ]); ?> - diff --git a/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml b/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml index 89bc9f9..d327899 100644 --- a/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml +++ b/modules/helpdesk/pages/helpdesk/handovers/create(default).phtml @@ -71,7 +71,7 @@ $steps = [
-
- - - diff --git a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml index 6e28e79..5dbd277 100644 --- a/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/risk-radar/index(default).phtml @@ -13,6 +13,7 @@ $periodLabels = [ ]; ?>
- - diff --git a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml index 6843488..3a6ff57 100644 --- a/modules/helpdesk/pages/helpdesk/settings/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/settings/index(default).phtml @@ -63,7 +63,7 @@ $tenantHasOAuthSecret = (bool) ($tenantHasOAuthSecret ?? false); $tenantAuthMode = trim((string) ($tenantRow['auth_mode'] ?? HelpdeskSettingsGateway::AUTH_MODE_BASIC)); $tenantIsOAuth2 = $tenantAuthMode === HelpdeskSettingsGateway::AUTH_MODE_OAUTH2; ?> -
+
- - diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml index 9dea5fb..8716d54 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -27,6 +27,7 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
diff --git a/modules/helpdesk/pages/helpdesk/team/index(default).phtml b/modules/helpdesk/pages/helpdesk/team/index(default).phtml index 253fcba..47035d8 100644 --- a/modules/helpdesk/pages/helpdesk/team/index(default).phtml +++ b/modules/helpdesk/pages/helpdesk/team/index(default).phtml @@ -15,6 +15,7 @@ $periodLabels = [ ]; ?>
- - diff --git a/modules/helpdesk/pages/helpdesk/ticket(default).phtml b/modules/helpdesk/pages/helpdesk/ticket(default).phtml index 78ca8da..5af11ca 100644 --- a/modules/helpdesk/pages/helpdesk/ticket(default).phtml +++ b/modules/helpdesk/pages/helpdesk/ticket(default).phtml @@ -22,6 +22,7 @@ $backUrl = $ticketCustomerNo !== '' ? lurl('helpdesk/debitor/' . rawurlencode($t ?>
t('logged an activity'), 'on' => t('on'), ]); ?> - - diff --git a/modules/helpdesk/web/js/handover-domain-select.js b/modules/helpdesk/web/js/handover-domain-select.js index 8d4c3bd..870841e 100644 --- a/modules/helpdesk/web/js/handover-domain-select.js +++ b/modules/helpdesk/web/js/handover-domain-select.js @@ -1,19 +1,34 @@ /** * Dynamic domain select for handover wizard step 1. - * - * Reads config from data-* attributes on #wizard-domain-group: - * data-domains-url, data-text-initial, data-text-loading, - * data-text-select, data-text-empty, data-text-error, - * data-initial-debitor, data-initial-domain */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; -const group = document.getElementById('wizard-domain-group'); -const lookupContainer = document.querySelector('[data-app-lookup]'); -const domainSelect = document.getElementById('wizard-domain'); -const domainUrlInput = document.getElementById('wizard-domain-url'); -const feedback = document.getElementById('wizard-domain-feedback'); +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveGroup = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]')) { + return host; + } + return host.querySelector('#wizard-domain-group[data-app-component="helpdesk-handover-domain-select"]'); +}; + +export function initHandoverDomainSelect(root = document) { + const group = resolveGroup(root); + if (!group) { + return EMPTY_API; + } + + const scope = group.closest('form') || document; + const lookupContainer = scope.querySelector('[data-app-lookup]'); + const domainSelect = group.querySelector('#wizard-domain'); + const domainUrlInput = group.querySelector('#wizard-domain-url'); + const feedback = group.querySelector('#wizard-domain-feedback'); + if (!lookupContainer || !domainSelect) { + return EMPTY_API; + } -if (group && lookupContainer && domainSelect) { const domainsUrl = group.dataset.domainsUrl || ''; const textInitial = group.dataset.textInitial || ''; const textLoading = group.dataset.textLoading || ''; @@ -21,35 +36,48 @@ if (group && lookupContainer && domainSelect) { const textEmpty = group.dataset.textEmpty || ''; const textError = group.dataset.textError || ''; - function clearOptions() { + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; + + const clearOptions = () => { while (domainSelect.options.length > 0) { domainSelect.remove(0); } - } + }; - function addPlaceholder(text) { + const addPlaceholder = (text) => { const opt = document.createElement('option'); opt.value = ''; opt.textContent = text; domainSelect.appendChild(opt); - } + }; - function setFeedback(text, variant) { - if (!feedback) return; + const setFeedback = (text, variant) => { + if (!feedback) { + return; + } feedback.textContent = text; feedback.hidden = !text; feedback.dataset.variant = variant || ''; - } + }; - function resetDomain() { + const resetDomain = () => { clearOptions(); addPlaceholder(textInitial); domainSelect.disabled = true; - if (domainUrlInput) domainUrlInput.value = ''; + if (domainUrlInput) { + domainUrlInput.value = ''; + } setFeedback('', ''); - } + }; - async function loadDomains(debitorNo) { + const loadDomains = async (debitorNo) => { clearOptions(); addPlaceholder(textLoading); domainSelect.disabled = true; @@ -57,10 +85,9 @@ if (group && lookupContainer && domainSelect) { setFeedback('', ''); try { - const res = await fetch(domainsUrl + '?debitor_no=' + encodeURIComponent(debitorNo), { - headers: { 'Accept': 'application/json' }, + const data = await getJson(`${domainsUrl}?debitor_no=${encodeURIComponent(debitorNo)}`, { + signal: requestController.signal, }); - const data = await res.json(); const items = Array.isArray(data) ? data : []; clearOptions(); @@ -72,7 +99,7 @@ if (group && lookupContainer && domainSelect) { return; } - items.forEach(function (item) { + items.forEach((item) => { const opt = document.createElement('option'); opt.value = item.value || ''; opt.textContent = item.label || item.value || ''; @@ -80,45 +107,53 @@ if (group && lookupContainer && domainSelect) { domainSelect.appendChild(opt); }); domainSelect.disabled = false; - } catch { - clearOptions(); - addPlaceholder(textSelect); - domainSelect.disabled = true; - setFeedback(textError, 'error'); + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + clearOptions(); + addPlaceholder(textSelect); + domainSelect.disabled = true; + setFeedback(textError, 'error'); + } } finally { group.removeAttribute('aria-busy'); } - } + }; - domainSelect.addEventListener('change', function () { + on(domainSelect, 'change', () => { const selected = domainSelect.options[domainSelect.selectedIndex]; if (domainUrlInput) { domainUrlInput.value = selected?.dataset.url || ''; } }); - lookupContainer.addEventListener('lookup:select', function (e) { - const debitorNo = e.detail?.value || ''; + on(lookupContainer, 'lookup:select', (event) => { + const debitorNo = event.detail?.value || ''; if (debitorNo) { - loadDomains(debitorNo); + void loadDomains(debitorNo); } else { resetDomain(); } }); - lookupContainer.addEventListener('lookup:clear', function () { + on(lookupContainer, 'lookup:clear', () => { resetDomain(); }); - // Restore selection if returning to step 1 with session data const initialDebitor = group.dataset.initialDebitor || ''; const initialDomain = group.dataset.initialDomain || ''; if (initialDebitor) { - loadDomains(initialDebitor).then(function () { + void loadDomains(initialDebitor).then(() => { if (initialDomain && !domainSelect.disabled) { domainSelect.value = initialDomain; domainSelect.dispatchEvent(new Event('change')); } }); } + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + }, + }; } diff --git a/modules/helpdesk/web/js/handover-schema-editor.js b/modules/helpdesk/web/js/handover-schema-editor.js index 1493bde..ea5e261 100644 --- a/modules/helpdesk/web/js/handover-schema-editor.js +++ b/modules/helpdesk/web/js/handover-schema-editor.js @@ -4,15 +4,19 @@ * Renders a structured field list from JSON and serializes changes * back to a hidden input on form submit. */ +import { resolveHost } from '/js/core/app-dom.js'; const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; +const EMPTY_API = Object.freeze({ destroy: () => {} }); -/** @type {HTMLElement|null} */ -const container = document.getElementById('handover-schema-editor'); -if (container) { - init(container); -} +const resolveEditor = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]')) { + return host; + } + return host.querySelector('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]'); +}; /** * Remove all child nodes from an element (safe alternative to setting markup). @@ -25,10 +29,19 @@ function clearElement(el) { } function init(root) { + 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 t = JSON.parse(root.dataset.translations || '{}'); const initialFields = JSON.parse(root.dataset.initialFields || '[]'); - const hiddenInput = document.getElementById('handover-schema-json'); - const previewContainer = document.getElementById('handover-schema-preview'); + const scope = root.closest('.app-details-container') || document; + const hiddenInput = scope.querySelector('#handover-schema-json'); + const previewContainer = scope.querySelector('#handover-schema-preview'); const form = hiddenInput?.closest('form'); /** @type {Array} */ @@ -38,9 +51,7 @@ function init(root) { renderPreview(); syncHiddenInput(); - if (form) { - form.addEventListener('submit', () => syncHiddenInput()); - } + on(form, 'submit', () => syncHiddenInput()); function render() { clearElement(root); @@ -56,7 +67,7 @@ function init(root) { addButton.className = 'secondary outline'; addButton.appendChild(createIcon('bi-plus-lg')); addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field'))); - addButton.addEventListener('click', () => { + on(addButton, 'click', () => { fields.push({ type: 'text', key: '', label: '', required: false, options: [] }); render(); notifyChange(); @@ -86,7 +97,7 @@ function init(root) { button.setAttribute('data-tooltip', t.add_field || 'Add field'); button.setAttribute('aria-label', t.add_field || 'Add field'); button.appendChild(createIcon('bi-plus')); - button.addEventListener('click', () => { + on(button, 'click', () => { fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] }); render(); notifyChange(); @@ -102,8 +113,8 @@ function init(root) { const row = document.createElement('div'); row.className = 'handover-schema-row'; row.dataset.fieldIndex = index; - row.addEventListener('mouseenter', () => highlightPreview(index, true)); - row.addEventListener('mouseleave', () => highlightPreview(index, false)); + on(row, 'mouseenter', () => highlightPreview(index, true)); + on(row, 'mouseleave', () => highlightPreview(index, false)); // --- Header: "Feld #N" title + action icons --- const header = document.createElement('div'); @@ -162,7 +173,7 @@ function init(root) { if (ft === field.type) opt.selected = true; typeSelect.appendChild(opt); }); - typeSelect.addEventListener('change', () => { + on(typeSelect, 'change', () => { field.type = typeSelect.value; if (DISPLAY_ONLY_TYPES.includes(field.type)) { delete field.key; @@ -194,7 +205,7 @@ function init(root) { } labelInput.value = field.label || ''; labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1)); - labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); }); + on(labelInput, 'input', () => { field.label = labelInput.value; notifyChange(); }); labelGroup.appendChild(labelInput); body.appendChild(labelGroup); @@ -209,7 +220,7 @@ function init(root) { const reqInput = document.createElement('input'); reqInput.type = 'checkbox'; reqInput.checked = !!field.required; - reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); }); + on(reqInput, 'change', () => { field.required = reqInput.checked; notifyChange(); }); reqLabel.appendChild(reqInput); reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required'))); reqRow.appendChild(reqLabel); @@ -233,7 +244,7 @@ function init(root) { addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option'); addOptButton.setAttribute('aria-label', t.add_option || 'Add option'); addOptButton.appendChild(createIcon('bi-plus-lg')); - addOptButton.addEventListener('click', () => { + on(addOptButton, 'click', () => { field.options.push({ value: '', label: '' }); render(); notifyChange(); @@ -252,7 +263,7 @@ function init(root) { lblInput.value = opt.label || ''; lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1); lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1)); - lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); }); + on(lblInput, 'input', () => { opt.label = lblInput.value; notifyChange(); }); optRow.appendChild(lblInput); optRow.appendChild(createIconButton( @@ -294,7 +305,7 @@ function init(root) { button.className = buttonClass; button.setAttribute('aria-label', ariaLabel); button.appendChild(createIcon(iconClass)); - button.addEventListener('click', onClick); + on(button, 'click', onClick); return button; } @@ -337,7 +348,7 @@ function init(root) { if (field.type === 'checkbox') { const input = document.createElement('input'); input.type = 'checkbox'; - input.addEventListener('click', (e) => e.preventDefault()); + on(input, 'click', (e) => e.preventDefault()); group.appendChild(input); const label = document.createElement('label'); label.textContent = field.label || ''; @@ -408,4 +419,19 @@ function init(root) { hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); } } + + return { + destroy: () => { + listenerController.abort(); + }, + }; +} + +export function initHandoverSchemaEditor(root = document) { + const container = resolveEditor(root); + if (!container) { + return EMPTY_API; + } + + return init(container); } diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index eea14e0..6ee8002 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -7,6 +7,8 @@ * - Aside: cross-ticket communication feed */ import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; import { readPageConfig } from '/js/core/app-page-config.js'; import { renderCommunicationError, @@ -15,12 +17,35 @@ import { } from './helpdesk-communication.js'; import { renderEmptyState } from './helpdesk-empty-state.js'; -const container = document.querySelector('.app-details-container[data-customer-no]'); -if (container) { - init(container); +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-detail"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-detail"]'); +}; + +export function initHelpdeskDetail(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } + + return init(container); } function init(container) { + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; + const customerNo = container.dataset.customerNo || ''; const customerName = container.dataset.customerName || ''; const supportDashboardUrl = container.dataset.supportDashboardUrl || ''; @@ -37,7 +62,9 @@ function init(container) { return normalized === '1' || normalized === 'true' || normalized === 'yes'; })(); - if (!customerNo || !customerName) return; + if (!customerNo || !customerName) { + return EMPTY_API; + } const i18nEl = document.getElementById('helpdesk-i18n'); const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; @@ -69,8 +96,9 @@ function init(container) { supportDashboardPromise = Promise.resolve({ ok: false, error: 'No support dashboard endpoint configured' }); } else { const params = buildBaseParams(); - supportDashboardPromise = fetch(supportDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + supportDashboardPromise = getJson(`${supportDashboardUrl}?${params.toString()}`, { + signal: requestController.signal, + }) .catch(() => ({ ok: false, error: 'Network error' })); } } @@ -84,8 +112,9 @@ function init(container) { salesDashboardPromise = Promise.resolve({ ok: false, error: 'No sales dashboard endpoint configured' }); } else { const params = buildBaseParams(); - salesDashboardPromise = fetch(salesDashboardUrl + '?' + params.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + salesDashboardPromise = getJson(`${salesDashboardUrl}?${params.toString()}`, { + signal: requestController.signal, + }) .catch(() => ({ ok: false, error: 'Network error' })); } } @@ -100,8 +129,9 @@ function init(container) { } else { const params = new URLSearchParams({ customerNo }); if (refreshFromUrl) params.set('refresh', '1'); - meetingsPromise = fetch(meetingsUrl + '?' + params.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + meetingsPromise = getJson(`${meetingsUrl}?${params.toString()}`, { + signal: requestController.signal, + }) .catch(() => ({ ok: false, error: 'Network error' })); } } @@ -111,8 +141,9 @@ function init(container) { function fetchContacts() { if (!contactsPromise) { const params = buildBaseParams(); - contactsPromise = fetch(contactsUrl + '?' + params.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + contactsPromise = getJson(`${contactsUrl}?${params.toString()}`, { + signal: requestController.signal, + }) .catch(() => ({ ok: false, error: 'Network error' })); } @@ -134,8 +165,9 @@ function init(container) { url.searchParams.set('refresh', '1'); } - ticketCategoriesPromise = fetch(url.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + ticketCategoriesPromise = getJson(url.toString(), { + signal: requestController.signal, + }) .catch(() => ({ ok: false, categories: [] })); } @@ -148,8 +180,9 @@ function init(container) { debitorCommunicationPromise = Promise.resolve({ ok: false, error: 'No communication endpoint configured' }); } else { const params = buildBaseParams(); - debitorCommunicationPromise = fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }) - .then(r => r.json()) + debitorCommunicationPromise = getJson(`${communicationUrl}?${params.toString()}`, { + signal: requestController.signal, + }) .catch(() => ({ ok: false, error: 'Network error' })); } } @@ -258,7 +291,9 @@ function init(container) { // Fetch type options from first data response const typeOptionsUrl = new URL(gridDataUrl.toString()); typeOptionsUrl.searchParams.set('limit', '1'); - const typeResp = await fetch(typeOptionsUrl.toString()).then((r) => r.json()).catch(() => null); + const typeResp = await getJson(typeOptionsUrl.toString(), { + signal: requestController.signal, + }).catch(() => null); const typeOptions = Array.isArray(typeResp?.type_options) ? typeResp.type_options : []; hydrateContactTypeFilter(contactsConfig, typeOptions); @@ -556,7 +591,7 @@ function init(container) { document.body.insertAdjacentHTML('beforeend', dialogHtml); const dialog = document.getElementById('helpdesk-contract-dialog'); - dialog.addEventListener('click', (e) => { + on(dialog, 'click', (e) => { if (e.target.closest('[data-dialog-close]')) { dialog.close(); dialog.remove(); @@ -568,12 +603,12 @@ function init(container) { dialog.remove(); } }); - dialog.addEventListener('close', () => { dialog.remove(); }); + on(dialog, 'close', () => { dialog.remove(); }); dialog.showModal(); } // Delegate click on contract cards and timeline rows - container.addEventListener('click', (e) => { + on(container, 'click', (e) => { const target = e.target.closest('.helpdesk-contract-card[data-contract-index], .helpdesk-tl-row-clickable[data-contract-index]'); if (!target) return; const index = Number(target.dataset.contractIndex); @@ -583,7 +618,7 @@ function init(container) { }); // KPI tile click → scroll to ticket list and apply filter or sort - container.addEventListener('click', (e) => { + on(container, 'click', (e) => { const kpi = e.target.closest('.helpdesk-kpi-clickable[data-kpi-filter], .helpdesk-kpi-clickable[data-kpi-sort]'); if (!kpi) return; @@ -662,7 +697,7 @@ function init(container) { } } - container.addEventListener('click', (e) => { + on(container, 'click', (e) => { if (e.target.closest('a')) return; const row = e.target.closest('.app-clickable-row[data-href]'); if (row) { @@ -673,7 +708,7 @@ function init(container) { } }); - container.addEventListener('keydown', (e) => { + on(container, 'keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const row = e.target.closest('.app-clickable-row[data-href]'); if (row) { @@ -1280,8 +1315,7 @@ function init(container) { params.set('periodDays', String(periodDays)); const url = controllingDashboardUrl + '?' + params.toString(); try { - const res = await fetch(url, { credentials: 'same-origin' }); - return await res.json(); + return await getJson(url, { signal: requestController.signal }); } catch { return null; } @@ -1521,18 +1555,16 @@ function init(container) { // Period selector (radio inputs) const periodSelector = document.getElementById('controlling-period-selector'); - if (periodSelector) { - periodSelector.addEventListener('change', (e) => { - const radio = e.target.closest('input[name="controlling-period"]'); - if (!radio) return; - const period = parseInt(radio.value, 10); - if (!period || period === controllingCurrentPeriod) return; + on(periodSelector, 'change', (e) => { + const radio = e.target.closest('input[name="controlling-period"]'); + if (!radio) return; + const period = parseInt(radio.value, 10); + if (!period || period === controllingCurrentPeriod) return; - controllingCurrentPeriod = period; - controllingTabRendered = false; - renderControllingTab(period); - }); - } + controllingCurrentPeriod = period; + controllingTabRendered = false; + void renderControllingTab(period); + }); const tabPanels = container.querySelectorAll('[data-tab-panel]'); const observer = new MutationObserver(() => { @@ -1558,18 +1590,28 @@ function init(container) { const refreshButton = container.querySelector('button[name="support-refresh"]') || document.getElementById('support-refresh-button'); - if (refreshButton) { - refreshButton.addEventListener('click', () => { - refreshButton.setAttribute('aria-busy', 'true'); - refreshButton.setAttribute('disabled', 'disabled'); + on(refreshButton, 'click', () => { + refreshButton.setAttribute('aria-busy', 'true'); + refreshButton.setAttribute('disabled', 'disabled'); - const url = new URL(window.location.href); - url.searchParams.set('refresh', '1'); - window.location.assign(url.toString()); - }); - } + const url = new URL(window.location.href); + url.searchParams.set('refresh', '1'); + window.location.assign(url.toString()); + }); renderSupportTab(); - initTicketsGrid(); - renderDebitorCommunicationAside(); + void initTicketsGrid(); + void renderDebitorCommunicationAside(); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + observer.disconnect(); + const dialog = document.getElementById('helpdesk-contract-dialog'); + if (dialog) { + dialog.remove(); + } + }, + }; } diff --git a/modules/helpdesk/web/js/helpdesk-domain-detail.js b/modules/helpdesk/web/js/helpdesk-domain-detail.js index 2c27e2d..007a125 100644 --- a/modules/helpdesk/web/js/helpdesk-domain-detail.js +++ b/modules/helpdesk/web/js/helpdesk-domain-detail.js @@ -8,6 +8,8 @@ * into innerHTML. This matches the pattern used in helpdesk-detail.js * and other helpdesk JS modules in this codebase. */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; import { renderEmptyState } from './helpdesk-empty-state.js'; function esc(str) { @@ -30,12 +32,18 @@ const STATUS_LABEL_MAP = { archived: 'Archived', }; -const container = document.querySelector('.app-details-container[data-domain-no]'); -if (container) { - init(container); -} +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-domain-detail"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-domain-detail"]'); +}; function init(container) { + const requestController = new AbortController(); const domainNo = container.dataset.domainNo || ''; const customerNo = container.dataset.customerNo || ''; const detailUrl = container.dataset.detailUrl || ''; @@ -58,8 +66,9 @@ function init(container) { try { const params = new URLSearchParams({ domainNo, customerNo }); - const res = await fetch(detailUrl + '?' + params, { credentials: 'same-origin' }); - const data = await res.json(); + const data = await getJson(`${detailUrl}?${params.toString()}`, { + signal: requestController.signal, + }); if (!data.ok) { showError(data.error || t('Network error — please try again')); @@ -74,7 +83,10 @@ function init(container) { if (loadingEl) loadingEl.hidden = true; if (contentEl) contentEl.hidden = false; - } catch { + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } showError(t('Network error — please try again')); } } @@ -317,5 +329,18 @@ function init(container) { // safe-html: all content built from esc()-escaped values el.innerHTML = items; } - + return { + destroy: () => { + requestController.abort(); + }, + }; +} + +export function initHelpdeskDomainDetail(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } + + return init(container); } diff --git a/modules/helpdesk/web/js/helpdesk-risk-radar.js b/modules/helpdesk/web/js/helpdesk-risk-radar.js index 6e3998a..11281e2 100644 --- a/modules/helpdesk/web/js/helpdesk-risk-radar.js +++ b/modules/helpdesk/web/js/helpdesk-risk-radar.js @@ -1,9 +1,34 @@ /** * Risk Radar — customer portfolio risk view. */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; + +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-risk-radar"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-risk-radar"]'); +}; + +export function initHelpdeskRiskRadar(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } + + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; -const container = document.querySelector('.app-details-container[data-risk-radar-url]'); -if (container) { const dataUrl = container.dataset.riskRadarUrl; const debitorBaseUrl = container.dataset.debitorBaseUrl || ''; const d = (key, fallback) => container.dataset[key] || fallback; @@ -22,35 +47,29 @@ if (container) { const dialogClose = dialog ? dialog.querySelector('.close') : null; let currentPeriod = 90; - let lastData = null; - function showState(state) { + const showState = (state) => { if (elLoading) elLoading.hidden = state !== 'loading'; if (elError) elError.hidden = state !== 'error'; if (elEmpty) elEmpty.hidden = state !== 'empty'; if (elContent) elContent.hidden = state !== 'success'; - } + }; - function h(tag, cls, text) { - const e = document.createElement(tag); - if (cls) e.className = cls; - if (text !== undefined) e.textContent = text; - return e; - } + const h = (tag, cls, text) => { + const element = document.createElement(tag); + if (cls) element.className = cls; + if (text !== undefined) element.textContent = text; + return element; + }; - function levelClass(level) { - return 'helpdesk-risk-level-' + level; - } + const levelClass = (level) => `helpdesk-risk-level-${level}`; - function formatAge(hours) { - if (hours < 24) return hours + 'h'; - return Math.floor(hours / 24) + 'd'; - } + const formatAge = (hours) => { + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; + }; - /** - * Dimension label lookup from data attributes. - */ - function dimLabel(id) { + const dimLabel = (id) => { const map = { 'open_pressure': d('labelOpenPressure', 'Open pressure'), 'trend': d('labelTrend', 'Trend'), @@ -58,57 +77,16 @@ if (container) { 'inactivity': d('labelInactivity', 'Inactivity'), }; return map[id] || id; - } + }; - /** - * Build a single customer risk card — accent border + score pill. - */ - function buildCard(customer) { - const card = h('article', 'helpdesk-risk-card ' + levelClass(customer.risk_level)); - card.setAttribute('aria-label', customer.customer_name || customer.customer_no); - - // Row 1: Name + Level on left, score pill on right - const header = h('div', 'helpdesk-risk-card-header'); - const info = h('div', 'helpdesk-risk-card-info'); - info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no)); - const levelLabels = { high: d('labelHigh', 'High risk'), medium: d('labelMedium', 'Medium risk'), low: d('labelLow', 'Low risk') }; - info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || '')); - header.appendChild(info); - const scoreEl = h('span', 'helpdesk-risk-card-score ' + levelClass(customer.risk_level), String(customer.risk_score)); - header.appendChild(scoreEl); - card.appendChild(header); - - // Row 2: One-line key facts separated by · - const kpis = customer.kpis || {}; - const parts = []; - if (kpis.open > 0) parts.push(kpis.open + ' ' + d('labelOpen', 'open')); - if (kpis.critical > 0) parts.push(kpis.critical + ' ' + d('labelCritical', 'critical')); - if (kpis.sla_overdue > 0) parts.push(kpis.sla_overdue + ' SLA'); - if (kpis.net_flow > 0) parts.push('↑' + kpis.net_flow); - else if (kpis.net_flow < 0) parts.push('↓' + Math.abs(kpis.net_flow)); - - if (parts.length > 0) { - card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · '))); + const openDetail = (customer) => { + if (!dialog || !dialogTitle || !dialogBody) { + return; } - // Click/keyboard to open detail - card.tabIndex = 0; - card.addEventListener('click', () => openDetail(customer)); - card.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(customer); } }); - - return card; - } - - /** - * Open detail dialog for a customer. - */ - function openDetail(customer) { - if (!dialog || !dialogTitle || !dialogBody) return; - - dialogTitle.textContent = (customer.customer_name || customer.customer_no) + ' — ' + customer.risk_score + '/100'; + dialogTitle.textContent = `${customer.customer_name || customer.customer_no} — ${customer.risk_score}/100`; dialogBody.replaceChildren(); - // Dimensions as points breakdown: "18 / 35" const dims = customer.dimensions || []; const dimTable = h('table', 'helpdesk-risk-detail-tickets'); const dimHead = h('thead'); @@ -122,7 +100,7 @@ if (container) { const tr = h('tr'); tr.appendChild(h('td', '', dimLabel(dim.id))); const points = Math.round(dim.weighted_points || 0); - tr.appendChild(h('td', '', points + ' / ' + dim.weight)); + tr.appendChild(h('td', '', `${points} / ${dim.weight}`)); dimBody.appendChild(tr); } const totalTr = h('tr'); @@ -132,16 +110,15 @@ if (container) { totalTr.appendChild(totalLabel); const totalVal = h('td', ''); totalVal.style.fontWeight = '700'; - totalVal.textContent = customer.risk_score + ' / 100'; + totalVal.textContent = `${customer.risk_score} / 100`; totalTr.appendChild(totalVal); dimBody.appendChild(totalTr); dimTable.appendChild(dimBody); dialogBody.appendChild(dimTable); - // Open tickets list const tickets = customer.open_tickets || []; if (tickets.length > 0) { - dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', d('labelTickets', 'Tickets') + ' (' + tickets.length + ')')); + dialogBody.appendChild(h('h3', 'helpdesk-support-section-title', `${d('labelTickets', 'Tickets')} (${tickets.length})`)); const table = h('table', 'helpdesk-risk-detail-tickets'); const thead = h('thead'); const headRow = h('tr'); @@ -152,19 +129,18 @@ if (container) { thead.appendChild(headRow); table.appendChild(thead); const tbody = h('tbody'); - for (const t of tickets) { - const tr = h('tr', t.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : ''); - tr.appendChild(h('td', '', t.no)); - tr.appendChild(h('td', '', t.escalation_code || '—')); - tr.appendChild(h('td', '', t.state)); - tr.appendChild(h('td', '', formatAge(t.age_hours))); + for (const ticket of tickets) { + const tr = h('tr', ticket.age_hours > 48 ? 'helpdesk-risk-detail-ticket-critical' : ''); + tr.appendChild(h('td', '', ticket.no)); + tr.appendChild(h('td', '', ticket.escalation_code || '—')); + tr.appendChild(h('td', '', ticket.state)); + tr.appendChild(h('td', '', formatAge(ticket.age_hours))); tbody.appendChild(tr); } table.appendChild(tbody); dialogBody.appendChild(table); } - // Link to debitor if (debitorBaseUrl && customer.customer_no) { const link = h('a', 'helpdesk-risk-detail-link', customer.customer_name || customer.customer_no); link.href = debitorBaseUrl + encodeURIComponent(customer.customer_no); @@ -174,19 +150,70 @@ if (container) { } dialog.showModal(); - } + }; - // Dialog close handlers - if (dialogClose) dialogClose.addEventListener('click', () => dialog.close()); - if (dialog) dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.close(); }); + const buildCard = (customer) => { + const card = h('article', `helpdesk-risk-card ${levelClass(customer.risk_level)}`); + card.setAttribute('aria-label', customer.customer_name || customer.customer_no); - function renderCards(customers) { - if (!elCards) return; + const header = h('div', 'helpdesk-risk-card-header'); + const info = h('div', 'helpdesk-risk-card-info'); + info.appendChild(h('span', 'helpdesk-risk-card-name', customer.customer_name || customer.customer_no)); + const levelLabels = { + high: d('labelHigh', 'High risk'), + medium: d('labelMedium', 'Medium risk'), + low: d('labelLow', 'Low risk'), + }; + info.appendChild(h('span', 'helpdesk-risk-card-level', levelLabels[customer.risk_level] || '')); + header.appendChild(info); + const scoreEl = h('span', `helpdesk-risk-card-score ${levelClass(customer.risk_level)}`, String(customer.risk_score)); + header.appendChild(scoreEl); + card.appendChild(header); + + const kpis = customer.kpis || {}; + const parts = []; + if (kpis.open > 0) parts.push(`${kpis.open} ${d('labelOpen', 'open')}`); + if (kpis.critical > 0) parts.push(`${kpis.critical} ${d('labelCritical', 'critical')}`); + if (kpis.sla_overdue > 0) parts.push(`${kpis.sla_overdue} SLA`); + if (kpis.net_flow > 0) parts.push(`↑${kpis.net_flow}`); + else if (kpis.net_flow < 0) parts.push(`↓${Math.abs(kpis.net_flow)}`); + + if (parts.length > 0) { + card.appendChild(h('p', 'helpdesk-risk-card-facts', parts.join(' · '))); + } + + card.tabIndex = 0; + on(card, 'click', () => openDetail(customer)); + on(card, 'keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openDetail(customer); + } + }); + + return card; + }; + + on(dialogClose, 'click', () => { + dialog?.close(); + }); + on(dialog, 'click', (event) => { + if (event.target === dialog) { + dialog.close(); + } + }); + + const renderCards = (customers) => { + if (!elCards) { + return; + } elCards.replaceChildren(); - for (const c of customers) elCards.appendChild(buildCard(c)); - } + for (const customer of customers) { + elCards.appendChild(buildCard(customer)); + } + }; - async function fetchData(refresh = false) { + const fetchData = async (refresh = false) => { showState('loading'); if (elTruncated) elTruncated.hidden = true; @@ -194,38 +221,48 @@ if (container) { if (refresh) params.set('refresh', '1'); try { - const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); - const json = await res.json(); - + const json = await getJson(`${dataUrl}?${params.toString()}`, { + signal: requestController.signal, + }); if (!json.ok) { showState('error'); return; } if (!json.customers || json.customers.length === 0) { showState('empty'); return; } - lastData = json; renderCards(json.customers); showState('success'); if (json.meta && json.meta.truncated && elTruncated) { elTruncated.hidden = false; } - } catch { + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } showState('error'); } - } + }; - if (periodSelector) { - periodSelector.addEventListener('change', (e) => { - const radio = e.target.closest('input[name="radar-period"]'); - if (!radio) return; - const period = parseInt(radio.value, 10); - if (!period || period === currentPeriod) return; - currentPeriod = period; - fetchData(); - }); - } + on(periodSelector, 'change', (event) => { + const radio = event.target.closest('input[name="radar-period"]'); + if (!radio) return; + const period = parseInt(radio.value, 10); + if (!period || period === currentPeriod) return; + currentPeriod = period; + void fetchData(); + }); - if (elRefresh) { - elRefresh.addEventListener('click', () => fetchData(true)); - } + on(elRefresh, 'click', () => { + void fetchData(true); + }); - fetchData(); + void fetchData(); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + if (dialog?.open) { + dialog.close(); + } + }, + }; } diff --git a/modules/helpdesk/web/js/helpdesk-settings.js b/modules/helpdesk/web/js/helpdesk-settings.js index 6ea81e0..681b234 100644 --- a/modules/helpdesk/web/js/helpdesk-settings.js +++ b/modules/helpdesk/web/js/helpdesk-settings.js @@ -2,133 +2,143 @@ * Helpdesk settings page — auth mode toggle and connection test. */ import { showAsyncFlash } from '/js/components/app-async-flash.js'; +import { postForm } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; -const settingsForm = document.getElementById('helpdesk-settings-form'); +const EMPTY_API = Object.freeze({ destroy: () => {} }); -const getMessage = (key, fallback) => { - if (!settingsForm || !settingsForm.dataset) { +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-settings"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-settings"]'); +}; + +const readMessage = (form, key, fallback) => { + if (!form || !form.dataset) { return fallback; } - - return settingsForm.dataset[key] || fallback; + return form.dataset[key] || fallback; }; -// Toggle OAuth2 vs Basic Auth field visibility -const authModeSelect = document.getElementById('auth_mode'); -const basicFields = document.getElementById('helpdesk-basic-auth-fields'); -const oauth2Fields = document.getElementById('helpdesk-oauth2-fields'); - -const toggleAuthFields = () => { - if (!authModeSelect || !basicFields || !oauth2Fields) { - return; +export function initHelpdeskSettings(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; } - const isOAuth2 = authModeSelect.value === 'oauth2'; - basicFields.toggleAttribute('hidden', isOAuth2); - oauth2Fields.toggleAttribute('hidden', !isOAuth2); -}; + const settingsForm = container.querySelector('#helpdesk-settings-form'); + if (!settingsForm) { + return EMPTY_API; + } -if (authModeSelect) { - authModeSelect.addEventListener('change', toggleAuthFields); + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; + + const authModeSelect = settingsForm.querySelector('#auth_mode'); + const basicFields = settingsForm.querySelector('#helpdesk-basic-auth-fields'); + const oauth2Fields = settingsForm.querySelector('#helpdesk-oauth2-fields'); + + const testButton = settingsForm.querySelector('#helpdesk-test-connection-button'); + const testResult = settingsForm.querySelector('#helpdesk-connection-test-result'); + + const overrideToggle = settingsForm.querySelector('#helpdesk-override-toggle'); + const tenantFields = settingsForm.querySelector('#helpdesk-tenant-fields'); + const saveTargetInput = settingsForm.querySelector('#helpdesk-settings-save-target'); + + const tenantAuthModeSelect = settingsForm.querySelector('#tenant_auth_mode'); + const tenantBasicFields = settingsForm.querySelector('#helpdesk-tenant-basic-auth-fields'); + const tenantOauth2Fields = settingsForm.querySelector('#helpdesk-tenant-oauth2-fields'); + + const setTestResult = (variant, message) => { + if (!testResult) { + return; + } + testResult.textContent = message; + testResult.dataset.variant = variant; + testResult.hidden = !message; + }; + + const setButtonLoadingState = (isLoading) => { + if (!testButton) { + return; + } + + const defaultLabel = testButton.dataset.labelDefault || readMessage(settingsForm, 'testDefaultLabel', 'Test connection'); + const loadingLabel = testButton.dataset.labelLoading || readMessage(settingsForm, 'testLoadingLabel', 'Testing...'); + + testButton.disabled = isLoading; + testButton.toggleAttribute('aria-busy', isLoading); + testButton.textContent = isLoading ? loadingLabel : defaultLabel; + }; + + const toggleAuthFields = () => { + if (!authModeSelect || !basicFields || !oauth2Fields) { + return; + } + + const isOAuth2 = authModeSelect.value === 'oauth2'; + basicFields.toggleAttribute('hidden', isOAuth2); + oauth2Fields.toggleAttribute('hidden', !isOAuth2); + }; + + on(authModeSelect, 'change', toggleAuthFields); toggleAuthFields(); -} -// Connection test button -const testButton = document.getElementById('helpdesk-test-connection-button'); -const testResult = document.getElementById('helpdesk-connection-test-result'); - -const setTestResult = (variant, message) => { - if (!testResult) { - return; - } - - testResult.textContent = message; - testResult.dataset.variant = variant; - testResult.hidden = !message; -}; - -const setButtonLoadingState = (isLoading) => { - if (!testButton) { - return; - } - - const defaultLabel = testButton.dataset.labelDefault || getMessage('testDefaultLabel', 'Test connection'); - const loadingLabel = testButton.dataset.labelLoading || getMessage('testLoadingLabel', 'Testing...'); - - testButton.disabled = isLoading; - testButton.toggleAttribute('aria-busy', isLoading); - testButton.textContent = isLoading ? loadingLabel : defaultLabel; -}; - -const parseJsonSafely = async (response) => { - try { - return await response.json(); - } catch { - return null; - } -}; - -// Tenant override toggle — show/hide tenant fields + set save_target -const overrideToggle = document.getElementById('helpdesk-override-toggle'); -const tenantFields = document.getElementById('helpdesk-tenant-fields'); -const saveTargetInput = document.getElementById('helpdesk-settings-save-target'); - -const tenantAuthModeSelect = document.getElementById('tenant_auth_mode'); -const tenantBasicFields = document.getElementById('helpdesk-tenant-basic-auth-fields'); -const tenantOauth2Fields = document.getElementById('helpdesk-tenant-oauth2-fields'); - -if (overrideToggle && tenantFields) { - overrideToggle.addEventListener('change', () => { - tenantFields.hidden = !overrideToggle.checked; + on(overrideToggle, 'change', () => { + if (tenantFields) { + tenantFields.hidden = !overrideToggle.checked; + } }); -} -// On form submit, set save_target based on active tab -if (settingsForm && saveTargetInput) { - settingsForm.addEventListener('submit', () => { + on(settingsForm, 'submit', () => { + if (!saveTargetInput) { + return; + } const tenantPanel = settingsForm.querySelector('[data-tab-panel="tenant"]:not([hidden])'); saveTargetInput.value = tenantPanel ? 'tenant' : 'global'; }); -} -// Tenant auth mode toggle -if (tenantAuthModeSelect && tenantBasicFields && tenantOauth2Fields) { - tenantAuthModeSelect.addEventListener('change', () => { + on(tenantAuthModeSelect, 'change', () => { + if (!tenantBasicFields || !tenantOauth2Fields || !tenantAuthModeSelect) { + return; + } const isOAuth2 = tenantAuthModeSelect.value === 'oauth2'; tenantBasicFields.hidden = isOAuth2; tenantOauth2Fields.hidden = !isOAuth2; }); -} -if (testButton) { - testButton.addEventListener('click', async () => { + on(testButton, 'click', async () => { setButtonLoadingState(true); - setTestResult('info', getMessage('testLoadingLabel', 'Testing...')); + setTestResult('info', readMessage(settingsForm, 'testLoadingLabel', 'Testing...')); try { + const csrfInput = settingsForm.querySelector('input[name="csrf_token"]'); const csrfMeta = document.querySelector('meta[name="csrf-token"]'); - const csrfInput = document.querySelector('input[name="csrf_token"]'); const csrfToken = csrfInput?.value || csrfMeta?.content || ''; - const formData = new FormData(); + const body = new URLSearchParams(); if (csrfToken) { - formData.append('csrf_token', csrfToken); + body.set('csrf_token', csrfToken); } - const response = await fetch( + const data = await postForm( new URL('helpdesk/settings/test-connection-data', document.baseURI).href, - { - method: 'POST', - body: formData, - credentials: 'same-origin', - }, + body, + { signal: requestController.signal } ); - const data = await parseJsonSafely(response); - const successMessage = getMessage('testSuccessMessage', 'Connection successful'); - const defaultErrorMessage = getMessage('testErrorMessage', 'Connection failed'); + const successMessage = readMessage(settingsForm, 'testSuccessMessage', 'Connection successful'); + const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed'); - if (response.ok && data?.ok) { + if (data?.ok === true) { const message = data.message || successMessage; setTestResult('success', message); showAsyncFlash('success', message); @@ -136,23 +146,36 @@ if (testButton) { let message = defaultErrorMessage; if (data?.error) { message = data.error; - } else if (!response.ok) { - message = `${defaultErrorMessage} (HTTP ${response.status})`; } - if (data?.debug?.http_code) { message += ` (HTTP ${data.debug.http_code})`; } - setTestResult('warning', message); showAsyncFlash('error', message); } - } catch { - const message = getMessage('testErrorMessage', 'Connection failed'); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + + const defaultErrorMessage = readMessage(settingsForm, 'testErrorMessage', 'Connection failed'); + let message = defaultErrorMessage; + const status = error?.status ?? 0; + if (status > 0) { + message = `${defaultErrorMessage} (HTTP ${status})`; + } + setTestResult('warning', message); showAsyncFlash('error', message); } finally { setButtonLoadingState(false); } }); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + }, + }; } diff --git a/modules/helpdesk/web/js/helpdesk-team.js b/modules/helpdesk/web/js/helpdesk-team.js index fa86d18..a581549 100644 --- a/modules/helpdesk/web/js/helpdesk-team.js +++ b/modules/helpdesk/web/js/helpdesk-team.js @@ -3,9 +3,34 @@ * Current tab: per-agent widget with section title + ticket table. * Performance tab: compact agent rows with resolved counts. */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; + +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-app-component="helpdesk-team"]')) { + return host; + } + return host.querySelector('.app-details-container[data-app-component="helpdesk-team"]'); +}; + +export function initHelpdeskTeam(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } + + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; -const container = document.querySelector('.app-details-container[data-team-workload-url]'); -if (container) { const dataUrl = container.dataset.teamWorkloadUrl; const labelQueue = container.dataset.labelQueue || 'Queue'; const labelTickets = container.dataset.labelTickets || 'Tickets'; @@ -202,7 +227,7 @@ if (container) { for (const item of items) { const li = h('li', 'helpdesk-team-perf-ranked-clickable'); - li.addEventListener('click', () => { + on(li, 'click', () => { if (activeItem === li) { activeItem.classList.remove('active'); activeItem = null; @@ -363,8 +388,9 @@ if (container) { if (refresh) params.set('refresh', '1'); try { - const res = await fetch(dataUrl + '?' + params.toString(), { credentials: 'same-origin' }); - const json = await res.json(); + const json = await getJson(`${dataUrl}?${params.toString()}`, { + signal: requestController.signal, + }); if (!json.ok) { showState('error'); return; } if (!json.members || json.members.length === 0) { showState('empty'); return; } @@ -372,25 +398,31 @@ if (container) { renderCurrentTab(json.members); renderPerformanceTab(json.members); showState('success'); - } catch { + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } showState('error'); } } - if (periodSelector) { - periodSelector.addEventListener('change', (e) => { - const radio = e.target.closest('input[name="team-period"]'); - if (!radio) return; - const period = parseInt(radio.value, 10); - if (!period || period === currentPeriod) return; - currentPeriod = period; - fetchData(); - }); - } + on(periodSelector, 'change', (e) => { + const radio = e.target.closest('input[name="team-period"]'); + if (!radio) return; + const period = parseInt(radio.value, 10); + if (!period || period === currentPeriod) return; + currentPeriod = period; + void fetchData(); + }); - if (elRefresh) { - elRefresh.addEventListener('click', () => fetchData(true)); - } + on(elRefresh, 'click', () => void fetchData(true)); - fetchData(); + void fetchData(); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + }, + }; } diff --git a/modules/helpdesk/web/js/helpdesk-ticket.js b/modules/helpdesk/web/js/helpdesk-ticket.js index 7e282d9..e1ee242 100644 --- a/modules/helpdesk/web/js/helpdesk-ticket.js +++ b/modules/helpdesk/web/js/helpdesk-ticket.js @@ -1,66 +1,82 @@ /** * Helpdesk ticket detail page — async communication aside. - * - * Loads the ticket communication feed via fetch and renders - * it in the aside panel, matching the debitor detail pattern. */ +import { getJson } from '/js/core/app-http.js'; +import { resolveHost } from '/js/core/app-dom.js'; import { renderCommunicationError, renderCommunicationFeed, renderCommunicationHint, } from './helpdesk-communication.js'; -const container = document.querySelector('.app-details-container[data-ticket-no]'); -if (container) { - init(container); -} +const EMPTY_API = Object.freeze({ destroy: () => {} }); + +const resolveContainer = (root) => { + const host = resolveHost(root); + if (host instanceof HTMLElement && host.matches('.app-details-container[data-ticket-no]')) { + return host; + } + return host.querySelector('.app-details-container[data-ticket-no]'); +}; + +export function initHelpdeskTicket(root = document) { + const container = resolveContainer(root); + if (!container) { + return EMPTY_API; + } -function init(container) { const ticketNo = container.dataset.ticketNo || ''; const communicationUrl = container.dataset.ticketCommunicationUrl || ''; const fileDownloadUrl = container.dataset.fileDownloadUrl || ''; + if (!ticketNo || !communicationUrl) { + return EMPTY_API; + } - if (!ticketNo || !communicationUrl) return; + const listenerController = new AbortController(); + const requestController = new AbortController(); + const on = (target, type, handler, options = {}) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); + }; - // i18n const i18nEl = document.getElementById('helpdesk-ticket-i18n'); - const i18n = i18nEl ? JSON.parse(i18nEl.textContent) : {}; + const i18n = i18nEl ? JSON.parse(i18nEl.textContent || '{}') : {}; const t = (key) => i18n[key] || key; - function showLoading(id) { + const toggleHidden = (id, hidden) => { const el = document.getElementById(id); - if (el) el.hidden = false; - } + if (el) { + el.hidden = hidden; + } + }; - function hideLoading(id) { - const el = document.getElementById(id); - if (el) el.hidden = true; - } - - function showContent(id) { - const el = document.getElementById(id); - if (el) el.hidden = false; - } - - async function loadCommunication(refresh = false) { + const loadCommunication = async (refresh = false) => { const feedEl = document.getElementById('ticket-communication-feed'); const loadingEl = document.getElementById('ticket-communication-loading'); const contentEl = document.getElementById('ticket-communication-content'); - if (!feedEl || !loadingEl || !contentEl) return; + if (!feedEl || !loadingEl || !contentEl) { + return; + } - if (contentEl) contentEl.hidden = true; - showLoading('ticket-communication-loading'); + contentEl.hidden = true; + loadingEl.hidden = false; try { const params = new URLSearchParams({ ticketNo }); - if (refresh) params.set('refresh', '1'); - const response = await fetch(communicationUrl + '?' + params.toString(), { credentials: 'same-origin' }); - const data = await response.json(); + if (refresh) { + params.set('refresh', '1'); + } - hideLoading('ticket-communication-loading'); - showContent('ticket-communication-content'); + const data = await getJson(`${communicationUrl}?${params.toString()}`, { + signal: requestController.signal, + }); - if (!data.ok) { + toggleHidden('ticket-communication-loading', true); + toggleHidden('ticket-communication-content', false); + + if (!data || data.ok !== true) { feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); return; } @@ -82,17 +98,30 @@ function init(container) { }); feedEl.innerHTML = html; - } catch { - hideLoading('ticket-communication-loading'); - showContent('ticket-communication-content'); - feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + toggleHidden('ticket-communication-loading', true); + toggleHidden('ticket-communication-content', false); + const feedEl = document.getElementById('ticket-communication-feed'); + if (feedEl) { + feedEl.innerHTML = renderCommunicationError(t('Could not load communication history.')); + } } - } + }; const refreshButton = container.querySelector('button[name="ticket-refresh"]'); - if (refreshButton) { - refreshButton.addEventListener('click', () => loadCommunication(true)); - } + on(refreshButton, 'click', () => { + void loadCommunication(true); + }); - loadCommunication(); + void loadCommunication(); + + return { + destroy: () => { + listenerController.abort(); + requestController.abort(); + }, + }; } diff --git a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js index 185fcbe..edfd223 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-domains-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-domains-index.js @@ -1,105 +1,97 @@ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; -import { readPageConfig } from '/js/core/app-page-config.js'; -import { warnOnce } from '/js/core/app-dom.js'; -import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { createListPageModule } from '/js/core/app-list-page-module.js'; +import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; -const config = readPageConfig('helpdesk-domains'); -if (config) { - const gridjs = window.gridjs; - if (!gridjs) { - warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed: Grid.js missing', { module: 'helpdesk-domains', component: 'grid' }); - } else { - const appBase = getAppBase(); +createListPageModule({ + configId: 'helpdesk-domains', + moduleId: 'helpdesk-domains', + missingGridMessage: 'Helpdesk domains grid init failed: Grid.js missing', + initErrorMessage: 'Helpdesk domains grid init failed', + setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; - const domainBaseUrl = config.domainBaseUrl || ''; const domainUrlIndex = 7; - const gridOptions = { - gridjs, - container: '#helpdesk-domains-grid', - dataUrl: config.dataUrl || 'helpdesk/domains-data', - appBase, - columns: [ - { - name: labels.no || 'No.', - sort: true, - formatter: (cell) => { - const no = escapeHtml(cell?.no || ''); - const detailUrl = String(cell?.url || '').trim(); - if (detailUrl === '') { - return gridjs.html(no); - } - const href = escapeHtml(detailUrl); - return gridjs.html(`${no}`); + const { gridConfig } = initListPage({ + grid: { + gridjs, + container: '#helpdesk-domains-grid', + dataUrl: config.dataUrl || 'helpdesk/domains-data', + appBase, + columns: [ + { + name: labels.no || 'No.', + sort: true, + formatter: (cell) => { + const no = escapeHtml(cell?.no || ''); + const detailUrl = String(cell?.url || '').trim(); + if (detailUrl === '') { + return gridjs.html(no); + } + const href = escapeHtml(detailUrl); + return gridjs.html(`${no}`); + }, }, - }, - { - name: labels.customerName || 'Customer Name', - sort: true, - formatter: (cell) => { - const name = escapeHtml(cell?.name || ''); - const detailUrl = String(cell?.url || '').trim(); - if (detailUrl === '') { - return gridjs.html(name); - } - const href = escapeHtml(withCurrentListReturn(detailUrl)); - return gridjs.html(`${name}`); + { + name: labels.customerName || 'Customer Name', + sort: true, + formatter: (cell) => { + const name = escapeHtml(cell?.name || ''); + const detailUrl = String(cell?.url || '').trim(); + if (detailUrl === '') { + return gridjs.html(name); + } + const href = escapeHtml(withCurrentListReturn(detailUrl)); + return gridjs.html(`${name}`); + }, }, - }, - { name: labels.url || 'URL', sort: true }, - { name: labels.contractType || 'Contract Type', sort: true }, - { - name: labels.state || 'State', - sort: true, - formatter: (cell) => { - const state = escapeHtml(cell?.state || ''); - const variant = cell?.variant || 'neutral'; - return gridjs.html(`${state}`); + { name: labels.url || 'URL', sort: true }, + { name: labels.contractType || 'Contract Type', sort: true }, + { + name: labels.state || 'State', + sort: true, + formatter: (cell) => { + const state = escapeHtml(cell?.state || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${state}`); + }, }, + { name: labels.administration || 'Administration', sort: true }, + { name: 'debitor_url', hidden: true }, + { name: 'domain_detail_url', hidden: true }, + ], + sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null], + paginationLimit: 10, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => { + const domainDetailUrl = domainBaseUrl && row.No + ? domainBaseUrl + encodeURIComponent(row.No) + : ''; + return [ + { no: row.No || '', url: domainDetailUrl }, + { name: row.Customer_Name || '', url: row.debitor_url || '' }, + row.URL || '', + row.contract_type || '', + { state: row.State || '', variant: row.state_variant || 'neutral' }, + row.Administration || '', + row.debitor_url || '', + domainDetailUrl, + ]; + }), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + rowInteraction: { linkColumn: 0 }, + rowDblClick: { + getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '', }, - { name: labels.administration || 'Administration', sort: true }, - { name: 'debitor_url', hidden: true }, - { name: 'domain_detail_url', hidden: true }, - ], - sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', null, null], - paginationLimit: 10, - language: config.gridLang || {}, - mapData: (data) => (data.data || []).map((row) => { - const domainDetailUrl = domainBaseUrl && row.No - ? domainBaseUrl + encodeURIComponent(row.No) - : ''; - return [ - { no: row.No || '', url: domainDetailUrl }, - { name: row.Customer_Name || '', url: row.debitor_url || '' }, - row.URL || '', - row.contract_type || '', - { state: row.State || '', variant: row.state_variant || 'neutral' }, - row.Administration || '', - row.debitor_url || '', - domainDetailUrl, - ]; - }), - search: config.gridSearch || null, - filterSchema: config.filterSchema || [], - urlSync: true, - rowInteraction: { linkColumn: 0 }, - rowDblClick: { - getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '', }, - }; - - const { gridConfig } = initStandardListPage({ - grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, watchInputs: ['#helpdesk-domains-search-input'], }, - }); + }) || {}; - if (!gridConfig || !gridConfig.grid) { - warnOnce('UI_INIT_FAIL', 'Helpdesk domains grid init failed', { module: 'helpdesk-domains', component: 'grid' }); - } - } -} + return gridConfig; + }, +}); diff --git a/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js index 91ae32e..5b62848 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-handovers-index.js @@ -1,17 +1,15 @@ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; -import { readPageConfig } from '/js/core/app-page-config.js'; -import { warnOnce } from '/js/core/app-dom.js'; -import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { createListPageModule } from '/js/core/app-list-page-module.js'; import { bindBulkVisibility } from '/js/components/app-bulk-selection.js'; import { confirmDialog } from '/js/core/app-confirm-dialog.js'; +import { postForm } from '/js/core/app-http.js'; +import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; -const config = readPageConfig('helpdesk-handovers'); -if (config) { - const gridjs = window.gridjs; - if (!gridjs) { - warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed: Grid.js missing', { module: 'helpdesk-handovers', component: 'grid' }); - } else { - const appBase = getAppBase(); +createListPageModule({ + configId: 'helpdesk-handovers', + moduleId: 'helpdesk-handovers', + missingGridMessage: 'Helpdesk handovers grid init failed: Grid.js missing', + initErrorMessage: 'Helpdesk handovers grid init failed', + setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; const canManage = config.canManage || false; @@ -100,7 +98,7 @@ if (config) { }; } - const { gridConfig } = initStandardListPage({ + const listResult = initListPage({ grid: gridOptions, filters: { mode: 'drawer', @@ -109,56 +107,51 @@ if (config) { }, }); - if (!gridConfig || !gridConfig.grid) { - warnOnce('UI_INIT_FAIL', 'Helpdesk handovers grid init failed', { module: 'helpdesk-handovers', component: 'grid' }); + const gridConfig = listResult?.gridConfig || null; + if (!canManage || !gridConfig) { + return gridConfig; } - if (canManage && gridConfig) { - bindBulkVisibility({ - containerSelector: '#helpdesk-handovers-grid', - buttonSelector: '[data-handovers-bulk]', - }); + bindBulkVisibility({ + containerSelector: '#helpdesk-handovers-grid', + buttonSelector: '[data-handovers-bulk]', + }); - const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]')); - bulkButtons.forEach((button) => { - button.addEventListener('click', async () => { - const action = button.dataset.handoversBulk || ''; - const ids = gridConfig.selection?.getSelectedIds?.() || []; - if (!ids.length || action !== 'delete') return; + const bulkButtons = Array.from(document.querySelectorAll('[data-handovers-bulk]')); + bulkButtons.forEach((button) => { + button.addEventListener('click', async () => { + const action = button.dataset.handoversBulk || ''; + const ids = gridConfig.selection?.getSelectedIds?.() || []; + if (!ids.length || action !== 'delete') { + return; + } - const confirmed = await confirmDialog.confirm({ - message: labels.bulkDeleteConfirm || 'Delete selected handovers?', - actionKind: 'delete', - }); - if (!confirmed) return; - - const body = new URLSearchParams({ - ids: ids.join(','), - [config.csrfKey]: config.csrfToken, - }); - - try { - const res = await fetch(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'X-Requested-With': 'fetch', - }, - body, - }); - const data = await res.json().catch(() => null); - if (res.ok && data?.ok) { - gridConfig.selection?.clear?.(); - gridConfig.grid?.forceRender(); - } else { - window.location.reload(); - } - } catch { - window.location.reload(); - } + const confirmed = await confirmDialog.confirm({ + message: labels.bulkDeleteConfirm || 'Delete selected handovers?', + actionKind: 'delete', }); + if (!confirmed) { + return; + } + + const body = new URLSearchParams({ + ids: ids.join(','), + [config.csrfKey]: config.csrfToken, + }); + + try { + const data = await postForm(new URL('helpdesk/handovers/bulk/delete', appBase).toString(), body); + if (data?.ok !== true) { + throw new Error('Bulk delete failed'); + } + gridConfig.selection?.clear?.(); + gridConfig.grid?.forceRender(); + } catch { + window.location.reload(); + } }); - } - } -} + }); + + return gridConfig; + }, +}); diff --git a/modules/helpdesk/web/js/pages/helpdesk-search-index.js b/modules/helpdesk/web/js/pages/helpdesk-search-index.js index cb05479..f0c7bf4 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-search-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-search-index.js @@ -1,73 +1,66 @@ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; -import { readPageConfig } from '/js/core/app-page-config.js'; -import { warnOnce } from '/js/core/app-dom.js'; -import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { createListPageModule } from '/js/core/app-list-page-module.js'; +import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; -const config = readPageConfig('helpdesk-search'); -if (config) { - const gridjs = window.gridjs; - if (!gridjs) { - warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed: Grid.js missing', { module: 'helpdesk-search', component: 'grid' }); - } else { - const appBase = getAppBase(); +createListPageModule({ + configId: 'helpdesk-search', + moduleId: 'helpdesk-search', + missingGridMessage: 'Helpdesk search grid init failed: Grid.js missing', + initErrorMessage: 'Helpdesk search grid init failed', + setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; - const gridOptions = { - gridjs, - container: '#helpdesk-search-grid', - dataUrl: config.dataUrl || 'helpdesk/search-data', - appBase, - columns: [ - { - name: labels.debtorNo || 'Debtor No.', - sort: true, - formatter: (cell) => { - const debtorNo = escapeHtml(cell?.no || ''); - const detailUrl = String(cell?.url || '').trim(); - if (detailUrl === '') { - return gridjs.html(debtorNo); - } - const href = escapeHtml(withCurrentListReturn(detailUrl)); - return gridjs.html(`${debtorNo}`); + const { gridConfig } = initListPage({ + grid: { + gridjs, + container: '#helpdesk-search-grid', + dataUrl: config.dataUrl || 'helpdesk/search-data', + appBase, + columns: [ + { + name: labels.debtorNo || 'Debtor No.', + sort: true, + formatter: (cell) => { + const debtorNo = escapeHtml(cell?.no || ''); + const detailUrl = String(cell?.url || '').trim(); + if (detailUrl === '') { + return gridjs.html(debtorNo); + } + const href = escapeHtml(withCurrentListReturn(detailUrl)); + return gridjs.html(`${debtorNo}`); + }, }, + { name: labels.name || 'Name', sort: true }, + { name: labels.city || 'City', sort: true }, + { name: labels.phone || 'Phone', sort: false }, + { name: labels.email || 'E-Mail', sort: false }, + { name: 'detail_url', hidden: true }, + ], + sortColumns: ['No', 'Name', 'City', null, null, null], + paginationLimit: 10, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => [ + { no: row.No || '', url: row.detail_url || '' }, + row.Name || '', + row.City || '', + row.Phone_No || '', + row.E_Mail || '', + row.detail_url || '', + ]), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + rowInteraction: { linkColumn: 0 }, + rowDblClick: { + getUrl: (rowData) => rowData?.cells?.[5]?.data || '', }, - { name: labels.name || 'Name', sort: true }, - { name: labels.city || 'City', sort: true }, - { name: labels.phone || 'Phone', sort: false }, - { name: labels.email || 'E-Mail', sort: false }, - { name: 'detail_url', hidden: true }, - ], - sortColumns: ['No', 'Name', 'City', null, null, null], - paginationLimit: 10, - language: config.gridLang || {}, - mapData: (data) => (data.data || []).map((row) => [ - { no: row.No || '', url: row.detail_url || '' }, - row.Name || '', - row.City || '', - row.Phone_No || '', - row.E_Mail || '', - row.detail_url || '', - ]), - search: config.gridSearch || null, - filterSchema: config.filterSchema || [], - urlSync: true, - rowInteraction: { linkColumn: 0 }, - rowDblClick: { - getUrl: (rowData) => rowData?.cells?.[5]?.data || '', }, - }; - - const { gridConfig } = initStandardListPage({ - grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, watchInputs: ['#helpdesk-search-input'], }, - }); + }) || {}; - if (!gridConfig || !gridConfig.grid) { - warnOnce('UI_INIT_FAIL', 'Helpdesk search grid init failed', { module: 'helpdesk-search', component: 'grid' }); - } - } -} + return gridConfig; + }, +}); diff --git a/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js b/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js index e9885e7..2baa66d 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-software-products-index.js @@ -1,81 +1,73 @@ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; -import { readPageConfig } from '/js/core/app-page-config.js'; -import { warnOnce } from '/js/core/app-dom.js'; -import { escapeHtml, getAppBase, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { createListPageModule } from '/js/core/app-list-page-module.js'; +import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js'; -const config = readPageConfig('helpdesk-software-products'); -if (config) { - const gridjs = window.gridjs; - if (!gridjs) { - warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed: Grid.js missing', { module: 'helpdesk-software-products', component: 'grid' }); - } else { - const appBase = getAppBase(); +createListPageModule({ + configId: 'helpdesk-software-products', + moduleId: 'helpdesk-software-products', + missingGridMessage: 'Helpdesk software products grid init failed: Grid.js missing', + initErrorMessage: 'Helpdesk software products grid init failed', + setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; - const editUrlIndex = 4; - const gridOptions = { - gridjs, - container: '#helpdesk-software-products-grid', - dataUrl: config.dataUrl || 'helpdesk/software-products-data', - appBase, - columns: [ - { - name: labels.code || 'Code', - sort: true, - formatter: (cell) => { - const code = escapeHtml(cell?.code || ''); - const editUrl = String(cell?.url || '').trim(); - if (editUrl === '') { - return gridjs.html(code); - } - const href = escapeHtml(withCurrentListReturn(editUrl)); - return gridjs.html(`${code}`); + const { gridConfig } = initListPage({ + grid: { + gridjs, + container: '#helpdesk-software-products-grid', + dataUrl: config.dataUrl || 'helpdesk/software-products-data', + appBase, + columns: [ + { + name: labels.code || 'Code', + sort: true, + formatter: (cell) => { + const code = escapeHtml(cell?.code || ''); + const editUrl = String(cell?.url || '').trim(); + if (editUrl === '') { + return gridjs.html(code); + } + const href = escapeHtml(withCurrentListReturn(editUrl)); + return gridjs.html(`${code}`); + }, }, - }, - { name: labels.bcDescription || 'BC Description', sort: true }, - { name: labels.name || 'Product Name', sort: true }, - { - name: labels.status || 'Status', - sort: true, - formatter: (cell) => { - const label = escapeHtml(cell?.label || ''); - const variant = cell?.variant || 'neutral'; - return gridjs.html(`${label}`); + { name: labels.bcDescription || 'BC Description', sort: true }, + { name: labels.name || 'Product Name', sort: true }, + { + name: labels.status || 'Status', + sort: true, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, }, + { name: 'edit_url', hidden: true }, + ], + sortColumns: ['code', 'bc_description', 'name', 'active', null], + paginationLimit: 20, + language: config.gridLang || {}, + mapData: (data) => (data.data || []).map((row) => [ + { code: row.code || '', url: row.edit_url || '' }, + row.bc_description || '', + row.name || '', + { label: row.active_label || '', variant: row.active_variant || 'neutral' }, + row.edit_url || '', + ]), + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, + rowInteraction: { linkColumn: 0 }, + rowDblClick: { + getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '', }, - { name: 'edit_url', hidden: true }, - ], - sortColumns: ['code', 'bc_description', 'name', 'active', null], - paginationLimit: 20, - language: config.gridLang || {}, - mapData: (data) => (data.data || []).map((row) => [ - { code: row.code || '', url: row.edit_url || '' }, - row.bc_description || '', - row.name || '', - { label: row.active_label || '', variant: row.active_variant || 'neutral' }, - row.edit_url || '', - ]), - search: config.gridSearch || null, - filterSchema: config.filterSchema || [], - urlSync: true, - rowInteraction: { linkColumn: 0 }, - rowDblClick: { - getUrl: (rowData) => rowData?.cells?.[editUrlIndex]?.data || '', }, - }; - - const { gridConfig } = initStandardListPage({ - grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, watchInputs: ['#helpdesk-software-products-search-input'], }, - }); + }) || {}; - if (!gridConfig || !gridConfig.grid) { - warnOnce('UI_INIT_FAIL', 'Helpdesk software products grid init failed', { module: 'helpdesk-software-products', component: 'grid' }); - } - } -} + return gridConfig; + }, +}); diff --git a/modules/helpdesk/web/js/pages/helpdesk-updates-index.js b/modules/helpdesk/web/js/pages/helpdesk-updates-index.js index ec98e0c..7ca7ffa 100644 --- a/modules/helpdesk/web/js/pages/helpdesk-updates-index.js +++ b/modules/helpdesk/web/js/pages/helpdesk-updates-index.js @@ -1,138 +1,132 @@ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; -import { readPageConfig } from '/js/core/app-page-config.js'; -import { warnOnce } from '/js/core/app-dom.js'; -import { escapeHtml, getAppBase } from '/js/pages/app-list-utils.js'; +import { showAsyncFlash } from '/js/components/app-async-flash.js'; +import { getJson, postForm } from '/js/core/app-http.js'; +import { createListPageModule } from '/js/core/app-list-page-module.js'; +import { escapeHtml } from '/js/pages/app-list-utils.js'; -const config = readPageConfig('helpdesk-updates'); -if (config) { - const gridjs = window.gridjs; - if (!gridjs) { - warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed: Grid.js missing', { module: 'helpdesk-updates', component: 'grid' }); - } else { - const appBase = getAppBase(); +createListPageModule({ + configId: 'helpdesk-updates', + moduleId: 'helpdesk-updates', + missingGridMessage: 'Helpdesk updates grid init failed: Grid.js missing', + initErrorMessage: 'Helpdesk updates grid init failed', + setup: ({ config, gridjs, appBase, initListPage }) => { const labels = config.labels || {}; const canManage = config.canManage || false; - const gridOptions = { - gridjs, - container: '#helpdesk-updates-grid', - dataUrl: config.dataUrl || 'helpdesk/updates-data', - appBase, - columns: [ - { - name: labels.ticketNo || 'Ticket', - sort: false, - formatter: (cell) => { - const no = escapeHtml(String(cell?.no || '')); - const url = String(cell?.url || '').trim(); - const desc = escapeHtml(String(cell?.description || '')); - const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : ''; - if (no === '') return gridjs.html(''); - if (url === '') return gridjs.html(`${no}`); - return gridjs.html(`${no}`); - }, - }, - { - name: labels.ticketState || 'Ticket status', - sort: false, - formatter: (cell) => { - const label = escapeHtml(cell?.label || ''); - const variant = cell?.variant || 'neutral'; - return gridjs.html(`${label}`); - }, - }, - { - name: labels.assignment || 'Assignment', - sort: false, - formatter: (cell) => { - const label = escapeHtml(cell?.label || ''); - const variant = cell?.variant || 'neutral'; - return gridjs.html(`${label}`); - }, - }, - { - name: labels.actions || '', - sort: false, - formatter: (cell) => { - if (!cell) return ''; - let buttons = ''; - // Assign/Edit button (only if user can manage) - if (canManage) { - // raw-html-ok: all values passed through escapeHtml, cell data is from our own API - const data = escapeHtml(JSON.stringify(cell)); - const buttonLabel = cell.status === 'open' - ? escapeHtml(labels.assign || 'Assign') - : escapeHtml(labels.edit || 'Edit'); - const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil'; - buttons += ``; - } - // Gitea link button (if URL exists) - const giteaUrl = String(cell.gitea_path || '').trim(); - if (giteaUrl !== '') { - const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea'); - buttons += ``; - } - if (buttons === '') return ''; - return gridjs.html(`${buttons}`); - }, - }, - { - name: labels.category || 'Type', - sort: false, - formatter: (cell) => { - const label = escapeHtml(cell?.label || ''); - const variant = cell?.variant || 'neutral'; - return gridjs.html(`${label}`); - }, - }, - { - name: labels.debitor || 'Customer', - sort: false, - formatter: (cell) => { - const name = escapeHtml(String(cell?.name || '')); - const url = String(cell?.url || '').trim(); - if (name === '') return gridjs.html(''); - const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom'; - if (url === '') return gridjs.html(`${name}`); - return gridjs.html(`${name}`); - }, - }, - { name: labels.domain || 'Domain', sort: false }, - { name: labels.createdOn || 'Created', sort: false }, - ], - paginationLimit: 50, - language: config.gridLang || {}, - mapData: (data) => { - const rows = data.rows || data.data || []; - // Column order: Ticket | Ticket-Status | Assignment | Actions | Type | Customer | Domain | Created - return rows.map((row) => [ - { no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' }, - { label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' }, - { label: row.status_label || '', variant: row.status_variant || 'neutral' }, + const listResult = initListPage({ + grid: { + gridjs, + container: '#helpdesk-updates-grid', + dataUrl: config.dataUrl || 'helpdesk/updates-data', + appBase, + columns: [ { - ticket_no: row.ticket_no || '', - debitor_no: row.debitor_no || '', - debitor_name: row.debitor_name || '', - category_code: row.category_code || '', - domain_no: row.domain_no || '', - domain_url: row.domain_url || '', - gitea_path: row.gitea_path || '', - notes: row.notes || '', - status: row.status || 'open', + name: labels.ticketNo || 'Ticket', + sort: false, + formatter: (cell) => { + const no = escapeHtml(String(cell?.no || '')); + const url = String(cell?.url || '').trim(); + const desc = escapeHtml(String(cell?.description || '')); + const tip = desc ? ` data-tooltip="${desc}" data-tooltip-pos="top"` : ''; + if (no === '') return gridjs.html(''); + if (url === '') return gridjs.html(`${no}`); + return gridjs.html(`${no}`); + }, }, - { label: row.category_label || '', variant: row.category_variant || 'neutral' }, - { name: row.debitor_name || '', url: row.debitor_url || '' }, - row.domain_url || '', - row.created_on || '', - ]); + { + name: labels.ticketState || 'Ticket status', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.assignment || 'Assignment', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.actions || '', + sort: false, + formatter: (cell) => { + if (!cell) return ''; + let buttons = ''; + if (canManage) { + // raw-html-ok: all values passed through escapeHtml, cell data is from our own API + const data = escapeHtml(JSON.stringify(cell)); + const buttonLabel = cell.status === 'open' + ? escapeHtml(labels.assign || 'Assign') + : escapeHtml(labels.edit || 'Edit'); + const icon = cell.status === 'open' ? 'bi-link-45deg' : 'bi-pencil'; + buttons += ``; + } + const giteaUrl = String(cell.gitea_path || '').trim(); + if (giteaUrl !== '') { + const giteaLabel = escapeHtml(labels.giteaLink || 'Gitea'); + buttons += ``; + } + if (buttons === '') return ''; + return gridjs.html(`${buttons}`); + }, + }, + { + name: labels.category || 'Type', + sort: false, + formatter: (cell) => { + const label = escapeHtml(cell?.label || ''); + const variant = cell?.variant || 'neutral'; + return gridjs.html(`${label}`); + }, + }, + { + name: labels.debitor || 'Customer', + sort: false, + formatter: (cell) => { + const name = escapeHtml(String(cell?.name || '')); + const url = String(cell?.url || '').trim(); + if (name === '') return gridjs.html(''); + const style = 'display:inline-block;max-width:20ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom'; + if (url === '') return gridjs.html(`${name}`); + return gridjs.html(`${name}`); + }, + }, + { name: labels.domain || 'Domain', sort: false }, + { name: labels.createdOn || 'Created', sort: false }, + ], + paginationLimit: 50, + language: config.gridLang || {}, + mapData: (data) => { + const rows = data.rows || data.data || []; + return rows.map((row) => [ + { no: row.ticket_no || '', url: row.ticket_url || '', description: row.ticket_description || '' }, + { label: row.ticket_state_label || '', variant: row.ticket_state_variant || 'neutral' }, + { label: row.status_label || '', variant: row.status_variant || 'neutral' }, + { + ticket_no: row.ticket_no || '', + debitor_no: row.debitor_no || '', + debitor_name: row.debitor_name || '', + category_code: row.category_code || '', + domain_no: row.domain_no || '', + domain_url: row.domain_url || '', + gitea_path: row.gitea_path || '', + notes: row.notes || '', + status: row.status || 'open', + }, + { label: row.category_label || '', variant: row.category_variant || 'neutral' }, + { name: row.debitor_name || '', url: row.debitor_url || '' }, + row.domain_url || '', + row.created_on || '', + ]); + }, + search: config.gridSearch || null, + filterSchema: config.filterSchema || [], + urlSync: true, }, - search: config.gridSearch || null, - filterSchema: config.filterSchema || [], - urlSync: true, - }; - - const { gridConfig } = initStandardListPage({ - grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, @@ -140,28 +134,23 @@ if (config) { }, }); - if (!gridConfig || !gridConfig.grid) { - warnOnce('UI_INIT_FAIL', 'Helpdesk updates grid init failed', { module: 'helpdesk-updates', component: 'grid' }); + const gridConfig = listResult?.gridConfig || null; + if (!gridConfig) { + return null; } - // Refresh button — bypasses session cache and reloads grid data from BC const refreshButton = document.getElementById('updates-refresh-button'); if (refreshButton) { refreshButton.addEventListener('click', async () => { refreshButton.disabled = true; refreshButton.setAttribute('aria-busy', 'true'); try { - // Ping the data endpoint with refresh=1 to invalidate the cache const refreshUrl = new URL(config.dataUrl, appBase); refreshUrl.searchParams.set('refresh', '1'); - await fetch(refreshUrl.toString(), { - credentials: 'same-origin', - headers: { 'Accept': 'application/json', 'X-Requested-With': 'fetch' }, - }); - // Now force the grid to re-render (next fetch will get fresh data) + await getJson(refreshUrl.toString()); gridConfig?.grid?.forceRender(); } catch { - // Ignore — grid will still show last data + showAsyncFlash('error', labels.refreshError || labels.assignError || 'Refresh failed'); } finally { refreshButton.disabled = false; refreshButton.removeAttribute('aria-busy'); @@ -169,157 +158,155 @@ if (config) { }); } - // Assignment dialog logic - if (canManage) { - const dialog = document.getElementById('update-assign-dialog'); - const form = document.getElementById('update-assign-form'); - const contextEl = document.getElementById('update-assign-context'); - const domainSelect = document.getElementById('update-assign-domain'); - const giteaInput = document.getElementById('update-assign-gitea'); - const notesInput = document.getElementById('update-assign-notes'); - const ticketNoInput = document.getElementById('update-assign-ticket-no'); - const debitorNoInput = document.getElementById('update-assign-debitor-no'); - const debitorNameInput = document.getElementById('update-assign-debitor-name'); - const categoryCodeInput = document.getElementById('update-assign-category-code'); - const domainUrlInput = document.getElementById('update-assign-domain-url'); - const submitButton = document.getElementById('update-assign-submit'); - - if (dialog && form) { - const closeDialog = () => { - try { dialog.close(); } catch { /* no-op */ } - document.body.classList.remove('modal-is-open'); - }; - - // Close buttons - dialog.querySelectorAll('[data-update-assign-close]').forEach((button) => { - button.addEventListener('click', (e) => { e.preventDefault(); closeDialog(); }); - }); - dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); }); - dialog.addEventListener('click', (e) => { if (e.target === dialog) closeDialog(); }); - - // Domain select change -> update hidden domain_url - domainSelect.addEventListener('change', () => { - const selected = domainSelect.options[domainSelect.selectedIndex]; - domainUrlInput.value = selected?.dataset?.url || ''; - }); - - /** - * Populate the domain assertSame(0, preg_match('/]*id=\"[^\"]*-filter\"/', $content), $file); diff --git a/web/js/app-init.js b/web/js/app-init.js index 9a057e6..386f13f 100644 --- a/web/js/app-init.js +++ b/web/js/app-init.js @@ -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. diff --git a/web/js/components/app-lookup-field.js b/web/js/components/app-lookup-field.js index fd9fc50..44636a3 100644 --- a/web/js/components/app-lookup-field.js +++ b/web/js/components/app-lookup-field.js @@ -1,24 +1,19 @@ /** * Generic autocomplete lookup field. - * - * Usage: - *
- * - * - *
- * - * 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(); + } + }); + }, + }; } diff --git a/web/js/core/app-http.js b/web/js/core/app-http.js new file mode 100644 index 0000000..113e48f --- /dev/null +++ b/web/js/core/app-http.js @@ -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 ?? {}), + }); +}; diff --git a/web/js/core/app-list-page-module.js b/web/js/core/app-list-page-module.js new file mode 100644 index 0000000..e149f80 --- /dev/null +++ b/web/js/core/app-list-page-module.js @@ -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; + } +} diff --git a/web/js/pages/app-list-utils.js b/web/js/pages/app-list-utils.js index f72dcb8..6a7a226 100644 --- a/web/js/pages/app-list-utils.js +++ b/web/js/pages/app-list-utils.js @@ -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 = '') => { diff --git a/web/js/pages/app-users-list.js b/web/js/pages/app-users-list.js index 5abc5fe..ce85106 100644 --- a/web/js/pages/app-users-list.js +++ b/web/js/pages/app-users-list.js @@ -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) {