Compare commits

...

5 Commits

Author SHA1 Message Date
philipp lang 63c0efcaa8 Update contribution
continuous-integration/drone/push Build is failing Details
2025-03-28 16:11:07 +01:00
philipp lang d07f316ab3 Remove global rules 2025-03-28 16:06:52 +01:00
philipp lang 15a5ba84f1 Add lazy loading to Contribution MemberData 2025-03-28 16:06:14 +01:00
philipp lang 5f2cf79c69 Add Validation for contribution form 2025-03-28 15:53:17 +01:00
philipp lang 831b4c1495 Remove comments 2025-03-28 15:33:40 +01:00
18 changed files with 131 additions and 310 deletions

View File

@ -1,36 +0,0 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\ContributionFactory;
use App\Country;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class FormAction
{
use AsAction;
/**
* @return array<string, mixed>
*/
public function handle(): array
{
return [
'countries' => Country::select('name', 'id')->get(),
'data' => [
'country' => Country::firstWhere('name', 'Deutschland')->id,
],
'compilers' => app(ContributionFactory::class)->compilerSelect(),
];
}
public function asController(): Response
{
session()->put('menu', 'contribution');
session()->put('title', 'Zuschüsse');
return Inertia::render('contribution/VIndex', $this->handle());
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\ContributionFactory;
use App\Contribution\Documents\ContributionDocument;
use App\Rules\JsonBase64Rule;
use Illuminate\Support\Facades\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class GenerateAction
{
use AsAction;
/**
* @param class-string<ContributionDocument> $document
* @param array<string, mixed> $payload
*/
public function handle(string $document, array $payload): BaseCompiler
{
return Tex::compile($document::fromRequest($payload));
}
public function asController(ActionRequest $request): BaseCompiler
{
$payload = $this->payload($request);
$type = data_get($payload, 'type');
ValidateAction::validateType($type);
Validator::make($payload, app(ContributionFactory::class)->rules($type))->validate();
return $this->handle($type, $payload);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'payload' => [new JsonBase64Rule()],
];
}
/**
* @return array<string, string>
*/
private function payload(ActionRequest $request): array
{
return json_decode(rawurldecode(base64_decode($request->input('payload', ''))), true);
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\Documents\ContributionDocument;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class GenerateApiAction
{
use AsAction;
/**
* @param class-string<ContributionDocument> $document
* @param array<string, mixed> $payload
*/
public function handle(string $document, array $payload): BaseCompiler
{
return Tex::compile($document::fromApiRequest($payload));
}
public function asController(ActionRequest $request): BaseCompiler
{
ValidateAction::validateType($request->input('type'));
return $this->handle($request->input('type'), $request->input());
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Contribution\Actions;
use App\Contribution\ContributionFactory;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Validator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class ValidateAction
{
use AsAction;
public function asController(): JsonResponse
{
return response()->json(['valid' => true]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return app(ContributionFactory::class)->rules(request()->type);
}
public function prepareForValidation(ActionRequest $request): void
{
static::validateType($request->input('type'));
}
public static function validateType(?string $type = null): void
{
Validator::make(['type' => $type], app(ContributionFactory::class)->typeRule())->validate();
}
}

View File

@ -10,7 +10,6 @@ use App\Contribution\Documents\CitySolingenDocument;
use App\Contribution\Documents\CityFrankfurtMainDocument; use App\Contribution\Documents\CityFrankfurtMainDocument;
use App\Contribution\Documents\WuppertalDocument; use App\Contribution\Documents\WuppertalDocument;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
class ContributionFactory class ContributionFactory
{ {
@ -36,27 +35,4 @@ class ContributionFactory
'id' => $document, 'id' => $document,
]); ]);
} }
/**
* @return array<string, mixed>
*/
public function typeRule(): array
{
return [
'type' => ['required', Rule::in($this->documents)],
];
}
/**
* @param class-string<ContributionDocument> $type
*
* @return array<string, mixed>
*/
public function rules(string $type): array
{
return [
...$type::globalRules(),
...$type::rules(),
];
}
} }

View File

