major update

This commit is contained in:
2026-03-04 15:56:58 +01:00
parent 16759a2732
commit 8f4dd5840d
478 changed files with 24313 additions and 8201 deletions

View File

@@ -4,16 +4,30 @@ namespace MintyPHP\Support;
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Service\Access\AccessServicesFactory;
use MintyPHP\Service\Access\PermissionGateway;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\Tenant\TenantService;
class Guard
{
private const TENANT_REFRESH_INTERVAL = 60;
private static ?AccessServicesFactory $accessServicesFactory = null;
private static ?PermissionGateway $permissionGateway = null;
/** @var (callable(): AuthService)|null */
private static $authServiceResolver = null;
/** @var (callable(): TenantService)|null */
private static $tenantServiceResolver = null;
/** @var (callable(): AuthorizationService)|null */
private static $authorizationServiceResolver = null;
public static function configure(
callable $authServiceResolver,
callable $tenantServiceResolver,
callable $authorizationServiceResolver
): void {
self::$authServiceResolver = $authServiceResolver;
self::$tenantServiceResolver = $tenantServiceResolver;
self::$authorizationServiceResolver = $authorizationServiceResolver;
}
public static function requireLogin(string $redirect = 'login'): void
{
@@ -23,7 +37,7 @@ class Guard
}
self::refreshTenantContext();
if (!empty($_SESSION['no_active_tenant'])) {
(new AuthServicesFactory())->createAuthService()->logout();
self::authService()->logout();
Flash::error('No active tenant assigned', 'login', 'no_active_tenant');
Router::redirect($redirect);
}
@@ -37,7 +51,7 @@ class Guard
}
$tenantId = (int) $currentTenant['id'];
$tenant = (new DirectoryServicesFactory())->createTenantService()->findById($tenantId);
$tenant = self::tenantService()->findById($tenantId);
if ($tenant) {
return;
}
@@ -45,7 +59,7 @@ class Guard
// Current tenant was deleted, refresh session data
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0) {
(new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId);
self::authService()->loadTenantDataIntoSession($userId);
} else {
unset($_SESSION['current_tenant']);
}
@@ -62,54 +76,114 @@ class Guard
if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) {
return;
}
(new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId);
self::authService()->loadTenantDataIntoSession($userId);
$_SESSION['tenant_context_refreshed_at'] = $now;
}
public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void
public static function requireAbility(string $ability, string $redirect = 'admin', array $context = []): void
{
self::requireLogin();
self::validateCurrentTenant();
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) {
if (!self::isAllowedByAbility($userId, $ability, $context)) {
Flash::error('Permission denied', $redirect, 'permission_denied');
Router::redirect($redirect);
}
}
public static function requirePermissionOrForbidden(string $permissionKey): void
public static function requireAbilityOrForbidden(string $ability, array $context = []): void
{
self::requireAbilityDecisionOrForbidden($ability, $context);
}
public static function requireAbilityDecisionOrForbidden(string $ability, array $context = []): AuthorizationDecision
{
self::requireLogin();
self::validateCurrentTenant();
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) {
$decision = self::authorizationService()->authorize($ability, [
'actor_user_id' => $userId,
...$context,
]);
if (!$decision->isAllowed()) {
if (Request::wantsJson()) {
http_response_code(403);
http_response_code($decision->status());
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'forbidden']);
$error = trim($decision->error());
echo json_encode(['error' => $error !== '' ? $error : 'forbidden']);
exit;
}
Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery()));
}
return $decision;
}
private static function permissionGateway(): PermissionGateway
public static function deny(string $redirect = 'error/forbidden'): void
{
if (self::$permissionGateway instanceof PermissionGateway) {
return self::$permissionGateway;
if (Request::wantsJson()) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'forbidden']);
exit;
}
self::$permissionGateway = self::accessServicesFactory()->createPermissionGateway();
return self::$permissionGateway;
if ($redirect === 'error/forbidden') {
Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery()));
return;
}
Router::redirect($redirect);
}
private static function accessServicesFactory(): AccessServicesFactory
private static function authorizationService(): AuthorizationService
{
if (self::$accessServicesFactory instanceof AccessServicesFactory) {
return self::$accessServicesFactory;
if (!is_callable(self::$authorizationServiceResolver)) {
throw new \RuntimeException('Guard is not configured for dependency: ' . AuthorizationService::class);
}
$service = (self::$authorizationServiceResolver)();
if (!$service instanceof AuthorizationService) {
throw new \RuntimeException('Guard resolver returned invalid dependency: ' . AuthorizationService::class);
}
return $service;
}
private static function authService(): AuthService
{
if (!is_callable(self::$authServiceResolver)) {
throw new \RuntimeException('Guard is not configured for dependency: ' . AuthService::class);
}
$service = (self::$authServiceResolver)();
if (!$service instanceof AuthService) {
throw new \RuntimeException('Guard resolver returned invalid dependency: ' . AuthService::class);
}
return $service;
}
private static function tenantService(): TenantService
{
if (!is_callable(self::$tenantServiceResolver)) {
throw new \RuntimeException('Guard is not configured for dependency: ' . TenantService::class);
}
$service = (self::$tenantServiceResolver)();
if (!$service instanceof TenantService) {
throw new \RuntimeException('Guard resolver returned invalid dependency: ' . TenantService::class);
}
return $service;
}
private static function isAllowedByAbility(int $userId, string $ability, array $context = []): bool
{
$ability = trim($ability);
if ($userId <= 0 || $ability === '') {
return false;
}
self::$accessServicesFactory = new AccessServicesFactory();
return self::$accessServicesFactory;
$decision = self::authorizationService()->authorize($ability, [
'actor_user_id' => $userId,
...$context,
]);
return $decision->isAllowed();
}
}

