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:
95
core/Service/Export/CsvExportService.php
Normal file
95
core/Service/Export/CsvExportService.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Service\Export;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes CSV rows to a stream.
|
||||||
|
*
|
||||||
|
* Two flavors:
|
||||||
|
* - csv: comma delimited, UTF-8, no BOM (RFC-4180-ish)
|
||||||
|
* - excel: semicolon delimited, UTF-8 with BOM (opens natively in Excel on Windows)
|
||||||
|
*
|
||||||
|
* Values are sanitized against formula injection (=, @, +, -).
|
||||||
|
* HTTP headers and output buffering are handled by the caller
|
||||||
|
* (see core/Support/helpers/export.php).
|
||||||
|
*/
|
||||||
|
final class CsvExportService
|
||||||
|
{
|
||||||
|
public const FLAVOR_CSV = 'csv';
|
||||||
|
public const FLAVOR_EXCEL = 'excel';
|
||||||
|
|
||||||
|
private const UTF8_BOM = "\xEF\xBB\xBF";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $handle
|
||||||
|
* @param iterable<array<string,mixed>> $rows
|
||||||
|
* @param list<ExportColumn> $columns
|
||||||
|
*/
|
||||||
|
public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void
|
||||||
|
{
|
||||||
|
if (!is_resource($handle)) {
|
||||||
|
throw new InvalidArgumentException('writeTo() expects an open stream resource');
|
||||||
|
}
|
||||||
|
if ($columns === []) {
|
||||||
|
throw new InvalidArgumentException('At least one column is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ',';
|
||||||
|
|
||||||
|
if ($flavor === self::FLAVOR_EXCEL) {
|
||||||
|
fwrite($handle, self::UTF8_BOM);
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = array_map(
|
||||||
|
static fn (ExportColumn $c): string => self::escapeValue($c->header),
|
||||||
|
$columns
|
||||||
|
);
|
||||||
|
fputcsv($handle, $headers, $delimiter, '"', '\\');
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$line = [];
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$value = ($column->extract)($row);
|
||||||
|
$line[] = self::escapeValue($value, $column->allowSignedNumeric);
|
||||||
|
}
|
||||||
|
fputcsv($handle, $line, $delimiter, '"', '\\');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefixes a single apostrophe to values that would be interpreted
|
||||||
|
* as a formula by spreadsheet apps. Defuses the leading-character
|
||||||
|
* set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR.
|
||||||
|
*
|
||||||
|
* When $allowSignedNumeric is true, purely numeric/phone-shaped values
|
||||||
|
* starting with + or - are left intact (useful for `+49 …` columns).
|
||||||
|
*
|
||||||
|
* @api Called from page actions that build rows manually
|
||||||
|
*/
|
||||||
|
public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
$string = (string) $value;
|
||||||
|
if ($string === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$first = $string[0];
|
||||||
|
if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") {
|
||||||
|
return "'" . $string;
|
||||||
|
}
|
||||||
|
if ($first === '+' || $first === '-') {
|
||||||
|
if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) {
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
return "'" . $string;
|
||||||
|
}
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
core/Service/Export/ExportColumn.php
Normal file
22
core/Service/Export/ExportColumn.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Service\Export;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column definition for CSV export.
|
||||||
|
*
|
||||||
|
* Header + extractor pair. The extractor receives the raw row
|
||||||
|
* and returns the cell value (scalar|null). Use $allowSignedNumeric
|
||||||
|
* for phone-number-like columns where a leading + or - is legitimate.
|
||||||
|
*/
|
||||||
|
final readonly class ExportColumn
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $header,
|
||||||
|
public Closure $extract,
|
||||||
|
public bool $allowSignedNumeric = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// Load all global helper groups used by templates and page actions.
|
// Load all global helper groups used by templates and page actions.
|
||||||
require __DIR__ . '/helpers/app.php';
|
require __DIR__ . '/helpers/app.php';
|
||||||
require __DIR__ . '/helpers/branding.php';
|
require __DIR__ . '/helpers/branding.php';
|
||||||
|
require __DIR__ . '/helpers/export.php';
|
||||||
require __DIR__ . '/helpers/grid.php';
|
require __DIR__ . '/helpers/grid.php';
|
||||||
require __DIR__ . '/helpers/i18n.php';
|
require __DIR__ . '/helpers/i18n.php';
|
||||||
require __DIR__ . '/helpers/request.php';
|
require __DIR__ . '/helpers/request.php';
|
||||||
|
|||||||
104
core/Support/helpers/export.php
Normal file
104
core/Support/helpers/export.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Http\Input\RequestInput;
|
||||||
|
use MintyPHP\Service\Export\CsvExportService;
|
||||||
|
use MintyPHP\Service\Export\ExportColumn;
|
||||||
|
|
||||||
|
if (!function_exists('exportRequireGetRequest')) {
|
||||||
|
/**
|
||||||
|
* Enforce GET for export endpoints. Exports are stateless and must not
|
||||||
|
* start a write transaction — GET matches semantics, keeps them
|
||||||
|
* bookmarkable and CSRF-free. Mirrors `gridRequireGetRequest()` and
|
||||||
|
* reads the method through `requestInput()` for GR-CORE-003 compliance.
|
||||||
|
*/
|
||||||
|
function exportRequireGetRequest(): void
|
||||||
|
{
|
||||||
|
if (requestInput()->isMethod('GET')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
header('Allow: GET');
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('exportResolveFlavor')) {
|
||||||
|
/**
|
||||||
|
* Reads `format=csv|excel` from the request. Unknown values fall
|
||||||
|
* back to 'csv'. Accepts legacy alias 'excel-csv'.
|
||||||
|
*/
|
||||||
|
function exportResolveFlavor(RequestInput $request, string $default = CsvExportService::FLAVOR_CSV): string
|
||||||
|
{
|
||||||
|
$raw = strtolower(trim((string) $request->query('format', $default)));
|
||||||
|
if ($raw === 'excel' || $raw === 'excel-csv' || $raw === 'xls') {
|
||||||
|
return CsvExportService::FLAVOR_EXCEL;
|
||||||
|
}
|
||||||
|
return CsvExportService::FLAVOR_CSV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('exportCapLimit')) {
|
||||||
|
/**
|
||||||
|
* Bounds a user-supplied limit to [1, $max]. Non-numeric / <=0
|
||||||
|
* values collapse to $max.
|
||||||
|
*/
|
||||||
|
function exportCapLimit(mixed $requested, int $max = 5000): int
|
||||||
|
{
|
||||||
|
$value = is_numeric($requested) ? (int) $requested : 0;
|
||||||
|
if ($value < 1 || $value > $max) {
|
||||||
|
return $max;
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('exportSendCsv')) {
|
||||||
|
/**
|
||||||
|
* Streams rows as a CSV download and ends the response.
|
||||||
|
*
|
||||||
|
* IMPORTANT: this helper does NOT enforce authentication or authorization.
|
||||||
|
* Callers MUST run `Guard::requireLogin()` and the appropriate
|
||||||
|
* `Guard::requireAbilityOrForbidden(...)` check BEFORE invoking it —
|
||||||
|
* see `modules/helpdesk/pages/helpdesk/domains/export().php` for the
|
||||||
|
* reference pattern.
|
||||||
|
*
|
||||||
|
* @param iterable<array<string,mixed>> $rows
|
||||||
|
* @param list<ExportColumn> $columns
|
||||||
|
*/
|
||||||
|
function exportSendCsv(
|
||||||
|
string $filename,
|
||||||
|
iterable $rows,
|
||||||
|
array $columns,
|
||||||
|
string $flavor = CsvExportService::FLAVOR_CSV
|
||||||
|
): void {
|
||||||
|
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $filename) ?: 'export.csv';
|
||||||
|
// Clamp total filename to 100 chars while preserving the .csv suffix.
|
||||||
|
if (strlen($safeName) > 100) {
|
||||||
|
$safeName = substr($safeName, 0, 96);
|
||||||
|
}
|
||||||
|
if (!str_ends_with(strtolower($safeName), '.csv')) {
|
||||||
|
$safeName .= '.csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$handle = fopen('php://output', 'w');
|
||||||
|
if ($handle === false) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new CsvExportService();
|
||||||
|
$service->writeTo($handle, $rows, $columns, $flavor);
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,6 +319,9 @@
|
|||||||
"Primary color is invalid": "Primärfarbe ist ungültig",
|
"Primary color is invalid": "Primärfarbe ist ungültig",
|
||||||
"The primary color affects buttons and accent elements across the app.": "Die Primärfarbe beeinflusst Buttons und Akzent-Elemente in der gesamten App.",
|
"The primary color affects buttons and accent elements across the app.": "Die Primärfarbe beeinflusst Buttons und Akzent-Elemente in der gesamten App.",
|
||||||
"Export CSV": "CSV exportieren",
|
"Export CSV": "CSV exportieren",
|
||||||
|
"Export": "Export",
|
||||||
|
"Export as CSV": "Als CSV exportieren",
|
||||||
|
"Export for Excel": "Für Excel exportieren",
|
||||||
"State": "Status",
|
"State": "Status",
|
||||||
"Edit": "Bearbeiten",
|
"Edit": "Bearbeiten",
|
||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
|
|||||||
@@ -319,6 +319,9 @@
|
|||||||
"Primary color is invalid": "Primary color is invalid",
|
"Primary color is invalid": "Primary color is invalid",
|
||||||
"The primary color affects buttons and accent elements across the app.": "The primary color affects buttons and accent elements across the app.",
|
"The primary color affects buttons and accent elements across the app.": "The primary color affects buttons and accent elements across the app.",
|
||||||
"Export CSV": "Export CSV",
|
"Export CSV": "Export CSV",
|
||||||
|
"Export": "Export",
|
||||||
|
"Export as CSV": "Export as CSV",
|
||||||
|
"Export for Excel": "Export for Excel",
|
||||||
"State": "State",
|
"State": "State",
|
||||||
"Edit": "Edit",
|
"Edit": "Edit",
|
||||||
"Status": "Status",
|
"Status": "Status",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use MintyPHP\Module\Helpdesk\Service\BcSoapGateway;
|
|||||||
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
use MintyPHP\Module\Helpdesk\Service\DebitorDetailService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
use MintyPHP\Module\Helpdesk\Service\DebitorSearchService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\DomainDetailService;
|
use MintyPHP\Module\Helpdesk\Service\DomainDetailService;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DomainListService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
|
use MintyPHP\Module\Helpdesk\Service\EffectiveHelpdeskSettingsService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway;
|
||||||
@@ -127,6 +128,11 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar
|
|||||||
$c->get(DomainSecurityLevelRepository::class)
|
$c->get(DomainSecurityLevelRepository::class)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$container->set(DomainListService::class, static fn (AppContainer $c): DomainListService => new DomainListService(
|
||||||
|
$c->get(BcODataGateway::class),
|
||||||
|
$c->get(DomainSecurityLevelService::class)
|
||||||
|
));
|
||||||
|
|
||||||
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
|
$container->set(HandoverRepository::class, static fn (): HandoverRepository => new HandoverRepository());
|
||||||
|
|
||||||
$container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository());
|
$container->set(HandoverRevisionRepository::class, static fn (): HandoverRevisionRepository => new HandoverRevisionRepository());
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Module\Helpdesk\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain list orchestrator.
|
||||||
|
*
|
||||||
|
* Owns the shared filter + enrichment flow used by both the Grid.js
|
||||||
|
* data endpoint (`domains-data()`) and the export endpoint
|
||||||
|
* (`domains/export()`). Keeps the two in lockstep — same filters
|
||||||
|
* visible in the grid, same rows produced by the export.
|
||||||
|
*
|
||||||
|
* Input: parsed filter values (from gridParseFiltersFromSchemaFile).
|
||||||
|
* Output: `rows` enriched with security-level + state variant,
|
||||||
|
* `total` matching the filtered set *before* paging.
|
||||||
|
*/
|
||||||
|
final class DomainListService
|
||||||
|
{
|
||||||
|
private const STATE_VARIANT_MAP = [
|
||||||
|
'Aktiv' => 'success',
|
||||||
|
'In Vorbereitung' => 'warning',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly BcODataGateway $bc,
|
||||||
|
private readonly DomainSecurityLevelService $securityLevels,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @api Called from domains-data and domains/export endpoints
|
||||||
|
* @param array{
|
||||||
|
* search?: string,
|
||||||
|
* customer?: string,
|
||||||
|
* contract_type?: string,
|
||||||
|
* state?: string,
|
||||||
|
* administration?: string,
|
||||||
|
* security_level?: string,
|
||||||
|
* order?: string,
|
||||||
|
* dir?: string,
|
||||||
|
* limit?: int,
|
||||||
|
* offset?: int
|
||||||
|
* } $filters
|
||||||
|
*
|
||||||
|
* @return array{rows: list<array<string,mixed>>, total: int}
|
||||||
|
*/
|
||||||
|
public function query(int $tenantId, array $filters): array
|
||||||
|
{
|
||||||
|
$search = trim((string) ($filters['search'] ?? ''));
|
||||||
|
$customer = trim((string) ($filters['customer'] ?? ''));
|
||||||
|
$contractType = trim((string) ($filters['contract_type'] ?? ''));
|
||||||
|
$state = $this->normalizeAll((string) ($filters['state'] ?? ''));
|
||||||
|
$administration = $this->normalizeAll((string) ($filters['administration'] ?? ''));
|
||||||
|
$securityLevel = $this->normalizeAll((string) ($filters['security_level'] ?? ''));
|
||||||
|
$order = (string) ($filters['order'] ?? 'Customer_Name');
|
||||||
|
$dir = strtolower((string) ($filters['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
$limit = max(0, (int) ($filters['limit'] ?? 0));
|
||||||
|
$offset = max(0, (int) ($filters['offset'] ?? 0));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$allDomains = $this->bc->listDomains();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ['rows' => [], 'total' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$enriched = $this->enrichContracts($allDomains);
|
||||||
|
|
||||||
|
$filtered = $this->applyFilters($enriched, $tenantId, [
|
||||||
|
'search' => $search,
|
||||||
|
'customer' => $customer,
|
||||||
|
'contract_type' => $contractType,
|
||||||
|
'state' => $state,
|
||||||
|
'administration' => $administration,
|
||||||
|
'security_level' => $securityLevel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$total = count($filtered);
|
||||||
|
|
||||||
|
usort($filtered, static function (array $a, array $b) use ($order, $dir): int {
|
||||||
|
$va = (string) ($a[$order] ?? '');
|
||||||
|
$vb = (string) ($b[$order] ?? '');
|
||||||
|
$cmp = strnatcasecmp($va, $vb);
|
||||||
|
return $dir === 'desc' ? -$cmp : $cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$filtered = array_slice($filtered, $offset, $limit);
|
||||||
|
} elseif ($offset > 0) {
|
||||||
|
$filtered = array_slice($filtered, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->prepareRows($filtered, $tenantId);
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAll(string $value): string
|
||||||
|
{
|
||||||
|
$trimmed = trim($value);
|
||||||
|
return $trimmed === 'all' ? '' : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function enrichContracts(array $domains): array
|
||||||
|
{
|
||||||
|
$lookup = [];
|
||||||
|
try {
|
||||||
|
foreach ($this->bc->listDomainContractLines() as $line) {
|
||||||
|
$dnsNo = trim((string) ($line['No'] ?? ''));
|
||||||
|
if ($dnsNo !== '' && !isset($lookup[$dnsNo])) {
|
||||||
|
$lookup[$dnsNo] = [
|
||||||
|
'contract_type' => trim((string) ($line['PI_Header_Type'] ?? '')),
|
||||||
|
'contract_no' => trim((string) ($line['Header_No'] ?? '')),
|
||||||
|
'contract_description' => trim((string) ($line['PI_Header_Description'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// best-effort: proceed without contract enrichment
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($domains as &$domain) {
|
||||||
|
$dnsNo = trim((string) ($domain['No'] ?? ''));
|
||||||
|
$contract = $lookup[$dnsNo] ?? null;
|
||||||
|
$domain['contract_type'] = $contract['contract_type'] ?? '';
|
||||||
|
$domain['contract_no'] = $contract['contract_no'] ?? '';
|
||||||
|
$domain['contract_description'] = $contract['contract_description'] ?? '';
|
||||||
|
}
|
||||||
|
unset($domain);
|
||||||
|
|
||||||
|
return $domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
* @param array{search: string, customer: string, contract_type: string, state: string, administration: string, security_level: string} $f
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function applyFilters(array $domains, int $tenantId, array $f): array
|
||||||
|
{
|
||||||
|
if ($f['search'] !== '') {
|
||||||
|
$needle = mb_strtolower($f['search']);
|
||||||
|
$domains = array_values(array_filter($domains, static function (array $d) use ($needle): bool {
|
||||||
|
$hay = mb_strtolower(
|
||||||
|
($d['Customer_No'] ?? '') . ' '
|
||||||
|
. ($d['Customer_Name'] ?? '') . ' '
|
||||||
|
. ($d['URL'] ?? '') . ' '
|
||||||
|
. ($d['No'] ?? '') . ' '
|
||||||
|
. ($d['contract_type'] ?? '')
|
||||||
|
);
|
||||||
|
return str_contains($hay, $needle);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['customer'] !== '') {
|
||||||
|
$needle = mb_strtolower($f['customer']);
|
||||||
|
$domains = array_values(array_filter($domains, static function (array $d) use ($needle): bool {
|
||||||
|
$hay = mb_strtolower(($d['Customer_No'] ?? '') . ' ' . ($d['Customer_Name'] ?? ''));
|
||||||
|
return str_contains($hay, $needle);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['contract_type'] !== '') {
|
||||||
|
$needle = mb_strtolower($f['contract_type']);
|
||||||
|
$domains = array_values(array_filter($domains, static fn (array $d): bool => str_contains(mb_strtolower((string) ($d['contract_type'] ?? '')), $needle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['state'] !== '') {
|
||||||
|
$value = $f['state'];
|
||||||
|
$domains = array_values(array_filter($domains, static fn (array $d): bool => trim((string) ($d['State'] ?? '')) === $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['administration'] !== '') {
|
||||||
|
$value = $f['administration'];
|
||||||
|
$domains = array_values(array_filter($domains, static fn (array $d): bool => trim((string) ($d['Administration'] ?? '')) === $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['security_level'] !== '' && $tenantId > 0) {
|
||||||
|
$domainNos = array_values(array_filter(
|
||||||
|
array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $domains),
|
||||||
|
static fn (string $n): bool => $n !== ''
|
||||||
|
));
|
||||||
|
$lookup = $domainNos !== [] ? $this->securityLevels->getAllLevelsForDomains($tenantId, $domainNos) : [];
|
||||||
|
$value = $f['security_level'];
|
||||||
|
$domains = array_values(array_filter($domains, static function (array $d) use ($value, $lookup): bool {
|
||||||
|
$no = trim((string) ($d['No'] ?? ''));
|
||||||
|
return ($lookup[$no] ?? 'normal') === $value;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function prepareRows(array $domains, int $tenantId): array
|
||||||
|
{
|
||||||
|
$lookup = [];
|
||||||
|
if ($tenantId > 0) {
|
||||||
|
$domainNos = array_values(array_filter(
|
||||||
|
array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $domains),
|
||||||
|
static fn (string $n): bool => $n !== ''
|
||||||
|
));
|
||||||
|
if ($domainNos !== []) {
|
||||||
|
$lookup = $this->securityLevels->getAllDetailsForDomains($tenantId, $domainNos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$debitorBaseUrl = lurl('helpdesk/debitor/');
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($domains as $domain) {
|
||||||
|
$customerNo = trim((string) ($domain['Customer_No'] ?? ''));
|
||||||
|
$domainState = (string) ($domain['State'] ?? '');
|
||||||
|
$domainNo = (string) ($domain['No'] ?? '');
|
||||||
|
$details = $lookup[$domainNo] ?? null;
|
||||||
|
$level = $details['level'] ?? 'normal';
|
||||||
|
$note = $details['note'] ?? '';
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'No' => $domainNo,
|
||||||
|
'Customer_No' => $customerNo,
|
||||||
|
'Customer_Name' => (string) ($domain['Customer_Name'] ?? ''),
|
||||||
|
'URL' => (string) ($domain['URL'] ?? ''),
|
||||||
|
'State' => $domainState,
|
||||||
|
'state_variant' => self::STATE_VARIANT_MAP[$domainState] ?? 'neutral',
|
||||||
|
'Administration' => (string) ($domain['Administration'] ?? ''),
|
||||||
|
'contract_type' => (string) ($domain['contract_type'] ?? ''),
|
||||||
|
'contract_no' => (string) ($domain['contract_no'] ?? ''),
|
||||||
|
'debitor_url' => $customerNo !== '' ? $debitorBaseUrl . rawurlencode($customerNo) : '',
|
||||||
|
'security_level' => $level,
|
||||||
|
'security_level_variant' => DomainSecurityLevelService::getBadgeVariant($level),
|
||||||
|
'security_level_note' => $note,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ return [
|
|||||||
['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'],
|
['path' => 'helpdesk/domains', 'target' => 'helpdesk/domains'],
|
||||||
['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'],
|
['path' => 'helpdesk/domains-data', 'target' => 'helpdesk/domains-data'],
|
||||||
['path' => 'helpdesk/domains/security-level-data', 'target' => 'helpdesk/domains/security-level-data'],
|
['path' => 'helpdesk/domains/security-level-data', 'target' => 'helpdesk/domains/security-level-data'],
|
||||||
|
['path' => 'helpdesk/domains/export', 'target' => 'helpdesk/domains/export'],
|
||||||
['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'],
|
['path' => 'helpdesk/domain/{id}', 'target' => 'helpdesk/domain'],
|
||||||
['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'],
|
['path' => 'helpdesk/domain-detail-data', 'target' => 'helpdesk/domain-detail-data'],
|
||||||
['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'],
|
['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'],
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||||
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
use MintyPHP\Module\Helpdesk\Service\DomainListService;
|
||||||
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
|
||||||
use MintyPHP\Support\Guard;
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
Guard::requireLogin();
|
Guard::requireLogin();
|
||||||
@@ -14,183 +13,9 @@ $session = app(SessionStoreInterface::class)->all();
|
|||||||
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||||
|
|
||||||
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/domains/filter-schema.php');
|
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/domains/filter-schema.php');
|
||||||
|
$filters['limit'] = (int) ($filters['limit'] ?? 10);
|
||||||
|
$filters['offset'] = (int) ($filters['offset'] ?? 0);
|
||||||
|
|
||||||
$search = trim((string) ($filters['search'] ?? ''));
|
$result = app(DomainListService::class)->query($tenantId, $filters);
|
||||||
$customer = trim((string) ($filters['customer'] ?? ''));
|
|
||||||
$contractType = trim((string) ($filters['contract_type'] ?? ''));
|
|
||||||
$state = trim((string) ($filters['state'] ?? ''));
|
|
||||||
$state = $state === 'all' ? '' : $state;
|
|
||||||
$administration = trim((string) ($filters['administration'] ?? ''));
|
|
||||||
$administration = $administration === 'all' ? '' : $administration;
|
|
||||||
$securityLevel = trim((string) ($filters['security_level'] ?? ''));
|
|
||||||
$securityLevel = $securityLevel === 'all' ? '' : $securityLevel;
|
|
||||||
$order = (string) ($filters['order'] ?? 'Customer_Name');
|
|
||||||
$dir = (string) ($filters['dir'] ?? 'asc');
|
|
||||||
$limit = (int) ($filters['limit'] ?? 10);
|
|
||||||
$offset = (int) ($filters['offset'] ?? 0);
|
|
||||||
|
|
||||||
$gateway = app(BcODataGateway::class);
|
gridJsonDataResult($result['rows'], $result['total']);
|
||||||
|
|
||||||
try {
|
|
||||||
$allDomains = $gateway->listDomains();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
gridJsonDataResult([], 0);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build contract type lookup: DNS number → {contract_type, contract_no, contract_description}
|
|
||||||
$contractLookup = [];
|
|
||||||
try {
|
|
||||||
$contractLines = $gateway->listDomainContractLines();
|
|
||||||
foreach ($contractLines as $line) {
|
|
||||||
$dnsNo = trim((string) ($line['No'] ?? ''));
|
|
||||||
if ($dnsNo !== '' && !isset($contractLookup[$dnsNo])) {
|
|
||||||
$contractLookup[$dnsNo] = [
|
|
||||||
'contract_type' => trim((string) ($line['PI_Header_Type'] ?? '')),
|
|
||||||
'contract_no' => trim((string) ($line['Header_No'] ?? '')),
|
|
||||||
'contract_description' => trim((string) ($line['PI_Header_Description'] ?? '')),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// Contract enrichment is best-effort — continue without it
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich domains with contract type before filtering (so search/sort can use it)
|
|
||||||
foreach ($allDomains as &$domain) {
|
|
||||||
$domainNo = trim((string) ($domain['No'] ?? ''));
|
|
||||||
$contract = $contractLookup[$domainNo] ?? null;
|
|
||||||
$domain['contract_type'] = $contract['contract_type'] ?? '';
|
|
||||||
$domain['contract_no'] = $contract['contract_no'] ?? '';
|
|
||||||
$domain['contract_description'] = $contract['contract_description'] ?? '';
|
|
||||||
}
|
|
||||||
unset($domain);
|
|
||||||
|
|
||||||
$filtered = $allDomains;
|
|
||||||
|
|
||||||
// Text search across Customer_No, Customer_Name, URL, No, contract_type
|
|
||||||
if ($search !== '') {
|
|
||||||
$searchLower = mb_strtolower($search);
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($searchLower): bool {
|
|
||||||
$haystack = mb_strtolower(
|
|
||||||
($domain['Customer_No'] ?? '') . ' '
|
|
||||||
. ($domain['Customer_Name'] ?? '') . ' '
|
|
||||||
. ($domain['URL'] ?? '') . ' '
|
|
||||||
. ($domain['No'] ?? '') . ' '
|
|
||||||
. ($domain['contract_type'] ?? '')
|
|
||||||
);
|
|
||||||
|
|
||||||
return str_contains($haystack, $searchLower);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customer filter (partial match on Customer_No + Customer_Name)
|
|
||||||
if ($customer !== '') {
|
|
||||||
$customerLower = mb_strtolower($customer);
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($customerLower): bool {
|
|
||||||
$haystack = mb_strtolower(
|
|
||||||
($domain['Customer_No'] ?? '') . ' '
|
|
||||||
. ($domain['Customer_Name'] ?? '')
|
|
||||||
);
|
|
||||||
|
|
||||||
return str_contains($haystack, $customerLower);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contract type filter (partial match)
|
|
||||||
if ($contractType !== '') {
|
|
||||||
$contractTypeLower = mb_strtolower($contractType);
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($contractTypeLower): bool {
|
|
||||||
return str_contains(mb_strtolower((string) ($domain['contract_type'] ?? '')), $contractTypeLower);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// State filter (exact match)
|
|
||||||
if ($state !== '') {
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($state): bool {
|
|
||||||
return trim((string) ($domain['State'] ?? '')) === $state;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Administration filter (exact match)
|
|
||||||
if ($administration !== '') {
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($administration): bool {
|
|
||||||
return trim((string) ($domain['Administration'] ?? '')) === $administration;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security level filter
|
|
||||||
if ($securityLevel !== '' && $tenantId > 0) {
|
|
||||||
$securityLevelService = app(DomainSecurityLevelService::class);
|
|
||||||
$allDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $filtered);
|
|
||||||
$allDomainNos = array_values(array_filter($allDomainNos, static fn (string $n): bool => $n !== ''));
|
|
||||||
$filterLookup = $allDomainNos !== [] ? $securityLevelService->getAllLevelsForDomains($tenantId, $allDomainNos) : [];
|
|
||||||
$filtered = array_values(array_filter($filtered, static function (array $domain) use ($securityLevel, $filterLookup): bool {
|
|
||||||
$domainNo = trim((string) ($domain['No'] ?? ''));
|
|
||||||
$currentLevel = $filterLookup[$domainNo] ?? 'normal';
|
|
||||||
|
|
||||||
return $currentLevel === $securityLevel;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sorting
|
|
||||||
$total = count($filtered);
|
|
||||||
|
|
||||||
usort($filtered, static function (array $a, array $b) use ($order, $dir): int {
|
|
||||||
$va = (string) ($a[$order] ?? '');
|
|
||||||
$vb = (string) ($b[$order] ?? '');
|
|
||||||
$cmp = strnatcasecmp($va, $vb);
|
|
||||||
|
|
||||||
return $dir === 'desc' ? -$cmp : $cmp;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination
|
|
||||||
$rows = array_slice($filtered, $offset, $limit);
|
|
||||||
|
|
||||||
// Security level enrichment for displayed rows
|
|
||||||
$securityLevelLookup = [];
|
|
||||||
if ($tenantId > 0) {
|
|
||||||
$securityLevelService = app(DomainSecurityLevelService::class);
|
|
||||||
$pagedDomainNos = array_map(static fn (array $d): string => trim((string) ($d['No'] ?? '')), $rows);
|
|
||||||
$pagedDomainNos = array_values(array_filter($pagedDomainNos, static fn (string $n): bool => $n !== ''));
|
|
||||||
if ($pagedDomainNos !== []) {
|
|
||||||
$securityLevelLookup = $securityLevelService->getAllDetailsForDomains($tenantId, $pagedDomainNos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row preparation
|
|
||||||
$stateVariantMap = [
|
|
||||||
'Aktiv' => 'success',
|
|
||||||
'In Vorbereitung' => 'warning',
|
|
||||||
];
|
|
||||||
|
|
||||||
$debitorBaseUrl = lurl('helpdesk/debitor/');
|
|
||||||
|
|
||||||
$preparedRows = [];
|
|
||||||
foreach ($rows as $domain) {
|
|
||||||
$customerNo = trim((string) ($domain['Customer_No'] ?? ''));
|
|
||||||
$domainState = (string) ($domain['State'] ?? '');
|
|
||||||
$domainNo = (string) ($domain['No'] ?? '');
|
|
||||||
$details = $securityLevelLookup[$domainNo] ?? null;
|
|
||||||
$level = $details['level'] ?? 'normal';
|
|
||||||
$note = $details['note'] ?? '';
|
|
||||||
|
|
||||||
$preparedRows[] = [
|
|
||||||
'No' => $domainNo,
|
|
||||||
'Customer_No' => $customerNo,
|
|
||||||
'Customer_Name' => (string) ($domain['Customer_Name'] ?? ''),
|
|
||||||
'URL' => (string) ($domain['URL'] ?? ''),
|
|
||||||
'State' => $domainState,
|
|
||||||
'state_variant' => $stateVariantMap[$domainState] ?? 'neutral',
|
|
||||||
'Administration' => (string) ($domain['Administration'] ?? ''),
|
|
||||||
'contract_type' => (string) ($domain['contract_type'] ?? ''),
|
|
||||||
'contract_no' => (string) ($domain['contract_no'] ?? ''),
|
|
||||||
'debitor_url' => $customerNo !== '' ? $debitorBaseUrl . rawurlencode($customerNo) : '',
|
|
||||||
'security_level' => $level,
|
|
||||||
'security_level_variant' => DomainSecurityLevelService::getBadgeVariant($level),
|
|
||||||
'security_level_note' => $note,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
gridJsonDataResult($preparedRows, $total);
|
|
||||||
|
|||||||
48
modules/helpdesk/pages/helpdesk/domains/export().php
Normal file
48
modules/helpdesk/pages/helpdesk/domains/export().php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
|
use MintyPHP\Module\Helpdesk\HelpdeskAuthorizationPolicy;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DomainListService;
|
||||||
|
use MintyPHP\Service\Export\ExportColumn;
|
||||||
|
use MintyPHP\Support\Guard;
|
||||||
|
|
||||||
|
Guard::requireLogin();
|
||||||
|
Guard::requireAbilityOrForbidden(HelpdeskAuthorizationPolicy::ABILITY_ACCESS);
|
||||||
|
exportRequireGetRequest();
|
||||||
|
|
||||||
|
$request = requestInput();
|
||||||
|
$session = app(SessionStoreInterface::class)->all();
|
||||||
|
$tenantId = (int) ($session['current_tenant']['id'] ?? 0);
|
||||||
|
|
||||||
|
$filters = gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php');
|
||||||
|
$filters['limit'] = exportCapLimit($request->query('limit'), 5000);
|
||||||
|
$filters['offset'] = 0;
|
||||||
|
|
||||||
|
$result = app(DomainListService::class)->query($tenantId, $filters);
|
||||||
|
|
||||||
|
$levelLabels = [
|
||||||
|
'niedrig' => t('Low'),
|
||||||
|
'normal' => t('Normal'),
|
||||||
|
'hoch' => t('High'),
|
||||||
|
'kritisch' => t('Critical'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$columns = [
|
||||||
|
new ExportColumn(t('No.'), static fn (array $row): string => (string) ($row['No'] ?? '')),
|
||||||
|
new ExportColumn(t('Customer No.'), static fn (array $row): string => (string) ($row['Customer_No'] ?? '')),
|
||||||
|
new ExportColumn(t('Customer Name'), static fn (array $row): string => (string) ($row['Customer_Name'] ?? '')),
|
||||||
|
new ExportColumn(t('URL'), static fn (array $row): string => (string) ($row['URL'] ?? '')),
|
||||||
|
new ExportColumn(t('Contract type'), static fn (array $row): string => (string) ($row['contract_type'] ?? '')),
|
||||||
|
new ExportColumn(t('Contract No.'), static fn (array $row): string => (string) ($row['contract_no'] ?? '')),
|
||||||
|
new ExportColumn(t('State'), static fn (array $row): string => (string) ($row['State'] ?? '')),
|
||||||
|
new ExportColumn(t('Administration'), static fn (array $row): string => (string) ($row['Administration'] ?? '')),
|
||||||
|
new ExportColumn(t('Security level'), static function (array $row) use ($levelLabels): string {
|
||||||
|
$level = (string) ($row['security_level'] ?? 'normal');
|
||||||
|
return (string) ($levelLabels[$level] ?? $level);
|
||||||
|
}),
|
||||||
|
new ExportColumn(t('Note'), static fn (array $row): string => (string) ($row['security_level_note'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
$filename = 'helpdesk-domains-' . date('Ymd-His') . '.csv';
|
||||||
|
|
||||||
|
exportSendCsv($filename, $result['rows'], $columns, exportResolveFlavor($request));
|
||||||
@@ -21,6 +21,9 @@ $isConfigured = $isConfigured ?? false;
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require templatePath('partials/app-list-export-dropdown.phtml');
|
||||||
|
$listTitleActionsHtml = ob_get_clean();
|
||||||
$listTitle = t('Domains');
|
$listTitle = t('Domains');
|
||||||
require templatePath('partials/app-list-titlebar.phtml');
|
require templatePath('partials/app-list-titlebar.phtml');
|
||||||
?>
|
?>
|
||||||
@@ -72,6 +75,7 @@ require templatePath('partials/app-list-filters.phtml');
|
|||||||
'dataUrl' => lurl('helpdesk/domains-data'),
|
'dataUrl' => lurl('helpdesk/domains-data'),
|
||||||
'domainBaseUrl' => lurl('helpdesk/domain/'),
|
'domainBaseUrl' => lurl('helpdesk/domain/'),
|
||||||
'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'),
|
'securityLevelDataUrl' => lurl('helpdesk/domains/security-level-data'),
|
||||||
|
'exportUrl' => lurl('helpdesk/domains/export'),
|
||||||
'gridLang' => gridLang(),
|
'gridLang' => gridLang(),
|
||||||
'gridSearch' => $searchConfig,
|
'gridSearch' => $searchConfig,
|
||||||
'filterSchema' => $clientFilterSchema,
|
'filterSchema' => $clientFilterSchema,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||||||
|
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\BcODataGateway;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DomainListService;
|
||||||
|
use MintyPHP\Module\Helpdesk\Service\DomainSecurityLevelService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class DomainListServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private const TENANT_ID = 7;
|
||||||
|
|
||||||
|
public function testFiltersByState(): void
|
||||||
|
{
|
||||||
|
$service = $this->buildService($this->sampleDomains());
|
||||||
|
$result = $service->query(self::TENANT_ID, ['state' => 'Aktiv']);
|
||||||
|
|
||||||
|
$this->assertSame(2, $result['total']);
|
||||||
|
$this->assertEqualsCanonicalizing(
|
||||||
|
['DNS001', 'DNS002'],
|
||||||
|
array_column($result['rows'], 'No')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFiltersBySearchAcrossMultipleColumns(): void
|
||||||
|
{
|
||||||
|
$service = $this->buildService($this->sampleDomains());
|
||||||
|
$result = $service->query(self::TENANT_ID, ['search' => 'example.com']);
|
||||||
|
|
||||||
|
$this->assertSame(1, $result['total']);
|
||||||
|
$this->assertSame('DNS001', $result['rows'][0]['No']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllValueDisablesFilter(): void
|
||||||
|
{
|
||||||
|
$service = $this->buildService($this->sampleDomains());
|
||||||
|
$result = $service->query(self::TENANT_ID, ['state' => 'all']);
|
||||||
|
|
||||||
|
$this->assertSame(3, $result['total']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSortingAscAndDesc(): void
|
||||||
|
{
|
||||||
|
$domains = $this->sampleDomains();
|
||||||
|
$service = $this->buildService($domains);
|
||||||
|
|
||||||
|
$asc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']);
|
||||||
|
$desc = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'desc']);
|
||||||
|
|
||||||
|
$this->assertSame(['DNS001', 'DNS002', 'DNS003'], array_column($asc['rows'], 'No'));
|
||||||
|
$this->assertSame(['DNS003', 'DNS002', 'DNS001'], array_column($desc['rows'], 'No'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLimitAndOffsetPaging(): void
|
||||||
|
{
|
||||||
|
$service = $this->buildService($this->sampleDomains());
|
||||||
|
$result = $service->query(self::TENANT_ID, [
|
||||||
|
'order' => 'No',
|
||||||
|
'dir' => 'asc',
|
||||||
|
'limit' => 1,
|
||||||
|
'offset' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(3, $result['total'], 'total counts the filtered set before paging');
|
||||||
|
$this->assertCount(1, $result['rows']);
|
||||||
|
$this->assertSame('DNS002', $result['rows'][0]['No']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnrichesRowsWithSecurityLevelVariantAndNote(): void
|
||||||
|
{
|
||||||
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
||||||
|
$levels->method('getAllDetailsForDomains')->willReturn([
|
||||||
|
'DNS001' => ['level' => 'hoch', 'note' => 'important'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
|
$gateway->method('listDomains')->willReturn($this->sampleDomains());
|
||||||
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
||||||
|
|
||||||
|
$service = new DomainListService($gateway, $levels);
|
||||||
|
$result = $service->query(self::TENANT_ID, ['order' => 'No', 'dir' => 'asc']);
|
||||||
|
|
||||||
|
$first = $result['rows'][0];
|
||||||
|
$this->assertSame('hoch', $first['security_level']);
|
||||||
|
$this->assertSame('important', $first['security_level_note']);
|
||||||
|
$this->assertSame(DomainSecurityLevelService::getBadgeVariant('hoch'), $first['security_level_variant']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBcFailureReturnsEmptyResult(): void
|
||||||
|
{
|
||||||
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
|
$gateway->method('listDomains')->willThrowException(new \RuntimeException('upstream down'));
|
||||||
|
|
||||||
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
||||||
|
|
||||||
|
$service = new DomainListService($gateway, $levels);
|
||||||
|
$result = $service->query(self::TENANT_ID, []);
|
||||||
|
|
||||||
|
$this->assertSame(['rows' => [], 'total' => 0], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSecurityLevelFilterRestrictsTenantScoped(): void
|
||||||
|
{
|
||||||
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
|
$gateway->method('listDomains')->willReturn($this->sampleDomains());
|
||||||
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
||||||
|
|
||||||
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
||||||
|
$levels->expects($this->once())
|
||||||
|
->method('getAllLevelsForDomains')
|
||||||
|
->with(self::TENANT_ID, $this->callback(static fn (array $nos): bool => $nos === ['DNS001', 'DNS002', 'DNS003']))
|
||||||
|
->willReturn(['DNS002' => 'hoch']);
|
||||||
|
$levels->method('getAllDetailsForDomains')->willReturn([]);
|
||||||
|
|
||||||
|
$service = new DomainListService($gateway, $levels);
|
||||||
|
$result = $service->query(self::TENANT_ID, ['security_level' => 'hoch']);
|
||||||
|
|
||||||
|
$this->assertSame(1, $result['total']);
|
||||||
|
$this->assertSame('DNS002', $result['rows'][0]['No']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $domains
|
||||||
|
*/
|
||||||
|
private function buildService(array $domains): DomainListService
|
||||||
|
{
|
||||||
|
$gateway = $this->createMock(BcODataGateway::class);
|
||||||
|
$gateway->method('listDomains')->willReturn($domains);
|
||||||
|
$gateway->method('listDomainContractLines')->willReturn([]);
|
||||||
|
|
||||||
|
$levels = $this->createMock(DomainSecurityLevelService::class);
|
||||||
|
$levels->method('getAllDetailsForDomains')->willReturn([]);
|
||||||
|
$levels->method('getAllLevelsForDomains')->willReturn([]);
|
||||||
|
|
||||||
|
return new DomainListService($gateway, $levels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private function sampleDomains(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['No' => 'DNS001', 'Customer_No' => 'C-1', 'Customer_Name' => 'Acme GmbH', 'URL' => 'example.com', 'State' => 'Aktiv', 'Administration' => 'A'],
|
||||||
|
['No' => 'DNS002', 'Customer_No' => 'C-2', 'Customer_Name' => 'Beta AG', 'URL' => 'beta.de', 'State' => 'Aktiv', 'Administration' => 'A'],
|
||||||
|
['No' => 'DNS003', 'Customer_No' => 'C-3', 'Customer_Name' => 'Gamma KG', 'URL' => 'gamma.io', 'State' => 'In Vorbereitung', 'Administration' => 'B'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createListPageModule } from '/js/core/app-list-page-module.js';
|
|||||||
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
import { escapeHtml, withCurrentListReturn } from '/js/pages/app-list-utils.js';
|
||||||
import { postForm } from '/js/core/app-http.js';
|
import { postForm } from '/js/core/app-http.js';
|
||||||
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
import { showAsyncFlash } from '/js/components/app-async-flash.js';
|
||||||
|
import { initListExport } from '/js/core/app-list-export.js';
|
||||||
|
|
||||||
const LEVEL_LABELS = {
|
const LEVEL_LABELS = {
|
||||||
niedrig: 'Low',
|
niedrig: 'Low',
|
||||||
@@ -314,6 +315,10 @@ createListPageModule({
|
|||||||
|
|
||||||
gridRef.grid = gridConfig?.grid || null;
|
gridRef.grid = gridConfig?.grid || null;
|
||||||
|
|
||||||
|
if (gridConfig && config.exportUrl) {
|
||||||
|
initListExport({ gridConfig, exportUrl: config.exportUrl });
|
||||||
|
}
|
||||||
|
|
||||||
return gridConfig;
|
return gridConfig;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
35
templates/partials/app-list-export-dropdown.phtml
Normal file
35
templates/partials/app-list-export-dropdown.phtml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Reusable export dropdown for list pages.
|
||||||
|
*
|
||||||
|
* Renders a zero-config <details class="dropdown"> with two triggers:
|
||||||
|
* [data-list-export="csv"] — plain CSV
|
||||||
|
* [data-list-export="excel"] — UTF-8 BOM + semicolon (opens in Excel)
|
||||||
|
*
|
||||||
|
* The URL is NOT set here. The JS module `/js/core/app-list-export.js`
|
||||||
|
* picks up the trigger clicks, reads the grid filter/sort state, and
|
||||||
|
* navigates to the configured export endpoint.
|
||||||
|
*
|
||||||
|
* Consumer contract:
|
||||||
|
* - Pass the export endpoint to the JS module via page config
|
||||||
|
* (initListExport({ gridConfig, exportUrl })).
|
||||||
|
* - Place this partial inside $listTitleActionsHtml.
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<details class="dropdown app-list-export-dropdown" data-list-export-menu>
|
||||||
|
<summary role="button" class="secondary outline">
|
||||||
|
<i class="bi bi-download" aria-hidden="true"></i> <?php e(t('Export')); ?>
|
||||||
|
</summary>
|
||||||
|
<ul dir="rtl">
|
||||||
|
<li dir="ltr">
|
||||||
|
<button class="transparent" type="button" data-list-export="csv">
|
||||||
|
<i class="bi bi-filetype-csv" aria-hidden="true"></i> <?php e(t('Export as CSV')); ?>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li dir="ltr">
|
||||||
|
<button class="transparent" type="button" data-list-export="excel">
|
||||||
|
<i class="bi bi-file-earmark-spreadsheet" aria-hidden="true"></i> <?php e(t('Export for Excel')); ?>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
146
tests/Service/Export/CsvExportServiceTest.php
Normal file
146
tests/Service/Export/CsvExportServiceTest.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Service\Export;
|
||||||
|
|
||||||
|
use MintyPHP\Service\Export\CsvExportService;
|
||||||
|
use MintyPHP\Service\Export\ExportColumn;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class CsvExportServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private CsvExportService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->service = new CsvExportService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCsvFlavorUsesCommaWithoutBom(): void
|
||||||
|
{
|
||||||
|
$output = $this->render(
|
||||||
|
rows: [
|
||||||
|
['name' => 'Alice', 'city' => 'Berlin'],
|
||||||
|
['name' => 'Bob', 'city' => 'Hamburg'],
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
new ExportColumn('Name', static fn (array $r): string => (string) $r['name']),
|
||||||
|
new ExportColumn('City', static fn (array $r): string => (string) $r['city']),
|
||||||
|
],
|
||||||
|
flavor: CsvExportService::FLAVOR_CSV,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('Name,City', $output);
|
||||||
|
$this->assertStringContainsString("Alice,Berlin", $output);
|
||||||
|
$this->assertStringContainsString("Bob,Hamburg", $output);
|
||||||
|
$this->assertStringNotContainsString("\xEF\xBB\xBF", $output, 'CSV flavor must not emit UTF-8 BOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcelFlavorUsesSemicolonWithBom(): void
|
||||||
|
{
|
||||||
|
$output = $this->render(
|
||||||
|
rows: [['name' => 'Alice', 'city' => 'Berlin']],
|
||||||
|
columns: [
|
||||||
|
new ExportColumn('Name', static fn (array $r): string => (string) $r['name']),
|
||||||
|
new ExportColumn('City', static fn (array $r): string => (string) $r['city']),
|
||||||
|
],
|
||||||
|
flavor: CsvExportService::FLAVOR_EXCEL,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringStartsWith("\xEF\xBB\xBF", $output, 'Excel flavor must start with UTF-8 BOM');
|
||||||
|
$this->assertStringContainsString('Name;City', $output);
|
||||||
|
$this->assertStringContainsString('Alice;Berlin', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFormulaInjectionIsEscaped(): void
|
||||||
|
{
|
||||||
|
foreach (['=SUM(A1:A10)', '@foo()', '+cmd', '-bad', "\tTAB", "\rCR"] as $payload) {
|
||||||
|
$this->assertSame(
|
||||||
|
"'" . $payload,
|
||||||
|
CsvExportService::escapeValue($payload),
|
||||||
|
'Value starting with leading special character must be quoted: ' . bin2hex($payload)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHeaderRowIsAlsoEscaped(): void
|
||||||
|
{
|
||||||
|
$output = $this->render(
|
||||||
|
rows: [['v' => 'safe']],
|
||||||
|
columns: [new ExportColumn('=HYPERLINK("x")', static fn (array $r): string => (string) $r['v'])],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The header itself must be escaped — a future consumer may pass user-provided column labels.
|
||||||
|
$this->assertStringContainsString('"\'=HYPERLINK(""x"")"', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSignedNumericIsAllowedForPhoneLikeColumns(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('+49 123 456', CsvExportService::escapeValue('+49 123 456', allowSignedNumeric: true));
|
||||||
|
$this->assertSame('-12.5', CsvExportService::escapeValue('-12.5', allowSignedNumeric: true));
|
||||||
|
// Non-numeric leading + still gets escaped even when flag set
|
||||||
|
$this->assertSame("'+SUM(1)", CsvExportService::escapeValue('+SUM(1)', allowSignedNumeric: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNullAndBoolsAreNormalized(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', CsvExportService::escapeValue(null));
|
||||||
|
$this->assertSame('1', CsvExportService::escapeValue(true));
|
||||||
|
$this->assertSame('0', CsvExportService::escapeValue(false));
|
||||||
|
$this->assertSame('', CsvExportService::escapeValue(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSpecialCharactersAreQuotedProperly(): void
|
||||||
|
{
|
||||||
|
$output = $this->render(
|
||||||
|
rows: [['value' => 'contains "quotes" and, commas']],
|
||||||
|
columns: [new ExportColumn('V', static fn (array $r): string => (string) $r['value'])],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('"contains ""quotes"" and, commas"', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultilineValuesAreQuoted(): void
|
||||||
|
{
|
||||||
|
$output = $this->render(
|
||||||
|
rows: [['note' => "line 1\nline 2"]],
|
||||||
|
columns: [new ExportColumn('Note', static fn (array $r): string => (string) $r['note'])],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString("\"line 1\nline 2\"", $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyColumnsThrow(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->render(rows: [['a' => 'b']], columns: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIterableRowsSupported(): void
|
||||||
|
{
|
||||||
|
$generator = (function () {
|
||||||
|
yield ['n' => 1];
|
||||||
|
yield ['n' => 2];
|
||||||
|
})();
|
||||||
|
|
||||||
|
$output = $this->render(
|
||||||
|
rows: $generator,
|
||||||
|
columns: [new ExportColumn('N', static fn (array $r): string => (string) $r['n'])],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringContainsString("N\n1\n2", $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<array<string,mixed>> $rows
|
||||||
|
* @param list<ExportColumn> $columns
|
||||||
|
*/
|
||||||
|
private function render(iterable $rows, array $columns, string $flavor = CsvExportService::FLAVOR_CSV): string
|
||||||
|
{
|
||||||
|
$handle = fopen('php://memory', 'w+');
|
||||||
|
$this->service->writeTo($handle, $rows, $columns, $flavor);
|
||||||
|
rewind($handle);
|
||||||
|
$contents = stream_get_contents($handle);
|
||||||
|
fclose($handle);
|
||||||
|
return (string) $contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/Service/Export/ExportHelpersTest.php
Normal file
46
tests/Service/Export/ExportHelpersTest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Service\Export;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin tests for the procedural helpers in core/Support/helpers/export.php.
|
||||||
|
* The functions are bootstrapped via composer autoload through
|
||||||
|
* core/Support/helpers.php, so they are globally available in tests.
|
||||||
|
*/
|
||||||
|
class ExportHelpersTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testExportCapLimitClampsNonNumericToMax(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(5000, exportCapLimit(null, 5000));
|
||||||
|
$this->assertSame(5000, exportCapLimit('', 5000));
|
||||||
|
$this->assertSame(5000, exportCapLimit('abc', 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportCapLimitClampsZeroAndNegativeToMax(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(5000, exportCapLimit(0, 5000));
|
||||||
|
$this->assertSame(5000, exportCapLimit(-1, 5000));
|
||||||
|
$this->assertSame(5000, exportCapLimit('-42', 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportCapLimitRespectsUserValueWhenInRange(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(250, exportCapLimit(250, 5000));
|
||||||
|
$this->assertSame(5000, exportCapLimit(5000, 5000));
|
||||||
|
$this->assertSame(1, exportCapLimit(1, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportCapLimitClampsAboveMaxToMax(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(5000, exportCapLimit(9999, 5000));
|
||||||
|
$this->assertSame(100, exportCapLimit(999999, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExportCapLimitHonoursCustomMax(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(50, exportCapLimit(50, 100));
|
||||||
|
$this->assertSame(100, exportCapLimit(200, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
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