@ -29,7 +29,7 @@ class MemberData extends Data
*/ */
public static function fromModels(array $ids): Collection public static function fromModels(array $ids): Collection
{ {
return Member::whereIn('id', $ids)->orderByRaw('lastname, firstname')->get()->map(fn ($member) => self::factory()->withoutMagicalCreation()->from([ return Member::with('leaderMemberships', 'gender')->whereIn('id', $ids)->orderByRaw('lastname, firstname')->get()->map(fn ($member) => self::factory()->withoutMagicalCreation()->from([
...$member->toArray(), ...$member->toArray(),
'birthday' => $member->birthday->toAtomString(), 'birthday' => $member->birthday->toAtomString(),
'isLeader' => $member->isLeader(), 'isLeader' => $member->isLeader(),

View File

@ -120,17 +120,4 @@ class BdkjHesse extends ContributionDocument
{ {
return 'BDKJ Hessen'; return 'BDKJ Hessen';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'country' => 'required|integer|exists:countries,id',
'zipLocation' => 'required|string',
];
}
} }

View File

@ -77,17 +77,4 @@ class CityFrankfurtMainDocument extends ContributionDocument
{ {
return 'Frankfurt'; return 'Frankfurt';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'country' => 'required|integer|exists:countries,id',
'zipLocation' => 'required|string',
];
}
} }

View File

@ -73,17 +73,4 @@ class CityRemscheidDocument extends ContributionDocument
{ {
return 'Remscheid'; return 'Remscheid';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'zipLocation' => 'required|string',
'country' => 'required|integer|exists:countries,id',
];
}
} }

View File

@ -97,16 +97,4 @@ class CitySolingenDocument extends ContributionDocument
{ {
return 'Stadt Solingen'; return 'Stadt Solingen';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'zipLocation' => 'required|string',
];
}
} }

View File

@ -21,23 +21,6 @@ abstract class ContributionDocument extends Document
*/ */
abstract public static function fromApiRequest(array $request): self; abstract public static function fromApiRequest(array $request): self;
/**
* @return array<string, mixed>
*/
abstract public static function rules(): array;
/**
* @return array<string, mixed>
*/
public static function globalRules(): array
{
return [
'eventName' => 'required|string',
'members' => 'present|array|min:1',
'members.*' => 'integer|exists:members,id',
];
}
public static function buttonName(): string public static function buttonName(): string
{ {
return 'Für ' . static::getName() . ' erstellen';; return 'Für ' . static::getName() . ' erstellen';;

View File

@ -68,17 +68,4 @@ class RdpNrwDocument extends ContributionDocument
{ {
return 'RdP NRW'; return 'RdP NRW';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'country' => 'required|integer|exists:countries,id',
'zipLocation' => 'required|string',
];
}
} }

View File

@ -64,16 +64,4 @@ class WuppertalDocument extends ContributionDocument
{ {
return 'Wuppertal'; return 'Wuppertal';
} }
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'dateFrom' => 'required|string|date_format:Y-m-d',
'dateUntil' => 'required|string|date_format:Y-m-d',
'zipLocation' => 'required|string',
];
}
} }

View File

