diff --git a/bin/js-contract-check.sh b/bin/js-contract-check.sh index 644c891..a1e0c9a 100755 --- a/bin/js-contract-check.sh +++ b/bin/js-contract-check.sh @@ -29,6 +29,14 @@ else ok "No window.alert usage in web/js and modules/*/web/js" fi +dom_ready_hits="$(rg -n "DOMContentLoaded|document\\.readyState" web/js modules/*/web/js -g '*.js' -g '!**/vendor/**' || true)" +if [[ -n "${dom_ready_hits}" ]]; then + fail "DOM-ready auto-init pattern detected (use lifecycle init(root, config) + runtime wiring):" + echo "${dom_ready_hits}" >&2 +else + ok "No DOM-ready auto-init pattern in web/js and modules/*/web/js" +fi + fetch_hits="$(rg -n "\\bfetch\\s*\\(" web/js modules/*/web/js -g '*.js' -g '!web/js/core/app-http.js' -g '!web/js/core/app-telemetry.js' || true)" if [[ -n "${fetch_hits}" ]]; then fail "Direct fetch(...) usage detected outside approved HTTP infrastructure:" diff --git a/modules/helpdesk/web/js/helpdesk-detail.js b/modules/helpdesk/web/js/helpdesk-detail.js index 6ee8002..fae4dfc 100644 --- a/modules/helpdesk/web/js/helpdesk-detail.js +++ b/modules/helpdesk/web/js/helpdesk-detail.js @@ -6,7 +6,7 @@ * - Controlling tab: placeholder * - Aside: cross-ticket communication feed */ -import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { initListSection } from '/js/core/app-list-page-module.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'; @@ -336,7 +336,10 @@ function init(container) { ? contactsSection.querySelector('[data-filter-live-region]') : null; - initStandardListPage({ + initListSection({ + moduleId: 'helpdesk-detail-contacts', + initErrorMessage: 'Helpdesk contacts grid init failed', + initOptions: { grid: gridOptions, filters: { mode: 'drawer', @@ -346,6 +349,7 @@ function init(container) { chips: contactsChipsEl ? { container: contactsChipsEl } : {}, live: contactsLiveEl ? { regionSelector: null } : {}, }, + }, }); contactsGridInitialized = true; } finally { @@ -815,13 +819,17 @@ function init(container) { rowInteraction: { linkColumn: 0 }, }; - initStandardListPage({ + initListSection({ + moduleId: 'helpdesk-detail-tickets', + initErrorMessage: 'Helpdesk tickets grid init failed', + initOptions: { grid: gridOptions, filters: { mode: 'drawer', chipMeta: config.filterChipMeta || {}, watchInputs: ['#helpdesk-ticket-search'], }, + }, }); ticketsGridInitialized = true; } finally { diff --git a/tests/Architecture/FrontendJsContractsTest.php b/tests/Architecture/FrontendJsContractsTest.php index 02dd21b..05dd1a6 100644 --- a/tests/Architecture/FrontendJsContractsTest.php +++ b/tests/Architecture/FrontendJsContractsTest.php @@ -21,6 +21,19 @@ final class FrontendJsContractsTest extends TestCase $this->assertSame([], $violations, "window.alert usage found in frontend JS scope:\n" . implode("\n", $violations)); } + public function testFrontendJsDoesNotUseDomReadyAutoInitPatterns(): void + { + $violations = $this->findPatternMatchesInFiles('web/js', '/DOMContentLoaded|document\.readyState/', ['js']); + foreach ($this->moduleJsDirectories() as $moduleJsDir) { + $violations = array_merge($violations, $this->findPatternMatchesInFiles($moduleJsDir, '/DOMContentLoaded|document\.readyState/', ['js'])); + } + + $violations = array_values(array_unique($violations)); + sort($violations); + + $this->assertSame([], $violations, "DOM-ready auto-init pattern found in frontend JS scope:\n" . implode("\n", $violations)); + } + public function testHelpdeskRuntimeComponentsDoNotAutoInitialize(): void { foreach ($this->helpdeskRuntimeComponentFiles() as $file) { diff --git a/web/js/components/app-error-debug.js b/web/js/components/app-error-debug.js index 6e7830c..914e615 100644 --- a/web/js/components/app-error-debug.js +++ b/web/js/components/app-error-debug.js @@ -1,32 +1,74 @@ /* Error Debug Page — interactive behavior (inlined by ErrorRenderer) */ -document.addEventListener('DOMContentLoaded', () => { - // ── Tab switching ────────────────────────────────── - const tabs = document.querySelectorAll('.app-error-debug-tab'); - const panels = document.querySelectorAll('.app-error-debug-panel'); +function initErrorDebug(root = document) { + if (!root || typeof root.querySelectorAll !== 'function') { + return { destroy: () => {} }; + } + + const body = root.body; + if (!(body instanceof HTMLElement)) { + return { destroy: () => {} }; + } + if (body.dataset.errorDebugBound === '1') { + return { destroy: () => {} }; + } + body.dataset.errorDebugBound = '1'; + + const cleanupFns = []; + const bind = (target, eventName, handler, options = undefined) => { + if (!target || typeof target.addEventListener !== 'function') { + return; + } + target.addEventListener(eventName, handler, options); + cleanupFns.push(() => target.removeEventListener(eventName, handler, options)); + }; + + const tabs = Array.from(root.querySelectorAll('.app-error-debug-tab')); + const panels = Array.from(root.querySelectorAll('.app-error-debug-panel')); const activateTab = (tab) => { + if (!(tab instanceof HTMLElement)) { + return; + } const target = tab.dataset.panel; - tabs.forEach(t => t.classList.remove('active')); - tabs.forEach(t => t.setAttribute('aria-selected', 'false')); - tabs.forEach(t => t.setAttribute('tabindex', '-1')); - panels.forEach(p => p.classList.remove('active')); + tabs.forEach((value) => { + if (value instanceof HTMLElement) { + value.classList.remove('active'); + value.setAttribute('aria-selected', 'false'); + value.setAttribute('tabindex', '-1'); + } + }); + panels.forEach((panel) => { + if (panel instanceof HTMLElement) { + panel.classList.remove('active'); + } + }); tab.classList.add('active'); tab.setAttribute('aria-selected', 'true'); tab.setAttribute('tabindex', '0'); - const panel = document.getElementById(target); - if (panel) panel.classList.add('active'); + const panel = root.getElementById(target || ''); + if (panel instanceof HTMLElement) { + panel.classList.add('active'); + } }; - tabs.forEach(tab => { - tab.addEventListener('click', () => { + tabs.forEach((tab) => { + if (!(tab instanceof HTMLElement)) { + return; + } + + bind(tab, 'click', () => { activateTab(tab); }); - tab.addEventListener('keydown', (event) => { - if (tabs.length === 0) return; - const currentIndex = Array.prototype.indexOf.call(tabs, tab); - if (currentIndex < 0) return; + bind(tab, 'keydown', (event) => { + if (!(event instanceof KeyboardEvent) || tabs.length === 0) { + return; + } + const currentIndex = tabs.indexOf(tab); + if (currentIndex < 0) { + return; + } let nextIndex = currentIndex; if (event.key === 'ArrowRight') { @@ -43,58 +85,81 @@ document.addEventListener('DOMContentLoaded', () => { event.preventDefault(); const nextTab = tabs[nextIndex]; - if (!nextTab) return; + if (!(nextTab instanceof HTMLElement)) { + return; + } activateTab(nextTab); nextTab.focus(); }); }); - // ── Frame expand/collapse ────────────────────────── - document.querySelectorAll('.app-error-debug-frame__toggle').forEach(toggle => { - toggle.addEventListener('click', () => { + root.querySelectorAll('.app-error-debug-frame__toggle').forEach((toggle) => { + bind(toggle, 'click', () => { const frame = toggle.closest('.app-error-debug-frame'); - if (!frame) return; + if (!(frame instanceof HTMLElement) || !(toggle instanceof HTMLElement)) { + return; + } const isOpen = frame.classList.toggle('open'); toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); }); }); - // Ensure ARIA reflects initial state. - document.querySelectorAll('.app-error-debug-frame').forEach(frame => { + root.querySelectorAll('.app-error-debug-frame').forEach((frame) => { const toggle = frame.querySelector('.app-error-debug-frame__toggle'); - if (!toggle) return; + if (!(frame instanceof HTMLElement) || !(toggle instanceof HTMLElement)) { + return; + } toggle.setAttribute('aria-expanded', frame.classList.contains('open') ? 'true' : 'false'); }); - // ── Query expand/collapse ────────────────────────── - document.querySelectorAll('.app-error-debug-query__toggle').forEach(toggle => { - toggle.addEventListener('click', () => { + root.querySelectorAll('.app-error-debug-query__toggle').forEach((toggle) => { + bind(toggle, 'click', () => { const query = toggle.closest('.app-error-debug-query'); - if (!query) return; + if (!(query instanceof HTMLElement) || !(toggle instanceof HTMLElement)) { + return; + } const isOpen = query.classList.toggle('open'); toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); }); }); - // ── Copy to clipboard ───────────────────────────── - document.querySelectorAll('.app-error-debug-copy-button').forEach(button => { - button.addEventListener('click', async (e) => { - e.stopPropagation(); + root.querySelectorAll('.app-error-debug-copy-button').forEach((button) => { + bind(button, 'click', async (event) => { + event.stopPropagation(); + if (!(button instanceof HTMLElement)) { + return; + } const text = button.dataset.copy; - if (!text) return; + if (!text || !navigator?.clipboard?.writeText) { + return; + } try { await navigator.clipboard.writeText(text); button.classList.add('copied'); const original = button.innerHTML; button.innerHTML = ''; - setTimeout(() => { + const timerId = window.setTimeout(() => { button.classList.remove('copied'); button.innerHTML = original; }, 1500); + cleanupFns.push(() => window.clearTimeout(timerId)); } catch { // Clipboard API may not be available in all contexts. } }); }); -}); + + const destroy = () => { + cleanupFns.forEach((cleanup) => cleanup()); + cleanupFns.length = 0; + delete body.dataset.errorDebugBound; + }; + + return { destroy }; +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + const api = initErrorDebug(document); + window.__appErrorDebugApi = api; +} diff --git a/web/js/core/app-list-page-module.js b/web/js/core/app-list-page-module.js index e149f80..1c11144 100644 --- a/web/js/core/app-list-page-module.js +++ b/web/js/core/app-list-page-module.js @@ -7,6 +7,45 @@ const noop = () => {}; const defaultErrorMessage = (moduleId) => `${moduleId} grid init failed`; +export function initListSection(options = {}) { + const { + moduleId = 'list-section', + initOptions = null, + initErrorMessage = '', + } = options; + + const safeModuleId = String(moduleId || '').trim() || 'list-section'; + if (!initOptions || typeof initOptions !== 'object') { + warnOnce('UI_CONFIG_INVALID', 'initListSection requires initOptions', { + 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, + }); + }; + + try { + const result = initStandardListPage(initOptions); + const gridConfig = result?.gridConfig || null; + if (!gridConfig || !gridConfig.grid) { + failInit(); + return null; + } + return result; + } catch (error) { + failInit(error); + return null; + } +} + export function createListPageModule(options = {}) { const { configId,