- Removed the old `connected-apps.create` implementation and components, simplifying the codebase. - Introduced modular OIDC integration using reusable components and traits, such as `WithSearchableChoices` and `WithRepeaterFields`. - Added `StoreOIDCAppForm` for handling OpenID Connect app-specific fields and validation. - Updated `choices.blade.php` and related components to leverage configuration-driven, dynamic multi-select functionality. - Updated routes to support new OIDC app creation under distinct connection protocol namespaces. - Enhanced app creation flow with support for dynamic fields like URIs (`repeater-input`) and scoped user roles.
6.8 KiB
WithSearchableChoices
A small Livewire trait that removes the repeated search{X} / selectAll{X} / clear{X}
boilerplate that shows up every time you wire a <x-shared.choices> multi-select to a
service-backed list (roles, permissions, users, tags, etc.).
Instead of writing three full methods per entity, you register one config entry per field and keep three one-line delegates. All the real logic lives once, in the trait.
Requirements
- PHP 8.2+ (uses a
readonlyclass with constructor promotion; first-class callable syntax(...)is PHP 8.1+, so this also runs fine on 8.1 if you swap those for closures) - Laravel (uses the
data_set()helper) - Livewire 3 or 4 — nothing here is version-specific. Traits operate at the PHP class
level, so this works whether your component is a classic two-file component or a
Livewire 4 single-file
⚡component.blade.php.
Files
app/Livewire/Concerns/ChoiceField.php
Describes a single searchable field: how to search it, how to select all of it, where its selected IDs live on the form object, and an optional side effect to run after a change.
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns;
final readonly class ChoiceField
{
public function __construct(
public \Closure $search, // fn(string $value): array
public \Closure $allIds, // fn(): array
public string $formPath, // e.g. 'form.roleIds'
public ?\Closure $after = null, // optional side effect, e.g. re-hydrate permissions
) {}
}
app/Livewire/Concerns/WithSearchableChoices.php
The trait. Holds one searchable-options property (keyed by field name) instead of one property per entity, plus the three generic operations.
<?php
declare(strict_types=1);
namespace App\Livewire\Concerns;
use InvalidArgumentException;
trait WithSearchableChoices
{
/** @var array<string, array<int, mixed>> */
public array $choicesSearchable = [];
/** @return array<string, ChoiceField> */
abstract protected function choiceFields(): array;
public function searchChoice(string $key, string $value = ''): void
{
$field = $this->resolveChoiceField($key);
$this->choicesSearchable[$key] = ($field->search)($value);
}
public function selectAllChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, ($field->allIds)());
$this->searchChoice($key);
$field->after?->__invoke();
}
public function clearChoice(string $key): void
{
$field = $this->resolveChoiceField($key);
data_set($this, $field->formPath, []);
$field->after?->__invoke();
}
protected function resolveChoiceField(string $key): ChoiceField
{
return $this->choiceFields()[$key]
?? throw new InvalidArgumentException("Unknown choice field [{$key}].");
}
}
Note that choiceFields() is a method, not a property. Closures can't be stored on
a public Livewire property (they're not JSON-serializable for the wire snapshot), so
keeping the config behind a method means it's rebuilt fresh each request and never
touches Livewire's hydration/dehydration cycle.
Usage
1. Use the trait and register your fields
use App\Livewire\Concerns\{WithSearchableChoices, ChoiceField};
class RoleAssignmentForm extends Component
{
use WithSearchableChoices;
protected function choiceFields(): array
{
return [
'roles' => new ChoiceField(
search: fn (string $value): array => $this->roleService->searchRolesForSelect($value)->toArray(),
allIds: $this->roleService->getAllRoleIds(...), // <-- '...' is important here, as this make the closure callable
formPath: 'form.roleIds', // <-- this should be the livewire form object's property path in dot notation
after: $this->hydratePermissionInForm(...), // <-- optional side effect, that will be run after a change
),
];
}
public function searchRoles(string $value = ''): void { $this->searchChoice('roles', $value); }
public function selectAllRoles(): void { $this->selectAllChoice('roles'); }
public function clearRoles(): void { $this->clearChoice('roles'); }
}
The three delegate methods exist because Livewire requires wire:click/action targets
to be real, declared public methods — it won't fall through to __call(). Keeping them
means your Blade markup and method names don't change at all; they just forward into
the trait.
2. Update the Blade options binding
Everything stays the same except where :options reads from, since there's now one
shared $choicesSearchable array keyed by field name instead of a separate property
per entity:
<x-shared.choices
wire:model.live="form.roleIds"
:options="$choicesSearchable['roles'] ?? []"
search-function="searchRoles"
select-all-action="selectAllRoles"
clear-action="clearRoles"
label="Roles"
/>
3. Add another field (e.g. permissions)
protected function choiceFields(): array
{
return [
'roles' => new ChoiceField(/* ... */),
'permissions' => new ChoiceField(
search: fn (string $value): array => $this->permissionService->searchPermissionsForSelect($value)->toArray(),
allIds: $this->permissionService->getAllPermissionIds(...),
formPath: 'form.permissionIds',
),
];
}
public function searchPermissions(string $value = ''): void { $this->searchChoice('permissions', $value); }
public function selectAllPermissions(): void { $this->selectAllChoice('permissions'); }
public function clearPermissions(): void { $this->clearChoice('permissions'); }
<x-shared.choices
wire:model.live="form.permissionIds"
:options="$choicesSearchable['permissions'] ?? []"
search-function="searchPermissions"
select-all-action="selectAllPermissions"
clear-action="clearPermissions"
label="Permissions"
/>
Notes & caveats
formPathis resolved with Laravel'sdata_set(), so it works against any nested object/array path on the component (form.roleIds,filters.tags, etc.), not just a single level.- The
aftercallback runs on select all and clear, matching the original behavior where selecting/clearing roles re-hydrates dependent permission state. Leave itnullfor fields with no side effects. - This assumes
<x-shared.choices>interpolatessearch-function/select-all-action/clear-actiondirectly intowire:click/wire:keyup-style attributes as plain method names. If that component does anything more elaborate internally (e.g. wraps the search call with extra arguments), double-check the generated action string against this trait's method signatures.