@ -2,14 +2,130 @@
namespace Modules\Contribution\Components; namespace Modules\Contribution\Components;
use App\Contribution\ContributionFactory;
use App\Country;
use Illuminate\Support\Collection;
use App\Member\Member;
use Carbon\Carbon;
use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Zoomyboy\Tex\Tex;
class FillList extends Component class FillList extends Component
{ {
#[Validate(rule: 'required|string|max:255', as: 'Veranstaltungs-Name')]
public string $eventName = '';
#[Validate(rule: 'required|date', as: 'Datum von')]
public Carbon $dateFrom;
#[Validate(rule: 'required|date', as: 'Datum bis')]
public Carbon $dateUntil;
#[Validate(rule: 'required|integer|gt:0', as: 'Land')]
public int $country;
#[Validate(rule: 'array|min:1', as: 'Mitglieder')]
public $members = [];
#[Validate(rule: 'required', as: 'Formular')]
public string $compiler;
#[Validate(rule: 'required', as: 'PLZ / Ort')]
public string $zipLocation;
public Collection $countries;
public string $search = '';
public Collection $compilers;
public Collection $memberResults;
public function rules(): array
{
return [
'eventName' => 'required|string|max:255',
];
}
public function mount(): void
{
$this->compilers = app(ContributionFactory::class)->compilerSelect();
$this->countries = Country::select('name', 'id')->get()->toBase();
$this->clearSearch();
$this->country = Country::firstWhere('name', 'Deutschland')->id;
}
public function submit()
{
$payload = $this->validate();
return Tex::compile($this->compiler::fromRequest($payload));
}
public function updatedSearch(): void
{
$this->memberResults = Member::search($this->search, fn ($engine, $query, $options) => $engine->search($query, [
...$options,
'filter' => ['birthday IS NOT NULL', 'address IS NOT EMPTY']
]))->get()->toBase();
}
public function onSubmitFirstMemberResult(): void
{
if (count($this->memberResults) === 0) {
$this->clearSearch();
return;
}
$this->onSubmitMemberResult($this->memberResults[0]->id);
}
public function onSubmitMemberResult(int $memberId): void
{
if (in_array($memberId, $this->members)) {
$this->members = array_values(array_filter($this->members, fn ($m) => $m !== $memberId));
} else {
$this->members[] = $memberId;
}
$this->js('document.querySelector("#search_input").focus()');
$this->clearSearch();
}
public function clearSearch(): void
{
$this->search = '';
$this->memberResults = collect([]);
}
public function render() public function render()
{ {
return <<<'HTML' return <<<'HTML'
<x-page::layout title="Zuschüsse" menu="contribution"> <x-page::layout title="Zuschüsse" menu="contribution">
<form wire:submit.prevent="submit" class="max-w-4xl w-full mx-auto gap-6 grid-cols-2 grid p-6">
<x-form::text name="eventName" wire:model="eventName" class="col-span-2" label="Veranstaltungs-Name" required></x-form::text>
<x-form::text name="dateFrom" wire:model="dateFrom" type="date" label="Datum von" required></x-form::text>
<x-form::text name="dateUntil" wire:model="dateUntil" type="date" label="Datum bis" required></x-form::text>
<x-form::text name="zipLocation" wire:model="zipLocation" label="PLZ / Ort" required></x-form::text>
<x-form::select name="country" wire:model="country" :options="$countries" label="Land" required></x-form::select>
<x-form::select name="compiler" wire:model="compiler" :options="$compilers" label="Formular" required></x-form::select>
<x-ui::box class="relative col-span-2" title="Mitglieder finden">
<x-ui::errors for="members" />
<x-form::text name="search_text" id="search_input" wire:model.live="search" class="col-span-2" label="Suchen …" size="sm" wire:keydown.enter="onSubmitFirstMemberResult"></x-form::text>
<div class="mt-2 grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2 col-span-2">
@foreach($memberResults as $member)
<x-form::lever
id="members-{{$member->id}}"
:wire:key="$member->id"
wire:model="members"
:label="$member->fullname"
name="members"
:value="$member->id"
size="sm"
wire:keydown.enter="onSubmitMemberResult({{$member->id}})"
></x-form::lever>
@endforeach
</div>
</x-ui::box>
<x-ui::button class="col-span-2" type="submit">Formular erstellen</x-ui::button>
</form>
</x-page::layout> </x-page::layout>
HTML; HTML;
} }

View File

@ -22,3 +22,17 @@ it('loads component', function () {
Livewire::test(FillList::class) Livewire::test(FillList::class)
->assertSee('Zuschüsse'); ->assertSee('Zuschüsse');
}); });
it('validates payload', function (array $attributes, array $error) {
Livewire::test(FillList::class)
->setArray($attributes)
->call('submit')
->assertHasErrors($error);
})->with([
[['eventName' => ''], ['eventName' => 'Veranstaltungs-Name ist erforderlich.']],
[['dateFrom' => ''], ['dateFrom' => 'Datum von ist erforderlich.']],
[['dateUntil' => ''], ['dateUntil' => 'Datum bis ist erforderlich.']],
[['country' => ''], ['country' => 'Land ist erforderlich.']],
[['zipLocation' => ''], ['zipLocation' => 'PLZ / Ort ist erforderlich.']],
[['compiler' => null], ['compiler' => 'Formular ist erforderlich.']],
]);

View File

