Add participant overview

This commit is contained in:
philipp lang 2024-02-08 23:09:51 +01:00
parent 7361dcccff
commit c7b23df01e
20 changed files with 192 additions and 33 deletions

View File

@ -6,7 +6,6 @@ use App\Form\Models\Form;
use App\Form\Resources\ParticipantResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class ParticipantIndexAction
@ -18,7 +17,7 @@ class ParticipantIndexAction
return $form->participants()->with('form')->paginate(15);
}
public function asController(ActionRequest $request, Form $form): AnonymousResourceCollection
public function asController(Form $form): AnonymousResourceCollection
{
return ParticipantResource::collection($this->handle($form))
->additional(['meta' => ParticipantResource::meta($form)]);

View File

@ -7,8 +7,6 @@ use Illuminate\Validation\Rule;
class CheckboxField extends Field
{
public string $name;
public string $key;
public bool $required;
public string $description;

View File

@ -7,8 +7,6 @@ use Illuminate\Validation\Rule;
class CheckboxesField extends Field
{
public string $name;
public string $key;
/** @var array<int, string> */
public array $options;

View File

@ -2,13 +2,13 @@
namespace App\Form\Fields;
use App\Form\Contracts\Displayable;
use Carbon\Carbon;
use Faker\Generator;
class DateField extends Field
{
public string $name;
public string $key;
public bool $required;
public bool $maxToday;
@ -73,4 +73,21 @@ class DateField extends Field
$this->key . '.before_or_equal' => $this->name . ' muss ein Datum vor oder gleich dem ' . now()->format('d.m.Y') . ' sein.',
];
}
/**
* @param mixed $value
* @return mixed
*/
public function presentValue($value)
{
return [
$this->key => $value,
$this->key . '_human' => $value ? Carbon::parse($value)->format('d.m.Y') : null,
];
}
public function displayAttribute(): string
{
return $this->key . '_human';
}
}

View File

@ -7,8 +7,6 @@ use Illuminate\Validation\Rule;
class DropdownField extends Field
{
public string $name;
public string $key;
public bool $required;
/** @var array<int, string> */
public array $options;

View File

@ -3,7 +3,6 @@
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;
@ -12,6 +11,9 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper;
abstract class Field extends Data
{
public string $key;
public string $name;
abstract public static function name(): string;
/** @return array<int, array{key: string, default: mixed, label: string, rules: array<string, mixed>}> */
@ -71,6 +73,15 @@ abstract class Field extends Data
return static::classFromType($config['type'])::withoutMagicalCreationFrom($config);
}
/**
* @param mixed $value
* @return mixed
*/
public function presentValue($value)
{
return [$this->key => $value];
}
/**
* @return array<string, string>
*/
@ -117,4 +128,9 @@ abstract class Field extends Data
],
];
}
public function displayAttribute(): string
{
return $this->key;
}
}

View File

@ -9,8 +9,6 @@ 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;
@ -79,4 +77,21 @@ class GroupField extends Field
{
return [];
}
/**
* @param mixed $value
* @return mixed
*/
public function presentValue($value)
{
return [
$this->key => $value,
$this->key . '_name' => Group::find($value)?->display() ?: ''
];
}
public function displayAttribute(): string
{
return $this->key . '_name';
}
}

View File

@ -7,8 +7,6 @@ use Illuminate\Validation\Rule;
class RadioField extends Field
{
public string $name;
public string $key;
public bool $required;
/** @var array<int, string> */
public array $options;

View File

@ -7,8 +7,6 @@ use Faker\Generator;
class TextField extends Field
{
public string $name;
public string $key;
public bool $required;
public static function name(): string

View File

@ -6,8 +6,6 @@ use Faker\Generator;
class TextareaField extends Field
{
public string $name;
public string $key;
public bool $required;
public static function name(): string

View File

@ -27,6 +27,7 @@ class Form extends Model implements HasMedia
public $casts = [
'config' => 'json',
'active_columns' => 'json',
'description' => 'json',
];
@ -133,4 +134,13 @@ class Form extends Model implements HasMedia
'name' => $this->name,
];
}
public static function booted(): void
{
static::saving(function (self $model) {
if (is_null($model->active_columns)) {
$model->setAttribute('active_columns', $model->getFields()->take(4)->pluck('key')->toArray());
}
});
}
}

View File