View File

@@ -3,11 +3,16 @@
namespace MintyPHP\Support\Search;
use MintyPHP\Service\User\UserAvatarService;
use MintyPHP\Service\User\UserServicesFactory;
class SearchItemMapperProvider
{
private static ?UserAvatarService $userAvatarService = null;
/** @var (callable(): UserAvatarService)|null */
private static $userAvatarServiceResolver = null;
public static function configure(callable $userAvatarServiceResolver): void
{
self::$userAvatarServiceResolver = $userAvatarServiceResolver;
}
public static function mapPreviewItem(string $key, array $data, string $query): ?array
{
@@ -59,6 +64,15 @@ class SearchItemMapperProvider
$label = $requestId;
}
$url = lurl('admin/api-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'system-audit') {
$eventType = trim((string) ($data['event_type'] ?? ''));
$outcome = strtolower(trim((string) ($data['outcome'] ?? '')));
$requestId = trim((string) ($data['request_id'] ?? ''));
$label = $eventType !== '' ? $eventType : $requestId;
if ($outcome !== '') {
$label = trim($label . ' — ' . $outcome);
}
$url = lurl('admin/system-audit/view/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'pages') {
$label = (string) ($data['slug'] ?? '');
$url = lurl('page/' . $label) . '?search=' . urlencode($query);
@@ -90,7 +104,8 @@ class SearchItemMapperProvider
$description = (string) ($data['email'] ?? '');
$uuid = (string) ($data['uuid'] ?? '');
$url = lurl('address-book/view/' . $uuid);
if ($uuid !== '' && self::userAvatarService()->hasAvatar($uuid)) {
$userAvatarService = self::userAvatarService();
if ($uuid !== '' && $userAvatarService->hasAvatar($uuid)) {
$image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64';
}
} elseif ($key === 'permissions') {
@@ -128,6 +143,13 @@ class SearchItemMapperProvider
}
$description = trim(($statusCode > 0 ? (string) $statusCode : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
$url = lurl('admin/api-audit/view/' . ($data['id'] ?? ''));
} elseif ($key === 'system-audit') {
$eventType = trim((string) ($data['event_type'] ?? ''));
$outcome = strtolower(trim((string) ($data['outcome'] ?? '')));
$requestId = trim((string) ($data['request_id'] ?? ''));
$title = $eventType !== '' ? $eventType : $requestId;
$description = trim(($outcome !== '' ? strtoupper($outcome) : '') . ($requestId !== '' ? ' — ' . $requestId : ''));
$url = lurl('admin/system-audit/view/' . ($data['id'] ?? ''));
} else {
$title = (string) ($data['description'] ?? '');
$description = $label;
@@ -150,11 +172,16 @@ class SearchItemMapperProvider
private static function userAvatarService(): UserAvatarService
{
if (self::$userAvatarService instanceof UserAvatarService) {
return self::$userAvatarService;
if (!is_callable(self::$userAvatarServiceResolver)) {
throw new \RuntimeException('SearchItemMapperProvider is not configured for dependency: ' . UserAvatarService::class);
}
self::$userAvatarService = (new UserServicesFactory())->createUserAvatarService();
return self::$userAvatarService;
$service = (self::$userAvatarServiceResolver)();
if (!$service instanceof UserAvatarService) {
throw new \RuntimeException('SearchItemMapperProvider resolver returned invalid dependency: ' . UserAvatarService::class);
}
return $service;
}
}

View File

@@ -131,6 +131,17 @@ class SearchSqlResourceProvider
'resultSql' => "select id, request_id, method, path, status_code, created_at from api_audit_log where (request_id like ? escape '\\\\' or path like ? escape '\\\\' or error_code like ? escape '\\\\' or ip like ? escape '\\\\') order by created_at desc",
'resultParams' => [$like, $like, $like, $like],
],
[
'key' => 'system-audit',
'label' => t('System audit'),
'permission' => PermissionService::SYSTEM_AUDIT_VIEW,
'countSql' => "select count(*) from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\')",
'countParams' => [$like, $like, $like, $like],
'previewSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc limit ?",
'previewParams' => [$like, $like, $like, $like],
'resultSql' => "select id, request_id, event_type, outcome, created_at from system_audit_log where (request_id like ? escape '\\\\' or event_type like ? escape '\\\\' or error_code like ? escape '\\\\' or path like ? escape '\\\\') order by created_at desc",
'resultParams' => [$like, $like, $like, $like],
],
];
}

View File

@@ -15,6 +15,7 @@ class SearchUiMetaProvider
'pages' => 'bi-file-text',
'mail-log' => 'bi-envelope-paper',
'api-audit' => 'bi-shield-check',
'system-audit' => 'bi-journal-lock',
'docs' => 'bi-journal-text',
];
@@ -29,6 +30,7 @@ class SearchUiMetaProvider
'pages',
'mail-log',
'api-audit',
'system-audit',
];
public static function iconForKey(string $key): string
@@ -51,6 +53,7 @@ class SearchUiMetaProvider
'pages' => lurl(''),
'mail-log' => lurl('admin/mail-log') . '?search=' . $encoded,
'api-audit' => lurl('admin/api-audit') . '?search=' . $encoded,
'system-audit' => lurl('admin/system-audit') . '?search=' . $encoded,
default => lurl(''),
};
}

