agent foundation

This commit is contained in:
2026-03-06 00:44:52 +01:00
parent 9819cba733
commit 9a08f96c11
199 changed files with 8522 additions and 1880 deletions

View File

@@ -0,0 +1,58 @@
@layer components {
dialog[data-app-confirm-dialog] > article.app-confirm-dialog {
max-width: 32rem;
}
dialog[data-app-confirm-dialog] > article.app-confirm-dialog > header {
position: relative;
padding-inline-end: 2rem;
}
dialog[data-app-confirm-dialog] > article.app-confirm-dialog > header h2 {
margin: 0;
}
dialog[data-app-confirm-dialog] [data-app-confirm-close].close {
position: absolute;
inset-inline-end: 1rem;
inset-block-start: 1rem;
float: none;
margin: 0;
}
dialog[data-app-confirm-dialog] [data-app-confirm-message] {
margin-bottom: 0.5rem;
white-space: pre-wrap;
}
dialog[data-app-confirm-dialog] [data-app-confirm-note] {
margin-top: 0;
margin-bottom: 0;
}
dialog[data-app-confirm-dialog] footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 10px;
}
dialog[data-app-confirm-dialog] footer button {
margin: 0;
}
dialog[data-app-confirm-dialog][data-confirm-variant="danger"] > article {
border-top: 3px solid var(--app-del-color, #b42318);
}
dialog[data-app-confirm-dialog][data-confirm-variant="warning"] > article {
border-top: 3px solid var(--app-warning-color, #b54708);
}
@media (max-width: 576px) {
dialog[data-app-confirm-dialog] footer {
flex-direction: column-reverse;
align-items: stretch;
}
}
}

View File

@@ -5,6 +5,7 @@ aside.aside-icon-bar {
--app-icon-bar-border-width: 3px;
--app-icon-bar-hover-bg: var(--app-accordion-border-color);
--app-icon-bar-border: 1px solid var(--app-border);
--app-icon-bar-mobile-safe-space: calc(3.5rem + env(safe-area-inset-bottom));
display: none;
}
@@ -104,5 +105,13 @@ aside.aside-icon-bar li button.active {
border-left: 0;
border-top: var(--app-icon-bar-border-width) solid var(--app-primary);
}
.app-main-content,
footer.site-footer {
padding-bottom: max(
var(--app-spacing),
var(--app-icon-bar-mobile-safe-space)
);
}
}
}

View File

@@ -218,7 +218,6 @@
padding: 0 var(--app-spacing);
font-size: var(--app-sidebar-summary-size);
color: var(--app-muted-color);
line-height: 1;
}
nav#aside-panel-people {

View File

@@ -1,3 +1,4 @@
import './core/app-telemetry.js';
import './components/app-flash-auto-dismiss.js';
import './components/app-tabs.js';
import './components/app-fslightbox-refresh.js';
@@ -20,3 +21,4 @@ import './components/app-color-default-toggle.js';
import './components/app-custom-field-options-toggle.js';
import './components/app-tenant-sso-toggle.js';
import './components/app-standard-detail-page.js';
import './components/app-settings-telemetry.js';

View File

@@ -1,2 +1,3 @@
import './core/app-telemetry.js';
import './components/app-flash-auto-dismiss.js';
import './components/app-password-hints.js';

View File

