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