Files
breadcrumb-the-shire/lib/Support/helpers/grid.php

866 lines
23 KiB
PHP
Raw Normal View History

2026-03-04 15:56:58 +01:00
<?php
/**
* Enforce GET-only access for grid/data endpoints with a JSON 405 response.
*/
function gridRequireGetRequest(): void
{
if (requestInput()->isMethod('GET')) {
return;
}
http_response_code(405);
\MintyPHP\Router::json(['error' => 'method_not_allowed']);
}
/**
* Load a local filter schema file and parse request query values.
*
* @return array<string,mixed>
*/
function gridParseFiltersFromSchemaFile(string $schemaFile, ?array $query = null): array
{
$schemaFile = trim($schemaFile);
if ($schemaFile === '' || !is_file($schemaFile)) {
return [];
}
$schema = require $schemaFile;
if (!is_array($schema)) {
return [];
}
return gridParseFilters($query ?? requestInput()->queryAll(), gridSchemaQuery($schema));
}
/**
* Emit a standardized grid JSON payload.
*/
function gridJsonDataResult(array $rows, int $total): void
{
\MintyPHP\Router::json([
'data' => array_values($rows),
'total' => max(0, $total),
]);
}
/**
* Resolve a badge variant from a status/outcome map.
*
* @param array<string,string> $variantMap
*/
function gridResolveBadgeVariant(
string $value,
array $variantMap,
string $default = 'neutral',
bool $normalizeLowercase = true
): string {
$key = trim($value);
if ($normalizeLowercase) {
$key = strtolower($key);
}
$variant = (string) ($variantMap[$key] ?? $default);
return trim($variant) !== '' ? $variant : $default;
}
/**
* Build a csv_strings sanitizer for string-backed enums.
*
* @param class-string<\BackedEnum> $enumClass
*/
function gridEnumSanitizer(string $enumClass, bool $lowercase = true): callable
{
return static function (string $value) use ($enumClass, $lowercase): string {
if (!enum_exists($enumClass) || !is_subclass_of($enumClass, \BackedEnum::class)) {
return '';
}
$value = trim($value);
if ($value === '') {
return '';
}
if ($lowercase) {
$value = strtolower($value);
}
foreach ($enumClass::cases() as $case) {
$candidate = (string) $case->value;
$normalizedCandidate = $lowercase ? strtolower($candidate) : $candidate;
if ($normalizedCandidate === $value) {
return $candidate;
}
}
return '';
};
}
/**
* Identity helper for filter schema declarations.
*
* @param array<string,mixed>|array<int,mixed> $schema
* @return array<string,mixed>|array<int,mixed>
*/
function gridFilterSchema(array $schema = []): array
{
return $schema;
}
/**
* Render a value as safe inline JavaScript JSON.
*/
function gridJsonForJs(mixed $value): void
{
$json = json_encode(
$value,
JSON_UNESCAPED_UNICODE
| JSON_UNESCAPED_SLASHES
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
);
if (!is_string($json)) {
$json = 'null';
}
echo $json;
}
/**
* Extract query parsing rules from a list filter schema.
*
* Supports two shapes:
* - ['query' => [...], 'toolbar' => [...]]
* - [['key' => '...', 'query' => [...]], ...]
*
* @return array<string,mixed>
*/
function gridSchemaQuery(array $schema): array
{
if (isset($schema['query']) && is_array($schema['query'])) {
return $schema['query'];
}
$rules = [];
foreach ($schema as $entry) {
if (!is_array($entry)) {
continue;
}
$key = trim((string) ($entry['key'] ?? ''));
if ($key === '') {
continue;
}
if (is_array($entry['query'] ?? null)) {
$rules[$key] = $entry['query'];
}
}
return $rules;
}
/**
* Extract toolbar field schema from a list filter schema.
*
* @return array<int,array<string,mixed>>
*/
function gridSchemaToolbar(array $schema): array
{
if (isset($schema['toolbar']) && is_array($schema['toolbar'])) {
return array_values(array_filter($schema['toolbar'], 'is_array'));
}
$toolbar = [];
foreach ($schema as $entry) {
if (!is_array($entry) || (($entry['render'] ?? true) === false)) {
continue;
}
if (!isset($entry['type']) || !isset($entry['key'])) {
continue;
}
$toolbar[] = $entry;
}
return $toolbar;
}
/**
* Split toolbar schema into search and drawer sections.
*
* @param array<int,array<string,mixed>> $toolbarSchema
* @param string[] $searchKeys
* @return array{search: array<int,array<string,mixed>>, drawer: array<int,array<string,mixed>>}
*/
function gridSplitToolbarSchema(array $toolbarSchema, array $searchKeys = []): array
{
$searchKeyMap = [];
foreach ($searchKeys as $searchKey) {
$key = trim((string) $searchKey);
if ($key !== '') {
$searchKeyMap[$key] = true;
}
}
$search = [];
$drawer = [];
foreach ($toolbarSchema as $field) {
if (!is_array($field)) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
$isSearch = (bool) ($field['search'] ?? false) || ($key !== '' && isset($searchKeyMap[$key]));
if ($isSearch) {
$search[] = $field;
continue;
}
$drawer[] = $field;
}
return ['search' => $search, 'drawer' => $drawer];
}
/**
* Index toolbar schema entries by filter key.
*
* @param array<int,array<string,mixed>> $toolbarSchema
* @return array<string,array<string,mixed>>
*/
function gridSchemaByKey(array $toolbarSchema): array
{
$schemaByKey = [];
foreach ($toolbarSchema as $field) {
if (!is_array($field)) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
$schemaByKey[$key] = $field;
}
return $schemaByKey;
}
/**
* Build toolbar state values from parsed filter state and toolbar schema defaults.
*
* @param array<int,array<string,mixed>> $toolbarSchema
* @param array<string,mixed> $filterState
* @return array<string,mixed>
*/
function gridBuildToolbarFilterState(array $toolbarSchema, array $filterState): array
{
$state = [];
foreach ($toolbarSchema as $field) {
if (!is_array($field)) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
$type = strtolower(trim((string) ($field['type'] ?? '')));
$default = array_key_exists('default', $field) ? $field['default'] : '';
$value = array_key_exists($key, $filterState) ? $filterState[$key] : $default;
if ($type === 'multi_csv') {
if (is_array($value)) {
$items = array_values(array_filter(
array_map(static fn (mixed $item): string => trim((string) $item), $value),
static fn (string $item): bool => $item !== ''
));
$state[$key] = array_values(array_unique($items));
continue;
}
$text = trim((string) $value);
if ($text === '') {
$state[$key] = [];
continue;
}
$items = array_values(array_filter(
array_map(static fn (string $item): string => trim($item), explode(',', $text)),
static fn (string $item): bool => $item !== ''
));
$state[$key] = array_values(array_unique($items));
continue;
}
if (is_array($value)) {
$value = (string) (reset($value) ?: '');
}
$state[$key] = trim((string) $value);
}
return $state;
}
/**
* Build normalized list-filter context used by index actions/templates.
*
* @param array<string,mixed> $filterSchema
* @param array<string,mixed> $options
* @return array{
* filterState: array<string,mixed>,
* toolbarFilterSchema: array<int,array<string,mixed>>,
* toolbarFilterState: array<string,mixed>,
* searchToolbarFilterSchema: array<int,array<string,mixed>>,
* drawerToolbarFilterSchema: array<int,array<string,mixed>>,
* schemaByKey: array<string,array<string,mixed>>,
* toolbarOptionSets: array<string,mixed>,
* clientFilterSchema: array<int,array<string,mixed>>,
* searchConfig: ?array<string,mixed>
* }
*/
function gridBuildListFilterContext(array $filterSchema, array $options = []): array
{
$query = is_array($options['query'] ?? null) ? $options['query'] : requestInput()->queryAll();
$querySchema = is_array($options['query_schema'] ?? null)
? $options['query_schema']
: gridSchemaQuery($filterSchema);
$filterState = is_array($options['filter_state'] ?? null)
? $options['filter_state']
: gridParseFilters($query, $querySchema);
$toolbarFilterSchema = gridSchemaToolbar($filterSchema);
$toolbarFilterState = gridBuildToolbarFilterState($toolbarFilterSchema, $filterState);
$toolbarStateOverrides = is_array($options['toolbar_state_overrides'] ?? null)
? $options['toolbar_state_overrides']
: [];
if ($toolbarStateOverrides) {
$toolbarFilterState = array_replace($toolbarFilterState, $toolbarStateOverrides);
}
$searchKeys = is_array($options['search_keys'] ?? null) ? $options['search_keys'] : ['search'];
$toolbarSchemaSections = gridSplitToolbarSchema($toolbarFilterSchema, $searchKeys);
$toolbarOptionSets = is_array($options['toolbar_option_sets'] ?? null) ? $options['toolbar_option_sets'] : [];
return [
'filterState' => $filterState,
'toolbarFilterSchema' => $toolbarFilterSchema,
'toolbarFilterState' => $toolbarFilterState,
'searchToolbarFilterSchema' => $toolbarSchemaSections['search'],
'drawerToolbarFilterSchema' => $toolbarSchemaSections['drawer'],
'schemaByKey' => gridSchemaByKey($toolbarFilterSchema),
'toolbarOptionSets' => $toolbarOptionSets,
'clientFilterSchema' => gridSchemaClientFilters($filterSchema),
'searchConfig' => gridSchemaClientSearch($filterSchema),
];
}
/**
* Build id => label maps from option set items.
*
* @param array<int,array<string,mixed>> $items
* @return array<string,string>
*/
function gridOptionMapFromItems(array $items): array
{
$map = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$id = trim((string) ($item['id'] ?? ''));
if ($id === '') {
continue;
}
$map[$id] = (string) ($item['description'] ?? $id);
}
return $map;
}
/**
* Build id => label maps from schema allowed declarations.
*
* @param array<string,mixed> $field
* @return array<string,string>
*/
function gridOptionMapFromAllowed(array $field): array
{
$map = [];
foreach ((array) ($field['allowed'] ?? []) as $item) {
if (!is_array($item)) {
continue;
}
$id = trim((string) ($item['id'] ?? ''));
if ($id === '') {
continue;
}
$label = (string) ($item['description'] ?? $id);
$translate = !array_key_exists('translate', $item) || (bool) $item['translate'];
$map[$id] = $translate ? t($label) : $label;
}
return $map;
}
/**
* Build client filter schema consumed by JS helper gridFiltersFromSchema().
*
* @return array<int,array<string,mixed>>
*/
function gridSchemaClientFilters(array $schema): array
{
if (isset($schema['client_filters']) && is_array($schema['client_filters'])) {
return array_values(array_filter($schema['client_filters'], 'is_array'));
}
$filters = [];
foreach (gridSchemaToolbar($schema) as $field) {
$type = strtolower(trim((string) ($field['type'] ?? '')));
if (!in_array($type, ['text', 'date', 'number', 'select', 'multi_csv', 'hidden'], true)) {
continue;
}
if ((bool) ($field['search'] ?? false)) {
continue;
}
if (($field['bind'] ?? true) === false) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
$inputId = trim((string) ($field['input_id'] ?? ''));
if ($inputId === '') {
$inputId = $key . '-filter';
}
$filter = [
'type' => $type,
'input' => '#' . $inputId,
'param' => (string) ($field['param'] ?? $key),
];
if (array_key_exists('default', $field)) {
$filter['default'] = $field['default'];
}
if (isset($field['event'])) {
$filter['event'] = (string) $field['event'];
}
if (isset($field['normalize'])) {
$filter['normalize'] = (string) $field['normalize'];
}
$filters[] = $filter;
}
return $filters;
}
/**
* Build client search config consumed by createServerGrid().
*
* @return array<string,mixed>|null
*/
function gridSchemaClientSearch(array $schema): ?array
{
foreach (gridSchemaToolbar($schema) as $field) {
if (!(bool) ($field['search'] ?? false)) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
$inputId = trim((string) ($field['input_id'] ?? ''));
if ($inputId === '') {
$inputId = $key . '-filter';
}
return [
'input' => '#' . $inputId,
'param' => (string) ($field['param'] ?? $key),
'debounce' => (int) ($field['debounce'] ?? 250),
];
}
return null;
}
/**
* Parse filter/query values by a compact schema.
*
* Supported types:
* - int
* - number
* - bool
* - string
* - enum
* - uuid
* - date (Y-m-d)
* - csv_ids
* - csv_uuid
* - csv_strings
* - order
* - dir
*
* @return array<string, mixed>
*/
function gridParseFilters(array $query, array $schema): array
{
$parsed = [];
foreach ($schema as $key => $rule) {
if (!is_array($rule)) {
$rule = ['type' => (string) $rule];
}
$type = strtolower(trim((string) ($rule['type'] ?? 'string')));
$source = (string) ($rule['source'] ?? $key);
if ($source === '') {
$source = (string) $key;
}
if ($type === 'int') {
$default = (int) ($rule['default'] ?? 0);
$min = isset($rule['min']) ? (int) $rule['min'] : null;
$max = isset($rule['max']) ? (int) $rule['max'] : null;
$value = (int) ($query[$source] ?? $default);
if ($min !== null && $value < $min) {
$value = $default;
}
if ($max !== null && $value > $max) {
$value = $max;
}
$parsed[$key] = $value;
continue;
}
if ($type === 'number') {
$default = (float) ($rule['default'] ?? 0);
$min = isset($rule['min']) ? (float) $rule['min'] : null;
$max = isset($rule['max']) ? (float) $rule['max'] : null;
$parsed[$key] = gridQueryNumber($query, $source, $default, $min, $max);
continue;
}
if ($type === 'bool') {
$default = (bool) ($rule['default'] ?? false);
$parsed[$key] = gridQueryBool($query, $source, $default);
continue;
}
if ($type === 'string') {
$parsed[$key] = gridQueryString($query, $source, (string) ($rule['default'] ?? ''));
continue;
}
if ($type === 'enum') {
$parsed[$key] = gridQueryEnum(
$query,
$source,
(array) ($rule['allowed'] ?? []),
(string) ($rule['default'] ?? ''),
(bool) ($rule['lowercase'] ?? false)
);
continue;
}
if ($type === 'date') {
$parsed[$key] = gridQueryDate($query, $source, (string) ($rule['default'] ?? ''));
continue;
}
if ($type === 'uuid') {
$parsed[$key] = gridQueryUuid($query, $source, (string) ($rule['default'] ?? ''));
continue;
}
if ($type === 'csv_ids') {
$ids = gridQueryCsvIds($query, $source, (int) ($rule['max'] ?? 200));
$parsed[$key] = (($rule['return'] ?? 'csv') === 'array')
? $ids
: implode(',', array_map('strval', $ids));
continue;
}
if ($type === 'csv_uuid') {
$items = gridQueryCsvUuids($query, $source, (int) ($rule['max'] ?? 200));
$parsed[$key] = (($rule['return'] ?? 'csv') === 'array')
? $items
: implode(',', $items);
continue;
}
if ($type === 'csv_strings') {
$items = gridQueryCsvStrings(
$query,
$source,
(int) ($rule['max'] ?? 200),
$rule['sanitizer'] ?? null
);
$parsed[$key] = (($rule['return'] ?? 'csv') === 'array')
? $items
: implode(',', $items);
continue;
}
if ($type === 'order') {
$allowed = array_values(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
(array) ($rule['allowed'] ?? [])
), static fn (string $item): bool => $item !== ''));
$default = (string) ($rule['default'] ?? ($allowed[0] ?? ''));
$parsed[$key] = in_array(trim((string) ($query[$source] ?? $default)), $allowed, true)
? trim((string) ($query[$source] ?? $default))
: $default;
continue;
}
if ($type === 'dir') {
$parsed[$key] = gridQueryEnum($query, $source, ['asc', 'desc'], (string) ($rule['default'] ?? 'asc'), true);
continue;
}
$parsed[$key] = gridQueryString($query, $source, (string) ($rule['default'] ?? ''));
}
return $parsed;
}
/**
* Read a trimmed string from query params.
*/
function gridQueryString(array $query, string $key, string $default = ''): string
{
return trim((string) ($query[$key] ?? $default));
}
/**
* Read and normalize a numeric query value.
*/
function gridQueryNumber(array $query, string $key, float $default = 0, ?float $min = null, ?float $max = null): float
{
$value = trim((string) ($query[$key] ?? ''));
if ($value === '' || !is_numeric($value)) {
$number = $default;
} else {
$number = (float) $value;
}
if ($min !== null && $number < $min) {
$number = $min;
}
if ($max !== null && $number > $max) {
$number = $max;
}
return $number;
}
/**
* Read and normalize a bool query value.
*/
function gridQueryBool(array $query, string $key, bool $default = false): bool
{
if (!array_key_exists($key, $query)) {
return $default;
}
return settingToBool(trim((string) $query[$key]), $default);
2026-03-04 15:56:58 +01:00
}
/**
* Read and validate a date (Y-m-d) from query params.
*/
function gridQueryDate(array $query, string $key, string $default = ''): string
{
$value = trim((string) ($query[$key] ?? $default));
if ($value === '') {
return '';
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
}
/**
* Read and validate a UUID from query params.
*/
function gridQueryUuid(array $query, string $key, string $default = ''): string
{
$value = strtolower(trim((string) ($query[$key] ?? $default)));
if ($value === '') {
return '';
}
return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/', $value) === 1
? $value
: '';
}
/**
* Read a single enum value from query params.
*
* @param string[] $allowed
*/
function gridQueryEnum(array $query, string $key, array $allowed, string $default = '', bool $lowercase = false): string
{
$value = trim((string) ($query[$key] ?? $default));
if ($value === '') {
return $default;
}
if ($lowercase) {
$value = strtolower($value);
$allowed = array_map(static fn (string $item): string => strtolower($item), $allowed);
}
return in_array($value, $allowed, true) ? $value : $default;
}
/**
* Normalize comma-separated positive IDs from query params.
*
* @return int[]
*/
function gridQueryCsvIds(array $query, string $key, int $max = 200): array
{
if ($max <= 0) {
return [];
}
$value = $query[$key] ?? '';
$items = [];
if (is_string($value)) {
$items = $value === '' ? [] : explode(',', $value);
} elseif (is_array($value)) {
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
$items[] = $item;
});
}
$ids = [];
$seen = [];
foreach ($items as $item) {
$id = (int) trim((string) $item);
if ($id <= 0 || isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$ids[] = $id;
if (count($ids) >= $max) {
break;
}
}
return $ids;
}
/**
* Normalize comma-separated UUIDs from query params.
*
* @return string[]
*/
function gridQueryCsvUuids(array $query, string $key, int $max = 200): array
{
if ($max <= 0) {
return [];
}
$value = $query[$key] ?? '';
$items = [];
if (is_string($value)) {
$items = $value === '' ? [] : explode(',', $value);
} elseif (is_array($value)) {
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
$items[] = $item;
});
}
$uuids = [];
$seen = [];
foreach ($items as $item) {
$uuid = gridQueryUuid(['v' => $item], 'v');
if ($uuid === '' || isset($seen[$uuid])) {
continue;
}
$seen[$uuid] = true;
$uuids[] = $uuid;
if (count($uuids) >= $max) {
break;
}
}
return $uuids;
}
/**
* Normalize comma-separated strings from query params.
*
* @return string[]
*/
function gridQueryCsvStrings(array $query, string $key, int $max = 200, ?callable $sanitizer = null): array
{
if ($max <= 0) {
return [];
}
$value = $query[$key] ?? '';
$items = [];
if (is_string($value)) {
$items = $value === '' ? [] : explode(',', $value);
} elseif (is_array($value)) {
array_walk_recursive($value, static function (mixed $item) use (&$items): void {
$items[] = $item;
});
}
$result = [];
$seen = [];
foreach ($items as $item) {
$text = trim((string) $item);
if ($text === '') {
continue;
}
if ($sanitizer !== null) {
$text = trim((string) $sanitizer($text));
if ($text === '') {
continue;
}
}
if (isset($seen[$text])) {
continue;
}
$seen[$text] = true;
$result[] = $text;
if (count($result) >= $max) {
break;
}
}
return $result;
}
/**
* Normalize a mixed value (string "a||b" or list) to a clean label list.
*
* @return string[]
*/
function gridNormalizeLabelList(mixed $value, string $separator = '||'): array
{
if (is_string($value)) {
$items = $value === '' ? [] : explode($separator, $value);
} elseif (is_array($value)) {
$items = $value;
} else {
return [];
}
$items = array_map(static fn (mixed $item): string => trim((string) $item), $items);
$items = array_values(array_filter($items, static fn (string $item): bool => $item !== ''));
return $items;
}