@@ -1,4 +1,10 @@
import { confirmDialog, requestSubmitWithFallback } from '../core/app-confirm-dialog.js';
const CONFIRM_ATTR = 'data-confirm-message';
const confirmedSubmitterByForm = new WeakMap();
const confirmedClickElements = new WeakSet();
const trimAttr = (element, attr) => String(element?.getAttribute(attr) || '').trim();
const isSubmitControl = (element) => {
if (!(element instanceof HTMLElement)) {
@@ -16,6 +22,38 @@ const isSubmitControl = (element) => {
};
const submitterByForm = new Map();
const belongsToForm = (control, form) => {
if (!(control instanceof HTMLElement) || !(form instanceof HTMLFormElement)) {
return false;
}
const owner = control.form;
if (owner instanceof HTMLFormElement) {
return owner === form;
}
const controlFormAttr = trimAttr(control, 'form');
const formId = trimAttr(form, 'id');
if (controlFormAttr !== '' && formId !== '') {
return controlFormAttr === formId;
}
return form.contains(control);
};
const readConfirmOption = (primary, fallback, suffix) => {
const attr = `data-confirm-${suffix}`;
return trimAttr(primary, attr) || trimAttr(fallback, attr);
};
const buildConfirmOptions = (message, primary, fallback) => ({
message,
title: readConfirmOption(primary, fallback, 'title'),
confirmLabel: readConfirmOption(primary, fallback, 'confirm-label'),
cancelLabel: readConfirmOption(primary, fallback, 'cancel-label'),
variant: readConfirmOption(primary, fallback, 'variant'),
focus: readConfirmOption(primary, fallback, 'focus'),
describedBy: readConfirmOption(primary, fallback, 'describedby')
|| readConfirmOption(primary, fallback, 'described-by'),
actionKind: readConfirmOption(primary, fallback, 'action-kind')
});
const onClickCapture = (event) => {
const trigger = event.target instanceof HTMLElement ? event.target.closest('a, button, input') : null;
@@ -23,6 +61,11 @@ const onClickCapture = (event) => {
return;
}
if (confirmedClickElements.has(trigger)) {
confirmedClickElements.delete(trigger);
return;
}
if (isSubmitControl(trigger)) {
const form = trigger.form instanceof HTMLFormElement ? trigger.form : null;
if (form) {
@@ -36,10 +79,17 @@ const onClickCapture = (event) => {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
event.preventDefault();
event.stopPropagation();
void confirmDialog.confirm(buildConfirmOptions(confirmMessage, trigger, null))
.then((approved) => {
if (!approved || !document.contains(trigger)) {
return;
}
confirmedClickElements.add(trigger);
trigger.click();
});
};
const onSubmitCapture = (event) => {
@@ -53,6 +103,12 @@ const onSubmitCapture = (event) => {
: (submitterByForm.get(form) || null);
submitterByForm.delete(form);
const confirmedSubmitter = confirmedSubmitterByForm.get(form);
if (confirmedSubmitter && (confirmedSubmitter === true || confirmedSubmitter === submitter || confirmedSubmitter === null)) {
confirmedSubmitterByForm.delete(form);
return;
}
const submitterMessage = String(submitter?.getAttribute(CONFIRM_ATTR) || '').trim();
const formMessage = String(form.getAttribute(CONFIRM_ATTR) || '').trim();
const confirmMessage = submitterMessage !== '' ? submitterMessage : formMessage;
@@ -60,10 +116,19 @@ const onSubmitCapture = (event) => {
if (confirmMessage === '') {
return;
}
if (!window.confirm(confirmMessage)) {
event.preventDefault();
event.stopPropagation();
}
event.preventDefault();
event.stopPropagation();
void confirmDialog.confirm(buildConfirmOptions(confirmMessage, submitter, form))
.then((approved) => {
if (!approved || !document.contains(form)) {
return;
}
const submitterForSubmit = submitter && belongsToForm(submitter, form) ? submitter : null;
confirmedSubmitterByForm.set(form, submitterForSubmit || true);
requestSubmitWithFallback(form, submitterForSubmit);
});
};
document.addEventListener('click', onClickCapture, true);

View File

@@ -0,0 +1,35 @@
const initTelemetrySettings = () => {
const panel = document.querySelector('[data-tab-panel="telemetry"]');
if (!panel) {
return;
}
const toggle = panel.querySelector('[data-telemetry-enabled-toggle]');
const samplingFieldset = panel.querySelector('[data-telemetry-sampling-fieldset]');
const samplingRow = panel.querySelector('[data-telemetry-sampling-row]');
const samplingSelect = panel.querySelector('[data-telemetry-sampling-select]');
if (!toggle || !samplingFieldset) {
return;
}
const syncSamplingVisibility = () => {
const active = Boolean(toggle.checked);
samplingFieldset.hidden = !active;
if (samplingRow) {
samplingRow.hidden = !active;
}
if (samplingSelect) {
samplingSelect.disabled = !active || toggle.disabled;
}
};
toggle.addEventListener('change', syncSamplingVisibility);
syncSamplingVisibility();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTelemetrySettings);
} else {
initTelemetrySettings();
}

View File

@@ -0,0 +1,325 @@
import { warnOnce } from './app-dom.js';
const VARIANTS = new Set(['default', 'warning', 'danger']);
const FOCUS_TARGETS = new Set(['confirm', 'cancel']);
const DANGER_KEYWORDS = /(delete|deleted|purge|revoke|remove|destroy|danger|erase|drop|wipe|lösch|loesch|entfern|widerruf)/i;
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'a[href]',
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
const trimValue = (value) => String(value ?? '').trim();
const getNativeConfirm = () => {
const fallback = window?.['confirm'];
return typeof fallback === 'function' ? fallback.bind(window) : null;
};
const normalizeVariant = (value) => {
const variant = trimValue(value).toLowerCase();
return VARIANTS.has(variant) ? variant : '';
};
const normalizeFocus = (value) => {
const focus = trimValue(value).toLowerCase();
return FOCUS_TARGETS.has(focus) ? focus : '';
};
const inferVariant = ({ variant = '', actionKind = '', message = '' } = {}) => {
const explicit = normalizeVariant(variant);
if (explicit !== '') {
return explicit;
}
const action = trimValue(actionKind).toLowerCase();
if (action.includes('danger') || action.includes('delete') || action.includes('purge') || action.includes('revoke')) {
return 'danger';
}
return DANGER_KEYWORDS.test(trimValue(message)) ? 'danger' : 'default';
};
const getFocusableElements = (dialog) => Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR))
.filter((element) => !element.hasAttribute('hidden'))
.filter((element) => element.getAttribute('aria-hidden') !== 'true');
const requestSubmitWithFallback = (form, submitter) => {
if (!(form instanceof HTMLFormElement)) {
return;
}
if (typeof form.requestSubmit === 'function') {
if (submitter instanceof HTMLElement) {
try {
form.requestSubmit(submitter);
return;
} catch {
// Fallback below.
}
}
form.requestSubmit();
return;
}
if (submitter instanceof HTMLElement && typeof submitter.click === 'function') {
submitter.click();
return;
}
form.submit();
};
class ConfirmDialogController {
constructor() {
this.queue = Promise.resolve();
}
enqueue(task) {
const next = this.queue.then(task, task);
this.queue = next.then(() => undefined, () => undefined);
return next;
}
resolveElements() {
const dialog = document.querySelector('[data-app-confirm-dialog]');
if (!(dialog instanceof HTMLDialogElement)) {
return null;
}
const titleEl = dialog.querySelector('[data-app-confirm-title]');
const messageEl = dialog.querySelector('[data-app-confirm-message]');
const noteEl = dialog.querySelector('[data-app-confirm-note]');
const cancelButton = dialog.querySelector('[data-app-confirm-cancel]');
const confirmButton = dialog.querySelector('[data-app-confirm-confirm]');
const closeButton = dialog.querySelector('[data-app-confirm-close]');
if (!(titleEl instanceof HTMLElement) || !(messageEl instanceof HTMLElement) || !(cancelButton instanceof HTMLButtonElement) || !(confirmButton instanceof HTMLButtonElement)) {
warnOnce('UI_CONFIRM_DIALOG_INVALID', 'Missing confirm dialog elements', { module: 'confirm-dialog' });
return null;
}
return {
dialog,
titleEl,
messageEl,
noteEl: noteEl instanceof HTMLElement ? noteEl : null,
cancelButton,
confirmButton,
closeButton: closeButton instanceof HTMLButtonElement ? closeButton : null
};
}
fallbackConfirm(options = {}) {
const message = trimValue(options.message);
if (message === '') {
return Promise.resolve(true);
}
const fallback = getNativeConfirm();
if (!fallback) {
return Promise.resolve(false);
}
return Promise.resolve(Boolean(fallback(message)));
}
run(options = {}) {
const message = trimValue(options.message);
if (message === '') {
return Promise.resolve(true);
}
const elements = this.resolveElements();
if (!elements || typeof elements.dialog.showModal !== 'function') {
return this.fallbackConfirm({ message });
}
return new Promise((resolve) => {
const {
dialog,
titleEl,
messageEl,
noteEl,
cancelButton,
confirmButton,
closeButton
} = elements;
const variant = inferVariant(options);
const focusTarget = normalizeFocus(options.focus) || (variant === 'danger' ? 'cancel' : 'confirm');
const dataset = dialog.dataset || {};
const title = trimValue(options.title) || trimValue(dataset.defaultTitle) || 'Please confirm';
const confirmLabel = trimValue(options.confirmLabel) || trimValue(dataset.defaultConfirmLabel) || 'Confirm';
const cancelLabel = trimValue(options.cancelLabel) || trimValue(dataset.defaultCancelLabel) || 'Cancel';
const describedByExternal = trimValue(options.describedBy);
const note = trimValue(options.note) || (variant === 'danger' ? trimValue(dataset.defaultDangerNote) : '');
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const cleanupCallbacks = [];
let resolved = false;
const addCleanup = (fn) => {
cleanupCallbacks.push(fn);
};
const cleanup = () => {
while (cleanupCallbacks.length > 0) {
const fn = cleanupCallbacks.pop();
try {
fn();
} catch {
// no-op
}
}
};
const finish = (approved) => {
if (resolved) {
return;
}
resolved = true;
cleanup();
try {
if (dialog.open) {
dialog.close(approved ? 'confirm' : 'cancel');
}
} catch {
// no-op
}
document.body.classList.remove('modal-is-open');
if (activeElement && document.contains(activeElement)) {
window.requestAnimationFrame(() => {
activeElement.focus({ preventScroll: true });
});
}
resolve(Boolean(approved));
};
const onCancelClick = (event) => {
event.preventDefault();
finish(false);
};
const onCloseClick = (event) => {
event.preventDefault();
finish(false);
};
const onConfirmClick = (event) => {
event.preventDefault();
finish(true);
};
const onDialogCancel = (event) => {
event.preventDefault();
finish(false);
};
const onBackdropClick = (event) => {
if (event.target === dialog) {
finish(false);
}
};
const onClose = () => {
finish(dialog.returnValue === 'confirm');
};
const onKeyDown = (event) => {
if (event.key !== 'Tab') {
return;
}
const focusable = getFocusableElements(dialog);
if (!focusable.length) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !dialog.contains(active)) {
event.preventDefault();
last.focus();
}
return;
}
if (active === last) {
event.preventDefault();
first.focus();
}
};
titleEl.textContent = title;
messageEl.textContent = message;
cancelButton.textContent = cancelLabel;
confirmButton.textContent = confirmLabel;
dialog.setAttribute('data-confirm-variant', variant);
confirmButton.classList.remove('primary', 'warning', 'danger');
if (variant === 'danger') {
confirmButton.classList.add('danger');
} else if (variant === 'warning') {
confirmButton.classList.add('warning');
} else {
confirmButton.classList.add('primary');
}
if (noteEl) {
if (note === '') {
noteEl.hidden = true;
noteEl.textContent = '';
} else {
noteEl.hidden = false;
noteEl.textContent = note;
}
}
const describedByIds = [
trimValue(messageEl.id),
noteEl && !noteEl.hidden ? trimValue(noteEl.id) : '',
describedByExternal
].filter(Boolean);
if (describedByIds.length) {
dialog.setAttribute('aria-describedby', describedByIds.join(' '));
} else {
dialog.removeAttribute('aria-describedby');
}
cancelButton.addEventListener('click', onCancelClick);
if (closeButton) {
closeButton.addEventListener('click', onCloseClick);
}
confirmButton.addEventListener('click', onConfirmClick);
dialog.addEventListener('cancel', onDialogCancel);
dialog.addEventListener('click', onBackdropClick);
dialog.addEventListener('close', onClose);
dialog.addEventListener('keydown', onKeyDown);
addCleanup(() => cancelButton.removeEventListener('click', onCancelClick));
if (closeButton) {
addCleanup(() => closeButton.removeEventListener('click', onCloseClick));
}
addCleanup(() => confirmButton.removeEventListener('click', onConfirmClick));
addCleanup(() => dialog.removeEventListener('cancel', onDialogCancel));
addCleanup(() => dialog.removeEventListener('click', onBackdropClick));
addCleanup(() => dialog.removeEventListener('close', onClose));
addCleanup(() => dialog.removeEventListener('keydown', onKeyDown));
document.body.classList.add('modal-is-open');
try {
dialog.showModal();
} catch {
cleanup();
document.body.classList.remove('modal-is-open');
resolve(this.fallbackConfirm({ message }));
return;
}
window.requestAnimationFrame(() => {
if (focusTarget === 'cancel') {
cancelButton.focus();
return;
}
confirmButton.focus();
});
});
}
confirm(options = {}) {
return this.enqueue(() => this.run(options));
}
}
export const confirmDialog = new ConfirmDialogController();
export { inferVariant as inferConfirmVariant, normalizeVariant as normalizeConfirmVariant, normalizeFocus as normalizeConfirmFocus, requestSubmitWithFallback };

View File

@@ -1,4 +1,5 @@
import { initDetailActionPolicy } from './app-details-action-policy.js';
import { confirmDialog } from './app-confirm-dialog.js';
const DEFAULT_UNSAVED_MESSAGE = 'You have unsaved changes. Leave without saving?';
@@ -434,17 +435,25 @@ export function initStandardDetailPage(options = {}) {
return '';
};
const onBackLinkClick = (event) => {
const onBackLinkClick = async (event) => {
if (!shouldWarn()) {
return;
}
const leaveWithoutSaving = window.confirm(unsavedMessage);
event.preventDefault();
const leaveWithoutSaving = await confirmDialog.confirm({
title: unsavedIndicatorLabel,
message: unsavedMessage,
variant: 'warning',
focus: 'cancel'
});
if (!leaveWithoutSaving) {
event.preventDefault();
return;
}
// Avoid a second native beforeunload prompt after explicit confirmation.
allowNextUnload = true;
if (backLink instanceof HTMLAnchorElement) {
window.location.href = backLink.href;
}
};
const onFormStateChange = () => {

View File

@@ -1,3 +1,5 @@
import { confirmDialog, requestSubmitWithFallback } from './app-confirm-dialog.js';
const escapeAttr = (value) => {
const raw = String(value || '');
if (typeof CSS !== 'undefined' && CSS && typeof CSS.escape === 'function') {
@@ -111,6 +113,36 @@ const readConfirmMessage = (submitter, form) => {
return String(form.getAttribute('data-detail-confirm-message') || '').trim();
};
const readConfirmOption = (submitter, form, detailSuffix, genericSuffix = detailSuffix) => {
const detailAttr = `data-detail-confirm-${detailSuffix}`;
const genericAttr = `data-confirm-${genericSuffix}`;
return String(
submitter?.getAttribute(detailAttr)
|| submitter?.getAttribute(genericAttr)
|| form.getAttribute(detailAttr)
|| form.getAttribute(genericAttr)
|| ''
).trim();
};
const readActionKind = (submitter, form) => String(
submitter?.getAttribute('data-detail-action-kind')
|| form.getAttribute('data-detail-action-kind')
|| ''
).trim();
const buildConfirmOptions = (message, submitter, form) => ({
message,
title: readConfirmOption(submitter, form, 'title'),
confirmLabel: readConfirmOption(submitter, form, 'confirm-label'),
cancelLabel: readConfirmOption(submitter, form, 'cancel-label'),
variant: readConfirmOption(submitter, form, 'variant'),
focus: readConfirmOption(submitter, form, 'focus'),
describedBy: readConfirmOption(submitter, form, 'describedby')
|| readConfirmOption(submitter, form, 'described-by'),
actionKind: readActionKind(submitter, form)
});
const readSubmitLockMode = (submitter, form) => normalizeSubmitLock(
submitter?.getAttribute('data-detail-submit-lock')
|| form.getAttribute('data-detail-submit-lock')
@@ -153,6 +185,7 @@ export function initDetailActionPolicy(options = {}) {
const submittingForms = new Set();
const submitterByForm = new Map();
const confirmedSubmitterByForm = new WeakMap();
const onClickCapture = (event) => {
const target = event.target instanceof HTMLElement
@@ -181,10 +214,26 @@ export function initDetailActionPolicy(options = {}) {
return;
}
const confirmMessage = readConfirmMessage(submitter, form);
if (confirmMessage !== '' && !window.confirm(confirmMessage)) {
event.preventDefault();
return;
const bypassSubmitter = confirmedSubmitterByForm.get(form);
if (bypassSubmitter && (bypassSubmitter === true || bypassSubmitter === submitter || bypassSubmitter === null)) {
confirmedSubmitterByForm.delete(form);
} else {
const confirmMessage = readConfirmMessage(submitter, form);
if (confirmMessage !== '') {
event.preventDefault();
event.stopPropagation();
void confirmDialog.confirm(buildConfirmOptions(confirmMessage, submitter, form))
.then((approved) => {
if (!approved || !document.contains(form)) {
return;
}
const submitterForSubmit = submitter && belongsToForm(submitter, form) ? submitter : null;
confirmedSubmitterByForm.set(form, submitterForSubmit || true);
requestSubmitWithFallback(form, submitterForSubmit);
});
return;
}
}
if (lockMode === 'none') {

View File

@@ -1,10 +1,37 @@
import { telemetry } from './app-telemetry.js';
const warned = new Set();
export const warnOnce = (code, message, details = {}) => {
const key = `${code}:${message}`;
const normalizedCode = String(code ?? '').trim() || 'UI_WARN';
const normalizedMessage = String(message ?? '').trim();
const key = `${normalizedCode}:${normalizedMessage}`;
if (warned.has(key)) {return;}
warned.add(key);
console.warn(`[${code}] ${message}`, details);
console.warn(`[${normalizedCode}] ${normalizedMessage}`, details);
const warnMeta = {
source: 'warn_once',
code: normalizedCode,
};
if (details && typeof details === 'object' && !Array.isArray(details)) {
if (typeof details.module === 'string' && details.module.trim() !== '') {
warnMeta.module = details.module.trim();
}
if (typeof details.component === 'string' && details.component.trim() !== '') {
warnMeta.component = details.component.trim();
}
if (typeof details.feature === 'string' && details.feature.trim() !== '') {
warnMeta.feature = details.feature.trim();
}
}
telemetry.capture('frontend.warn_once', {
severity: 'warning',
message: `[${normalizedCode}] ${normalizedMessage}`,
meta: warnMeta,
});
};
export const requireEl = (selector, details = {}) => {

View File

@@ -1,4 +1,5 @@
import { warnOnce } from './app-dom.js';
import { confirmDialog } from './app-confirm-dialog.js';
import { gridFiltersFromSchema } from '../pages/app-list-utils.js';
import { initListFilterExperience } from '../pages/app-list-filter-experience.js';
import { buildChipsFromMeta, removeChipFromMetaState, clearMetaState } from '../pages/app-list-filter-state.js';
@@ -58,6 +59,42 @@ const escapeHtmlAttr = (value) => String(value ?? '')
.replaceAll('>', '>')
.replaceAll('"', '"');
const normalizeColumnIdPart = (value) => String(value ?? '')
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '_')
.replaceAll(/^_+|_+$/g, '');
const uniqueColumnId = (candidate, usedIds, fallbackPrefix = 'col') => {
const base = normalizeColumnIdPart(candidate) || fallbackPrefix;
let next = base;
let counter = 2;
while (usedIds.has(next)) {
next = `${base}_${counter}`;
counter += 1;
}
usedIds.add(next);
return next;
};
const normalizeGridColumns = (columns, sortColumns) => {
const source = Array.isArray(columns) ? columns : [];
const sortKeys = Array.isArray(sortColumns) ? sortColumns : [];
const usedIds = new Set();
return source.map((column, index) => {
const baseColumn = (column && typeof column === 'object' && !Array.isArray(column))
? column
: { name: String(column ?? '') };
const explicitId = typeof baseColumn.id === 'string' ? baseColumn.id : '';
const sortKey = typeof sortKeys[index] === 'string' ? sortKeys[index] : '';
const label = typeof baseColumn.name === 'string' ? baseColumn.name : '';
const fallback = `col_${index + 1}`;
const id = uniqueColumnId(explicitId || sortKey || label || fallback, usedIds, fallback);
return { ...baseColumn, id };
});
};
export function createServerGrid(options) {
const {
container,
@@ -99,6 +136,8 @@ export function createServerGrid(options) {
return null;
}
const normalizedColumns = normalizeGridColumns(columns, sortColumns);
const params = new URLSearchParams(window.location.search);
const urlStateConfig = {
pageParam: 'page',
@@ -428,6 +467,7 @@ export function createServerGrid(options) {
};
const explicitActionConfig = actions && actions.enabled ? {
id: String(actions.id || 'row_actions').trim() || 'row_actions',
label: actions.label || 'Actions',
index: Number.isInteger(actions.index) ? actions.index : null,
items: Array.isArray(actions.items) ? actions.items : [],
@@ -436,6 +476,7 @@ export function createServerGrid(options) {
onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null;
const rowOpenActionConfig = !explicitActionConfig && showRowActionButton ? {
id: 'row_actions',
label: gridjsLib.html(
`<span class="app-grid-actions-header" data-tooltip="${escapeHtmlAttr(rowEnterHint)}" data-tooltip-pos="top" aria-label="${escapeHtmlAttr(rowEnterHint)}">${escapeHtmlAttr(rowActionColumnLabel)}</span>`
),
@@ -470,9 +511,25 @@ export function createServerGrid(options) {
selectAllEnabled: selection.selectAllEnabled !== false
} : null;
if (selectionConfig) {
const usedColumnIds = new Set(
normalizedColumns
.map((column) => (column && typeof column === 'object' ? String(column.id || '').trim() : ''))
.filter(Boolean)
);
selectionConfig.id = uniqueColumnId(selectionConfig.id, usedColumnIds, 'select_row');
}
const insertActionColumn = (cols) => {
if (!actionConfig) {return cols;}
const usedColumnIds = new Set(
cols
.map((column) => (column && typeof column === 'object' ? String(column.id || '').trim() : ''))
.filter(Boolean)
);
const actionColumnId = uniqueColumnId(actionConfig.id, usedColumnIds, 'row_actions');
const actionColumn = {
id: actionColumnId,
name: actionConfig.label,
sort: false,
formatter: (_cell, row) => {
@@ -642,7 +699,7 @@ export function createServerGrid(options) {
}
const grid = new gridjsLib.Grid({
columns: insertActionColumn(insertSelectionColumn(columns)),
columns: insertActionColumn(insertSelectionColumn(normalizedColumns)),
server: serverConfig,
search: false,
sort: sortConfig,
@@ -890,8 +947,18 @@ export function createServerGrid(options) {
const confirmText = typeof actionItem.confirm === 'function'
? actionItem.confirm({ row: rowData, id })
: actionItem.confirm;
if (confirmText && !window.confirm(confirmText)) {
return;
if (confirmText) {
const confirmOptionsFromItem = typeof actionItem.confirmOptions === 'function'
? (actionItem.confirmOptions({ row: rowData, id, action: actionKey }) || {})
: (actionItem.confirmOptions || {});
const approved = await confirmDialog.confirm({
...confirmOptionsFromItem,
message: String(confirmText),
actionKind: String(confirmOptionsFromItem.actionKind || actionKey || '')
});
if (!approved) {
return;
}
}
if (actionItem.type === 'link') {
const href = typeof actionItem.href === 'function'

View File

@@ -0,0 +1,565 @@
const DEFAULT_SAMPLE_RATE = 0.2;
const DEFAULT_DEDUPE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_MAX_EVENTS_PER_MINUTE = 20;
const MAX_MESSAGE_LENGTH = 280;
const MAX_FINGERPRINT_LENGTH = 96;
const MAX_META_ENTRIES = 12;
const MAX_META_VALUE_LENGTH = 140;
const MAX_META_JSON_LENGTH = 1800;
const EVENT_KEY_TO_TYPE = Object.freeze({
warn_once: 'frontend.warn_once',
ajax_error: 'frontend.ajax_error',
});
const EVENT_TYPE_TO_KEY = Object.freeze({
'frontend.warn_once': 'warn_once',
'frontend.ajax_error': 'ajax_error',
});
const DEFAULT_ALLOWED_EVENT_KEYS = Object.freeze(['warn_once', 'ajax_error']);
const DEFAULT_ALLOWED_EVENT_TYPES = new Set(DEFAULT_ALLOWED_EVENT_KEYS.map((key) => EVENT_KEY_TO_TYPE[key]));
const AJAX_ERROR_TYPE = EVENT_KEY_TO_TYPE.ajax_error;
const ALLOWED_META_KEYS = new Set([
'action',
'code',
'component',
'error_code',
'feature',
'http_method',
'http_status',
'location',
'module',
'page_request_id',
'request_path',
'source',
'status_text',
]);
const seenFingerprints = new Map();
let fetchHookInstalled = false;
let clientRateWindowStartMs = 0;
let clientRateWindowCount = 0;
const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
const clampRate = (value, fallback = DEFAULT_SAMPLE_RATE) => {
const numeric = Number.parseFloat(String(value ?? ''));
if (!Number.isFinite(numeric)) {
return fallback;
}
if (numeric < 0) {return 0;}
if (numeric > 1) {return 1;}
return numeric;
};
const clampPositiveInt = (value, fallback) => {
const numeric = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(numeric) || numeric < 1) {
return fallback;
}
return numeric;
};
const normalizeEventType = (eventType) => {
const normalized = normalizeWhitespace(eventType).toLowerCase();
if (normalized in EVENT_KEY_TO_TYPE) {
return EVENT_KEY_TO_TYPE[normalized];
}
if (normalized in EVENT_TYPE_TO_KEY) {
return normalized;
}
return '';
};
const normalizeSeverity = (severity, eventType) => {
const normalized = normalizeWhitespace(severity).toLowerCase();
if (normalized === 'error' || normalized === 'warning' || normalized === 'info') {
return normalized;
}
return eventType === AJAX_ERROR_TYPE ? 'error' : 'warning';
};
const normalizeRoute = (value) => {
const raw = normalizeWhitespace(value);
if (raw === '') {
return normalizeWhitespace(window.location.pathname || '/') || '/';
}
try {
const url = new URL(raw, window.location.href);
if (url.origin !== window.location.origin) {
return '';
}
return normalizeWhitespace(url.pathname || '/') || '/';
} catch {
const stripped = raw.split('?')[0].split('#')[0];
return normalizeWhitespace(stripped || '/') || '/';
}
};
const normalizeMessage = (value) => {
const normalized = normalizeWhitespace(value);
if (normalized === '') {
return '';
}
return normalized.slice(0, MAX_MESSAGE_LENGTH);
};
const normalizeFingerprint = (value) => {
const normalized = normalizeWhitespace(value).toLowerCase();
if (normalized === '') {
return '';
}
const safe = normalized.replace(/[^a-z0-9:_-]/g, '');
return safe.slice(0, MAX_FINGERPRINT_LENGTH);
};
const hashString = (value) => {
let hash = 2166136261;
const source = String(value ?? '');
for (let index = 0; index < source.length; index += 1) {
hash ^= source.charCodeAt(index);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return (hash >>> 0).toString(16).padStart(8, '0');
};
const buildFingerprint = (eventType, message, route) => normalizeFingerprint(
`v1_${hashString(`${eventType}|${route}|${message}`)}`
);
const normalizeOccurredAt = (value) => {
const raw = normalizeWhitespace(value);
if (raw !== '') {
const timestamp = Date.parse(raw);
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString();
}
}
return new Date().toISOString();
};
const normalizeMetaValue = (value) => {
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : '';
}
if (typeof value === 'string') {
return normalizeWhitespace(value).slice(0, MAX_META_VALUE_LENGTH);
}
if (value instanceof Error) {
return normalizeWhitespace(value.name || 'Error').slice(0, MAX_META_VALUE_LENGTH);
}
if (value === null || value === undefined) {
return '';
}
return normalizeWhitespace(String(value)).slice(0, MAX_META_VALUE_LENGTH);
};
const normalizeMeta = (meta) => {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
return {};
}
const normalized = {};
let index = 0;
Object.entries(meta).forEach(([rawKey, rawValue]) => {
if (index >= MAX_META_ENTRIES) {
return;
}
const key = normalizeWhitespace(rawKey).toLowerCase();
if (key === '' || !ALLOWED_META_KEYS.has(key)) {
return;
}
const value = normalizeMetaValue(rawValue);
if (value === '') {
return;
}
normalized[key] = value;
index += 1;
});
return normalized;
};
const normalizeAllowedEventKeys = (value) => {
const keys = new Set();
const raw = Array.isArray(value)
? value
: String(value ?? '')
.split(/[,\n]/g)
.map((entry) => entry.trim())
.filter(Boolean);
raw.forEach((entry) => {
const key = String(entry).trim().toLowerCase();
if (key in EVENT_KEY_TO_TYPE) {
keys.add(key);
} else if (key in EVENT_TYPE_TO_KEY) {
keys.add(EVENT_TYPE_TO_KEY[key]);
}
});
if (keys.size === 0) {
DEFAULT_ALLOWED_EVENT_KEYS.forEach((key) => keys.add(key));
}
return keys;
};
const resolveConfig = () => {
const root = document.documentElement;
if (!root) {
return {
enabled: false,
endpoint: '',
csrfKey: '',
csrfToken: '',
sampleRate: DEFAULT_SAMPLE_RATE,
maxEventsPerMinute: DEFAULT_MAX_EVENTS_PER_MINUTE,
allowedEventTypes: DEFAULT_ALLOWED_EVENT_TYPES,
};
}
const enabledRaw = normalizeWhitespace(root.dataset.telemetryEnabled || '');
const enabled = enabledRaw !== '' && ['1', 'true', 'yes', 'on'].includes(enabledRaw.toLowerCase());
const allowedEventKeys = normalizeAllowedEventKeys(root.dataset.telemetryAllowedEvents || '');
const allowedEventTypes = new Set();
allowedEventKeys.forEach((key) => {
const eventType = EVENT_KEY_TO_TYPE[key];
if (eventType) {
allowedEventTypes.add(eventType);
}
});
return {
enabled,
endpoint: normalizeWhitespace(root.dataset.telemetryUrl || ''),
csrfKey: normalizeWhitespace(root.dataset.telemetryCsrfKey || ''),
csrfToken: normalizeWhitespace(root.dataset.telemetryCsrfToken || ''),
sampleRate: clampRate(root.dataset.telemetrySampleRate || DEFAULT_SAMPLE_RATE, DEFAULT_SAMPLE_RATE),
maxEventsPerMinute: clampPositiveInt(
root.dataset.telemetryMaxEventsPerMinute || DEFAULT_MAX_EVENTS_PER_MINUTE,
DEFAULT_MAX_EVENTS_PER_MINUTE
),
allowedEventTypes: allowedEventTypes.size > 0 ? allowedEventTypes : DEFAULT_ALLOWED_EVENT_TYPES,
};
};
const shouldSample = (rate) => {
if (rate <= 0) {return false;}
if (rate >= 1) {return true;}
return Math.random() < rate;
};
const trimSeenFingerprints = (now = Date.now()) => {
seenFingerprints.forEach((expiresAt, key) => {
if (expiresAt <= now) {
seenFingerprints.delete(key);
}
});
};
const isDuplicateFingerprint = (fingerprint, ttlMs, now = Date.now()) => {
trimSeenFingerprints(now);
const expiresAt = seenFingerprints.get(fingerprint) || 0;
if (expiresAt > now) {
return true;
}
seenFingerprints.set(fingerprint, now + ttlMs);
return false;
};
const hasClientRateBudget = (maxEventsPerMinute, nowMs = Date.now()) => {
const limit = clampPositiveInt(maxEventsPerMinute, DEFAULT_MAX_EVENTS_PER_MINUTE);
const windowMs = 60 * 1000;
if (clientRateWindowStartMs <= 0 || (nowMs - clientRateWindowStartMs) >= windowMs) {
clientRateWindowStartMs = nowMs;
clientRateWindowCount = 0;
}
if (clientRateWindowCount >= limit) {
return false;
}
clientRateWindowCount += 1;
return true;
};
const toUrlSearchParams = (payload, config) => {
const body = new URLSearchParams();
body.set('event_type', payload.eventType);
body.set('severity', payload.severity);
body.set('message', payload.message);
body.set('fingerprint', payload.fingerprint);
body.set('occurred_at', payload.occurredAt);
const metaJson = JSON.stringify(payload.meta);
if (metaJson && metaJson.length <= MAX_META_JSON_LENGTH) {
body.set('meta', metaJson);
}
body.set(config.csrfKey, config.csrfToken);
return body;
};
const sendPayload = (payload, config) => {
if (!config.endpoint || !config.csrfKey || !config.csrfToken) {
return false;
}
const body = toUrlSearchParams(payload, config);
try {
if (navigator.sendBeacon && navigator.sendBeacon(config.endpoint, body)) {
return true;
}
} catch {
// continue with fetch fallback.
}
try {
void fetch(config.endpoint, {
method: 'POST',
credentials: 'same-origin',
keepalive: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'fetch',
'X-Telemetry-Request': '1',
},
body,
}).catch(() => {});
return true;
} catch {
return false;
}
};
const headerValue = (headers, key) => {
if (!headers) {
return '';
}
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
return normalizeWhitespace(headers.get(key) || '');
}
if (Array.isArray(headers)) {
const tuple = headers.find((entry) => Array.isArray(entry) && String(entry[0] || '').toLowerCase() === key.toLowerCase());
return tuple ? normalizeWhitespace(tuple[1] || '') : '';
}
if (typeof headers === 'object') {
const direct = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
return normalizeWhitespace(direct || '');
}
return '';
};
const fetchInputUrl = (input) => {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof Request !== 'undefined' && input instanceof Request) {
return input.url;
}
return '';
};
const fetchInputMethod = (input, init) => {
const initMethod = normalizeWhitespace(init?.method || '').toUpperCase();
if (initMethod !== '') {
return initMethod;
}
if (typeof Request !== 'undefined' && input instanceof Request) {
const requestMethod = normalizeWhitespace(input.method || '').toUpperCase();
if (requestMethod !== '') {
return requestMethod;
}
}
return 'GET';
};
const isTelemetryFetch = (input, init, telemetryPath) => {
if (telemetryPath === '') {
return false;
}
const requestHeaders = init?.headers;
const inputHeaders = typeof Request !== 'undefined' && input instanceof Request ? input.headers : null;
const marker = headerValue(requestHeaders, 'X-Telemetry-Request') || headerValue(inputHeaders, 'X-Telemetry-Request');
if (marker === '1') {
return true;
}
const requestPath = normalizeRoute(fetchInputUrl(input));
return requestPath !== '' && requestPath === telemetryPath;
};
const shouldTrackFetch = (input, init, config) => {
if (!config.enabled || !config.allowedEventTypes.has(AJAX_ERROR_TYPE)) {
return false;
}
const rawUrl = fetchInputUrl(input);
if (rawUrl === '') {
return false;
}
const path = normalizeRoute(rawUrl);
if (path === '') {
return false;
}
const telemetryPath = normalizeRoute(config.endpoint);
if (isTelemetryFetch(input, init, telemetryPath)) {
return false;
}
return true;
};
export const telemetry = {
capture(eventType, payload = {}, options = {}) {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return false;
}
const normalizedEventType = normalizeEventType(eventType);
if (normalizedEventType === '') {
return false;
}
const config = resolveConfig();
if (!config.enabled || !config.allowedEventTypes.has(normalizedEventType)) {
return false;
}
const message = normalizeMessage(payload.message || '');
if (message === '') {
return false;
}
const route = normalizeRoute(payload.route || window.location.pathname || '/');
const fingerprint = normalizeFingerprint(payload.fingerprint || '')
|| buildFingerprint(normalizedEventType, message, route);
if (fingerprint === '') {
return false;
}
const dedupeTtlMs = Math.max(0, Number.parseInt(String(options.dedupeTtlMs ?? DEFAULT_DEDUPE_TTL_MS), 10) || 0);
if (!options.skipDedupe && dedupeTtlMs > 0 && isDuplicateFingerprint(fingerprint, dedupeTtlMs)) {
return false;
}
const sampleRate = clampRate(
options.sampleRate !== undefined ? options.sampleRate : config.sampleRate,
config.sampleRate
);
if (!shouldSample(sampleRate)) {
return false;
}
const maxEventsPerMinute = clampPositiveInt(
options.maxEventsPerMinute !== undefined ? options.maxEventsPerMinute : config.maxEventsPerMinute,
config.maxEventsPerMinute
);
if (!options.skipClientRateLimit && !hasClientRateBudget(maxEventsPerMinute)) {
return false;
}
const meta = normalizeMeta(payload.meta || {});
if (route !== '') {
meta.location = route;
}
const pageRequestId = normalizeWhitespace(document.documentElement?.dataset.requestId || '');
if (pageRequestId !== '') {
meta.page_request_id = pageRequestId.slice(0, 36);
}
return sendPayload({
eventType: normalizedEventType,
severity: normalizeSeverity(payload.severity, normalizedEventType),
message,
fingerprint,
meta,
occurredAt: normalizeOccurredAt(payload.occurredAt),
}, config);
},
installGlobalFetchHook() {
if (fetchHookInstalled || typeof window === 'undefined' || typeof window.fetch !== 'function') {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init) => {
const config = resolveConfig();
if (!shouldTrackFetch(input, init, config)) {
return originalFetch(input, init);
}
const method = fetchInputMethod(input, init);
const requestPath = normalizeRoute(fetchInputUrl(input));
return originalFetch(input, init)
.then((response) => {
const shouldTrackStatus = response.status >= 500 || response.status === 429;
if (!response.ok && shouldTrackStatus) {
telemetry.capture(AJAX_ERROR_TYPE, {
severity: response.status >= 500 ? 'error' : 'warning',
message: `AJAX request failed (${response.status}) ${method} ${requestPath || '/'}`,
meta: {
source: 'fetch',
module: 'global-fetch',
http_method: method,
http_status: response.status,
status_text: response.statusText || '',
request_path: requestPath || '/',
},
});
}
return response;
})
.catch((error) => {
if (!(error instanceof Error && error.name === 'AbortError')) {
telemetry.capture(AJAX_ERROR_TYPE, {
severity: 'error',
message: `AJAX network error ${method} ${requestPath || '/'}`,
meta: {
source: 'fetch',
module: 'global-fetch',
http_method: method,
request_path: requestPath || '/',
error_code: error instanceof Error ? error.name : 'NetworkError',
},
});
}
throw error;
});
};
fetchHookInstalled = true;
},
};
if (typeof window !== 'undefined') {
telemetry.installGlobalFetchHook();
}
export { DEFAULT_DEDUPE_TTL_MS };

View File

@@ -2,6 +2,7 @@ import { initMultiSelect } from '../components/app-multiselect-init.js';
import { initMultiSelectCascade } from '../components/app-multiselect-cascade.js';
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from './app-list-utils.js';
const config = readPageConfig('address-book-index');
@@ -125,7 +126,7 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Address book grid init failed');
warnOnce('UI_INIT_FAIL', 'Address book grid init failed', { module: 'address-book-index', component: 'grid' });
}
initMultiSelect().then(() => {

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-api-audit-index');
@@ -138,7 +139,7 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
console.warn('API audit grid init failed');
warnOnce('UI_INIT_FAIL', 'API audit grid init failed', { module: 'admin-api-audit-index', component: 'grid' });
return null;
}
return gridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-departments-index');
@@ -111,7 +112,7 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Departments grid init failed');
warnOnce('UI_INIT_FAIL', 'Departments grid init failed', { module: 'admin-departments-index', component: 'grid' });
return null;
}
return gridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-import-audit-index');
@@ -98,6 +99,6 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Import audit grid init failed');
warnOnce('UI_INIT_FAIL', 'Import audit grid init failed', { module: 'admin-import-audit-index', component: 'grid' });
}
}

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-mail-log-index');
@@ -92,7 +93,7 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Mail log grid init failed');
warnOnce('UI_INIT_FAIL', 'Mail log grid init failed', { module: 'admin-mail-log-index', component: 'grid' });
return null;
}
return gridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-permissions-index');
@@ -67,7 +68,7 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Permissions grid init failed');
warnOnce('UI_INIT_FAIL', 'Permissions grid init failed', { module: 'admin-permissions-index', component: 'grid' });
return null;
}
return gridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-roles-index');
@@ -116,7 +117,7 @@ if (config) {
},
});
if (!standardGridConfig || !standardGridConfig.grid) {
console.warn('Roles grid init failed');
warnOnce('UI_INIT_FAIL', 'Roles grid init failed', { module: 'admin-roles-index', component: 'grid' });
return null;
}
return standardGridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-scheduled-jobs-index');
@@ -91,6 +92,6 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Scheduled jobs grid init failed');
warnOnce('UI_INIT_FAIL', 'Scheduled jobs grid init failed', { module: 'admin-scheduled-jobs-index', component: 'grid' });
}
}

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-system-audit-index');
@@ -103,6 +104,6 @@ if (config) {
const gridConfig = initSystemAuditGrid();
if (!gridConfig || !gridConfig.grid) {
console.warn('System audit grid init failed');
warnOnce('UI_INIT_FAIL', 'System audit grid init failed', { module: 'admin-system-audit-index', component: 'grid' });
}
}

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { badgeHtml, escapeHtml, getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-tenants-index');
@@ -160,7 +161,7 @@ if (config) {
},
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Tenants grid init failed');
warnOnce('UI_INIT_FAIL', 'Tenants grid init failed', { module: 'admin-tenants-index', component: 'grid' });
return null;
}
return gridConfig;

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase } from './app-list-utils.js';
const config = readPageConfig('admin-user-lifecycle-audit-index');
@@ -71,6 +72,6 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('User lifecycle audit grid init failed');
warnOnce('UI_INIT_FAIL', 'User lifecycle audit grid init failed', { module: 'admin-user-lifecycle-audit-index', component: 'grid' });
}
}

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { initUsersListPage } from './app-users-list.js';
import { bindBulkVisibility } from '../components/app-bulk-selection.js';
import { buildUrl, badgeHtml, getAppBase, escapeHtml, buildBadgeList } from './app-list-utils.js';
@@ -166,7 +167,7 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Users grid init failed');
warnOnce('UI_INIT_FAIL', 'Users grid init failed', { module: 'admin-users-index', component: 'grid' });
return null;
}

View File

@@ -1,3 +1,6 @@
import { confirmDialog } from '../core/app-confirm-dialog.js';
import { warnOnce } from '../core/app-dom.js';
export function initUsersListPage(options = {}) {
const {
gridConfig,
@@ -73,7 +76,10 @@ export function initUsersListPage(options = {}) {
const validActions = ['activate', 'deactivate', 'delete', 'send-access', 'access-pdf'];
if (!ids.length || !validActions.includes(action)) {
if (action && !validActions.includes(action)) {
console.warn(`Unsupported users bulk action: ${action}`);
warnOnce('UI_ACTION_UNSUPPORTED', `Unsupported users bulk action: ${action}`, {
module: 'users-list',
component: 'bulk-action',
});
}
return;
}
@@ -85,8 +91,14 @@ export function initUsersListPage(options = {}) {
'access-pdf': labels.bulkAccessPdfConfirm
};
const confirmText = confirmTexts[action];
if (confirmText && !window.confirm(confirmText)) {
return;
if (confirmText) {
const approved = await confirmDialog.confirm({
message: confirmText,
actionKind: action,
});
if (!approved) {
return;
}
}
const downloadFormSelector = bulkDownloadForms?.[action];

View File

@@ -1,5 +1,6 @@
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, escapeHtml } from './app-list-utils.js';
const config = readPageConfig('search-index');
@@ -64,6 +65,6 @@ if (config) {
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Search results grid init failed');
warnOnce('UI_INIT_FAIL', 'Search results grid init failed', { module: 'search-index', component: 'grid' });
}
}