View File

@@ -3,5 +3,7 @@
// Load all global helper groups used by templates and page actions.
require __DIR__ . '/helpers/app.php';
require __DIR__ . '/helpers/auth.php';
require __DIR__ . '/helpers/grid.php';
require __DIR__ . '/helpers/i18n.php';
require __DIR__ . '/helpers/request.php';
require __DIR__ . '/helpers/ui.php';

View File

@@ -24,6 +24,59 @@ function templatePath(string $path): string
return dirname(__DIR__, 3) . '/templates/' . ltrim($path, '/');
}
/**
* Register the app service container for the current request.
*/
function setAppContainer(\MintyPHP\App\AppContainer $container): void
{
$GLOBALS['minty_app_container'] = $container;
\MintyPHP\Http\ApiAuth::configure(
static fn (): \MintyPHP\Service\Auth\AuthScopeGateway => $container->get(\MintyPHP\Service\Auth\AuthScopeGateway::class),
static fn (): \MintyPHP\Service\Auth\ApiTokenService => $container->get(\MintyPHP\Service\Auth\ApiTokenService::class),
static fn (): \MintyPHP\Repository\Access\UserRoleRepository => $container->get(\MintyPHP\Repository\Access\UserRoleRepository::class),
static fn (): \MintyPHP\Repository\Access\RolePermissionRepository => $container->get(\MintyPHP\Repository\Access\RolePermissionRepository::class),
static fn (): \MintyPHP\Service\User\UserTenantContextService => $container->get(\MintyPHP\Service\User\UserTenantContextService::class),
static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class)
);
\MintyPHP\Http\ApiBootstrap::configure(
static fn (): \MintyPHP\Service\Audit\ApiAuditService => $container->get(\MintyPHP\Service\Audit\ApiAuditService::class),
static fn (): \MintyPHP\Service\Settings\SettingGateway => $container->get(\MintyPHP\Service\Settings\SettingGateway::class),
static fn (): \MintyPHP\Service\Security\RateLimiterService => $container->get(\MintyPHP\Service\Security\RateLimiterService::class),
static fn (): \MintyPHP\Http\ApiSystemAuditReporter => $container->get(\MintyPHP\Http\ApiSystemAuditReporter::class)
);
\MintyPHP\Http\ApiResponse::configure(
static fn (): \MintyPHP\Service\Audit\ApiAuditService => $container->get(\MintyPHP\Service\Audit\ApiAuditService::class),
static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class),
static fn (): \MintyPHP\Http\ApiSystemAuditReporter => $container->get(\MintyPHP\Http\ApiSystemAuditReporter::class)
);
\MintyPHP\Support\Guard::configure(
static fn (): \MintyPHP\Service\Auth\AuthService => $container->get(\MintyPHP\Service\Auth\AuthService::class),
static fn (): \MintyPHP\Service\Tenant\TenantService => $container->get(\MintyPHP\Service\Tenant\TenantService::class),
static fn (): \MintyPHP\Service\Access\AuthorizationService => $container->get(\MintyPHP\Service\Access\AuthorizationService::class)
);
\MintyPHP\Support\Search\SearchItemMapperProvider::configure(
static fn (): \MintyPHP\Service\User\UserAvatarService => $container->get(\MintyPHP\Service\User\UserAvatarService::class)
);
}
/**
* Resolve a service from the app container.
*/
function app(string $id): mixed
{
$container = $GLOBALS['minty_app_container'] ?? null;
if (!$container instanceof \MintyPHP\App\AppContainer) {
throw new \RuntimeException('App container is not initialized');
}
return $container->get($id);
}
/**
* Build an asset URL with filemtime cache-busting when available.
*/
@@ -127,72 +180,11 @@ function appLogoUrlAbsolute(int $size = 128): string
*/
function appSetting(string $key): ?string
{
if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) {
if (!class_exists('MintyPHP\\Service\\Settings\\SettingCacheService')) {
return null;
}
static $settingsFactory = null;
if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) {
$settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory();
}
return $settingsFactory->createSettingCacheService()->get($key);
}
/**
* Shared per-request factory for tenant/department/role domain services.
*/
function directoryServicesFactory(): \MintyPHP\Service\Directory\DirectoryServicesFactory
{
static $factory = null;
if (!$factory instanceof \MintyPHP\Service\Directory\DirectoryServicesFactory) {
$factory = new \MintyPHP\Service\Directory\DirectoryServicesFactory();
}
return $factory;
}
/**
* Shared per-request factory for access/permission services.
*/
function accessServicesFactory(): \MintyPHP\Service\Access\AccessServicesFactory
{
static $factory = null;
if (!$factory instanceof \MintyPHP\Service\Access\AccessServicesFactory) {
$factory = new \MintyPHP\Service\Access\AccessServicesFactory();
}
return $factory;
}
/**
* Shared per-request permission gateway wrapper.
*/
function permissionGateway(): \MintyPHP\Service\Access\PermissionGateway
{
return accessServicesFactory()->createPermissionGateway();
}
/**
* Shared per-request factory for audit services.
*/
function auditServicesFactory(): \MintyPHP\Service\Audit\AuditServicesFactory
{
static $factory = null;
if (!$factory instanceof \MintyPHP\Service\Audit\AuditServicesFactory) {
$factory = new \MintyPHP\Service\Audit\AuditServicesFactory();
}
return $factory;
}
/**
* Shared per-request factory for branding services.
*/
function brandingServicesFactory(): \MintyPHP\Service\Branding\BrandingServicesFactory
{
static $factory = null;
if (!$factory instanceof \MintyPHP\Service\Branding\BrandingServicesFactory) {
$factory = new \MintyPHP\Service\Branding\BrandingServicesFactory();
}
return $factory;
return app(\MintyPHP\Service\Settings\SettingCacheService::class)->get($key);
}
/**
@@ -200,19 +192,14 @@ function brandingServicesFactory(): \MintyPHP\Service\Branding\BrandingServicesF
*/
function appThemes(): array
{
if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) {
if (!class_exists('MintyPHP\\Service\\Settings\\ThemeConfigService')) {
return [
'light' => 'Light',
'dark' => 'Dark',
];
}
static $settingsFactory = null;
if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) {
$settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory();
}
return $settingsFactory->createThemeConfigService()->all();
return app(\MintyPHP\Service\Settings\ThemeConfigService::class)->all();
}
/**
@@ -393,8 +380,8 @@ function appPrimaryCssVars(): string
*/
function appLogoUrl(?int $size = null): string
{
if (class_exists('MintyPHP\\Service\\Branding\\BrandingServicesFactory')) {
$logoService = brandingServicesFactory()->createBrandingLogoService();
if (class_exists('MintyPHP\\Service\\Branding\\BrandingLogoService')) {
$logoService = app(\MintyPHP\Service\Branding\BrandingLogoService::class);
if ($logoService->hasLogo()) {
$query = $size ? '?size=' . (int) $size : '';
return lurl('branding/logo' . $query);
@@ -409,13 +396,8 @@ function appLogoUrl(?int $size = null): string
function appFaviconUrl(string $file): string
{
$tenantUuid = $_SESSION['current_tenant']['uuid'] ?? '';
if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantServicesFactory')) {
static $tenantServicesFactory = null;
if (!$tenantServicesFactory instanceof \MintyPHP\Service\Tenant\TenantServicesFactory) {
$tenantServicesFactory = new \MintyPHP\Service\Tenant\TenantServicesFactory();
}
if ($tenantServicesFactory->createTenantFaviconService()->hasFavicon($tenantUuid)) {
if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantFaviconService')) {
if (app(\MintyPHP\Service\Tenant\TenantFaviconService::class)->hasFavicon($tenantUuid)) {
return asset('favicon/tenants/' . $tenantUuid . '/favicon/' . ltrim($file, '/'));
}
}

View File

@@ -9,29 +9,3 @@ function accountUrl(): string
$uuid = (string) ($user['uuid'] ?? '');
return $uuid !== '' ? lurl('profile') : lurl('login');
}
/**
* Check if the current user has a permission key.
*/
function can(string $permissionKey): bool
{
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId <= 0) {
return false;
}
if (!function_exists('permissionGateway')) {
return false;
}
$permissionGateway = permissionGateway();
// First try the in-memory/session cache, then fall back to a direct fetch.
$keys = $permissionGateway->getCachedPermissions($userId);
if (!$keys) {
$keys = $permissionGateway->getUserPermissions($userId);
if (!$keys) {
return false;
}
}
return in_array($permissionKey, $keys, true);
}

View File

@@ -0,0 +1,934 @@
<?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;
}
/**
* Normalize common limit/offset query params.
*
* @return array{0:int,1:int}
*/
function gridQueryLimitOffset(array $query, int $defaultLimit = 10, int $maxLimit = 100): array
{
$limit = (int) ($query['limit'] ?? $defaultLimit);
if ($limit < 1) {
$limit = $defaultLimit;
} elseif ($limit > $maxLimit) {
$limit = $maxLimit;
}
$offset = (int) ($query['offset'] ?? 0);
if ($offset < 0) {
$offset = 0;
}
return [$limit, $offset];
}
/**
* Normalize order/dir query params against an allow-list.
*
* @return array{0:string,1:string}
*/
function gridQueryOrderDir(array $query, array $allowedOrder, string $defaultOrder, string $defaultDir = 'asc'): array
{
if (!in_array($defaultDir, ['asc', 'desc'], true)) {
$defaultDir = 'asc';
}
$order = trim((string) ($query['order'] ?? $defaultOrder));
if (!in_array($order, $allowedOrder, true)) {
$order = $defaultOrder;
}
$dir = strtolower(trim((string) ($query['dir'] ?? $defaultDir)));
if (!in_array($dir, ['asc', 'desc'], true)) {
$dir = $defaultDir;
}
return [$order, $dir];
}
/**
* 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;
}
$value = strtolower(trim((string) $query[$key]));
if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($value, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
/**
* 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;
}
/**
* Normalize mixed values to a unique list of positive integer IDs.
*
* @return int[]
*/
function gridNormalizePositiveIntList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
$ids = array_map('intval', $value);
$ids = array_values(array_filter($ids, static fn (int $id): bool => $id > 0));
return array_values(array_unique($ids));
}

View File

@@ -0,0 +1,38 @@
<?php
use MintyPHP\Http\Input\FormErrors;
use MintyPHP\Http\Input\RequestInput;
use MintyPHP\Http\Input\RequestInputFactory;
use MintyPHP\Support\Flash;
function requestInput(): RequestInput
{
static $requestInput = null;
if ($requestInput instanceof RequestInput) {
return $requestInput;
}
$factory = app(RequestInputFactory::class);
$requestInput = $factory->create();
return $requestInput;
}
function formErrors(): FormErrors
{
return new FormErrors();
}
function formErrorsFrom(array|FormErrors $errors): FormErrors
{
return formErrors()->merge($errors);
}
function flashFormErrors(FormErrors $errors, ?string $scope = null, string $keyPrefix = 'form_error'): void
{
$index = 0;
foreach ($errors->toFlatList() as $message) {
$key = $keyPrefix . '_' . $index;
Flash::error((string) $message, $scope, $key);
$index++;
}
}

View File

@@ -76,6 +76,212 @@ function multiSelectFilter(
<?php
}
/**
* Render a standard select filter field.
*
* @param array<int, array{id:mixed,description:mixed,translate?:bool,attributes?:array<string,mixed>}> $items
* @param array<string,mixed> $labelAttributes
* @param array<string,mixed> $selectAttributes
*/
function selectFilter(
string $id,
string $label,
array $items,
string $selected = '',
array $labelAttributes = [],
array $selectAttributes = []
): void {
$labelClass = trim((string) ($labelAttributes['class'] ?? ''));
if ($labelClass === '') {
$labelClass = 'app-field';
} elseif (!str_contains($labelClass, 'app-field')) {
$labelClass = 'app-field ' . $labelClass;
}
$labelAttributes['class'] = $labelClass;
$selectAttributes['id'] = $id;
?>
<label<?php uiRenderAttributes($labelAttributes); ?>>
<span><?php e(t($label)); ?></span>
<select<?php uiRenderAttributes($selectAttributes); ?>>
<?php foreach ($items as $item): ?>
<?php $itemId = (string) ($item['id'] ?? ''); ?>
<?php $itemLabel = (string) ($item['description'] ?? ''); ?>
<?php $translateLabel = !array_key_exists('translate', $item) || (bool) $item['translate']; ?>
<?php $optionAttributes = is_array($item['attributes'] ?? null) ? $item['attributes'] : []; ?>
<?php $optionAttributes['value'] = $itemId; ?>
<?php if ($itemId === $selected): ?>
<?php $optionAttributes['selected'] = true; ?>
<?php endif; ?>
<option<?php uiRenderAttributes($optionAttributes); ?>>
<?php if ($translateLabel): ?>
<?php e(t($itemLabel)); ?>
<?php else: ?>
<?php e($itemLabel); ?>
<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php
}
/**
* Render a full toolbar from a filter schema.
*
* @param array<int,array<string,mixed>> $schema
* @param array<string,mixed> $active
* @param array<string,array<int,array<string,mixed>>> $optionSets
*/
function renderGridFilterToolbar(array $schema, array $active = [], array $optionSets = []): void
{
foreach ($schema as $field) {
if (!is_array($field)) {
continue;
}
if (($field['render'] ?? true) === false) {
continue;
}
$key = trim((string) ($field['key'] ?? ''));
if ($key === '') {
continue;
}
$type = strtolower(trim((string) ($field['type'] ?? 'text')));
if (!in_array($type, ['text', 'date', 'number', 'hidden', 'select', 'multi_csv'], true)) {
continue;
}
$inputId = trim((string) ($field['input_id'] ?? ''));
if ($inputId === '') {
$inputId = $key . '-filter';
}
$label = (string) ($field['label'] ?? $key);
$placeholder = (string) ($field['placeholder'] ?? '');
$defaultValue = $field['default'] ?? '';
$activeValue = $active[$key] ?? $defaultValue;
$labelAttributes = is_array($field['label_attributes'] ?? null) ? $field['label_attributes'] : [];
$inputAttributes = is_array($field['input_attributes'] ?? null) ? $field['input_attributes'] : [];
if ($type === 'hidden') {
$inputAttributes['type'] = 'hidden';
$inputAttributes['id'] = $inputId;
$inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : '';
echo '<input';
uiRenderAttributes($inputAttributes);
echo '>';
continue;
}
if ($type === 'select') {
$items = uiResolveFilterItems($field, $optionSets);
$selected = is_scalar($activeValue) ? (string) $activeValue : (string) $defaultValue;
selectFilter(
$inputId,
$label,
$items,
$selected,
$labelAttributes,
$inputAttributes
);
continue;
}
if ($type === 'multi_csv') {
$items = uiResolveFilterItems($field, $optionSets);
$selected = uiNormalizeFilterSelection($activeValue);
multiSelectFilter(
$inputId,
$label,
$placeholder !== '' ? $placeholder : 'Select options',
$items,
$selected
);
continue;
}
$labelClass = trim((string) ($labelAttributes['class'] ?? ''));
if ($labelClass === '') {
$labelClass = 'app-field';
} elseif (!str_contains($labelClass, 'app-field')) {
$labelClass = 'app-field ' . $labelClass;
}
$labelAttributes['class'] = $labelClass;
$inputAttributes['id'] = $inputId;
$inputAttributes['type'] = $type === 'number' ? 'number' : $type;
if ($placeholder !== '' && !isset($inputAttributes['placeholder']) && $type === 'text') {
$inputAttributes['placeholder'] = t($placeholder);
}
if (in_array($type, ['text', 'date', 'number'], true)) {
$inputAttributes['value'] = is_scalar($activeValue) ? (string) $activeValue : '';
}
echo '<label';
uiRenderAttributes($labelAttributes);
echo '>';
echo '<span>';
e(t($label));
echo '</span>';
echo '<input';
uiRenderAttributes($inputAttributes);
echo '>';
echo '</label>';
}
}
/**
* @param array<string,mixed> $field
* @param array<string,array<int,array<string,mixed>>> $optionSets
* @return array<int,array<string,mixed>>
*/
function uiResolveFilterItems(array $field, array $optionSets): array
{
$allowed = $field['allowed'] ?? null;
if (is_array($allowed)) {
return array_values(array_filter(array_map(static function (mixed $item): array {
if (is_array($item)) {
return $item;
}
if (is_scalar($item)) {
$value = (string) $item;
return ['id' => $value, 'description' => $value, 'translate' => false];
}
return [];
}, $allowed), static fn (array $item): bool => $item !== []));
}
$optionsKey = trim((string) ($field['options_key'] ?? ''));
if ($optionsKey !== '' && isset($optionSets[$optionsKey])) {
return array_values(array_filter($optionSets[$optionsKey], static fn (array $item): bool => $item !== []));
}
return [];
}
/**
* @return string[]
*/
function uiNormalizeFilterSelection(mixed $value): array
{
if (is_array($value)) {
return array_values(array_filter(array_map(
static fn (mixed $item): string => trim((string) $item),
$value
), static fn (string $item): bool => $item !== ''));
}
if (!is_scalar($value)) {
return [];
}
$text = trim((string) $value);
if ($text === '') {
return [];
}
return array_values(array_filter(array_map('trim', explode(',', $text)), static fn (string $item): bool => $item !== ''));
}
/**
* Render a multi-select form field for data entry.
*
@@ -144,6 +350,31 @@ function multiSelectForm(
<?php
}
/**
* Render arbitrary HTML attributes from a key-value map.
*
* @param array<string,mixed> $attributes
*/
function uiRenderAttributes(array $attributes): void
{
foreach ($attributes as $name => $value) {
$attrName = trim((string) $name);
if ($attrName === '') {
continue;
}
if ($value === false || $value === null) {
continue;
}
if ($value === true) {
echo ' ' . $attrName;
continue;
}
echo ' ' . $attrName . '="';
e((string) $value);
echo '"';
}
}
/**
* Resolve nav active state for one or many paths.
*