= e791c08ce1 Feat: (UI) Replace connected-app creation with modular OIDC service and components
- 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.
2026-06-18 09:48:07 +00:00

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 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

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

  • 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.