Compare commits

...

4 Commits

Author SHA1 Message Date
philipp lang a456cc1889 Fix: Sort participants by created_at
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2024-12-16 14:18:02 +01:00
philipp lang 784b49c3dd Add invoice full text search
continuous-integration/drone/push Build is failing Details
2024-12-15 16:05:38 +01:00
philipp lang c3a0381a0a Fix invoice settings when fields are empty
continuous-integration/drone/push Build is passing Details
2024-12-15 12:06:44 +01:00
philipp lang 101ac7402a Add Phpunit to gitignore
continuous-integration/drone/push Build is passing Details
2024-12-15 11:56:13 +01:00
14 changed files with 166 additions and 72 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ Homestead.json
/public/sprite.svg /public/sprite.svg
/.php-cs-fixer.cache /.php-cs-fixer.cache
/groups.sql /groups.sql
/.phpunit.cache

View File

@ -20,7 +20,7 @@ class UpdateParticipantSearchIndexAction
[ [
'filterableAttributes' => [...$form->getFields()->filterables()->getKeys(), 'parent-id'], 'filterableAttributes' => [...$form->getFields()->filterables()->getKeys(), 'parent-id'],
'searchableAttributes' => $form->getFields()->searchables()->getKeys(), 'searchableAttributes' => $form->getFields()->searchables()->getKeys(),
'sortableAttributes' => [...$form->getFields()->sortables()->getKeys(), 'id'], 'sortableAttributes' => [...$form->getFields()->sortables()->getKeys(), 'id', 'created_at'],
'displayedAttributes' => [...$form->getFields()->filterables()->getKeys(), ...$form->getFields()->searchables()->getKeys(), 'id'], 'displayedAttributes' => [...$form->getFields()->filterables()->getKeys(), ...$form->getFields()->searchables()->getKeys(), 'id'],
'pagination' => [ 'pagination' => [
'maxTotalHits' => 1000000, 'maxTotalHits' => 1000000,

View File

@ -112,6 +112,6 @@ class Participant extends Model implements Preventable
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
return [...$this->data, 'parent-id' => $this->parent_id]; return [...$this->data, 'parent-id' => $this->parent_id, 'created_at' => $this->created_at->timestamp];
} }
} }

View File

@ -6,9 +6,9 @@ use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use App\Invoice\Resources\InvoiceResource; use App\Invoice\Resources\InvoiceResource;
use App\Invoice\Scopes\InvoiceFilterScope; use App\Invoice\Scopes\InvoiceFilterScope;
use Illuminate\Pagination\LengthAwarePaginator;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Laravel\Scout\Builder;
use Lorisleiva\Actions\ActionRequest; use Lorisleiva\Actions\ActionRequest;
class InvoiceIndexAction class InvoiceIndexAction
@ -17,11 +17,11 @@ class InvoiceIndexAction
/** /**
* @return LengthAwarePaginator<Invoice> * @return Builder<Invoice>
*/ */
public function handle(InvoiceFilterScope $filter): LengthAwarePaginator public function handle(InvoiceFilterScope $filter): Builder
{ {
return Invoice::withFilter($filter)->with('positions')->paginate(15); return $filter->getQuery()->query(fn ($q) => $q->with('positions'));
} }
public function asController(ActionRequest $request): Response public function asController(ActionRequest $request): Response
@ -32,7 +32,7 @@ class InvoiceIndexAction
$filter = InvoiceFilterScope::fromRequest($request->input('filter', '')); $filter = InvoiceFilterScope::fromRequest($request->input('filter', ''));
return Inertia::render('invoice/Index', [ return Inertia::render('invoice/Index', [
'data' => InvoiceResource::collection($this->handle($filter)), 'data' => InvoiceResource::collection($this->handle($filter)->paginate(15)),
]); ]);
} }
} }

View File

