- 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.
204 lines
6.8 KiB
Markdown
204 lines
6.8 KiB
Markdown
# 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 `readonly` class 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
|
|
<?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
|
|
<?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
|
|
|
|
```php
|
|
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:
|
|
|
|
```blade
|
|
<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)
|
|
|
|
```php
|
|
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'); }
|
|
```
|
|
|
|
```blade
|
|
<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
|
|
|
|
- `formPath` is resolved with Laravel's `data_set()`, so it works against any nested
|
|
object/array path on the component (`form.roleIds`, `filters.tags`, etc.), not just a
|
|
single level.
|
|
- The `after` callback runs on **select all** and **clear**, matching the original
|
|
behavior where selecting/clearing roles re-hydrates dependent permission state. Leave
|
|
it `null` for fields with no side effects.
|
|
- This assumes `<x-shared.choices>` interpolates `search-function` / `select-all-action`
|
|
/ `clear-action` directly into `wire: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.
|