@ -8,7 +8,6 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
use Livewire\Livewire; use Livewire\Livewire;
use Modules\Fileshare\Components\SettingView; use Modules\Fileshare\Components\SettingView;
use Tests\FileshareTestCase; use Tests\FileshareTestCase;
use Tests\TestCase;
uses(FileshareTestCase::class); uses(FileshareTestCase::class);
uses(DatabaseTransactions::class); uses(DatabaseTransactions::class);
@ -30,24 +29,6 @@ it('displays overview', function () {
->assertSee('lokaler Server') ->assertSee('lokaler Server')
->assertSee('Verbindung erfolgreich') ->assertSee('Verbindung erfolgreich')
->assertSee('Owncloud'); ->assertSee('Owncloud');
// ->assertJsonPath('data.0.name', 'lokaler Server')
// ->assertJsonPath('data.0.type', OwncloudConnection::class)
// ->assertJsonPath('data.0.config.user', 'badenpowell')
// ->assertJsonPath('data.0.config.password', 'secret')
// ->assertJsonPath('data.0.config.base_url', env('TEST_OWNCLOUD_DOMAIN'))
// ->assertJsonPath('data.0.id', $connection->id)
// ->assertJsonPath('data.0.is_active', true)
// ->assertJsonPath('data.0.type_human', 'Owncloud')
// ->assertJsonPath('data.0.links.update', route('fileshare.update', ['fileshare' => $connection]))
// ->assertJsonPath('meta.default.name', '')
// ->assertJsonPath('meta.links.store', route('fileshare.store'))
// ->assertJsonPath('meta.types.0.id', NextcloudConnection::class)
// ->assertJsonPath('meta.types.0.name', 'Nextcloud')
// ->assertJsonPath('meta.types.0.defaults.base_url', '')
// ->assertJsonPath('meta.types.1.id', OwncloudConnection::class)
// ->assertJsonPath('meta.types.1.name', 'Owncloud')
// ->assertJsonPath('meta.types.1.defaults.base_url', '')
// ->assertJsonPath('meta.types.0.fields.1', ['label' => 'Benutzer', 'key' => 'user', 'type' => 'text']);
}); });
it('displays wrong connection', function () { it('displays wrong connection', function () {

View File

@ -1,11 +1,9 @@
<?php <?php
use App\Contribution\Actions\GenerateApiAction as ContributionGenerateApiAction;
use App\Form\Actions\FormApiListAction; use App\Form\Actions\FormApiListAction;
use App\Form\Actions\RegisterAction; use App\Form\Actions\RegisterAction;
use App\Group\Actions\GroupApiIndexAction; use App\Group\Actions\GroupApiIndexAction;
Route::post('/contribution-generate', ContributionGenerateApiAction::class)->name('api.contribution.generate')->middleware('client:contribution-generate');
Route::post('/form/{form}/register', RegisterAction::class)->name('form.register'); Route::post('/form/{form}/register', RegisterAction::class)->name('form.register');
Route::get('/group/{group?}', GroupApiIndexAction::class)->name('api.group'); Route::get('/group/{group?}', GroupApiIndexAction::class)->name('api.group');
Route::get('/form', FormApiListAction::class)->name('api.form.index'); Route::get('/form', FormApiListAction::class)->name('api.form.index');

View File

@ -11,7 +11,6 @@ use App\Activity\Api\SubactivityStoreAction;
use App\Activity\Api\SubactivityUpdateAction; use App\Activity\Api\SubactivityUpdateAction;
use App\Contribution\Actions\FormAction as ContributionFormAction; use App\Contribution\Actions\FormAction as ContributionFormAction;
use App\Contribution\Actions\GenerateAction as ContributionGenerateAction; use App\Contribution\Actions\GenerateAction as ContributionGenerateAction;
use App\Contribution\Actions\ValidateAction as ContributionValidateAction;
use App\Course\Actions\CourseDestroyAction; use App\Course\Actions\CourseDestroyAction;
use App\Course\Actions\CourseIndexAction; use App\Course\Actions\CourseIndexAction;
use App\Course\Actions\CourseStoreAction; use App\Course\Actions\CourseStoreAction;
@ -102,11 +101,6 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/subactivity/{subactivity}', SubactivityShowAction::class)->name('api.subactivity.show'); Route::get('/subactivity/{subactivity}', SubactivityShowAction::class)->name('api.subactivity.show');
Route::post('/api/member/search', SearchAction::class)->name('member.search'); Route::post('/api/member/search', SearchAction::class)->name('member.search');
// ------------------------------- Contributions -------------------------------
Route::get('/contribution', ContributionFormAction::class)->name('contribution.form');
Route::get('/contribution-generate', ContributionGenerateAction::class)->name('contribution.generate');
Route::post('/contribution-validate', ContributionValidateAction::class)->name('contribution.validate');
// ----------------------------------- mail ------------------------------------ // ----------------------------------- mail ------------------------------------
Route::get('/maildispatcher', IndexAction::class)->name('maildispatcher.index'); Route::get('/maildispatcher', IndexAction::class)->name('maildispatcher.index');
Route::get('/maildispatcher/create', CreateAction::class)->name('maildispatcher.create'); Route::get('/maildispatcher/create', CreateAction::class)->name('maildispatcher.create');