@ -2,6 +2,7 @@
namespace App\Form\Resources;
use App\Form\Fields\Field;
use App\Form\Models\Form;
use Illuminate\Http\Resources\Json\JsonResource;
@ -15,19 +16,28 @@ class ParticipantResource extends JsonResource
*/
public function toArray($request)
{
return $this->form->getFields()->mapWithKeys(function ($field) {
return [$field['key'] => $this->data[$field['key']]];
})->toArray();
$attributes = collect([]);
foreach ($this->form->getFields() as $field) {
$attributes = $attributes->merge(Field::fromConfig($field)->presentValue($this->data[$field['key']]));
}
return $attributes;
}
public static function meta(Form $form): array
{
return [
'columns' => $form->getFields()->map(fn ($field) => [
'name' => $field['name'],
'base_type' => class_basename($field['type']),
'id' => $field['key'],
])
'active_columns' => $form->active_columns,
'columns' => $form->getFields()->map(function ($field) {
$field = Field::fromConfig($field);
return [
'name' => $field->name,
'base_type' => class_basename($field),
'id' => $field->key,
'display_attribute' => $field->displayAttribute(),
];
})
];
}
}

View File

@ -55,10 +55,15 @@ class Group extends Model
return $result
->reduce(
fn ($before, $group) => $before->concat([['id' => $group->id, 'name' => $prefix . ($group->inner_name ?: $group->name)]])
fn ($before, $group) => $before->concat([['id' => $group->id, 'name' => $prefix . ($group->display())]])
->concat($group->children_count > 0 ? self::forSelect($group, $prefix . '-- ') : []),
collect([])
)
->toArray();
}
public function display(): string
{
return $this->inner_name ?: $this->name;
}
}

View File

@ -37,4 +37,9 @@ class GroupFactory extends Factory
{
return $this->state(['name' => $name]);
}
public function innerName(string $name): self
{
return $this->state(['inner_name' => $name]);
}
}

View File

@ -33,6 +33,7 @@ return new class extends Migration
$table->dateTime('registration_until')->nullable();
$table->text('mail_top')->nullable();
$table->text('mail_bottom')->nullable();
$table->json('active_columns');
$table->timestamps();
});
}

View File

@ -29,4 +29,14 @@ return new class extends Migration
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('media');
}
};

View File

@ -1,6 +1,9 @@
<template>
<div class="fixed z-40 top-0 left-0 w-full h-full flex items-center justify-center p-6">
<div class="relative rounded-lg p-8 bg-zinc-800 shadow-2xl shadow-black border border-zinc-700 border-solid w-full max-h-full overflow-auto" :class="full ? 'h-full' : innerWidth">
<div
class="relative rounded-lg p-8 bg-zinc-800 shadow-2xl shadow-black border border-zinc-700 border-solid w-full max-h-full flex flex-col overflow-auto"
:class="full ? 'h-full' : innerWidth"
>
<div class="absolute top-0 right-0 mt-6 mr-6 flex space-x-6">
<slot name="actions"></slot>
<a href="#" @click.prevent="$emit('close')">
@ -8,8 +11,18 @@
</a>
</div>
<h3 v-if="heading" class="font-semibold text-primary-200 text-xl" v-html="heading"></h3>
<div class="text-primary-100 group is-popup">
<slot></slot>
<div class="text-primary-100 group is-popup grow flex flex-col">
<suspense>
<div>
<slot></slot>
</div>
<template #fallback>
<div class="flex flex-col items-center justify-center h-full">
<ui-spinner class="border-primary-400 w-32 h-32"></ui-spinner>
<div class="text-3xl mt-10">Lade </div>
</div>
</template>
</suspense>
</div>
</div>
</div>

View File

@ -31,6 +31,10 @@
</div>
</ui-popup>
<ui-popup v-if="showing !== null" :heading="`Teilnehmende für ${showing.name}`" full @close="showing = null">
<participants :url="showing.links.participant_index"></participants>
</ui-popup>
<ui-popup v-if="single !== null && single.config !== null" :heading="`Veranstaltung ${single.id ? 'bearbeiten' : 'erstellen'}`" full @close="cancel">
<div class="flex flex-col mt-3">
<ui-tabs v-model="active" :entries="tabs"></ui-tabs>
@ -109,6 +113,7 @@
</td>
<td>
<a v-tooltip="`Bearbeiten`" href="#" class="ml-2 inline-flex btn btn-warning btn-sm" @click.prevent="edit(form)"><ui-sprite src="pencil"></ui-sprite></a>
<a v-tooltip="`Teilnehmende anzeigen`" href="#" class="ml-2 inline-flex btn btn-info btn-sm" @click.prevent="showParticipants(form)"><ui-sprite src="user"></ui-sprite></a>
<a v-tooltip="`Löschen`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = form"><ui-sprite src="trash"></ui-sprite></a>
</td>
</tr>
@ -123,12 +128,14 @@
import {ref} from 'vue';
import {indexProps, useIndex} from '../../composables/useInertiaApiIndex.js';
import FormBuilder from '../formtemplate/FormBuilder.vue';
import Participants from './Participants.vue';
const props = defineProps(indexProps);
var {meta, data, reloadPage, create, single, edit, cancel, submit, remove, getFilter, setFilter} = useIndex(props.data, 'form');
const active = ref(0);
const deleting = ref(null);
const showing = ref(null);
const tabs = [{title: 'Allgemeines'}, {title: 'Formular'}, {title: 'E-Mail'}, {title: 'Export'}];
@ -136,4 +143,8 @@ function setTemplate(template) {
active.value = 0;
single.value.config = template.config;
}
function showParticipants(form) {
showing.value = form;
}
</script>