@ -8,27 +8,17 @@ use Lorisleiva\Actions\ActionRequest;
class InvoiceSettings extends LocalSettings implements Storeable class InvoiceSettings extends LocalSettings implements Storeable
{ {
public string $from_long; public ?string $from_long;
public ?string $from;
public string $from; public ?string $mobile;
public ?string $email;
public string $mobile; public ?string $website;
public ?string $address;
public string $email; public ?string $place;
public ?string $zip;
public string $website; public ?string $iban;
public ?string $bic;
public string $address; public ?int $rememberWeeks;
public string $place;
public string $zip;
public string $iban;
public string $bic;
public int $rememberWeeks;
public static function group(): string public static function group(): string
{ {

View File

@ -17,12 +17,14 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Scout\Searchable;
use stdClass; use stdClass;
class Invoice extends Model class Invoice extends Model
{ {
/** @use HasFactory<InvoiceFactory> */ /** @use HasFactory<InvoiceFactory> */
use HasFactory; use HasFactory;
use Searchable;
public $guarded = []; public $guarded = [];
@ -120,15 +122,6 @@ class Invoice extends Model
->where('last_remembered_at', '<=', now()->subWeeks($weeks)); ->where('last_remembered_at', '<=', now()->subWeeks($weeks));
} }
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeWithFilter(Builder $query, InvoiceFilterScope $filter): Builder
{
return $filter->apply($query);
}
public function getMailRecipient(): stdClass public function getMailRecipient(): stdClass
{ {
return (object) [ return (object) [
@ -154,4 +147,20 @@ class Invoice extends Model
]); ]);
} }
} }
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
return [
'to' => implode(', ', $this->to),
'usage' => $this->usage,
'greeting' => $this->greeting,
'mail_email' => $this->mail_email,
'status' => $this->status->value,
];
}
} }

View File

@ -5,42 +5,43 @@ namespace App\Invoice\Scopes;
use App\Invoice\Enums\InvoiceStatus; use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use App\Lib\Filter; use App\Lib\Filter;
use Illuminate\Database\Eloquent\Builder; use App\Lib\ScoutFilter;
use Laravel\Scout\Builder;
use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/** /**
* @extends Filter<Invoice> * @extends ScoutFilter<Invoice>
*/ */
#[MapInputName(SnakeCaseMapper::class)] #[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)] #[MapOutputName(SnakeCaseMapper::class)]
class InvoiceFilterScope extends Filter class InvoiceFilterScope extends ScoutFilter
{ {
/** /**
* @param array<int, string> $statuses * @param array<int, string> $statuses
*/ */
public function __construct( public function __construct(
public ?array $statuses = null, public ?array $statuses = null,
public ?string $search = null
) { ) {
}
/**
* @inheritdoc
*/
public function apply(Builder $query): Builder
{
$query = $query->whereIn('status', $this->statuses);
return $query;
}
/**
* @inheritdoc
*/
public function toDefault(): self
{
$this->statuses = $this->statuses === null ? InvoiceStatus::defaultVisibleValues()->toArray() : $this->statuses; $this->statuses = $this->statuses === null ? InvoiceStatus::defaultVisibleValues()->toArray() : $this->statuses;
return $this; }
/**
* @inheritdoc
*/
public function getQuery(): Builder
{
$this->search = $this->search ?: '';
return Invoice::search($this->search, function ($engine, string $query, array $options) {
if (empty($this->statuses)) {
$filter = 'status = "asa6aeruuni4BahC7Wei6ahm1"';
} else {
$filter = collect($this->statuses)->map(fn (string $status) => "status = \"$status\"")->join(' OR ');
}
return $engine->search($query, [...$options, 'filter' => $filter]);
});
} }
} }

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Form\Models\Form; use App\Form\Models\Form;
use App\Invoice\Models\Invoice;
use App\Member\Member; use App\Member\Member;
return [ return [
@ -153,7 +154,16 @@ return [
'pagination' => [ 'pagination' => [
'maxTotalHits' => 1000000, 'maxTotalHits' => 1000000,
] ]
] ],
Invoice::class => [
'filterableAttributes' => ['to', 'usage', 'greeting', 'mail_email', 'status', 'id'],
'searchableAttributes' => ['to', 'usage', 'greeting', 'mail_email', 'status', 'id'],
'sortableAttributes' => [],
'displayedAttributes' => ['to', 'usage', 'greeting', 'mail_email', 'status', 'id'],
'pagination' => [
'maxTotalHits' => 1000000,
]
],
], ],
], ],

View File

