Add register action

This commit is contained in:
philipp lang 2024-02-06 01:45:25 +01:00
parent 89429f9812
commit 5d9d7a0ffc
15 changed files with 757 additions and 3 deletions

View File

@ -0,0 +1,61 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class RegisterAction
{
use AsAction;
public function handle(Form $form, array $input): Participant
{
return $form->participants()->create([
'data' => $input
]);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Form */
$form = request()->route('form');
return $form->getRegistrationRules();
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
/** @var Form */
$form = request()->route('form');
return $form->getRegistrationAttributes();
}
/**
* @return array<string, mixed>
*/
public function getValidationMessages(): array
{
/** @var Form */
$form = request()->route('form');
return $form->getRegistrationMessages();
}
public function asController(ActionRequest $request, Form $form): JsonResponse
{
$participant = $this->handle($form, $request->validated());
return response()->json($participant);
}
}

View File

@ -3,9 +3,15 @@
namespace App\Form\Fields;
use Faker\Generator;
use Illuminate\Validation\Rule;
class CheckboxField extends Field
{
public string $name;
public string $key;
public bool $required;
public string $description;
public static function name(): string
{
return 'Checkbox';
@ -31,4 +37,32 @@ class CheckboxField extends Field
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [
$this->key => $this->required ? ['boolean', 'accepted'] : ['present', 'boolean'],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -3,9 +3,15 @@
namespace App\Form\Fields;
use Faker\Generator;
use Illuminate\Validation\Rule;
class CheckboxesField extends Field
{
public string $name;
public string $key;
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Checkboxes';
@ -29,4 +35,34 @@ class CheckboxesField extends Field
'options' => $faker->words(4),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [
$this->key => 'array',
$this->key . '.*' => ['string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [
...collect($this->options)->mapWithKeys(fn ($option, $key) => [$this->key . '.' . $key => $this->name])->toArray(),
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -6,6 +6,12 @@ use Faker\Generator;
class DateField extends Field
{
public string $name;
public string $key;
public bool $required;
public bool $maxToday;
public static function name(): string
{
return 'Datum';
@ -31,4 +37,40 @@ class DateField extends Field
'max_today' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
$rules = [$this->required ? 'required' : 'nullable'];
$rules[] = 'date';
if ($this->maxToday) {
$rules[] = 'before_or_equal:' . now()->format('Y-m-d');
}
return [$this->key => $rules];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [
$this->key . '.before_or_equal' => $this->name . ' muss ein Datum vor oder gleich dem ' . now()->format('d.m.Y') . ' sein.',
];
}
}

View File

@ -3,9 +3,16 @@
namespace App\Form\Fields;
use Faker\Generator;
use Illuminate\Validation\Rule;
class DropdownField extends Field
{
public string $name;
public string $key;
public bool $required;
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Dropdown';
@ -31,4 +38,30 @@ class DropdownField extends Field
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [
$this->key => $this->required ? ['required', 'string', Rule::in($this->options)] : ['nullable', 'string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -4,8 +4,12 @@ namespace App\Form\Fields;
use Faker\Generator;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
abstract class Field
#[MapInputName(SnakeCaseMapper::class)]
abstract class Field extends Data
{
abstract public static function name(): string;
@ -16,6 +20,15 @@ abstract class Field
/** @return mixed */
abstract public static function default();
/** @return array<string, mixed> */
abstract public function getRegistrationRules(): array;
/** @return array<string, mixed> */
abstract public function getRegistrationAttributes(): array;
/** @return array<string, mixed> */
abstract public function getRegistrationMessages(): array;
/** @return array<string, mixed> */
abstract public static function fake(Generator $faker): array;
@ -50,6 +63,14 @@ abstract class Field
return $fieldClass;
}
/**
* @param array<string, mixed> $config
*/
public static function fromConfig(array $config): static
{
return static::classFromType($config['type'])::withoutMagicalCreationFrom($config);
}
/**
* @return array<string, string>
*/

View File

@ -9,6 +9,12 @@ use Illuminate\Validation\Rule;
class GroupField extends Field
{
public string $name;
public string $key;
public bool $required;
public ?string $parentField = null;
public ?int $parentGroup = null;
public static function name(): string
{
return 'Gruppierungs-Auswahl';
@ -36,4 +42,41 @@ class GroupField extends Field
'parent_group' => null,
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
$rules = [$this->required ? 'required' : 'nullable'];
$rules[] = 'integer';
if ($this->parentGroup) {
$rules[] = Rule::in(Group::find($this->parentGroup)->children()->pluck('id'));
}
if ($this->parentField && request()->input($this->parentField)) {
$rules[] = Rule::in(Group::find(request()->input($this->parentField))->children()->pluck('id'));
}
return [$this->key => $rules];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -3,9 +3,16 @@
namespace App\Form\Fields;
use Faker\Generator;
use Illuminate\Validation\Rule;
class RadioField extends Field
{
public string $name;
public string $key;
public bool $required;
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Radio';
@ -31,4 +38,30 @@ class RadioField extends Field
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [
$this->key => $this->required ? ['required', 'string', Rule::in($this->options)] : ['nullable', 'string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -6,6 +6,11 @@ use Faker\Generator;
class TextField extends Field
{
public string $name;
public string $key;
public bool $required;
public static function name(): string
{
return 'Text';
@ -29,4 +34,28 @@ class TextField extends Field
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [$this->key => $this->required ? ['required', 'string'] : ['nullable', 'string']];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -6,6 +6,10 @@ use Faker\Generator;
class TextareaField extends Field
{
public string $name;
public string $key;
public bool $required;
public static function name(): string
{
return 'Textarea';
@ -31,4 +35,28 @@ class TextareaField extends Field
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(): array
{
return [$this->key => $this->required ? ['required', 'string'] : ['nullable', 'string']];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(): array
{
return [];
}
}

View File

@ -2,9 +2,12 @@
namespace App\Form\Models;
use App\Form\Fields\Field;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
@ -27,6 +30,9 @@ class Form extends Model implements HasMedia
'description' => 'json',
];
/** @var array<int, string> */
public $dates = ['from', 'to', 'registration_from', 'registration_until'];
/**
* @return SluggableConfig
*/
@ -37,6 +43,15 @@ class Form extends Model implements HasMedia
];
}
/**
* @return HasMany<Participant, self>
*/
public function participants(): HasMany
{
return $this->hasMany(Participant::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('headerImage')
@ -48,8 +63,59 @@ class Form extends Model implements HasMedia
});
}
/** @var array<int, string> */
public $dates = ['from', 'to', 'registration_from', 'registration_until'];
/**
* @return array<string, mixed>
*/
public function getRegistrationRules(): array
{
return $this->getFields()->reduce(function ($carry, $current) {
$field = Field::fromConfig($current);
return [
...$carry,
...$field->getRegistrationRules(),
];
}, []);
}
/**
* @return array<string, mixed>
*/
public function getRegistrationMessages(): array
{
return $this->getFields()->reduce(function ($carry, $current) {
$field = Field::fromConfig($current);
return [
...$carry,
...$field->getRegistrationMessages(),
];
}, []);
}
/**
* @return array<string, mixed>
*/
public function getRegistrationAttributes(): array
{
return $this->getFields()->reduce(function ($carry, $current) {
$field = Field::fromConfig($current);
return [
...$carry,
...$field->getRegistrationAttributes(),
];
}, []);
}
/**
* @return Collection<string, mixed>
*/
public function getFields(): Collection
{
return collect($this->config['sections'])->reduce(fn ($carry, $current) => $carry->merge($current['fields']), collect([]));
}
// --------------------------------- Searching ---------------------------------
// *****************************************************************************

View File

@ -0,0 +1,17 @@
<?php
namespace App\Form\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Participant extends Model
{
use HasFactory;
public $guarded = [];
public $casts = [
'data' => 'json',
];
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('participants', function (Blueprint $table) {
$table->id();
$table->json('data');
$table->foreignId('form_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('participants');
}
};

View File

@ -30,6 +30,7 @@ use App\Form\Actions\FormtemplateIndexAction;
use App\Form\Actions\FormtemplateStoreAction;
use App\Form\Actions\FormtemplateUpdateAction;
use App\Form\Actions\FormUpdateAction;
use App\Form\Actions\RegisterAction;
use App\Initialize\Actions\InitializeAction;
use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
@ -156,6 +157,7 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::post('/formtemplate', FormtemplateStoreAction::class)->name('formtemplate.store');
Route::patch('/formtemplate/{formtemplate}', FormtemplateUpdateAction::class)->name('formtemplate.update');
Route::post('/form', FormStoreAction::class)->name('form.store');
Route::post('/form/{form}/register', RegisterAction::class)->name('form.register');
});
Route::get('/api/group/{group?}', GroupApiIndexAction::class)->name('api.group');

View File

@ -0,0 +1,276 @@
<?php
namespace Tests\Feature\Form;
use App\Form\Fields\CheckboxesField;
use App\Form\Fields\CheckboxField;
use App\Form\Fields\DateField;
use App\Form\Fields\DropdownField;
use App\Form\Fields\GroupField;
use App\Form\Fields\RadioField;
use App\Form\Fields\TextareaField;
use App\Form\Fields\TextField;
use App\Form\Models\Form;
use App\Group;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
use Illuminate\Support\Facades\Storage;
class FormRegisterActionTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
Storage::fake('temp');
}
public function testItSavesParticipantAsModel(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()
->sections([
FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(TextField::class)->key('vorname'),
FormtemplateFieldRequest::type(TextField::class)->key('nachname'),
]),
FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(TextField::class)->key('spitzname'),
]),
])
->create();
$this->postJson(route('form.register', ['form' => $form]), ['vorname' => 'Max', 'nachname' => 'Muster', 'spitzname' => 'Abraham'])
->assertOk();
$participants = $form->fresh()->participants;
$this->assertCount(1, $participants);
$this->assertEquals('Max', $participants->first()->data['vorname']);
$this->assertEquals('Muster', $participants->first()->data['nachname']);
$this->assertEquals('Abraham', $participants->first()->data['spitzname']);
}
/**
* @dataProvider validationDataProvider
*/
public function testItValidatesInput(FormtemplateFieldRequest $fieldGenerator, array $payload, ?array $messages): void
{
$this->login()->loginNami();
$form = Form::factory()
->sections([FormtemplateSectionRequest::new()->fields([$fieldGenerator])])
->create();
$response = $this->postJson(route('form.register', ['form' => $form]), $payload);
if ($messages) {
$response->assertJsonValidationErrors($messages);
} else {
$response->assertOk();
}
}
private function validationDataProvider(): Generator
{
yield [
FormtemplateFieldRequest::type(DateField::class)->name('Geburtsdatum')->maxToday(false)->key('birthday'),
['birthday' => 'aa'],
['birthday' => 'Geburtsdatum muss ein gültiges Datum sein.']
];
yield [
FormtemplateFieldRequest::type(DateField::class)->name('Geburtsdatum')->maxToday(false)->key('birthday'),
['birthday' => '2021-05-06'],
null,
];
yield [
FormtemplateFieldRequest::type(DateField::class)->name('Geburtsdatum')->maxToday(true)->key('birthday'),
['birthday' => now()->addDay(1)->format('Y-m-d')],
['birthday' => 'Geburtsdatum muss ein Datum vor oder gleich dem ' . now()->format('d.m.Y') . ' sein.'],
];
yield [
FormtemplateFieldRequest::type(DateField::class)->name('Geburtsdatum')->maxToday(true)->key('birthday'),
['birthday' => now()->format('Y-m-d')],
null,
];
yield [
FormtemplateFieldRequest::type(TextField::class)->name('Vorname der Mutter')->required(true)->key('vorname'),
['vorname' => ''],
['vorname' => 'Vorname der Mutter ist erforderlich.']
];
yield [
FormtemplateFieldRequest::type(TextField::class)->name('Vorname der Mutter')->required(true)->key('vorname'),
['vorname' => 5],
['vorname' => 'Vorname der Mutter muss ein String sein.']
];
yield [
FormtemplateFieldRequest::type(RadioField::class)->name('Ja oder Nein')->required(true)->key('yes_or_no'),
['yes_or_no' => null],
['yes_or_no' => 'Ja oder Nein ist erforderlich.']
];
yield [
FormtemplateFieldRequest::type(RadioField::class)->name('Buchstabe')->options(['A', 'B'])->required(false)->key('letter'),
['letter' => 'Z'],
['letter' => 'Der gewählte Wert für Buchstabe ist ungültig.']
];
yield [
FormtemplateFieldRequest::type(RadioField::class)->name('Buchstabe')->options(['A', 'B'])->required(true)->key('letter'),
['letter' => 'Z'],
['letter' => 'Der gewählte Wert für Buchstabe ist ungültig.']
];
yield [
FormtemplateFieldRequest::type(RadioField::class)->name('Buchstabe')->options(['A', 'B'])->required(true)->key('letter'),
['letter' => 'A'],
null
];
yield [
FormtemplateFieldRequest::type(CheckboxesField::class)->name('Buchstabe')->options(['A', 'B'])->key('letter'),
['letter' => ['Z']],
['letter.0' => 'Der gewählte Wert für Buchstabe ist ungültig.'],
];
yield [
FormtemplateFieldRequest::type(CheckboxesField::class)->name('Buchstabe')->options(['A', 'B'])->key('letter'),
['letter' => 77],
['letter' => 'Buchstabe muss ein Array sein.'],
];
yield [
FormtemplateFieldRequest::type(CheckboxesField::class)->name('Buchstabe')->options(['A', 'B'])->key('letter'),
['letter' => ['A']],
null,
];
yield [
FormtemplateFieldRequest::type(CheckboxesField::class)->name('Buchstabe')->options(['A', 'B'])->key('letter'),
['letter' => []],
null,
];
yield [
FormtemplateFieldRequest::type(CheckboxField::class)->name('Datenschutz')->required(false)->key('data'),
['data' => 5],
['data' => 'Datenschutz muss ein Wahrheitswert sein.'],
];
yield [
FormtemplateFieldRequest::type(CheckboxField::class)->name('Datenschutz')->required(false)->key('data'),
['data' => false],
null
];
yield [
FormtemplateFieldRequest::type(CheckboxField::class)->name('Datenschutz')->required(true)->key('data'),
['data' => false],
['data' => 'Datenschutz muss akzeptiert werden.'],
];
yield [
FormtemplateFieldRequest::type(CheckboxField::class)->name('Datenschutz')->required(true)->key('data'),
['data' => true],
null,
];
yield [
FormtemplateFieldRequest::type(DropdownField::class)->name('Ja oder Nein')->required(true)->key('yes_or_no'),
['yes_or_no' => null],
['yes_or_no' => 'Ja oder Nein ist erforderlich.']
];
yield [
FormtemplateFieldRequest::type(DropdownField::class)->name('Buchstabe')->options(['A', 'B'])->required(false)->key('letter'),
['letter' => 'Z'],
['letter' => 'Der gewählte Wert für Buchstabe ist ungültig.']
];
yield [
FormtemplateFieldRequest::type(DropdownField::class)->name('Buchstabe')->options(['A', 'B'])->required(true)->key('letter'),
['letter' => 'Z'],
['letter' => 'Der gewählte Wert für Buchstabe ist ungültig.']
];
yield [
FormtemplateFieldRequest::type(DropdownField::class)->name('Buchstabe')->options(['A', 'B'])->required(true)->key('letter'),
['letter' => 'A'],
null
];
yield [
FormtemplateFieldRequest::type(TextareaField::class)->name('Vorname der Mutter')->required(true)->key('vorname'),
['vorname' => ''],
['vorname' => 'Vorname der Mutter ist erforderlich.']
];
yield [
FormtemplateFieldRequest::type(TextareaField::class)->name('Vorname der Mutter')->required(true)->key('vorname'),
['vorname' => 5],
['vorname' => 'Vorname der Mutter muss ein String sein.']
];
yield [
FormtemplateFieldRequest::type(TextareaField::class)->name('Vorname der Mutter')->required(true)->key('vorname'),
['vorname' => 5],
['vorname' => 'Vorname der Mutter muss ein String sein.']
];
}
public function testItValidatesGroupFieldWithParentGroupField(): void
{
$this->login()->loginNami();
$group = Group::factory()->has(Group::factory()->count(3), 'children')->create();
$foreignGroup = Group::factory()->create();
$form = Form::factory()
->sections([FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(GroupField::class)->name('Gruppe')->parentGroup($group->id)->required(true)->key('group')
])])
->create();
$this->postJson(route('form.register', ['form' => $form]), ['group' => null])
->assertJsonValidationErrors(['group' => 'Gruppe ist erforderlich.']);
$this->postJson(route('form.register', ['form' => $form]), ['group' => $foreignGroup->id])
->assertJsonValidationErrors(['group' => 'Der gewählte Wert für Gruppe ist ungültig.']);
}
public function testGroupCanBeNull(): void
{
$this->login()->loginNami();
$form = Form::factory()
->sections([FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(GroupField::class)->parentGroup(Group::factory()->create()->id)->required(false)->key('group')
])])
->create();
$this->postJson(route('form.register', ['form' => $form]), ['group' => null])
->assertOk();
}
public function testItValidatesGroupWithParentFieldField(): void
{
$this->login()->loginNami();
$group = Group::factory()->has(Group::factory()->has(Group::factory()->count(3), 'children'), 'children')->create();
$foreignGroup = Group::factory()->create();
$form = Form::factory()
->sections([FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(GroupField::class)->name('Übergeordnete Gruppe')->parentGroup($group->id)->required(true)->key('parentgroup'),
FormtemplateFieldRequest::type(GroupField::class)->name('Gruppe')->parentField('parentgroup')->required(true)->key('group')
])])
->create();
$this->postJson(route('form.register', ['form' => $form]), ['parentgroup' => $group->children->first()->id, 'group' => $foreignGroup->id])
->assertJsonValidationErrors(['group' => 'Der gewählte Wert für Gruppe ist ungültig.']);
$this->postJson(route('form.register', ['form' => $form]), ['parentgroup' => $group->children->first()->id, 'group' => $group->children->first()->children->first()->id])
->assertOk();
}
}