From 54c37fccd1c2425a07af157d8af1e78dee8221af Mon Sep 17 00:00:00 2001 From: philipp lang Date: Wed, 11 Dec 2024 22:32:23 +0100 Subject: [PATCH] Add searchable participants via full text --- app/Form/Actions/ParticipantIndexAction.php | 6 +++-- .../UpdateParticipantSearchIndexAction.php | 27 +++++++++++++++++++ app/Form/Data/FieldCollection.php | 8 ++++++ app/Form/Models/Form.php | 11 +++++++- app/Form/Models/Participant.php | 21 ++++++++------- app/Form/Scopes/ParticipantFilterScope.php | 17 ++++++++++-- .../Form/ParticipantIndexActionTest.php | 24 +++++++++++++++++ 7 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 app/Form/Actions/UpdateParticipantSearchIndexAction.php diff --git a/app/Form/Actions/ParticipantIndexAction.php b/app/Form/Actions/ParticipantIndexAction.php index 11ee3092..4f8bdd2c 100644 --- a/app/Form/Actions/ParticipantIndexAction.php +++ b/app/Form/Actions/ParticipantIndexAction.php @@ -9,6 +9,7 @@ use App\Form\Scopes\ParticipantFilterScope; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Pagination\LengthAwarePaginator; +use Laravel\Scout\Builder; use Lorisleiva\Actions\Concerns\AsAction; class ParticipantIndexAction @@ -18,9 +19,10 @@ class ParticipantIndexAction /** * @return HasMany */ - protected function getQuery(Form $form, ParticipantFilterScope $filter): HasMany + protected function getQuery(Form $form, ParticipantFilterScope $filter): Builder { - return $form->participants()->withFilter($filter)->withCount('children')->with('form'); + return $filter->setForm($form)->getQuery() + ->query(fn ($q) => $q->withCount('children')->with('form')); } /** diff --git a/app/Form/Actions/UpdateParticipantSearchIndexAction.php b/app/Form/Actions/UpdateParticipantSearchIndexAction.php new file mode 100644 index 00000000..de4ba4f1 --- /dev/null +++ b/app/Form/Actions/UpdateParticipantSearchIndexAction.php @@ -0,0 +1,27 @@ +searchableUsing()->updateIndexSettings( + $form->participantsSearchableAs(), + [ + 'filterableAttributes' => $form->getFields()->getKeys(), + 'searchableAttributes' => $form->getFields()->getKeys(), + 'sortableAttributes' => [], + 'displayedAttributes' => [...$form->getFields()->getKeys(), 'id'], + 'pagination' => [ + 'maxTotalHits' => 1000000, + ] + ] + ); + } +} diff --git a/app/Form/Data/FieldCollection.php b/app/Form/Data/FieldCollection.php index 2bf6a450..dfcc3e94 100644 --- a/app/Form/Data/FieldCollection.php +++ b/app/Form/Data/FieldCollection.php @@ -117,4 +117,12 @@ class FieldCollection extends Collection { return $this->first(fn ($field) => $field->specialType === $specialType); } + + /** + * @return array + */ + public function getKeys(): array + { + return $this->map(fn ($field) => $field->key)->toArray(); + } } diff --git a/app/Form/Models/Form.php b/app/Form/Models/Form.php index 06eac492..b94f8208 100644 --- a/app/Form/Models/Form.php +++ b/app/Form/Models/Form.php @@ -2,6 +2,7 @@ namespace App\Form\Models; +use App\Form\Actions\UpdateParticipantSearchIndexAction; use App\Form\Data\ExportData; use App\Form\Data\FieldCollection; use App\Form\Data\FormConfigData; @@ -14,7 +15,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Scout\Searchable; use Spatie\Image\Enums\Fit; -use Spatie\Image\Manipulations; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -172,5 +172,14 @@ class Form extends Model implements HasMedia return; } }); + + static::saved(function ($model) { + UpdateParticipantSearchIndexAction::dispatch($model); + }); + } + + public function participantsSearchableAs(): string + { + return config('scout.prefix') . 'forms_' . $this->id . '_participants'; } } diff --git a/app/Form/Models/Participant.php b/app/Form/Models/Participant.php index 5e8c0ce5..3c8577dc 100644 --- a/app/Form/Models/Participant.php +++ b/app/Form/Models/Participant.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Mail; +use Laravel\Scout\Searchable; use stdClass; class Participant extends Model implements Preventable @@ -22,6 +23,7 @@ class Participant extends Model implements Preventable /** @use HasFactory */ use HasFactory; + use Searchable; public $guarded = []; @@ -46,15 +48,6 @@ class Participant extends Model implements Preventable return $this->hasMany(self::class, 'parent_id'); } - /** - * @param Builder $query - * @return Builder - */ - public function scopeWithFilter(Builder $query, ParticipantFilterScope $filter): Builder - { - return $filter->apply($query); - } - /** * @return BelongsTo */ @@ -110,4 +103,14 @@ class Participant extends Model implements Preventable { return 'Nachweise erforderlich für deine Anmeldung zu ' . $this->form->name; } + + public function searchableAs() + { + return $this->form->participantsSearchableAs(); + } + + public function toSearchableArray(): array + { + return $this->data; + } } diff --git a/app/Form/Scopes/ParticipantFilterScope.php b/app/Form/Scopes/ParticipantFilterScope.php index 38ab982e..14435afd 100644 --- a/app/Form/Scopes/ParticipantFilterScope.php +++ b/app/Form/Scopes/ParticipantFilterScope.php @@ -5,8 +5,9 @@ namespace App\Form\Scopes; use App\Form\Models\Form; use App\Form\Models\Participant; use App\Lib\Filter; -use Illuminate\Database\Eloquent\Builder; +use App\Lib\ScoutFilter; use Illuminate\Support\Arr; +use Laravel\Scout\Builder; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Mappers\SnakeCaseMapper; @@ -16,20 +17,32 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper; */ #[MapInputName(SnakeCaseMapper::class)] #[MapOutputName(SnakeCaseMapper::class)] -class ParticipantFilterScope extends Filter +class ParticipantFilterScope extends ScoutFilter { /** * @param array $data */ public function __construct( public array $data = [], + public string $search = '', + public array $options = [], ) { } public static string $nan = 'deeb3ef4-d185-44b1-a4bc-0a4e7addebc3d8900c6f-a344-4afb-b54e-065ed483a7ba'; + public Form $form; + + public function getQuery(): Builder + { + $this->search = $this->search ?: ''; + + return Participant::search($this->search)->within($this->form->participantsSearchableAs()); + } public function setForm(Form $form): self { + $this->form = $form; + foreach ($form->getFields() as $field) { if (!Arr::has($this->data, $field->key)) { data_set($this->data, $field->key, static::$nan); diff --git a/tests/EndToEnd/Form/ParticipantIndexActionTest.php b/tests/EndToEnd/Form/ParticipantIndexActionTest.php index debe4464..8e29bb82 100644 --- a/tests/EndToEnd/Form/ParticipantIndexActionTest.php +++ b/tests/EndToEnd/Form/ParticipantIndexActionTest.php @@ -99,6 +99,30 @@ it('testItFiltersParticipantsByCheckboxValue', function () { ->assertJsonCount(2, 'data'); }); +it('test it handles full text search', function (array $memberAttributes, string $search, bool $includes) { + $this->login()->loginNami()->withoutExceptionHandling(); + $form = Form::factory() + ->has(Participant::factory()->data(['vorname' => 'Max', 'select' => 'Pfadfinder', ...$memberAttributes])) + ->fields([ + $this->textField('vorname')->name('Vorname'), + $this->checkboxesField('select')->options(['Wölflinge', 'Pfadfinder']), + ]) + ->create(); + + sleep(2); + $this->callFilter('form.participant.index', ['search' => $search], ['form' => $form]) + ->assertJsonCount($includes ? 1 : 0, 'data'); +})->with([ + [['vorname' => 'Max'], 'Max', true], + [['vorname' => 'Jane'], 'Max', false], + [['select' => 'Pfadfinder'], 'Pfadfinder', true], + [['select' => 'Pfadfinder'], 'Rov', false], + [['select' => 'Wölflinge'], 'Wölflinge', true], + [['select' => 'Wölflinge'], 'Wölf', true], + [['vorname' => 'Max', 'nachname' => 'Muster'], 'Max Muster', true], + [['vorname' => 'Max', 'nachname' => 'Muster'], 'Jane Doe', false], +]); + it('testItFiltersParticipantsByDropdownValue', function () { $this->login()->loginNami()->withoutExceptionHandling(); $form = Form::factory()->fields([$this->dropdownField('drop')->options(['A', 'B'])])