feat(js): enforce global lifecycle hard-cuts and helpdesk list adapter
This commit is contained in:
@@ -29,6 +29,14 @@ else
|
|||||||
ok "No window.alert usage in web/js and modules/*/web/js"
|
ok "No window.alert usage in web/js and modules/*/web/js"
|
||||||
fi
|
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)"
|
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
|
if [[ -n "${fetch_hits}" ]]; then
|
||||||
fail "Direct fetch(...) usage detected outside approved HTTP infrastructure:"
|
fail "Direct fetch(...) usage detected outside approved HTTP infrastructure:"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - Controlling tab: placeholder
|
* - Controlling tab: placeholder
|
||||||
* - Aside: cross-ticket communication feed
|
* - 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 { getJson } from '/js/core/app-http.js';
|
||||||
import { resolveHost } from '/js/core/app-dom.js';
|
import { resolveHost } from '/js/core/app-dom.js';
|
||||||
import { readPageConfig } from '/js/core/app-page-config.js';
|
import { readPageConfig } from '/js/core/app-page-config.js';
|
||||||
@@ -336,7 +336,10 @@ function init(container) {
|
|||||||
? contactsSection.querySelector('[data-filter-live-region]')
|
? contactsSection.querySelector('[data-filter-live-region]')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
initStandardListPage({
|
initListSection({
|
||||||
|
moduleId: 'helpdesk-detail-contacts',
|
||||||
|
initErrorMessage: 'Helpdesk contacts grid init failed',
|
||||||
|
initOptions: {
|
||||||
grid: gridOptions,
|
grid: gridOptions,
|
||||||
filters: {
|
filters: {
|
||||||
mode: 'drawer',
|
mode: 'drawer',
|
||||||
@@ -346,6 +349,7 @@ function init(container) {
|
|||||||
chips: contactsChipsEl ? { container: contactsChipsEl } : {},
|
chips: contactsChipsEl ? { container: contactsChipsEl } : {},
|
||||||
live: contactsLiveEl ? { regionSelector: null } : {},
|
live: contactsLiveEl ? { regionSelector: null } : {},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
contactsGridInitialized = true;
|
contactsGridInitialized = true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -815,13 +819,17 @@ function init(container) {
|
|||||||
rowInteraction: { linkColumn: 0 },
|
rowInteraction: { linkColumn: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
initStandardListPage({
|
initListSection({
|
||||||
|
moduleId: 'helpdesk-detail-tickets',
|
||||||
|
initErrorMessage: 'Helpdesk tickets grid init failed',
|
||||||
|
initOptions: {
|
||||||
grid: gridOptions,
|
grid: gridOptions,
|
||||||
filters: {
|
filters: {
|
||||||
mode: 'drawer',
|
mode: 'drawer',
|
||||||
chipMeta: config.filterChipMeta || {},
|
chipMeta: config.filterChipMeta || {},
|
||||||
watchInputs: ['#helpdesk-ticket-search'],
|
watchInputs: ['#helpdesk-ticket-search'],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
ticketsGridInitialized = true;
|
ticketsGridInitialized = true;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ final class FrontendJsContractsTest extends TestCase
|
|||||||
$this->assertSame([], $violations, "window.alert usage found in frontend JS scope:\n" . implode("\n", $violations));
|
$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
|
public function testHelpdeskRuntimeComponentsDoNotAutoInitialize(): void
|
||||||
{
|
{
|
||||||
foreach ($this->helpdeskRuntimeComponentFiles() as $file) {
|
foreach ($this->helpdeskRuntimeComponentFiles() as $file) {
|
||||||
|
|||||||
@@ -1,32 +1,74 @@
|
|||||||
/* Error Debug Page — interactive behavior (inlined by ErrorRenderer) */
|
/* Error Debug Page — interactive behavior (inlined by ErrorRenderer) */
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
function initErrorDebug(root = document) {
|
||||||
// ── Tab switching ──────────────────────────────────
|
if (!root || typeof root.querySelectorAll !== 'function') {
|
||||||
const tabs = document.querySelectorAll('.app-error-debug-tab');
|
return { destroy: () => {} };
|
||||||
const panels = document.querySelectorAll('.app-error-debug-panel');
|
}
|
||||||
|
|
||||||
|
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) => {
|
const activateTab = (tab) => {
|
||||||
|
if (!(tab instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = tab.dataset.panel;
|
const target = tab.dataset.panel;
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach((value) => {
|
||||||
tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
|
if (value instanceof HTMLElement) {
|
||||||
tabs.forEach(t => t.setAttribute('tabindex', '-1'));
|
value.classList.remove('active');
|
||||||
panels.forEach(p => p.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.classList.add('active');
|
||||||
tab.setAttribute('aria-selected', 'true');
|
tab.setAttribute('aria-selected', 'true');
|
||||||
tab.setAttribute('tabindex', '0');
|
tab.setAttribute('tabindex', '0');
|
||||||
const panel = document.getElementById(target);
|
const panel = root.getElementById(target || '');
|
||||||
if (panel) panel.classList.add('active');
|
if (panel instanceof HTMLElement) {
|
||||||
|
panel.classList.add('active');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tabs.forEach(tab => {
|
tabs.forEach((tab) => {
|
||||||
tab.addEventListener('click', () => {
|
if (!(tab instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bind(tab, 'click', () => {
|
||||||
activateTab(tab);
|
activateTab(tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
tab.addEventListener('keydown', (event) => {
|
bind(tab, 'keydown', (event) => {
|
||||||
if (tabs.length === 0) return;
|
if (!(event instanceof KeyboardEvent) || tabs.length === 0) {
|
||||||
const currentIndex = Array.prototype.indexOf.call(tabs, tab);
|
return;
|
||||||
if (currentIndex < 0) return;
|
}
|
||||||
|
const currentIndex = tabs.indexOf(tab);
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let nextIndex = currentIndex;
|
let nextIndex = currentIndex;
|
||||||
if (event.key === 'ArrowRight') {
|
if (event.key === 'ArrowRight') {
|
||||||
@@ -43,58 +85,81 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const nextTab = tabs[nextIndex];
|
const nextTab = tabs[nextIndex];
|
||||||
if (!nextTab) return;
|
if (!(nextTab instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
activateTab(nextTab);
|
activateTab(nextTab);
|
||||||
nextTab.focus();
|
nextTab.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Frame expand/collapse ──────────────────────────
|
root.querySelectorAll('.app-error-debug-frame__toggle').forEach((toggle) => {
|
||||||
document.querySelectorAll('.app-error-debug-frame__toggle').forEach(toggle => {
|
bind(toggle, 'click', () => {
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
const frame = toggle.closest('.app-error-debug-frame');
|
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');
|
const isOpen = frame.classList.toggle('open');
|
||||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure ARIA reflects initial state.
|
root.querySelectorAll('.app-error-debug-frame').forEach((frame) => {
|
||||||
document.querySelectorAll('.app-error-debug-frame').forEach(frame => {
|
|
||||||
const toggle = frame.querySelector('.app-error-debug-frame__toggle');
|
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');
|
toggle.setAttribute('aria-expanded', frame.classList.contains('open') ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Query expand/collapse ──────────────────────────
|
root.querySelectorAll('.app-error-debug-query__toggle').forEach((toggle) => {
|
||||||
document.querySelectorAll('.app-error-debug-query__toggle').forEach(toggle => {
|
bind(toggle, 'click', () => {
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
const query = toggle.closest('.app-error-debug-query');
|
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');
|
const isOpen = query.classList.toggle('open');
|
||||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Copy to clipboard ─────────────────────────────
|
root.querySelectorAll('.app-error-debug-copy-button').forEach((button) => {
|
||||||
document.querySelectorAll('.app-error-debug-copy-button').forEach(button => {
|
bind(button, 'click', async (event) => {
|
||||||
button.addEventListener('click', async (e) => {
|
event.stopPropagation();
|
||||||
e.stopPropagation();
|
if (!(button instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const text = button.dataset.copy;
|
const text = button.dataset.copy;
|
||||||
if (!text) return;
|
if (!text || !navigator?.clipboard?.writeText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
button.classList.add('copied');
|
button.classList.add('copied');
|
||||||
const original = button.innerHTML;
|
const original = button.innerHTML;
|
||||||
button.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
button.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
|
||||||
setTimeout(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
button.classList.remove('copied');
|
button.classList.remove('copied');
|
||||||
button.innerHTML = original;
|
button.innerHTML = original;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
cleanupFns.push(() => window.clearTimeout(timerId));
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API may not be available in all contexts.
|
// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,45 @@ const noop = () => {};
|
|||||||
|
|
||||||
const defaultErrorMessage = (moduleId) => `${moduleId} grid init failed`;
|
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 = {}) {
|
export function createListPageModule(options = {}) {
|
||||||
const {
|
const {
|
||||||
configId,
|
configId,
|
||||||
|
|||||||
Reference in New Issue
Block a user