forked from fa/breadcrumb-the-shire
feat(core): generic CSV export primitive + helpdesk domains consumer
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>
This commit is contained in:
79
web/js/core/app-list-export.js
Normal file
79
web/js/core/app-list-export.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Generic export trigger for Grid.js list pages.
|
||||
*
|
||||
* Binds click handlers to `[data-list-export]` elements. Each click builds
|
||||
* an export URL that mirrors the grid's current filter/search/sort state,
|
||||
* appends `format=<flavor>`, and navigates the browser there so the
|
||||
* Content-Disposition download fires with the user's session cookie intact.
|
||||
*
|
||||
* Usage:
|
||||
* initListExport({
|
||||
* gridConfig, // returned by initListPage()
|
||||
* exportUrl: '/helpdesk/domains/export',
|
||||
* scope: document, // optional, defaults to document
|
||||
* });
|
||||
*/
|
||||
|
||||
const DEFAULT_TRIGGER_SELECTOR = '[data-list-export]';
|
||||
|
||||
export function initListExport({ gridConfig, exportUrl, scope = document, triggers = DEFAULT_TRIGGER_SELECTOR } = {}) {
|
||||
if (!gridConfig || typeof gridConfig.baseUrl !== 'function') {
|
||||
return;
|
||||
}
|
||||
const target = typeof exportUrl === 'string' ? exportUrl.trim() : '';
|
||||
if (target === '') {
|
||||
return;
|
||||
}
|
||||
const root = scope || document;
|
||||
const nodes = root.querySelectorAll(triggers);
|
||||
if (!nodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onClick = (event) => {
|
||||
const button = event.currentTarget;
|
||||
if (!(button instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const flavor = (button.dataset.listExport || 'csv').toLowerCase();
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(target, window.location.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror grid filters + search onto the export URL
|
||||
try {
|
||||
const dataUrl = new URL(gridConfig.baseUrl(), window.location.href);
|
||||
dataUrl.searchParams.forEach((value, key) => {
|
||||
if (key === 'limit' || key === 'offset') {
|
||||
return;
|
||||
}
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
} catch {
|
||||
// ignore malformed grid base URLs — fall through without filters
|
||||
}
|
||||
|
||||
// Current sort
|
||||
const sort = typeof gridConfig.getSortParams === 'function' ? gridConfig.getSortParams() : null;
|
||||
if (sort && sort.order) {
|
||||
url.searchParams.set('order', sort.order);
|
||||
url.searchParams.set('dir', sort.dir || 'asc');
|
||||
}
|
||||
|
||||
url.searchParams.set('format', flavor === 'excel' ? 'excel' : 'csv');
|
||||
|
||||
// Close parent <details> dropdown if present
|
||||
const details = button.closest('details');
|
||||
if (details) {
|
||||
details.open = false;
|
||||
}
|
||||
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
nodes.forEach((node) => node.addEventListener('click', onClick));
|
||||
}
|
||||
Reference in New Issue
Block a user