View File

@ -0,0 +1,43 @@
<template>
<div class="mt-5">
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm">
<thead>
<th v-for="column in activeColumns" :key="column.id" v-text="column.name"></th>
<th></th>
</thead>
<tr v-for="(form, index) in data" :key="index">
<td v-for="column in activeColumns" :key="column.id">
<div v-text="form[column.display_attribute]"></div>
</td>
<td>
<a v-tooltip="`Bearbeiten`" href="#" class="ml-2 inline-flex btn btn-warning btn-sm" @click.prevent="edit(form)"><ui-sprite src="pencil"></ui-sprite></a>
<a v-tooltip="`Löschen`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm" @click.prevent="deleting = form"><ui-sprite src="trash"></ui-sprite></a>
</td>
</tr>
</table>
<div class="px-6">
<ui-pagination class="mt-4" :value="meta" @reload="reloadPage"></ui-pagination>
</div>
</div>
</template>
<script setup>
import {ref, computed} from 'vue';
import {useApiIndex} from '../../composables/useApiIndex.js';
import FormBuilder from '../formtemplate/FormBuilder.vue';
const props = defineProps({
url: {
type: String,
required: true,
validator: (value) => value.startsWith('http'),
},
});
var {meta, data, reload, reloadPage} = useApiIndex(props.url, 'participant');
await reload();
const activeColumns = computed(() => meta.value.columns.filter((c) => meta.value.active_columns.includes(c.id)));
</script>

View File

@ -3,10 +3,13 @@
namespace Tests\Feature\Form;
use App\Form\Fields\CheckboxesField;
use App\Form\Fields\DateField;
use App\Form\Fields\DropdownField;
use App\Form\Fields\GroupField;
use App\Form\Fields\TextField;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Group;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
@ -18,13 +21,19 @@ class ParticipantIndexActionTest extends TestCase
public function testItShowsParticipantsAndColumns(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$group = Group::factory()->innerName('Stamm')->create();
$form = Form::factory()
->has(Participant::factory()->data(['vorname' => 'Max', 'select' => 'A', 'stufe' => 'Pfadfinder']))
->has(Participant::factory()->data(['vorname' => 'Max', 'select' => 'A', 'stufe' => 'Pfadfinder', 'test1' => '', 'test2' => '', 'test3' => '', 'birthday' => '1991-04-20', 'bezirk' => $group->id]))
->sections([
FormtemplateSectionRequest::new()->fields([
FormtemplateFieldRequest::type(TextField::class)->name('Vorname')->key('vorname'),
FormtemplateFieldRequest::type(CheckboxesField::class)->key('select')->options(['A', 'B', 'C']),
FormtemplateFieldRequest::type(DropdownField::class)->key('stufe')->options(['Wölfling', 'Jungpfadfinder', 'Pfadfinder']),
FormtemplateFieldRequest::type(TextField::class)->name('Test 1')->key('test1'),
FormtemplateFieldRequest::type(TextField::class)->name('Test 2')->key('test2'),
FormtemplateFieldRequest::type(TextField::class)->name('Test 3')->key('test3'),
FormtemplateFieldRequest::type(DateField::class)->name('Geburtsdatum')->key('birthday'),
FormtemplateFieldRequest::type(GroupField::class)->name('bezirk')->key('bezirk'),
]),
])
->create();
@ -33,8 +42,15 @@ class ParticipantIndexActionTest extends TestCase
->assertOk()
->assertJsonPath('data.0.vorname', 'Max')
->assertJsonPath('data.0.stufe', 'Pfadfinder')
->assertJsonPath('data.0.bezirk', $group->id)
->assertJsonPath('data.0.bezirk_name', 'Stamm')
->assertJsonPath('data.0.birthday_human', '20.04.1991')
->assertJsonPath('data.0.birthday', '1991-04-20')
->assertJsonPath('meta.columns.0.name', 'Vorname')
->assertJsonPath('meta.columns.0.base_type', class_basename(TextField::class))
->assertJsonPath('meta.columns.0.id', 'vorname');
->assertJsonPath('meta.columns.0.id', 'vorname')
->assertJsonPath('meta.columns.6.display_attribute', 'birthday_human')
->assertJsonPath('meta.columns.0.display_attribute', 'vorname')
->assertJsonPath('meta.active_columns', ['vorname', 'select', 'stufe', 'test1']);
}
}