Add a reusable CSV-export building block to the core so any Grid.js list
page can gain a filter/sort-aware download with two clicks of glue.
Core primitive:
- core/Service/Export/CsvExportService: flavor-aware writer (plain CSV
or Excel-compatible UTF-8+BOM+semicolon), with OWASP-aligned formula-
injection escape (=, @, +, -, TAB, CR) applied to both rows *and*
headers. Value objects for columns (ExportColumn) carry an extractor
closure and an allowSignedNumeric flag for phone-number-shaped cells.
- core/Support/helpers/export.php: thin HTTP layer (exportSendCsv,
exportCapLimit, exportResolveFlavor, exportRequireGetRequest) reusing
the requestInput() contract for GR-CORE-003 consistency. Filename is
sanitized and length-clamped; callers must still enforce auth.
- templates/partials/app-list-export-dropdown.phtml: zero-config
<details class="dropdown"> with CSV + Excel triggers.
- web/js/core/app-list-export.js: initListExport({ gridConfig, exportUrl })
mirrors the current grid filters + sort onto the export URL and
navigates, preserving session cookies for download.
First consumer — Helpdesk domains:
- DomainListService extracts the shared filter/enrich/sort logic from
domains-data so the grid endpoint and the new domains/export endpoint
cannot drift.
- domains/export endpoint delegates to DomainListService, declares
ExportColumns (with translated level labels), and exits via
exportSendCsv.
- domains-data now ~20 lines, delegating to DomainListService.
Tests:
- CsvExportServiceTest (10 cases): both flavors, BOM/no-BOM, formula
escapes incl. TAB/CR, header escape, signed-numeric allowlist,
quoting, multiline, empty columns, generator-compatible iterable.
- ExportHelpersTest (5 cases): exportCapLimit bounds.
- DomainListServiceTest (8 cases): filter, search, "all" sentinel,
sort, paging, enrichment, BC failure, tenant-scoped security filter.
Gates: PHPUnit green, PHPStan clean on touched files, module:sync ok.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
import { createListPageModule } from '/js/core/app-list-page-module.js';
|
|
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
|
import { postForm } from '/js/core/app-http.js';
|
|
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
|
import { initListExport } from '/js/core/app-list-export.js';
|
|
|
|
const LEVEL_LABELS = {
|
|
niedrig: 'Low',
|
|
normal: 'Normal',
|
|
hoch: 'High',
|
|
kritisch: 'Critical',
|
|
};
|
|
|
|
function resolveDialog() {
|
|
const dialog = document.querySelector('[data-app-security-dialog]');
|
|
if (!(dialog instanceof HTMLDialogElement)) {
|
|
return null;
|
|
}
|
|
return {
|
|
dialog,
|
|
domainEl: dialog.querySelector('#sec-dialog-domain'),
|
|
levelSelect: dialog.querySelector('#sec-dialog-level'),
|
|
noteTextarea: dialog.querySelector('#sec-dialog-note'),
|
|
csrfInput: dialog.querySelector('#sec-dialog-csrf'),
|
|
saveButton: dialog.querySelector('[data-sec-dialog-save]'),
|
|
cancelButton: dialog.querySelector('[data-sec-dialog-cancel]'),
|
|
closeButton: dialog.querySelector('[data-sec-dialog-close]'),
|
|
};
|
|
}
|
|
|
|
function openSecurityDialog(badge, securityLevelUrl, labels, onSaved) {
|
|
const els = resolveDialog();
|
|
if (!els) {
|
|
return;
|
|
}
|
|
|
|
const domainNo = badge.dataset.domainNo || '';
|
|
const currentLevel = badge.dataset.level || 'normal';
|
|
const currentNote = badge.dataset.note || '';
|
|
const domainName = badge.dataset.domainName || domainNo;
|
|
|
|
els.domainEl.textContent = domainName;
|
|
els.levelSelect.value = currentLevel;
|
|
els.noteTextarea.value = currentNote;
|
|
els.saveButton.disabled = false;
|
|
|
|
const activeElement = document.activeElement;
|
|
let saved = false;
|
|
|
|
const cleanup = () => {
|
|
els.saveButton.removeEventListener('click', onSave);
|
|
els.cancelButton.removeEventListener('click', onCancel);
|
|
if (els.closeButton) {
|
|
els.closeButton.removeEventListener('click', onCancel);
|
|
}
|
|
els.dialog.removeEventListener('close', onClose);
|
|
els.dialog.removeEventListener('cancel', onCancelDialog);
|
|
els.dialog.removeEventListener('click', onBackdrop);
|
|
};
|
|
|
|
const closeDialog = () => {
|
|
saved = true;
|
|
cleanup();
|
|
try {
|
|
if (els.dialog.open) {
|
|
els.dialog.close();
|
|
}
|
|
} catch { /* no-op */ }
|
|
document.body.classList.remove('modal-is-open');
|
|
if (activeElement && document.contains(activeElement)) {
|
|
window.requestAnimationFrame(() => {
|
|
activeElement.focus({ preventScroll: true });
|
|
});
|
|
}
|
|
};
|
|
|
|
const onSave = async () => {
|
|
const newLevel = els.levelSelect.value;
|
|
const note = els.noteTextarea.value.trim();
|
|
const csrfKey = els.csrfInput?.name || 'csrf_token';
|
|
const csrfToken = els.csrfInput?.value || '';
|
|
|
|
const errorLabel = labels?.saveError || 'Save failed';
|
|
|
|
if (!csrfToken) {
|
|
showAsyncFlash('error', errorLabel);
|
|
return;
|
|
}
|
|
|
|
els.saveButton.disabled = true;
|
|
els.saveButton.setAttribute('aria-busy', 'true');
|
|
|
|
const body = new URLSearchParams({
|
|
domain_no: domainNo,
|
|
security_level: newLevel,
|
|
note,
|
|
[csrfKey]: csrfToken,
|
|
});
|
|
|
|
try {
|
|
const resp = await postForm(securityLevelUrl, body);
|
|
if (resp?.ok) {
|
|
closeDialog();
|
|
if (typeof onSaved === 'function') {
|
|
onSaved();
|
|
}
|
|
return;
|
|
}
|
|
showAsyncFlash('error', resp?.error || errorLabel);
|
|
} catch (error) {
|
|
showAsyncFlash('error', error?.message || errorLabel);
|
|
} finally {
|
|
els.saveButton.disabled = false;
|
|
els.saveButton.removeAttribute('aria-busy');
|
|
}
|
|
};
|
|
|
|
const onCancel = (event) => {
|
|
event.preventDefault();
|
|
closeDialog();
|
|
};
|
|
|
|
const onCancelDialog = (event) => {
|
|
event.preventDefault();
|
|
closeDialog();
|
|
};
|
|
|
|
const onClose = () => {
|
|
if (!saved) {
|
|
saved = true;
|
|
cleanup();
|
|
document.body.classList.remove('modal-is-open');
|
|
}
|
|
};
|
|
|
|
const onBackdrop = (event) => {
|
|
if (event.target === els.dialog) {
|
|
closeDialog();
|
|
}
|
|
};
|
|
|
|
els.saveButton.addEventListener('click', onSave);
|
|
els.cancelButton.addEventListener('click', onCancel);
|
|
if (els.closeButton) {
|
|
els.closeButton.addEventListener('click', onCancel);
|
|
}
|
|
els.dialog.addEventListener('cancel', onCancelDialog);
|
|
els.dialog.addEventListener('close', onClose);
|
|
els.dialog.addEventListener('click', onBackdrop);
|
|
|
|
document.body.classList.add('modal-is-open');
|
|
|
|
try {
|
|
els.dialog.showModal();
|
|
} catch {
|
|
cleanup();
|
|
document.body.classList.remove('modal-is-open');
|
|
return;
|
|
}
|
|
|
|
window.requestAnimationFrame(() => {
|
|
els.levelSelect.focus();
|
|
});
|
|
}
|
|
|
|
createListPageModule({
|
|
configId: 'helpdesk-domains',
|
|
moduleId: 'helpdesk-domains',
|
|
missingGridMessage: 'Helpdesk domains grid init failed: Grid.js missing',
|
|
initErrorMessage: 'Helpdesk domains grid init failed',
|
|
setup: ({ config, gridjs, appBase, initListPage }) => {
|
|
const labels = config.labels || {};
|
|
const domainBaseUrl = config.domainBaseUrl || '';
|
|
const securityLevelUrl = (config.securityLevelDataUrl || '').trim();
|
|
const domainUrlIndex = 8;
|
|
|
|
const gridContainer = document.querySelector('#helpdesk-domains-grid');
|
|
const gridRef = { grid: null };
|
|
const refreshGrid = () => {
|
|
gridRef.grid?.forceRender();
|
|
};
|
|
|
|
gridContainer.addEventListener('click', (event) => {
|
|
const badge = event.target.closest('td .badge[data-domain-no][role="button"]');
|
|
if (!badge) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openSecurityDialog(badge, securityLevelUrl, labels, refreshGrid);
|
|
});
|
|
|
|
gridContainer.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
return;
|
|
}
|
|
const badge = event.target.closest('.badge[data-domain-no][role="button"]');
|
|
if (!badge) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openSecurityDialog(badge, securityLevelUrl, labels, refreshGrid);
|
|
});
|
|
|
|
const { gridConfig } = initListPage({
|
|
grid: {
|
|
gridjs,
|
|
container: '#helpdesk-domains-grid',
|
|
dataUrl: config.dataUrl || 'helpdesk/domains-data',
|
|
appBase,
|
|
columns: [
|
|
{
|
|
name: labels.no || 'No.',
|
|
sort: true,
|
|
formatter: (cell) => {
|
|
const no = escapeHtml(cell?.no || '');
|
|
const detailUrl = String(cell?.url || '').trim();
|
|
if (detailUrl === '') {
|
|
return gridjs.html(no);
|
|
}
|
|
const href = escapeHtml(detailUrl);
|
|
return gridjs.html(`<a href="${href}">${no}</a>`);
|
|
},
|
|
},
|
|
{
|
|
name: labels.customerName || 'Customer Name',
|
|
sort: true,
|
|
formatter: (cell) => {
|
|
const name = escapeHtml(cell?.name || '');
|
|
const detailUrl = String(cell?.url || '').trim();
|
|
if (detailUrl === '') {
|
|
return gridjs.html(name);
|
|
}
|
|
const href = escapeHtml(withCurrentListReturn(detailUrl));
|
|
return gridjs.html(`<a href="${href}">${name}</a>`);
|
|
},
|
|
},
|
|
{ name: labels.url || 'URL', sort: true },
|
|
{ name: labels.contractType || 'Contract Type', sort: true },
|
|
{
|
|
name: labels.state || 'State',
|
|
sort: true,
|
|
formatter: (cell) => {
|
|
const state = escapeHtml(cell?.state || '');
|
|
const variant = cell?.variant || 'neutral';
|
|
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}">${state}</span>`);
|
|
},
|
|
},
|
|
{ name: labels.administration || 'Administration', sort: true },
|
|
{
|
|
name: labels.securityLevel || 'Security level',
|
|
sort: true,
|
|
formatter: (cell) => {
|
|
const level = cell?.level || 'normal';
|
|
const variant = cell?.variant || 'neutral';
|
|
const domainNo = cell?.domainNo || '';
|
|
const note = cell?.note || '';
|
|
const domainName = cell?.domainName || domainNo;
|
|
const levelLabel = labels?.[level] ?? LEVEL_LABELS[level] ?? level;
|
|
const noteAttr = note ? ` data-note="${escapeHtml(note)}"` : '';
|
|
return gridjs.html(`<span class="badge" data-variant="${escapeHtml(variant)}" data-domain-no="${escapeHtml(domainNo)}" data-level="${escapeHtml(level)}" data-domain-name="${escapeHtml(domainName)}" role="button" tabindex="0"${noteAttr}>${escapeHtml(levelLabel)}</span>`);
|
|
},
|
|
},
|
|
{
|
|
name: labels.note || 'Note',
|
|
sort: true,
|
|
formatter: (cell) => {
|
|
const note = String(cell || '').trim();
|
|
if (note === '') {
|
|
return gridjs.html('<span class="text-muted">—</span>');
|
|
}
|
|
const escaped = escapeHtml(note);
|
|
return gridjs.html(`<span class="app-cell-note" title="${escaped}">${escaped}</span>`);
|
|
},
|
|
},
|
|
{ name: 'debitor_url', hidden: true },
|
|
{ name: 'domain_detail_url', hidden: true },
|
|
],
|
|
sortColumns: ['No', 'Customer_Name', 'URL', 'contract_type', 'State', 'Administration', 'security_level', 'security_level_note', null, null],
|
|
paginationLimit: 10,
|
|
language: config.gridLang || {},
|
|
mapData: (data) => (data.data || []).map((row) => {
|
|
const domainDetailUrl = domainBaseUrl && row.No
|
|
? domainBaseUrl + encodeURIComponent(row.No)
|
|
: '';
|
|
return [
|
|
{ no: row.No || '', url: domainDetailUrl },
|
|
{ name: row.Customer_Name || '', url: row.debitor_url || '' },
|
|
row.URL || '',
|
|
row.contract_type || '',
|
|
{ state: row.State || '', variant: row.state_variant || 'neutral' },
|
|
row.Administration || '',
|
|
{ level: row.security_level || 'normal', variant: row.security_level_variant || 'neutral', domainNo: row.No || '', note: row.security_level_note || '', domainName: row.Customer_Name || row.No || '' },
|
|
row.security_level_note || '',
|
|
row.debitor_url || '',
|
|
domainDetailUrl,
|
|
];
|
|
}),
|
|
search: config.gridSearch || null,
|
|
filterSchema: config.filterSchema || [],
|
|
urlSync: true,
|
|
rowInteraction: { linkColumn: 0 },
|
|
rowDblClick: {
|
|
getUrl: (rowData) => rowData?.cells?.[domainUrlIndex]?.data || '',
|
|
exclude: '.badge[role="button"], .grid-actions, button, a, input, select, textarea',
|
|
},
|
|
},
|
|
filters: {
|
|
mode: 'drawer',
|
|
chipMeta: config.filterChipMeta || {},
|
|
watchInputs: ['#helpdesk-domains-search-input'],
|
|
},
|
|
}) || {};
|
|
|
|
gridRef.grid = gridConfig?.grid || null;
|
|
|
|
if (gridConfig && config.exportUrl) {
|
|
initListExport({ gridConfig, exportUrl: config.exportUrl });
|
|
}
|
|
|
|
return gridConfig;
|
|
},
|
|
});
|