@ -0,0 +1,27 @@
<?php
use App\Invoice\Models\Invoice;
use Illuminate\Database\Migrations\Migration;
use Laravel\Scout\Console\SyncIndexSettingsCommand;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Artisan::call(SyncIndexSettingsCommand::class);
foreach (Invoice::get() as $invoice) {
$invoice->searchable();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -0,0 +1,30 @@
<?php
use App\Form\Actions\UpdateParticipantSearchIndexAction;
use App\Form\Models\Form;
use App\Lib\Sorting;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
foreach (Form::get() as $form) {
UpdateParticipantSearchIndexAction::run($form);
foreach ($form->participants as $participant) {
$participant->searchable();
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -74,6 +74,7 @@
</ui-popup> </ui-popup>
<page-filter breakpoint="xl" :filterable="false"> <page-filter breakpoint="xl" :filterable="false">
<template #buttons> <template #buttons>
<f-text id="search" :model-value="getFilter('search')" label="Suchen …" size="sm" @update:model-value="setFilter('search', $event)"></f-text>
<f-multipleselect <f-multipleselect
id="statuses" id="statuses"
:options="meta.statuses" :options="meta.statuses"

View File

@ -80,20 +80,23 @@ it('testItShowsEmptyFilters', function () {
$this->callFilter('form.participant.index', ['data' => []], ['form' => $form])->assertJsonPath('meta.filter.data.check', ParticipantFilterScope::$nan); $this->callFilter('form.participant.index', ['data' => []], ['form' => $form])->assertJsonPath('meta.filter.data.check', ParticipantFilterScope::$nan);
}); });
it('sorts by active colums sorting by default', function () { it('sorts by active colums sorting by default', function (array $sorting, string $by, bool $direction) {
$this->login()->loginNami()->withoutExceptionHandling(); $this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->fields([ $form = Form::factory()->fields([
$this->checkboxField('check'), $this->checkboxField('check'),
$this->checkboxField('vorname'), $this->checkboxField('vorname'),
])->create(); ])->create();
$form->update(['meta' => ['active_columns' => [], 'sorting' => ['by' => 'vorname', 'direction' => true]]]); $form->update(['meta' => ['active_columns' => [], 'sorting' => $sorting]]);
sleep(2); sleep(2);
$this->callFilter('form.participant.index', [], ['form' => $form]) $this->callFilter('form.participant.index', [], ['form' => $form])
->assertOk() ->assertOk()
->assertJsonPath('meta.filter.sort.by', 'vorname') ->assertJsonPath('meta.filter.sort.by', $by)
->assertJsonPath('meta.filter.sort.direction', true); ->assertJsonPath('meta.filter.sort.direction', $direction);
}); })->with([
[['by' => 'vorname', 'direction' => true], 'vorname', true],
[['by' => 'created_at', 'direction' => true], 'created_at', true],
]);
it('testItDisplaysHasNamiField', function () { it('testItDisplaysHasNamiField', function () {

View File

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\Invoice; namespace Tests\Feature\EndToEnd;
use App\Invoice\BillKind; use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus; use App\Invoice\Enums\InvoiceStatus;
@ -9,8 +9,11 @@ use App\Invoice\Models\InvoicePosition;
use App\Member\Member; use App\Member\Member;
use App\Payment\Subscription; use App\Payment\Subscription;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\EndToEndTestCase;
use Tests\Feature\Invoice\ReceiverRequestFactory;
uses(DatabaseTransactions::class); uses(DatabaseTransactions::class);
uses(EndToEndTestCase::class);
it('testItDisplaysInvoices', function () { it('testItDisplaysInvoices', function () {
$this->login()->loginNami()->withoutExceptionHandling(); $this->login()->loginNami()->withoutExceptionHandling();
@ -25,6 +28,7 @@ it('testItDisplaysInvoices', function () {
->status(InvoiceStatus::SENT) ->status(InvoiceStatus::SENT)
->create(['usage' => 'Usa', 'mail_email' => 'a@b.de']); ->create(['usage' => 'Usa', 'mail_email' => 'a@b.de']);
sleep(2);
test()->get(route('invoice.index')) test()->get(route('invoice.index'))
->assertInertiaPath('data.data.0.to.name', 'Familie Blabla') ->assertInertiaPath('data.data.0.to.name', 'Familie Blabla')
->assertInertiaPath('data.data.0.id', $invoice->id) ->assertInertiaPath('data.data.0.id', $invoice->id)
@ -78,19 +82,35 @@ it('testValuesCanBeNull', function () {
test()->login()->loginNami()->withoutExceptionHandling(); test()->login()->loginNami()->withoutExceptionHandling();
Invoice::factory()->create(); Invoice::factory()->create();
sleep(2);
test()->get(route('invoice.index')) test()->get(route('invoice.index'))
->assertInertiaPath('data.data.0.sent_at_human', ''); ->assertInertiaPath('data.data.0.sent_at_human', '');
}); });
it('testItFiltersForInvoiceStatus', function () { it('filters for invoice status', function (array $filter, int $count) {
test()->login()->loginNami()->withoutExceptionHandling(); test()->login()->loginNami()->withoutExceptionHandling();
Invoice::factory()->status(InvoiceStatus::NEW)->create(); Invoice::factory()->status(InvoiceStatus::NEW)->create();
Invoice::factory()->status(InvoiceStatus::SENT)->count(2)->create(); Invoice::factory()->status(InvoiceStatus::SENT)->count(2)->create();
Invoice::factory()->status(InvoiceStatus::PAID)->count(3)->create(); Invoice::factory()->status(InvoiceStatus::PAID)->count(3)->create();
test()->callFilter('invoice.index', [])->assertInertiaCount('data.data', 3); sleep(2);
test()->callFilter('invoice.index', ['statuses' => []])->assertInertiaCount('data.data', 0); test()->callFilter('invoice.index', $filter)->assertInertiaCount('data.data', $count);
test()->callFilter('invoice.index', ['statuses' => ['Neu']])->assertInertiaCount('data.data', 1); })->with([
test()->callFilter('invoice.index', ['statuses' => ['Neu', 'Rechnung beglichen']])->assertInertiaCount('data.data', 4); [[], 3],
test()->callFilter('invoice.index', ['statuses' => ['Neu', 'Rechnung beglichen', 'Rechnung gestellt']])->assertInertiaCount('data.data', 6); [['statuses' => []], 0],
}); [['statuses' => ['Neu']], 1],
[['statuses' => ['Neu', 'Rechnung beglichen']], 4],
[['statuses' => ['Neu', 'Rechnung beglichen', 'Rechnung gestellt']], 6],
]);
it('searches invoice usage', function (array $filter, int $count) {
test()->login()->loginNami()->withoutExceptionHandling();
Invoice::factory()->status(InvoiceStatus::NEW)->create(['usage' => 'Kein Zweck']);
Invoice::factory()->status(InvoiceStatus::NEW)->create(['usage' => 'Mitgliedsbeitrag']);
sleep(2);
test()->callFilter('invoice.index', $filter)->assertInertiaCount('data.data', $count);
})->with([
[['search' => 'Mitgliedsbeitrag'], 1],
[['search' => 'Kein'], 1],
]);

View File

@ -3,6 +3,7 @@
namespace Tests; namespace Tests;
use App\Form\Models\Form; use App\Form\Models\Form;
use App\Invoice\Models\Invoice;
use App\Member\Member; use App\Member\Member;
use Laravel\Scout\Console\FlushCommand; use Laravel\Scout\Console\FlushCommand;
use Laravel\Scout\Console\SyncIndexSettingsCommand; use Laravel\Scout\Console\SyncIndexSettingsCommand;
@ -26,6 +27,7 @@ abstract class EndToEndTestCase extends TestCase
config()->set('scout.driver', 'meilisearch'); config()->set('scout.driver', 'meilisearch');
Artisan::call(FlushCommand::class, ['model' => Member::class]); Artisan::call(FlushCommand::class, ['model' => Member::class]);
Artisan::call(FlushCommand::class, ['model' => Form::class]); Artisan::call(FlushCommand::class, ['model' => Form::class]);
Artisan::call(FlushCommand::class, ['model' => Invoice::class]);
Artisan::call(SyncIndexSettingsCommand::class); Artisan::call(SyncIndexSettingsCommand::class);
return $this; return $this;