Compare commits

...

148 Commits

Author SHA1 Message Date
philipp lang 44ea68cce7 Mod registration mail
continuous-integration/drone/push Build is failing Details
2024-03-24 11:09:56 +01:00
philipp lang c0096c0794 Remove adrema-plugin submodule
continuous-integration/drone/push Build is failing Details
2024-03-17 16:50:18 +01:00
philipp lang 05bcb376c8 Update oc-adrema-plugin 2024-03-17 16:42:47 +01:00
philipp lang 913cabc7a3 Mod Adrema plugin path
continuous-integration/drone/push Build is failing Details
2024-03-17 16:21:54 +01:00
philipp lang 7cc1f349a1 Update adrema-plugin 2024-03-17 02:08:14 +01:00
philipp lang 8d2f8ec03d Fix tests 2024-03-17 02:08:14 +01:00
philipp lang 9144ae1028 Add mail for registration 2024-03-17 02:08:14 +01:00
philipp lang c3a2417cac Lint 2024-03-17 02:08:14 +01:00
philipp lang 91943a6321 Add subject for participant mail 2024-03-17 02:08:14 +01:00
philipp lang 6f03063cba Add SpecialType to form field 2024-03-17 02:08:14 +01:00
philipp lang 5d576cda6a Add mail to participant 2024-03-17 02:08:14 +01:00
philipp lang 2a979d932e Add value for Participant fields 2024-03-17 02:08:14 +01:00
philipp lang 80def4abde --wip-- [skip ci] 2024-03-17 02:08:14 +01:00
philipp lang 64c2cb9408 Add value for field 2024-03-17 02:08:14 +01:00
philipp lang 78d6c6d864 Add participants parent and child in request filter 2024-03-17 02:08:14 +01:00
philipp lang a0842afd47 Add participants parent id 2024-03-17 02:08:14 +01:00
philipp lang 43846d1703 Add matching for member 2024-03-17 02:08:14 +01:00
philipp lang 171dd51200 Add email to NamiField 2024-03-17 02:08:14 +01:00
philipp lang c844f2143a Add data objects for form 2024-03-17 02:08:14 +01:00
philipp lang 6ac539417f Lint 2024-03-17 02:08:14 +01:00
philipp lang 56ad09c6fa Fix form 2024-03-17 02:08:14 +01:00
philipp lang 2f2e95cd23 Fix: Remove page param from search action input 2024-03-17 02:08:14 +01:00
philipp lang 3d270ce9a4 Fix: Remove active columns when field removed 2024-03-17 02:08:14 +01:00
philipp lang eaa880127d Add fresh Login for Remote login 2024-03-17 02:08:14 +01:00
philipp lang 6f334765bf Fix remote search 2024-03-17 02:08:13 +01:00
philipp lang b806cafc8b Add agegroups to form meta 2024-03-17 02:08:13 +01:00
philipp lang ada03a11ea Lint 2024-03-17 02:08:13 +01:00
philipp lang a12cb53274 Fixed tests 2024-03-17 02:08:13 +01:00
philipp lang 458c643615 Add remote search 2024-03-17 02:08:13 +01:00
philipp lang 60b0052622 Fix setting form meta when form has no fields 2024-03-17 02:08:13 +01:00
philipp lang 0bc00207b2 Add remote login 2024-03-17 02:08:13 +01:00
philipp lang 8cd2f74477 Add form meta route 2024-03-17 02:08:13 +01:00
philipp lang 9d11294fde Add update of active columns in participants table 2024-03-17 02:08:13 +01:00
philipp lang acec968963 Lint 2024-03-17 02:08:13 +01:00
philipp lang 3cbb4768e8 Add sorting and active_columns meta for form 2024-03-17 02:08:13 +01:00
philipp lang 09e3bcab9e Return namiTypes from form 2024-03-17 02:08:13 +01:00
philipp lang 66a76ad2b1 Fix: Match Group of member 2024-03-17 02:08:13 +01:00
philipp lang 5919082211 Add trait for creating form fields 2024-03-17 02:08:13 +01:00
philipp lang 2a5c203cc0 Lint 2024-03-17 02:08:13 +01:00
philipp lang 5f48b4e64d Add presenter for nami field 2024-03-17 02:08:13 +01:00
philipp lang 5f47729bb6 Add mitgliedsnr column to sub member 2024-03-17 02:08:13 +01:00
philipp lang c9cbf86626 Lint 2024-03-17 02:08:13 +01:00
philipp lang 8713f941c1 Fix: Register participant with no nami member 2024-03-17 02:08:13 +01:00
philipp lang 26ce8637c1 Mod field name of nami_type in FormBuilder 2024-03-17 02:08:13 +01:00
philipp lang ebf7a1d5c9 Add: Destroy formtemplate 2024-03-17 02:08:13 +01:00
philipp lang b86b2b6063 Add for_members field for sections 2024-03-17 02:08:13 +01:00
philipp lang 94397165c6 Lint 2024-03-17 02:08:13 +01:00
philipp lang 9d7d039530 Lint Registration 2024-03-17 02:08:13 +01:00
philipp lang 34fb96dabe Simplify test 2024-03-17 02:08:13 +01:00
philipp lang f601167893 Add FormTestCase 2024-03-17 02:08:13 +01:00
philipp lang 54b650fb91 Rename test method 2024-03-17 02:08:13 +01:00
philipp lang f24064b9f3 Add createMember method 2024-03-17 02:08:13 +01:00
philipp lang a96d879023 Set fields not fillable 2024-03-17 02:08:13 +01:00
philipp lang b194e77966 Add validation for other member fields 2024-03-17 02:08:13 +01:00
philipp lang a830f366ad Fix tests 2024-03-17 02:08:13 +01:00
philipp lang 974c73af71 Lint 2024-03-17 02:08:13 +01:00
philipp lang 445590e060 Add other fields from Member 2024-03-17 02:08:13 +01:00
philipp lang ef47480cc9 Add member registration via nami type 2024-03-17 02:08:13 +01:00
philipp lang 35b269c647 Add lastname nami field 2024-03-17 02:08:13 +01:00
philipp lang e508dd9e6f Add nami fields to form field 2024-03-17 02:08:13 +01:00
philipp lang 891f0c21ad Add presenters 2024-03-17 02:08:13 +01:00
philipp lang a0bcd99642 Lint 2024-03-17 02:08:13 +01:00
philipp lang 857051434e Lint 2024-03-17 02:08:13 +01:00
philipp lang c7b23df01e Add participant overview 2024-03-17 02:08:13 +01:00
philipp lang 7361dcccff Fix page navigation in useApiIndex 2024-03-17 02:08:13 +01:00
philipp lang 590696750b Add Backend for participant index 2024-03-17 02:08:13 +01:00
philipp lang 48383b25da Add participants count for form index 2024-03-17 02:08:13 +01:00
philipp lang de76e195e7 Move Api routes 2024-03-17 02:08:13 +01:00
philipp lang ff6c2bbc2c Update Adrema plugin 2024-03-17 02:08:13 +01:00
philipp lang 5d9d7a0ffc Add register action 2024-03-17 02:08:13 +01:00
philipp lang 89429f9812 Add options and maxToday method 2024-03-17 02:08:13 +01:00
philipp lang b813664632 Fix: snake method in tests 2024-03-17 02:08:13 +01:00
philipp lang a7394e12aa Add Type to FormtemplateFieldRequest 2024-03-17 02:08:13 +01:00
philipp lang ddb44d64d6 Add adrema_base_url 2024-03-17 02:08:13 +01:00
philipp lang 3d62970baf Fixed tests 2024-03-17 02:08:13 +01:00
philipp lang 9a3a0402d9 Set minHeight for editor 2024-03-17 02:08:13 +01:00
philipp lang 665b43c797 Add lists 2024-03-17 02:08:13 +01:00
philipp lang ee926467ed Add style for headings 2024-03-17 02:08:13 +01:00
philipp lang 4ffc14d503 Fix MemberTest 2024-03-17 02:08:13 +01:00
philipp lang 1edbdab8fd Add editorJS for Form Description 2024-03-17 02:08:13 +01:00
philipp lang 6e6a4595cd Lint 2024-03-17 02:08:13 +01:00
philipp lang e3a0ca7673 Fix searching via api 2024-03-17 02:08:13 +01:00
philipp lang 28c821eeaf Add searching and filtering for events 2024-03-17 02:08:13 +01:00
philipp lang e45a59c5ff Add from and to date to form index 2024-03-17 02:08:13 +01:00
philipp lang d3c1e89a03 Add media conversion for squared form header image format 2024-03-17 02:08:13 +01:00
Philipp Lang 6ede944d85 Remove laravel sail 2024-03-17 02:08:13 +01:00
Philipp Lang b69c895921 Add image to api response 2024-03-17 02:08:13 +01:00
Philipp Lang 539e41cffe Lint 2024-03-17 02:08:13 +01:00
philipp lang bdf3fa54a9 Add image component to form 2024-03-17 02:08:13 +01:00
philipp lang 97fabf0f24 Add image to form 2024-03-17 02:08:13 +01:00
philipp lang e011b52534 Add form api to display event overview on website 2024-03-17 02:08:13 +01:00
philipp lang e005ed0d0d lint 2024-03-17 02:08:13 +01:00
philipp lang ae19a6ed2b lint 2024-03-17 02:08:13 +01:00
philipp lang 4ce7d428d4 lint 2024-03-17 02:08:13 +01:00
philipp lang e6d0a30c7f lint 2024-03-17 02:08:12 +01:00
philipp lang d16f02c8b3 fix tests 2024-03-17 02:08:12 +01:00
philipp lang 888f4ee976 Lint 2024-03-17 02:08:12 +01:00
philipp lang 2a6e3aac03 Fix tests 2024-03-17 02:08:12 +01:00
philipp lang 7abf746897 Update Form Builder 2024-03-17 02:08:12 +01:00
philipp lang ed8e7b045d Fix drone 2024-03-17 02:08:12 +01:00
philipp lang 2a2cbd196d Add node 2024-03-17 02:08:12 +01:00
philipp lang 4690e0037f Mod adrema plugin 2024-03-17 02:08:12 +01:00
philipp lang 1d28827353 Update adrema-plugin 2024-03-17 02:08:12 +01:00
philipp lang 703084dbc1 update submodules 2024-03-17 02:08:12 +01:00
philipp lang 7aa445ca74 Mod modules 2024-03-17 02:08:12 +01:00
philipp lang a2d7a36fc1 mod submodule 2024-03-17 02:08:12 +01:00
philipp lang 23c60d4330 Fix submodules 2024-03-17 02:08:12 +01:00
philipp lang 79f1c71dd9 Fix tests 2024-03-17 02:08:12 +01:00
philipp lang 452416d0a4 fix tests 2024-03-17 02:08:12 +01:00
philipp lang 589b713907 Add form editor 2024-03-17 02:08:12 +01:00
philipp lang eb60852bbf Fix textarea hint 2024-03-17 02:08:12 +01:00
philipp lang c3db854eb5 Add formtemplate to form index 2024-03-17 02:08:12 +01:00
philipp lang 5cf49489d5 Add config to index 2024-03-17 02:08:12 +01:00
philipp lang 7d880c6e29 --wip-- [skip ci] 2024-03-17 02:08:12 +01:00
philipp lang 743c041c35 Add IndexAction for forms 2024-03-17 02:08:12 +01:00
philipp lang d3ad48ed30 Add more form fields 2024-03-17 02:08:12 +01:00
philipp lang 4776c0d71b Add eventform submodule 2024-03-17 02:08:12 +01:00
philipp lang 9355daee7e Lint 2024-03-17 02:08:12 +01:00
philipp lang 89b5cbd4b5 Add group field 2024-03-17 02:08:12 +01:00
philipp lang c8bc69ae30 Add date field 2024-03-17 02:08:12 +01:00
philipp lang 0fde33803b Add slot for meta info to formbuilder 2024-03-17 02:08:12 +01:00
philipp lang 1b5c83a414 add StoreForm route 2024-03-17 02:08:12 +01:00
philipp lang e0558e4b13 update form 2024-03-17 02:08:12 +01:00
philipp lang b421f85d89 Move Config to FormBuilder 2024-03-17 02:08:12 +01:00
philipp lang 0eb8928b22 Fix TextField Meta 2024-03-17 02:08:12 +01:00
philipp lang f1d4d3e428 Add StoreAction for form 2024-03-17 02:08:12 +01:00
philipp lang 5b2a290b49 Add FormtemplateUpdateActionTest 2024-03-17 02:08:12 +01:00
philipp lang f55c174019 Fix tests 2024-03-17 02:08:12 +01:00
philipp lang d597d5ea2b Add fakers for fields 2024-03-17 02:08:12 +01:00
Philipp Lang d4c8fdff3f Remove test for name 2024-03-17 02:08:12 +01:00
philipp lang b26935fc07 Add deleteSection 2024-03-17 02:08:12 +01:00
philipp lang 4ddf76943d Add column selector 2024-03-17 02:08:12 +01:00
philipp lang d7e7fb3c27 Add faker for TextField 2024-03-17 02:08:12 +01:00
philipp lang 87ee15936d Add default value for field to Request 2024-03-17 02:08:12 +01:00
Philipp Lang 6262ccdd28 Add validation for update 2024-03-17 02:08:12 +01:00
Philipp Lang b109d40ebb add field meta 2024-03-17 02:08:12 +01:00
Philipp Lang 3c81dfe7db Add fields 2024-03-17 02:08:12 +01:00
philipp lang cc52437568 fix field 2024-03-17 02:08:12 +01:00
philipp lang ac28466c8d Add checkboxes 2024-03-17 02:08:12 +01:00
philipp lang bde8d48807 Add fields 2024-03-17 02:08:12 +01:00
philipp lang 288533efd3 Add update for form 2024-03-17 02:08:12 +01:00
philipp lang 104b04b639 Add field management 2024-03-17 02:08:12 +01:00
philipp lang 8bf0019c98 Add form builder 2024-03-17 02:08:12 +01:00
philipp lang 9f85809195 Mod FormtemplateResource 2024-03-17 02:08:12 +01:00
philipp lang 131690262e Lint 2024-03-17 02:08:12 +01:00
philipp lang edb1eeb0d8 Add path alias for packages 2024-03-17 02:08:12 +01:00
philipp lang 2ecd4b9643 Ad backend for index and store 2024-03-17 02:08:12 +01:00
Philipp Lang ae679641d7 Add event module 2024-03-17 02:08:12 +01:00
207 changed files with 10594 additions and 1706 deletions

View File

@ -5,7 +5,7 @@ RUN composer install --ignore-platform-reqs --no-dev
RUN php artisan telescope:publish
RUN php artisan horizon:publish
FROM node:17.9.0-slim as node
FROM node:18.13.0-slim as node
WORKDIR /app
COPY . /app
RUN npm install && npm run prod && npm run img && rm -R node_modules

View File

@ -22,9 +22,9 @@ steps:
- while ! mysqladmin ping -h db -u db -pdb --silent; do sleep 1; done
- name: node
image: node:17.9.0-slim
image: node:18.13.0-slim
commands:
- npm ci && npm run img && npm run prod && rm -R node_modules
- npm ci && cd packages/adrema-plugin && npm ci && npm run build && cd ../../ && npm run img && npm run prod && rm -R node_modules
- name: tests
image: zoomyboy/adrema-base:latest

7
.gitmodules vendored
View File

@ -7,3 +7,10 @@
[submodule "packages/tex"]
path = packages/tex
url = https://git.zoomyboy.de/pille/tex
[submodule "packages/adrema-form"]
path = packages/adrema-form
url = https://git.zoomyboy.de/silva/adrema-form.git
[submodule "packages/medialibrary-helper"]
path = packages/medialibrary-helper
url = https://git.zoomyboy.de/zoomyboy/medialibrary-helper.git
branch = version2

View File

@ -0,0 +1,33 @@
<?php
namespace App\Form\Actions;
use App\Form\Scopes\FormFilterScope;
use App\Form\Models\Form;
use App\Form\Resources\FormApiResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class FormApiListAction
{
use AsAction;
/**
* @param string $filter
* @return LengthAwarePaginator<Form>
*/
public function handle(string $filter, int $perPage): LengthAwarePaginator
{
return FormFilterScope::fromRequest($filter)->getQuery()->paginate($perPage);
}
public function asController(ActionRequest $request): AnonymousResourceCollection
{
return FormApiResource::collection($this->handle(
$request->input('filter', ''),
$request->input('perPage', 10)
));
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Events\Succeeded;
use Lorisleiva\Actions\Concerns\AsAction;
class FormDestroyAction
{
use AsAction;
public function asController(Form $form): void
{
$form->delete();
Succeeded::message('Veranstaltung gelöscht.')->dispatch();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Form\Actions;
use App\Form\Scopes\FormFilterScope;
use App\Form\Models\Form;
use App\Form\Resources\FormResource;
use Illuminate\Pagination\LengthAwarePaginator;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class FormIndexAction
{
use AsAction;
/**
* @return LengthAwarePaginator<Form>
*/
public function handle(string $filter): LengthAwarePaginator
{
return FormFilterScope::fromRequest($filter)->getQuery()->query(fn ($query) => $query->withCount('participants'))->paginate(15);
}
public function asController(ActionRequest $request): Response
{
session()->put('menu', 'form');
session()->put('title', 'Veranstaltungen');
return Inertia::render('form/Index', [
'data' => FormResource::collection($this->handle($request->input('filter', ''))),
]);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\ActionRequest;
class FormStoreAction
{
use AsAction;
use HasValidation;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
...$this->globalRules(),
'description.time' => 'required|integer',
'description.blocks' => 'required|array',
'description.version' => 'required|string',
'excerpt' => 'required|string|max:130',
'from' => 'required|date',
'to' => 'required|date',
'registration_from' => 'present|nullable|date',
'registration_until' => 'present|nullable|date',
'mail_top' => 'nullable|string',
'mail_bottom' => 'nullable|string',
'header_image' => 'required|exclude',
];
}
/**
* @param array<string, mixed> $attributes
*/
public function handle(array $attributes): Form
{
return tap(
Form::create($attributes),
fn ($form) => $form->setDeferredUploads(request()->input('header_image'))
);
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
...$this->globalValidationAttributes(),
'from' => 'Start',
'to' => 'Ende',
'header_image' => 'Bild',
'description.blocks' => 'Beschreibung',
];
}
public function asController(ActionRequest $request): JsonResponse
{
$this->handle($request->validated());
Succeeded::message('Veranstaltung gespeichert.')->dispatch();
return response()->json([]);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\ActionRequest;
class FormUpdateAction
{
use AsAction;
use HasValidation;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
...$this->globalRules(),
'description' => 'required|array',
'description.time' => 'required|integer',
'description.blocks' => 'required|array',
'description.version' => 'required|string',
'excerpt' => 'required|string|max:130',
'from' => 'required|date',
'to' => 'required|date',
'registration_from' => 'present|nullable|date',
'registration_until' => 'present|nullable|date',
'mail_top' => 'nullable|string',
'mail_bottom' => 'nullable|string',
];
}
/**
* @param array<string, mixed> $attributes
*/
public function handle(Form $form, array $attributes): Form
{
$form->update($attributes);
return $form;
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
...$this->globalValidationAttributes(),
'from' => 'Start',
'to' => 'Ende',
'description.blocks' => 'Beschreibung',
];
}
public function asController(Form $form, ActionRequest $request): JsonResponse
{
$this->handle($form, $request->validated());
Succeeded::message('Veranstaltung aktualisiert.')->dispatch();
return response()->json([]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use Illuminate\Validation\Rule;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Symfony\Component\HttpFoundation\JsonResponse;
class FormUpdateMetaAction
{
use AsAction;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Form */
$form = request()->route('form');
return [
'sorting' => 'array',
'sorting.0' => 'required|string',
'sorting.1' => 'required|string|in:asc,desc',
'active_columns' => 'array',
'active_columns.*' => ['string', Rule::in($form->getFields()->pluck('key')->toArray())]
];
}
/**
* @param array<string, mixed> $input
*/
public function handle(Form $form, array $input): void
{
$form->update(['meta' => $input]);
}
public function asController(Form $form, ActionRequest $request): JsonResponse
{
$this->handle($form, $request->validated());
return response()->json($form->fresh()->meta);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Formtemplate;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class FormtemplateDestroyAction
{
use AsAction;
public function handle(Formtemplate $formtemplate): void
{
$formtemplate->delete();
}
public function asController(Formtemplate $formtemplate): JsonResponse
{
$this->handle($formtemplate);
Succeeded::message('Vorlage gelöscht.')->dispatch();
return response()->json([]);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Formtemplate;
use App\Form\Resources\FormtemplateResource;
use Illuminate\Pagination\LengthAwarePaginator;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class FormtemplateIndexAction
{
use AsAction;
/**
* @return LengthAwarePaginator<Formtemplate>
*/
public function handle(): LengthAwarePaginator
{
return Formtemplate::paginate(15);
}
public function asController(): Response
{
session()->put('menu', 'form');
session()->put('title', 'Formular-Vorlagen');
return Inertia::render('formtemplate/Index', [
'data' => FormtemplateResource::collection($this->handle()),
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Formtemplate;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class FormtemplateStoreAction
{
use AsAction;
use HasValidation;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
...$this->globalRules(),
];
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
...$this->globalValidationAttributes(),
];
}
/**
* @param array<string, mixed> $attributes
*/
public function handle(array $attributes): Formtemplate
{
return Formtemplate::create($attributes);
}
public function asController(ActionRequest $request): JsonResponse
{
$this->handle($request->validated());
Succeeded::message('Vorlage gespeichert.')->dispatch();
return response()->json([]);
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Formtemplate;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class FormtemplateUpdateAction
{
use AsAction;
use HasValidation;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
...$this->globalRules(),
];
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
...$this->globalValidationAttributes(),
];
}
/**
* @param array<string, mixed> $attributes
*/
public function handle(Formtemplate $formtemplate, array $attributes): void
{
$formtemplate->update($attributes);
}
public function asController(Formtemplate $formtemplate, ActionRequest $request): JsonResponse
{
$this->handle($formtemplate, $request->validated());
Succeeded::message('Vorlage aktualisiert.')->dispatch();
return response()->json([]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Form\Actions;
use Illuminate\Validation\Rule;
use App\Form\Fields\Field;
use Illuminate\Validation\Validator;
use Lorisleiva\Actions\ActionRequest;
trait HasValidation
{
/**
* @return array<string, mixed>
*/
public function globalRules(): array
{
return [
'name' => 'required|string|max:255',
'config' => 'array',
'config.sections.*.name' => 'required',
'config.sections.*.fields' => 'array',
'config.sections.*.fields.*.name' => 'required|string',
'config.sections.*.fields.*.type' => ['required', 'string', Rule::in(array_column(Field::asMeta(), 'id'))],
'config.sections.*.fields.*.key' => ['required', 'string', 'regex:/^[a-zA-Z_]*$/'],
'config.sections.*.fields.*.columns' => 'required|array',
'config.sections.*.fields.*.*' => '',
'config.sections.*.fields.*.columns.mobile' => 'required|numeric|gt:0|lte:2',
'config.sections.*.fields.*.columns.tablet' => 'required|numeric|gt:0|lte:4',
'config.sections.*.fields.*.columns.desktop' => 'required|numeric|gt:0|lte:6',
];
}
/**
* @return array<string, mixed>
*/
public function globalValidationAttributes(): array
{
return [
'config.sections.*.name' => 'Sektionsname',
'config.sections.*.fields.*.name' => 'Feldname',
'config.sections.*.fields.*.type' => 'Feldtyp',
'config.sections.*.fields.*.key' => 'Feldkey',
];
}
public function withValidator(Validator $validator, ActionRequest $request): void
{
if (!$validator->passes()) {
return;
}
foreach ($request->input('config.sections') as $sindex => $section) {
foreach (data_get($section, 'fields') as $findex => $field) {
$fieldClass = Field::classFromType($field['type']);
if (!$fieldClass) {
continue;
}
foreach ($fieldClass::metaRules() as $fieldName => $rules) {
$validator->addRules(["config.sections.{$sindex}.fields.{$findex}.{$fieldName}" => $rules]);
}
foreach ($fieldClass::metaAttributes() as $fieldName => $attribute) {
$validator->addCustomAttributes(["config.sections.{$sindex}.fields.{$findex}.{$fieldName}" => $attribute]);
}
}
}
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Resources\ParticipantResource;
use App\Form\Scopes\ParticipantFilterScope;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lorisleiva\Actions\Concerns\AsAction;
class ParticipantIndexAction
{
use AsAction;
/**
* @return LengthAwarePaginator<Participant>
*/
public function handle(Form $form, ParticipantFilterScope $filter): LengthAwarePaginator
{
return $form->participants()->withFilter($filter)->with('form')->paginate(15);
}
public function asController(Form $form): AnonymousResourceCollection
{
$filter = ParticipantFilterScope::fromRequest(request()->input('filter'));
return ParticipantResource::collection($this->handle($form, $filter))
->additional(['meta' => ParticipantResource::meta($form)]);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Form\Actions;
use App\Form\Data\FieldCollection;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Member\Member;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class RegisterAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*/
public function handle(Form $form, array $input): Participant
{
$memberQuery = FieldCollection::fromRequest($form, $input)
->withNamiType()
->reduce(fn ($query, $field) => $field->namiType->performQuery($query, $field->value), (new Member())->newQuery());
$mglnr = $form->getFields()->withNamiType()->count() && $memberQuery->count() === 1 ? $memberQuery->first()->mitgliedsnr : null;
$participant = $form->participants()->create([
'data' => $input,
'mitgliedsnr' => $mglnr,
]);
$form->getFields()->each(fn ($field) => $field->afterRegistration($form, $participant, $input));
$participant->sendConfirmationMail();
return $participant;
}
/**
* @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

@ -0,0 +1,30 @@
<?php
namespace App\Form\Casts;
use App\Form\Fields\Field;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Support\DataProperty;
class CollectionCast implements Cast
{
/**
* @param class-string<Data> $target
*/
public function __construct(public string $target)
{
}
/**
* @param array<int, array<string, mixed>> $value
* @param array<string, mixed> $context
* @return Collection<int, Data>
*/
public function cast(DataProperty $property, mixed $value, array $context): mixed
{
return collect($value)->map(fn ($item) => $this->target::from($item));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Form\Casts;
use App\Form\Data\FieldCollection;
use App\Form\Fields\Field;
use Spatie\LaravelData\Casts\Cast;
use Spatie\LaravelData\Support\DataProperty;
class FieldCollectionCast implements Cast
{
/**
* @param array<int, array<string, mixed>> $value
* @param array<string, mixed> $context
* @return FieldCollection
*/
public function cast(DataProperty $property, mixed $value, array $context): mixed
{
return new FieldCollection(collect($value)->map(fn ($value) => Field::classFromType($value['type'])::from($value))->all());
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Form\Data;
use Spatie\LaravelData\Data;
class ColumnData extends Data
{
public function __construct(
public int $mobile,
public int $tablet,
public int $desktop,
) {
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Form\Data;
use App\Form\Enums\SpecialType;
use App\Form\Fields\Field;
use App\Form\Fields\NamiField;
use App\Form\Models\Form;
use Illuminate\Support\Collection;
use stdClass;
/**
* @extends Collection<int, Field>
*/
class FieldCollection extends Collection
{
public function forMembers(): self
{
return $this->filter(fn ($field) => $field->forMembers === true);
}
public function withNamiType(): self
{
return $this->filter(fn ($field) => $field->namiType !== null);
}
public function noNamiType(): self
{
return $this->filter(fn ($field) => $field->namiType === null);
}
public function noNamiField(): self
{
return $this->filter(fn ($field) => !is_a($field, NamiField::class));
}
/**
* @return stdClass
*/
public function getMailRecipient(): ?stdClass
{
$email = $this->findBySpecialType(SpecialType::EMAIL)?->value;
return $this->getFullname() && $email
? (object) [
'name' => $this->getFullname(),
"email" => $email,
] : null;
}
public function getFullname(): ?string
{
$firstname = $this->findBySpecialType(SpecialType::FIRSTNAME)?->value;
$lastname = $this->findBySpecialType(SpecialType::LASTNAME)?->value;
return $firstname && $lastname ? "$firstname $lastname" : null;
}
/**
* @param array<string, mixed> $input
*/
public static function fromRequest(Form $form, array $input): self
{
return $form->getFields()->each(fn ($field) => $field->value = array_key_exists($field->key, $input) ? $input[$field->key] : $field->default());
}
public function find(Field $givenField): ?Field
{
return $this->first(fn ($field) => $field->key === $givenField->key);
}
/**
* @return array<string, mixed>
*/
public function present(): array
{
$attributes = collect([]);
foreach ($this as $field) {
$attributes = $attributes->merge($field->present());
}
return $attributes->toArray();
}
private function findBySpecialType(SpecialType $specialType): ?Field
{
return $this->first(fn ($field) => $field->specialType === $specialType);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Form\Data;
use App\Form\Casts\CollectionCast;
use App\Form\Transformers\CollectionTransformer;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Attributes\WithTransformer;
class FormConfigData extends Data
{
/**
* @param Collection<int, SectionData> $sections
*/
public function __construct(
#[WithCast(CollectionCast::class, target: SectionData::class)]
#[WithTransformer(CollectionTransformer::class, target: SectionData::class)]
public Collection $sections
) {
}
public function fields(): FieldCollection
{
return $this->sections->reduce(
fn ($carry, $current) => $carry->merge($current->fields->all()),
new FieldCollection([])
);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Form\Data;
use App\Form\Casts\FieldCollectionCast;
use Spatie\LaravelData\Data;
use App\Form\Transformers\FieldCollectionTransformer;
use Spatie\LaravelData\Attributes\WithCast;
use Spatie\LaravelData\Attributes\WithTransformer;
class SectionData extends Data
{
public function __construct(
public string $name,
#[WithCast(FieldCollectionCast::class)]
#[WithTransformer(FieldCollectionTransformer::class)]
public FieldCollection $fields
) {
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Form\Enums;
use App\Group\Enums\Level;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
enum NamiType: string
{
case FIRSTNAME = 'Vorname';
case LASTNAME = 'Nachname';
case BIRTHDAY = 'Geburtstag';
case REGION = 'Bezirk';
case STAMM = 'Stamm';
case EMAIL = 'E-Mail-Adresse';
/**
* @return array<int, array{name: string, id: string}>
*/
public static function forSelect(): array
{
return collect(static::cases())
->map(fn ($case) => ['id' => $case->value, 'name' => $case->value])
->toArray();
}
public function getMemberAttribute(Member $member): ?string
{
return match ($this) {
static::FIRSTNAME => $member->firstname,
static::LASTNAME => $member->lastname,
static::BIRTHDAY => $member->birthday?->format('Y-m-d'),
static::REGION => $this->matchRegion($member),
static::STAMM => $this->matchGroup($member),
static::EMAIL => $member->email,
};
}
/**
* @param Builder<Member> $query
* @return Builder<Member>
*/
public function performQuery(Builder $query, mixed $value): Builder
{
return match ($this) {
static::FIRSTNAME => $query->where('firstname', $value),
static::LASTNAME => $query->where('lastname', $value),
static::BIRTHDAY => $query->where('birthday', $value),
static::REGION => $query,
static::STAMM => $query,
static::EMAIL => $query->where('email', $value),
};
}
protected function matchGroup(Member $member): ?int
{
if ($member->group->level === Level::GROUP) {
return $member->group_id;
}
return null;
}
protected function matchRegion(Member $member): ?int
{
if ($member->group->parent?->level === Level::REGION) {
return $member->group->parent->id;
}
if ($member->group->level === Level::REGION) {
return $member->group_id;
}
return null;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Form\Enums;
enum SpecialType: string
{
case FIRSTNAME = 'Vorname';
case LASTNAME = 'Nachname';
case EMAIL = 'E-Mail-Adresse';
/**
* @return array<int, array{name: string, id: string}>
*/
public static function forSelect(): array
{
return collect(static::cases())
->map(fn ($case) => ['id' => $case->value, 'name' => $case->value])
->toArray();
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Faker\Generator;
use Illuminate\Validation\Rule;
class CheckboxField extends Field
{
public bool $required;
public string $description;
public static function name(): string
{
return 'Checkbox';
}
public static function meta(): array
{
return [
['key' => 'description', 'default' => '', 'rules' => ['description' => 'required|string'], 'label' => 'Beschreibung'],
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
];
}
public static function default()
{
return false;
}
public static function fake(Generator $faker): array
{
return [
'description' => $faker->text(),
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [
$this->key => $this->required ? ['boolean', 'accepted'] : ['present', 'boolean'],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\EnumPresenter;
use App\Form\Presenters\Presenter;
use Faker\Generator;
use Illuminate\Validation\Rule;
class CheckboxesField extends Field
{
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Checkboxes';
}
public static function meta(): array
{
return [
['key' => 'options', 'default' => [], 'rules' => ['options' => 'array', 'options.*' => 'required|string'], 'label' => 'Optionen'],
];
}
public static function default()
{
return [];
}
public static function fake(Generator $faker): array
{
return [
'options' => $faker->words(4),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [
$this->key => 'array',
$this->key . '.*' => ['string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [
...collect($this->options)->mapWithKeys(fn ($option, $key) => [$this->key . '.' . $key => $this->name])->toArray(),
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
public function getPresenter(): Presenter
{
return app(EnumPresenter::class);
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Form\Fields;
use App\Form\Contracts\Displayable;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\DatePresenter;
use App\Form\Presenters\Presenter;
use Carbon\Carbon;
use Faker\Generator;
class DateField extends Field
{
public bool $required;
public bool $maxToday;
public static function name(): string
{
return 'Datum';
}
public static function meta(): array
{
return [
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
['key' => 'max_today', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Nur daten bis heute erlauben'],
];
}
public static function default(): ?string
{
return null;
}
public static function fake(Generator $faker): array
{
return [
'required' => $faker->boolean(),
'max_today' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): 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(Form $form): array
{
return [
$this->key => $this->name,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [
$this->key . '.before_or_equal' => $this->name . ' muss ein Datum vor oder gleich dem ' . now()->format('d.m.Y') . ' sein.',
];
}
public function getPresenter(): Presenter
{
return app(DatePresenter::class);
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Faker\Generator;
use Illuminate\Validation\Rule;
class DropdownField extends Field
{
public bool $required;
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Dropdown';
}
public static function meta(): array
{
return [
['key' => 'options', 'default' => [], 'rules' => ['options' => 'present|array', 'options.*' => 'string'], 'label' => 'Optionen'],
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
];
}
public static function default()
{
return null;
}
public static function fake(Generator $faker): array
{
return [
'options' => $faker->words(4),
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [
$this->key => $this->required ? ['required', 'string', Rule::in($this->options)] : ['nullable', 'string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

167
app/Form/Fields/Field.php Normal file
View File

@ -0,0 +1,167 @@
<?php
namespace App\Form\Fields;
use App\Form\Data\ColumnData;
use App\Form\Enums\NamiType;
use App\Form\Enums\SpecialType;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\DefaultPresenter;
use App\Form\Presenters\Presenter;
use Faker\Generator;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
abstract class Field extends Data
{
public string $key;
public string $name;
public ?NamiType $namiType = null;
public ColumnData $columns;
public bool $forMembers;
public ?SpecialType $specialType = null;
/** @var mixed */
public $value;
/**
* @param array<array-key, mixed> $input
*/
abstract public function afterRegistration(Form $form, Participant $participant, array $input): void;
abstract public static function name(): string;
/** @return array<int, array{key: string, default: mixed, label: string, rules: array<string, mixed>}> */
abstract public static function meta(): array;
/** @return mixed */
abstract public static function default();
/** @return array<string, mixed> */
abstract public function getRegistrationRules(Form $form): array;
/** @return array<string, mixed> */
abstract public function getRegistrationAttributes(Form $form): array;
/** @return array<string, mixed> */
abstract public function getRegistrationMessages(Form $form): array;
/** @return array<string, mixed> */
abstract public static function fake(Generator $faker): array;
/**
* @return array<int, array<string, mixed>>
*/
public static function asMeta(): array
{
return array_map(fn ($class) => $class::allMeta(), self::classNames());
}
/**
* @return array<int, class-string<self>>
*/
private static function classNames(): array
{
return collect(glob(base_path('app/Form/Fields/*.php')))
->filter(fn ($fieldClass) => preg_match('/[A-Za-z]Field\.php$/', $fieldClass) === 1)
->map(fn ($fieldClass) => str($fieldClass)->replace(base_path(''), '')->replace('/app', '/App')->replace('.php', '')->replace('/', '\\')->toString())
->values()
->toArray();
}
public static function classFromType(string $type): ?string
{
/** @var class-string<Field> */
$fieldClass = '\\App\\Form\\Fields\\' . $type;
if (!class_exists($fieldClass)) {
return null;
}
return $fieldClass;
}
/**
* @return mixed
*/
public function present()
{
return [
$this->key => $this->value,
$this->getDisplayAttribute() => $this->presentRaw(),
];
}
/**
* @return mixed
*/
public function presentRaw()
{
return $this->getPresenter()->present($this->value);
}
/**
* @return array<string, string>
*/
public static function metaAttributes(): array
{
return collect(static::meta())->mapWithKeys(fn ($meta) => [$meta['key'] => $meta['label']])->toArray();
}
/**
* @return array<string, mixed>
**/
public static function metaRules(): array
{
$result = [];
foreach (static::meta() as $meta) {
foreach ($meta['rules'] as $fieldName => $rules) {
$result[$fieldName] = $rules;
}
}
return $result;
}
public static function type(): string
{
return class_basename(static::class);
}
/**
* @return array<string, mixed>
*/
public static function allMeta(): array
{
return [
'id' => static::type(),
'name' => static::name(),
'default' => [
'name' => '',
'type' => static::type(),
'columns' => ['mobile' => 2, 'tablet' => 4, 'desktop' => 6],
'value' => static::default(),
'required' => false,
'nami_type' => null,
'for_members' => true,
'special_type' => null,
...collect(static::meta())->mapWithKeys(fn ($meta) => [$meta['key'] => $meta['default']])->toArray(),
],
];
}
public function getPresenter(): Presenter
{
return app(DefaultPresenter::class);
}
public function getDisplayAttribute(): string
{
return $this->key . '_display';
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\GroupPresenter;
use App\Form\Presenters\Presenter;
use App\Group;
use App\Group\Enums\Level;
use Faker\Generator;
use Illuminate\Validation\Rule;
class GroupField extends Field
{
public bool $required;
public ?string $parentField = null;
public ?int $parentGroup = null;
public static function name(): string
{
return 'Gruppierungs-Auswahl';
}
public static function meta(): array
{
return [
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
['key' => 'parent_field', 'default' => null, 'rules' => ['parent_field' => 'present|nullable|string'], 'label' => 'Übergeordnetes Feld'],
['key' => 'parent_group', 'default' => null, 'rules' => ['parent_group' => ['present', 'nullable', Rule::in(Group::pluck('id')->toArray())]], 'label' => 'Übergeordnete Gruppierung'],
];
}
public static function default(): string
{
return '';
}
public static function fake(Generator $faker): array
{
return [
'required' => $faker->boolean(),
'parent_field' => null,
'parent_group' => null,
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): 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(Form $form): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
public function getPresenter(): Presenter
{
return app(GroupPresenter::class);
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Form\Fields;
use App\Form\Data\FieldCollection;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\NamiPresenter;
use App\Form\Presenters\Presenter;
use App\Member\Member;
use Faker\Generator;
class NamiField extends Field
{
public static function name(): string
{
return 'NaMi-Mitglieder';
}
public static function meta(): array
{
return [];
}
/**
* @return array<string, mixed>
*/
public static function default(): array
{
return [];
}
public static function fake(Generator $faker): array
{
return [];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
$rules = [$this->key => 'present|array'];
$c = $form->getFields()->forMembers()->noNamiType()->noNamiField()
->map(fn ($field) => $field->getRegistrationRules($form))
->toArray();
foreach ($c as $field) {
foreach ($field as $ruleKey => $rule) {
$rules[$this->key . '.*.' . $ruleKey] = $rule;
}
}
return [
$this->key . '.*.id' => ['required', 'numeric', 'exists:members,mitgliedsnr'],
...$rules,
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
$rules = [];
$inputMembers = request($this->key);
if (!is_array($inputMembers)) {
return [];
}
$c = $form->getFields()->noNamiField()->forMembers();
foreach ($c as $field) {
foreach ($field->getRegistrationRules($form) as $ruleKey => $rule) {
foreach ($inputMembers as $memberIndex => $inputMember) {
$message = $field->name . ' für ein Mitglied';
$rules = array_merge(
$rules,
str($ruleKey)->contains('*')
? collect(request($this->key . '.' . $memberIndex . '.' . $field->key))
->mapWithKeys(fn ($value, $key) => [$this->key . '.' . $memberIndex . '.' . str($ruleKey)->replace('*', $key) => $message])
->toArray()
: [$this->key . '.' . $memberIndex . '.' . $ruleKey => $message]
);
}
}
}
foreach ($inputMembers as $memberIndex => $inputMember) {
$rules[$this->key . '.' . $memberIndex . '.id'] = 'Mitglied Nr ' . $inputMember['id'];
}
return [
$this->key => $this->name,
...$rules,
];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
foreach ($input[$this->key] as $memberData) {
$member = Member::firstWhere(['mitgliedsnr' => $memberData['id']]);
$data = [];
foreach (FieldCollection::fromRequest($form, $memberData) as $field) {
$data[$field->key] = $field->namiType === null
? $field->value
: $field->namiType->getMemberAttribute($member);
}
$data[$this->key] = [];
$form->participants()->create(['data' => $data, 'mitgliedsnr' => $memberData['id'], 'parent_id' => $participant->id]);
}
}
public function getPresenter(): Presenter
{
return app(NamiPresenter::class);
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Presenters\Presenter;
use Faker\Generator;
use Illuminate\Validation\Rule;
class RadioField extends Field
{
public bool $required;
/** @var array<int, string> */
public array $options;
public static function name(): string
{
return 'Radio';
}
public static function meta(): array
{
return [
['key' => 'options', 'default' => [], 'rules' => ['options' => 'present|array', 'options.*' => 'required|string'], 'label' => 'Optionen'],
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
];
}
public static function default()
{
return null;
}
public static function fake(Generator $faker): array
{
return [
'options' => $faker->words(4),
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [
$this->key => $this->required ? ['required', 'string', Rule::in($this->options)] : ['nullable', 'string', Rule::in($this->options)],
];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Faker\Generator;
class TextField extends Field
{
public bool $required;
public static function name(): string
{
return 'Text';
}
public static function meta(): array
{
return [
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
];
}
public static function default(): string
{
return '';
}
public static function fake(Generator $faker): array
{
return [
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [$this->key => $this->required ? ['required', 'string'] : ['nullable', 'string']];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Form\Fields;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Faker\Generator;
class TextareaField extends Field
{
public bool $required;
public int $rows;
public static function name(): string
{
return 'Textarea';
}
public static function meta(): array
{
return [
['key' => 'rows', 'default' => 5, 'rules' => ['rows' => 'present|integer|gt:0'], 'label' => 'Zeilen'],
['key' => 'required', 'default' => false, 'rules' => ['required' => 'present|boolean'], 'label' => 'Erforderlich'],
];
}
public static function default(): string
{
return '';
}
public static function fake(Generator $faker): array
{
return [
'rows' => $faker->numberBetween(5, 10),
'required' => $faker->boolean(),
];
}
/**
* @inheritdoc
*/
public function getRegistrationRules(Form $form): array
{
return [$this->key => $this->required ? ['required', 'string'] : ['nullable', 'string']];
}
/**
* @inheritdoc
*/
public function getRegistrationAttributes(Form $form): array
{
return [$this->key => $this->name];
}
/**
* @inheritdoc
*/
public function getRegistrationMessages(Form $form): array
{
return [];
}
/**
* @inheritdoc
*/
public function afterRegistration(Form $form, Participant $participant, array $input): void
{
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Form\Mails;
use App\Form\Data\FormConfigData;
use App\Form\Models\Participant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ConfirmRegistrationMail extends Mailable
{
use Queueable, SerializesModels;
public string $fullname;
public FormConfigData $config;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public Participant $participant)
{
$this->fullname = $participant->getFields()->getFullname();
$this->config = $participant->getConfig();
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: 'Deine Anmeldung zu ' . $this->participant->form->name,
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
return new Content(
markdown: 'mail.form.confirm-registration',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, mixed>
*/
public function attachments()
{
return [];
}
}

144
app/Form/Models/Form.php Normal file
View File

@ -0,0 +1,144 @@
<?php
namespace App\Form\Models;
use App\Form\Data\FieldCollection;
use App\Form\Data\FormConfigData;
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\Arr;
use Illuminate\Support\Collection;
use Laravel\Scout\Searchable;
use Spatie\Image\Manipulations;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Zoomyboy\MedialibraryHelper\DefersUploads;
class Form extends Model implements HasMedia
{
use HasFactory;
use Sluggable;
use InteractsWithMedia;
use DefersUploads;
use Searchable;
public $guarded = [];
public $casts = [
'config' => FormConfigData::class,
'meta' => 'json',
'description' => 'json',
];
/** @var array<int, string> */
public $dates = ['from', 'to', 'registration_from', 'registration_until'];
/**
* @return SluggableConfig
*/
public function sluggable(): array
{
return [
'slug' => ['source' => ['name']],
];
}
/**
* @return HasMany<Participant>
*/
public function participants(): HasMany
{
return $this->hasMany(Participant::class);
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('headerImage')
->singleFile()
->maxWidth(fn () => 500)
->forceFileName(fn (Form $model, string $name) => $model->slug)
->registerMediaConversions(function (Media $media) {
$this->addMediaConversion('square')->fit(Manipulations::FIT_CROP, 400, 400);
});
}
/**
* @return array<string, mixed>
*/
public function getRegistrationRules(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
...$carry,
...$field->getRegistrationRules($this),
], []);
}
/**
* @return array<string, mixed>
*/
public function getRegistrationMessages(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
...$carry,
...$field->getRegistrationMessages($this),
], []);
}
/**
* @return array<string, mixed>
*/
public function getRegistrationAttributes(): array
{
return $this->getFields()->reduce(fn ($carry, $field) => [
...$carry,
...$field->getRegistrationAttributes($this),
], []);
}
public function getFields(): FieldCollection
{
return $this->config->fields();
}
// --------------------------------- Searching ---------------------------------
// *****************************************************************************
/**
* Get the indexable data array for the model.
*
* @return array<string, mixed>
*/
public function toSearchableArray()
{
return [
'from' => $this->from->timestamp,
'to' => $this->to->timestamp,
'name' => $this->name,
];
}
public static function booted(): void
{
static::saving(function (self $model) {
if (is_null($model->meta)) {
$model->setAttribute('meta', [
'active_columns' => $model->getFields()->count() ? $model->getFields()->take(4)->pluck('key')->toArray() : null,
'sorting' => $model->getFields()->count() ? [$model->getFields()->first()->key, 'asc'] : null,
]);
}
if (is_array(data_get($model->meta, 'active_columns'))) {
$model->setAttribute('meta', [
...$model->meta,
'active_columns' => array_values(array_intersect($model->getFields()->pluck('key')->toArray(), $model->meta['active_columns'])),
]);
}
});
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Form\Models;
use App\Form\Data\FormConfigData;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property FormConfigData $config
*/
class Formtemplate extends Model
{
use HasFactory;
public $guarded = [];
public $casts = [
'config' => FormConfigData::class,
];
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Form\Models;
use App\Form\Data\FieldCollection;
use App\Form\Data\FormConfigData;
use App\Form\Mails\ConfirmRegistrationMail;
use App\Form\Scopes\ParticipantFilterScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Mail;
class Participant extends Model
{
use HasFactory;
public $guarded = [];
public $casts = [
'data' => 'json',
];
/**
* @return BelongsTo<Form, self>
*/
public function form(): BelongsTo
{
return $this->belongsTo(Form::class);
}
/**
* @return HasMany<self>
*/
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeWithFilter(Builder $query, ParticipantFilterScope $filter): Builder
{
return $filter->apply($query);
}
public function getFields(): FieldCollection
{
return FieldCollection::fromRequest($this->form, $this->data);
}
public function getConfig(): FormConfigData
{
return tap($this->form->config, function ($config) {
$config->sections->each(function ($section) {
$section->fields->each(function ($field) {
$field->value = $this->getFields()->find($field)->value;
});
});
});
}
public function sendConfirmationMail(): void
{
if (!$this->getFields()->getMailRecipient()) {
return;
}
Mail::to($this->getFields()->getMailRecipient())->queue(new ConfirmRegistrationMail($this));
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Form\Policies;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Spatie\MediaLibrary\HasMedia;
class FormPolicy
{
use HandlesAuthorization;
public function listMedia(User $user, HasMedia $model): bool
{
return true;
}
public function storeMedia(User $user, ?HasMedia $model, ?string $collection = null): bool
{
return true;
}
public function updateMedia(User $user, HasMedia $model, string $collection): bool
{
return true;
}
public function destroyMedia(User $user, HasMedia $model, string $collection): bool
{
return true;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Form\Presenters;
use Carbon\Carbon;
class DatePresenter extends Presenter
{
public function present($value): string
{
return $value ? Carbon::parse($value)->format('d.m.Y') : '';
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Form\Presenters;
class DefaultPresenter extends Presenter
{
/**
* @param mixed $value
*/
public function present($value): string
{
return ((string) $value) ?: '';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Form\Presenters;
class EnumPresenter extends Presenter
{
/**
* @param ?array<int, string> $value
*/
public function present($value): string
{
return is_array($value)
? implode(', ', $value)
: '';
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Form\Presenters;
use App\Group;
use Carbon\Carbon;
class GroupPresenter extends Presenter
{
/**
* @param ?int $value
*/
public function present($value): string
{
if (!$value) {
return '';
}
return Group::find($value)?->display() ?: '';
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Form\Presenters;
class NamiPresenter extends Presenter
{
/**
* @param ?array<int, array{id: int}> $value
*/
public function present($value): string
{
return collect(array_column($value, 'id'))->implode(', ');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Form\Presenters;
abstract class Presenter
{
/** @var mixed */
public $value;
/**
* @param mixed $value
*/
abstract public function present($value): string;
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Form\Resources;
use App\Form\Models\Form;
use App\Lib\HasMeta;
use App\Subactivity;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Form
*/
class FormApiResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'from_human' => $this->from->format('d.m.Y'),
'to_human' => $this->to->format('d.m.Y'),
'excerpt' => $this->excerpt,
'description' => $this->description,
'config' => $this->config,
'slug' => $this->slug,
'dates' => $this->from->equalTo($this->to) ? $this->from->format('d.m.Y') : $this->from->format('d.m.Y') . ' - ' . $this->to->format('d.m.Y'),
'image' => $this->getMedia('headerImage')->first()->getFullUrl('square'),
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'base_url' => url(''),
'agegroups' => Subactivity::remote()->where('is_age_group', true)->get()
->map(fn ($subactivity) => ['id' => $subactivity->nami_id, 'name' => $subactivity->name]),
];
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Form\Resources;
use App\Form\Enums\NamiType;
use App\Form\Enums\SpecialType;
use App\Form\Fields\Field;
use App\Form\Scopes\FormFilterScope;
use App\Form\Models\Form;
use App\Form\Models\Formtemplate;
use App\Group;
use App\Lib\HasMeta;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Form
*/
class FormResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'from_human' => $this->from->format('d.m.Y'),
'to_human' => $this->to->format('d.m.Y'),
'from' => $this->from->format('Y-m-d'),
'to' => $this->to->format('Y-m-d'),
'excerpt' => $this->excerpt,
'description' => $this->description,
'mail_top' => $this->mail_top,
'mail_bottom' => $this->mail_bottom,
'registration_from' => $this->registration_from?->format('Y-m-d H:i:s'),
'registration_until' => $this->registration_until?->format('Y-m-d H:i:s'),
'config' => $this->config,
'participants_count' => $this->participants_count,
'links' => [
'participant_index' => route('form.participant.index', ['form' => $this->getModel()]),
'update' => route('form.update', ['form' => $this->getModel()]),
'destroy' => route('form.destroy', ['form' => $this->getModel()]),
]
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'base_url' => url(''),
'groups' => Group::forSelect(),
'fields' => Field::asMeta(),
'filter' => FormFilterScope::fromRequest(request()->input('filter', '')),
'links' => [
'store' => route('form.store'),
'formtemplate_index' => route('formtemplate.index'),
],
'templates' => FormtemplateResource::collection(Formtemplate::get()),
'namiTypes' => NamiType::forSelect(),
'specialTypes' => SpecialType::forSelect(),
'default' => [
'description' => [],
'name' => '',
'excerpt' => '',
'from' => null,
'to' => null,
'registration_from' => null,
'registration_until' => null,
'mail_top' => null,
'mail_bottom' => null,
'config' => null,
'header_image' => null,
'id' => null,
],
'section_default' => [
'name' => '',
'intro' => '',
'fields' => [],
]
];
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Form\Resources;
use App\Form\Enums\NamiType;
use App\Form\Enums\SpecialType;
use App\Form\Fields\Field;
use App\Form\Models\Formtemplate;
use App\Group;
use App\Lib\HasMeta;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Formtemplate
*/
class FormtemplateResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
...parent::toArray($request),
'links' => [
'update' => route('formtemplate.update', ['formtemplate' => $this->getModel()]),
'destroy' => route('formtemplate.destroy', ['formtemplate' => $this->getModel()]),
]
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'base_url' => url(''),
'groups' => Group::forSelect(),
'fields' => Field::asMeta(),
'namiTypes' => NamiType::forSelect(),
'specialTypes' => SpecialType::forSelect(),
'links' => [
'store' => route('formtemplate.store'),
'form_index' => route('form.index'),
],
'default' => [
'name' => '',
'config' => [
'sections' => [],
]
],
'section_default' => [
'name' => '',
'intro' => '',
'fields' => [],
]
];
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Form\Resources;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Participant
*/
class ParticipantResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return $this->getModel()->getFields()->present();
}
/**
* @return array<string, mixed>
*/
public static function meta(Form $form): array
{
return [
'form_meta' => $form->meta,
'links' => [
'update_form_meta' => route('form.update-meta', ['form' => $form]),
],
'columns' => $form->getFields()
->map(fn ($field) => [
'name' => $field->name,
'base_type' => class_basename($field),
'id' => $field->key,
'display_attribute' => $field->getDisplayAttribute(),
])
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Form\Scopes;
use Laravel\Scout\Builder;
use App\Form\Models\Form;
use App\Lib\ScoutFilter;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/**
* @extends ScoutFilter<Form>
*/
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class FormFilterScope extends ScoutFilter
{
public function __construct(
public ?string $search = '',
public bool $past = false,
) {
}
public function getQuery(): Builder
{
$this->search = $this->search ?: '';
return Form::search($this->search, function ($engine, string $query, array $options) {
$options['sort'] = ['from:asc'];
$filters = collect([]);
if ($this->past === false) {
$filters->push('to > ' . now()->timestamp);
}
$options['filter'] = $filters->implode(' AND ');
return $engine->search($query, $options);
});
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Form\Scopes;
use App\Form\Models\Participant;
use App\Lib\Filter;
use Illuminate\Database\Eloquent\Builder;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/**
* @extends Filter<Participant>
*/
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class ParticipantFilterScope extends Filter
{
public function __construct(
public ?int $parent = null,
) {
}
/**
* @inheritdoc
*/
public function apply(Builder $query): Builder
{
if ($this->parent === -1) {
$query = $query->whereNull('parent_id');
}
if (!is_null($this->parent) && $this->parent > 0) {
$query = $query->where('parent_id', $this->parent);
}
return $query;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Form\Transformers;
use App\Form\Fields\Field;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Support\DataProperty;
use Spatie\LaravelData\Transformers\Transformer;
class CollectionTransformer implements Transformer
{
public function __construct(public string $target)
{
}
/**
* @param Collection<int, Field> $value
* @return array<string, mixed>
*/
public function transform(DataProperty $property, mixed $value): mixed
{
return $value->toArray();
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Form\Transformers;
use App\Form\Fields\Field;
use Illuminate\Support\Collection;
use Spatie\LaravelData\Support\DataProperty;
use Spatie\LaravelData\Transformers\Transformer;
class FieldCollectionTransformer implements Transformer
{
/**
* @param Collection<int, Field> $value
* @return array<string, mixed>
*/
public function transform(DataProperty $property, mixed $value): mixed
{
return $value->map(fn ($field) => [
...$field->toArray(),
'type' => class_basename($field),
])->toArray();
}
}

View File

@ -45,4 +45,25 @@ class Group extends Model
}
});
}
/**
* @return array<int, array{id: int, name: string}>
*/
public static function forSelect(?self $parent = null, string $prefix = ''): array
{
$result = self::where('parent_id', $parent ? $parent->id : null)->withCount('children')->get();
return $result
->reduce(
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

@ -18,9 +18,9 @@ class NamiSearchAction
*
* @return LengthAwarePaginator<MemberEntry>
*/
public function handle(Api $api, int $page, array $params): LengthAwarePaginator
public function handle(Api $api, int $page, array $params, int $perPage = 10): LengthAwarePaginator
{
return $api->pageSearch($params, $page, 10);
return $api->pageSearch($params, $page, $perPage);
}
/**

View File

@ -2,8 +2,8 @@
namespace App\Lib;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Builder;
use Spatie\LaravelData\Data;
/**
@ -14,10 +14,10 @@ abstract class Filter extends Data
{
/**
* @return Builder
* @param Builder<T> $query
* @return Builder<T>
*/
abstract public function getQuery(): Builder;
protected Builder $query;
abstract public function apply(Builder $query): Builder;
/**
* @param array<string, mixed>|string|null $request

41
app/Lib/ScoutFilter.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Lib;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Builder;
use Spatie\LaravelData\Data;
/**
* @template T of Model
* @property Builder $query
*/
abstract class ScoutFilter extends Data
{
/**
* @return Builder
*/
abstract public function getQuery(): Builder;
protected Builder $query;
/**
* @param array<string, mixed>|string|null $request
*/
public static function fromRequest(array|string|null $request = null): static
{
$payload = is_string($request)
? json_decode(rawurldecode(base64_decode($request)), true)
: $request;
return static::fromPost($payload);
}
/**
* @param array<string, mixed> $post
*/
public static function fromPost(?array $post = null): static
{
return static::withoutMagicalCreationFrom($post ?: []);
}
}

View File

@ -5,8 +5,8 @@ namespace App\Member;
use App\Activity;
use App\Group;
use App\Invoice\BillKind;
use App\Lib\Filter;
use App\Subactivity;
use App\Lib\ScoutFilter;
use Illuminate\Support\Collection;
use Laravel\Scout\Builder;
use Spatie\LaravelData\Attributes\MapInputName;
@ -14,11 +14,11 @@ use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/**
* @extends Filter<Member>
* @extends ScoutFilter<Member>
*/
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class FilterScope extends Filter
class FilterScope extends ScoutFilter
{
/**
* @param array<int, int> $activityIds

View File

@ -93,7 +93,7 @@ class Member extends Model implements Geolocatable
}
/**
* @return array<string, array{source: array<int, string>}>
* @return SluggableConfig
*/
public function sluggable(): array
{

View File

@ -7,12 +7,14 @@ enum Module: string
case BILL = 'bill';
case COURSE = 'course';
case EVENT = 'event';
public function title(): string
{
return match ($this) {
static::BILL => 'Zahlungs-Management',
static::COURSE => 'Ausbildung',
static::EVENT => 'Veranstaltungen',
};
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Form\Models\Form;
use App\Mailgateway\Types\LocalType;
use App\Mailgateway\Types\MailmanType;
use Illuminate\Http\RedirectResponse;
@ -33,6 +34,8 @@ class AppServiceProvider extends ServiceProvider
LocalType::class,
MailmanType::class,
]));
app()->extend('media-library-helpers', fn ($p) => $p->put('form', Form::class));
}
/**

View File

@ -2,6 +2,8 @@
namespace App\Providers;
use App\Form\Models\Form;
use App\Form\Policies\FormPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Laravel\Passport\Passport;
@ -10,10 +12,10 @@ class AuthServiceProvider extends ServiceProvider
/**
* The policy mappings for the application.
*
* @var array
* @var array<class-string, class-string>
*/
protected $policies = [
// 'App\Model' => 'App\Policies\ModelPolicy',
Form::class => FormPolicy::class,
];
/**

View File

@ -57,5 +57,8 @@ class RouteServiceProvider extends ServiceProvider
Route::middleware('api')
->prefix('api')
->group(base_path('routes/api.php'));
Route::middleware('api')
->prefix('remote')
->group(base_path('routes/remote.php'));
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Remote\Actions;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Crypt;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\LaravelNami\Nami;
class LoginAction
{
use AsAction;
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'mglnr' => 'required|numeric',
'password' => 'required|string',
];
}
public function handle(ActionRequest $request): JsonResponse
{
Nami::freshLogin($request->mglnr, $request->password);
return response()->json([
'access_token' => Crypt::encryptString(json_encode(['mglnr' => $request->mglnr, 'password' => $request->password])),
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Remote\Actions;
use App\Initialize\Actions\NamiSearchAction;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Crypt;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\LaravelNami\Nami;
class SearchAction
{
use AsAction;
/**
* @return LengthAwarePaginator<array<string, mixed>>
*/
public function handle(ActionRequest $request): LengthAwarePaginator
{
$token = str($request->header('Authorization'))->replace('Bearer ', '')->toString();
$credentials = json_decode(Crypt::decryptString($token));
$api = Nami::login($credentials->mglnr, $credentials->password);
$results = NamiSearchAction::run($api, $request->input('page', 1), $request->except('page'), 50);
$results->transform(fn ($member) => ['id' => $member->memberId, 'name' => $member->firstname . ' ' . $member->lastname]);
return $results;
}
}

View File

@ -20,6 +20,13 @@
"options": {
"symlink": true
}
},
{
"type": "path",
"url": "./packages/medialibrary-helper",
"options": {
"symlink": true
}
}
],
"license": "MIT",
@ -53,12 +60,12 @@
"zoomyboy/laravel-nami": "dev-master",
"zoomyboy/osm": "1.0.3",
"zoomyboy/phone": "^1.0",
"zoomyboy/tex": "dev-main as 1.0"
"zoomyboy/tex": "dev-main as 1.0",
"zoomyboy/medialibrary-helper": "dev-master as 1.0"
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"laravel/envoy": "^2.8",
"laravel/sail": "^1.0.1",
"mockery/mockery": "^1.4.4",
"larastan/larastan": "^2.0",
"orchestra/testbench": "^7.0",

2983
composer.lock generated

File diff suppressed because it is too large Load Diff

267
config/media-library.php Normal file
View File

@ -0,0 +1,267 @@
<?php
return [
/*
* The disk on which to store added files and derived images by default. Choose
* one or more of the disks you've configured in config/filesystems.php.
*/
'disk_name' => env('MEDIA_DISK', 'public'),
/*
* The maximum file size of an item in bytes.
* Adding a larger file will result in an exception.
*/
'max_file_size' => 1024 * 1024 * 10, // 10MB
/*
* This queue connection will be used to generate derived and responsive images.
* Leave empty to use the default queue connection.
*/
'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'),
/*
* This queue will be used to generate derived and responsive images.
* Leave empty to use the default queue.
*/
'queue_name' => '',
/*
* By default all conversions will be performed on a queue.
*/
'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true),
/*
* The fully qualified class name of the media model.
*/
'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
/*
* When enabled, media collections will be serialised using the default
* laravel model serialization behaviour.
*
* Keep this option disabled if using Media Library Pro components (https://medialibrary.pro)
*/
'use_default_collection_serialization' => false,
/*
* The fully qualified class name of the model used for temporary uploads.
*
* This model is only used in Media Library Pro (https://medialibrary.pro)
*/
'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class,
/*
* When enabled, Media Library Pro will only process temporary uploads that were uploaded
* in the same session. You can opt to disable this for stateless usage of
* the pro components.
*/
'enable_temporary_uploads_session_affinity' => true,
/*
* When enabled, Media Library pro will generate thumbnails for uploaded file.
*/
'generate_thumbnails_for_temporary_uploads' => true,
/*
* This is the class that is responsible for naming generated files.
*/
'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class,
/*
* The class that contains the strategy for determining a media file's path.
*/
'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class,
/*
* The class that contains the strategy for determining how to remove files.
*/
'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class,
/*
* Here you can specify which path generator should be used for the given class.
*/
'custom_path_generators' => [
// Model::class => PathGenerator::class
// or
// 'model_morph_alias' => PathGenerator::class
],
/*
* When urls to files get generated, this class will be called. Use the default
* if your files are stored locally above the site root or on s3.
*/
'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class,
/*
* Moves media on updating to keep path consistent. Enable it only with a custom
* PathGenerator that uses, for example, the media UUID.
*/
'moves_media_on_update' => false,
/*
* Whether to activate versioning when urls to files get generated.
* When activated, this attaches a ?v=xx query string to the URL.
*/
'version_urls' => false,
/*
* The media library will try to optimize all converted images by removing
* metadata and applying a little bit of compression. These are
* the optimizers that will be used by default.
*/
'image_optimizers' => [
Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [
'-m85', // set maximum quality to 85%
'--force', // ensure that progressive generation is always done also if a little bigger
'--strip-all', // this strips out all text information such as comments and EXIF data
'--all-progressive', // this will make sure the resulting image is a progressive one
],
Spatie\ImageOptimizer\Optimizers\Pngquant::class => [
'--force', // required parameter for this package
],
Spatie\ImageOptimizer\Optimizers\Optipng::class => [
'-i0', // this will result in a non-interlaced, progressive scanned image
'-o2', // this set the optimization level to two (multiple IDAT compression trials)
'-quiet', // required parameter for this package
],
Spatie\ImageOptimizer\Optimizers\Svgo::class => [
'--disable=cleanupIDs', // disabling because it is known to cause troubles
],
Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [
'-b', // required parameter for this package
'-O3', // this produces the slowest but best results
],
Spatie\ImageOptimizer\Optimizers\Cwebp::class => [
'-m 6', // for the slowest compression method in order to get the best compression.
'-pass 10', // for maximizing the amount of analysis pass.
'-mt', // multithreading for some speed improvements.
'-q 90', //quality factor that brings the least noticeable changes.
],
Spatie\ImageOptimizer\Optimizers\Avifenc::class => [
'-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63).
'-j all', // number of jobs (worker threads, "all" uses all available cores).
'--min 0', // min quantizer for color (0-63).
'--max 63', // max quantizer for color (0-63).
'--minalpha 0', // min quantizer for alpha (0-63).
'--maxalpha 63', // max quantizer for alpha (0-63).
'-a end-usage=q', // rate control mode set to Constant Quality mode.
'-a tune=ssim', // SSIM as tune the encoder for distortion metric.
],
],
/*
* These generators will be used to create an image of media files.
*/
'image_generators' => [
Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class,
Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class,
],
/*
* The path where to store temporary files while performing image conversions.
* If set to null, storage_path('media-library/temp') will be used.
*/
'temporary_directory_path' => null,
/*
* The engine that should perform the image conversions.
* Should be either `gd` or `imagick`.
*/
'image_driver' => env('IMAGE_DRIVER', 'gd'),
/*
* FFMPEG & FFProbe binaries paths, only used if you try to generate video
* thumbnails and have installed the php-ffmpeg/php-ffmpeg composer
* dependency.
*/
'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'),
'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'),
/*
* Here you can override the class names of the jobs used by this package. Make sure
* your custom jobs extend the ones provided by the package.
*/
'jobs' => [
'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class,
'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class,
],
/*
* When using the addMediaFromUrl method you may want to replace the default downloader.
* This is particularly useful when the url of the image is behind a firewall and
* need to add additional flags, possibly using curl.
*/
'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class,
'remote' => [
/*
* Any extra headers that should be included when uploading media to
* a remote disk. Even though supported headers may vary between
* different drivers, a sensible default has been provided.
*
* Supported by S3: CacheControl, Expires, StorageClass,
* ServerSideEncryption, Metadata, ACL, ContentEncoding
*/
'extra_headers' => [
'CacheControl' => 'max-age=604800',
],
],
'responsive_images' => [
/*
* This class is responsible for calculating the target widths of the responsive
* images. By default we optimize for filesize and create variations that each are 30%
* smaller than the previous one. More info in the documentation.
*
* https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images
*/
'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class,
/*
* By default rendering media to a responsive image will add some javascript and a tiny placeholder.
* This ensures that the browser can already determine the correct layout.
*/
'use_tiny_placeholders' => true,
/*
* This class will generate the tiny placeholder used for progressive image loading. By default
* the media library will use a tiny blurred jpg image.
*/
'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class,
],
/*
* When enabling this option, a route will be registered that will enable
* the Media Library Pro Vue and React components to move uploaded files
* in a S3 bucket to their right place.
*/
'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false),
/*
* When converting Media instances to response the media library will add
* a `loading` attribute to the `img` tag. Here you can set the default
* value of that attribute.
*
* Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction.
*
* More info: https://css-tricks.com/native-lazy-loading/
*/
'default_loading_attribute_value' => null,
/*
* You can specify a prefix for that is used for storing all media.
* If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory.
*/
'prefix' => env('MEDIA_PREFIX', ''),
/*
* The temp disk for deferred media data.
* Only used by the medialibrary-helper package.
*/
'temp_disk' => 'temp',
];

View File

@ -1,5 +1,6 @@
<?php
use App\Form\Models\Form;
use App\Member\Member;
return [
@ -140,6 +141,12 @@ return [
'searchableAttributes' => ['fullname', 'address'],
'sortableAttributes' => ['lastname', 'firstname'],
'displayedAttributes' => ['age_group_icon', 'group_name', 'links', 'is_leader', 'lastname', 'firstname', 'fullname', 'address', 'ausstand', 'birthday', 'id', 'memberships', 'bill_kind', 'group_id'],
],
Form::class => [
'filterableAttributes' => ['to'],
'searchableAttributes' => ['name'],
'sortableAttributes' => ['from',],
'displayedAttributes' => ['from', 'name', 'id', 'to'],
]
],
],

View File

@ -0,0 +1,78 @@
<?php
namespace Database\Factories\Form\Models;
use App\Form\Models\Form;
use Database\Factories\Traits\FakesMedia;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Feature\Form\FormtemplateFieldRequest;
use Tests\Feature\Form\FormtemplateSectionRequest;
/**
* @extends Factory<Form>
* @method self name(string $name)
* @method self from(string $from)
* @method self to(string $to)
* @method self mailTop(string $content)
* @method self mailBottom(string $content)
* @method self excerpt(string $excerpt)
* @method self description(string $description)
* @method self registrationFrom(string|null $date)
* @method self registrationUntil(string|null $date)
*/
class FormFactory extends Factory
{
use FakesMedia;
/**
* The name of the factory's corresponding model.
*
* @var class-string<Form>
*/
protected $model = Form::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(4, true),
'description' => $this->faker->text(),
'excerpt' => $this->faker->words(10, true),
'config' => ['sections' => []],
'from' => $this->faker->dateTimeBetween('+1 week', '+4 weeks')->format('Y-m-d H:i:s'),
'to' => $this->faker->dateTimeBetween('+1 week', '+4 weeks')->format('Y-m-d H:i:s'),
'registration_from' => $this->faker->dateTimeBetween('-2 weeks', 'now')->format('Y-m-d H:i:s'),
'registration_until' => $this->faker->dateTimeBetween('now', '+2 weeks')->format('Y-m-d H:i:s'),
'mail_top' => $this->faker->text(),
'mail_bottom' => $this->faker->text(),
];
}
/**
* @param array<int, FormtemplateSectionRequest> $sections
*/
public function sections(array $sections): self
{
return $this->state(['config' => ['sections' => array_map(fn ($section) => $section->create(), $sections)]]);
}
/**
* @param array<int, FormtemplateFieldRequest> $fields
*/
public function fields(array $fields): self
{
return $this->sections([FormtemplateSectionRequest::new()->fields($fields)]);
}
/**
* @param mixed $parameters
*/
public function __call($method, $parameters): self
{
return $this->state([str($method)->snake()->toString() => $parameters[0]]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace Database\Factories\Form\Models;
use App\Form\Models\Formtemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Feature\Form\FormtemplateSectionRequest;
/**
* @extends Factory<Formtemplate>
* @method self name(string $name)
*/
class FormtemplateFactory extends Factory
{
public $model = Formtemplate::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(4, true),
'config' => [
'sections' => [],
],
];
}
/**
* @param array<int, FormtemplateSectionRequest> $sections
*/
public function sections(array $sections): self
{
return $this->state(['config' => ['sections' => array_map(fn ($section) => $section->create(), $sections)]]);
}
/**
* @param mixed $parameters
*/
public function __call($method, $parameters): self
{
return $this->state([str($method)->snake()->toString() => $parameters[0]]);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Database\Factories\Form\Models;
use App\Form\Models\Participant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Feature\Form\FormtemplateSectionRequest;
/**
* @extends Factory<Participant>
*/
class ParticipantFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<Participant>
*/
protected $model = Participant::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'data' => [],
];
}
/**
* @param array<string, mixed> $data
*/
public function data(array $data): self
{
return $this->state(['data' => $data]);
}
public function nr(int $number): self
{
return $this->state(['mitgliedsnr' => $number]);
}
}

View File

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

View File

@ -52,7 +52,7 @@ class MemberFactory extends Factory
: Nationality::factory()->create();
$subscription = Subscription::count()
? Subscription::get()->random()
: Subscription::factory()->for(Fee::factory())->create();
: Subscription::factory()->forFee()->create();
return $this
->for($country)

View File

@ -19,10 +19,16 @@ class SubscriptionFactory extends Factory
{
return [
'name' => $this->faker->word,
'fee_id' => Fee::factory()->createOne()->id,
];
}
public function forFee(?int $namiId = null): self
{
$namiId = $namiId ?: $this->faker->numberBetween(600, 800);
return $this->for(Fee::factory()->inNami($namiId));
}
public function name(string $name): self
{
return $this->state(['name' => $name]);

View File

@ -48,9 +48,9 @@ class SubactivityFactory extends Factory
return $this->state(['nami_id' => $namiId]);
}
public function ageGroup(): self
public function ageGroup(bool $ageGroup = true): self
{
return $this->state(['is_age_group' => true]);
return $this->state(['is_age_group' => $ageGroup]);
}
public function filterable(): self

View File

@ -0,0 +1,24 @@
<?php
namespace Database\Factories\Traits;
use Illuminate\Http\UploadedFile;
use Spatie\MediaLibrary\HasMedia;
trait FakesMedia
{
public function withImage(string $collection, string $filename): self
{
return $this->afterCreating(function (HasMedia $model) use ($filename, $collection) {
$pathinfo = pathinfo($filename);
UploadedFile::fake()->image($filename, 1000, 1000)->storeAs('media-library', $filename, 'temp');
$model->addMediaFromDisk('media-library/' . $filename, 'temp')
->usingName($pathinfo['filename'])
->usingFileName($pathinfo['basename'])
->toMediaCollection($collection);
});
}
}

View File

@ -0,0 +1,51 @@
<?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('formtemplates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->json('config');
$table->timestamps();
});
Schema::create('forms', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug');
$table->json('description');
$table->text('excerpt');
$table->json('config');
$table->date('from');
$table->date('to');
$table->dateTime('registration_from')->nullable();
$table->dateTime('registration_until')->nullable();
$table->text('mail_top')->nullable();
$table->text('mail_bottom')->nullable();
$table->json('meta');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('formtemplates');
Schema::dropIfExists('forms');
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('media', function (Blueprint $table) {
$table->id();
$table->morphs('model');
$table->uuid('uuid')->nullable()->unique();
$table->string('collection_name');
$table->string('name');
$table->string('file_name');
$table->string('mime_type')->nullable();
$table->string('disk');
$table->string('conversions_disk')->nullable();
$table->unsignedBigInteger('size');
$table->json('manipulations');
$table->json('custom_properties');
$table->json('generated_conversions');
$table->json('responsive_images');
$table->unsignedInteger('order_column')->nullable()->index();
$table->nullableTimestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('media');
}
};

View File

@ -0,0 +1,35 @@
<?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->foreignId('parent_id')->nullable();
$table->string('mitgliedsnr')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('participants');
}
};

55
package-lock.json generated
View File

@ -5,6 +5,10 @@
"packages": {
"": {
"dependencies": {
"@editorjs/editorjs": "^2.29.0",
"@editorjs/header": "^2.8.1",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@inertiajs/vue3": "^1.0.14",
"@tailwindcss/typography": "^0.5.10",
"@vitejs/plugin-vue": "^4.6.2",
@ -25,6 +29,7 @@
"wnumb": "^1.2.0"
},
"devDependencies": {
"accounting": "^0.4.1",
"autoprefixer": "^10.4.17",
"axios": "^1.6.6",
"dayjs": "^1.11.10",
@ -67,6 +72,11 @@
"node": ">=6.0.0"
}
},
"node_modules/@codexteam/icons": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
"integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA=="
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@ -85,6 +95,45 @@
"kuler": "^2.0.0"
}
},
"node_modules/@editorjs/editorjs": {
"version": "2.29.0",
"resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.29.0.tgz",
"integrity": "sha512-w2BVboSHokMBd/cAOZn0UU328o3gSZ8XUvFPA2e9+ciIGKILiRSPB70kkNdmhHkuNS3q2px+vdaIFaywBl7wGA=="
},
"node_modules/@editorjs/header": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.1.tgz",
"integrity": "sha512-y0HVXRP7m2W617CWo3fsb5HhXmSLaRpb9GzFx0Vkp/HEm9Dz5YO1s8tC7R8JD3MskwoYh7V0hRFQt39io/r6hA==",
"dependencies": {
"@codexteam/icons": "^0.0.5"
}
},
"node_modules/@editorjs/nested-list": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@editorjs/nested-list/-/nested-list-1.4.2.tgz",
"integrity": "sha512-qb1dAoJ+bihqmlR3822TC2GuIxEjTCLTZsZVWNces3uJIZ+W4019G3IJKBt/MOOgz4Evzad/RvUEKwPCPe6YOQ==",
"dependencies": {
"@codexteam/icons": "^0.0.2"
}
},
"node_modules/@editorjs/nested-list/node_modules/@codexteam/icons": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.2.tgz",
"integrity": "sha512-KdeKj3TwaTHqM3IXd5YjeJP39PBUZTb+dtHjGlf5+b0VgsxYD4qzsZkb11lzopZbAuDsHaZJmAYQ8LFligIT6Q=="
},
"node_modules/@editorjs/paragraph": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@editorjs/paragraph/-/paragraph-2.11.3.tgz",
"integrity": "sha512-ON72lhvhgWzPrq4VXpHUeut9bsFeJgVATDeL850FVToOwYHKvdsNpfu0VgxEodhkXgzU/IGl4FzdqC2wd3AJUQ==",
"dependencies": {
"@codexteam/icons": "^0.0.4"
}
},
"node_modules/@editorjs/paragraph/node_modules/@codexteam/icons": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.4.tgz",
"integrity": "sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q=="
},
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@ -1083,6 +1132,12 @@
"node": ">=10.0.0"
}
},
"node_modules/accounting": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/accounting/-/accounting-0.4.1.tgz",
"integrity": "sha512-RU6KY9Y5wllyaCNBo1W11ZOTnTHMMgOZkIwdOOs6W5ibMTp72i4xIbEA48djxVGqMNTUNbvrP/1nWg5Af5m2gQ==",
"dev": true
},
"node_modules/acorn": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",

View File

@ -12,6 +12,7 @@
"fix": "eslint \"resources/js/**/*.{js,vue}\" --fix"
},
"devDependencies": {
"accounting": "^0.4.1",
"autoprefixer": "^10.4.17",
"axios": "^1.6.6",
"dayjs": "^1.11.10",
@ -23,6 +24,10 @@
"vue-axios": "^3.5.2"
},
"dependencies": {
"@editorjs/editorjs": "^2.29.0",
"@editorjs/header": "^2.8.1",
"@editorjs/nested-list": "^1.4.2",
"@editorjs/paragraph": "^2.11.3",
"@inertiajs/vue3": "^1.0.14",
"@tailwindcss/typography": "^0.5.10",
"@vitejs/plugin-vue": "^4.6.2",

1
packages/adrema-form Submodule

@ -0,0 +1 @@
Subproject commit 2e232a95fd84f6dcca80abe4d2ed37e1fac7e352

@ -0,0 +1 @@
Subproject commit 91e5cc3e3b6d7d0a8a3e2361514ec8c1ce9cb655

View File

@ -24,6 +24,7 @@ parameters:
ContributionApiRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, member_data: array<int, ContributionMemberData>}'
MailgatewayCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string}'
MailgatewayParsedCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string, is_required: bool}'
SluggableConfig: 'array<string, array{source: array<int, string>}>'
ignoreErrors:
-
@ -136,16 +137,6 @@ parameters:
count: 1
path: app/Payment/SubscriptionResource.php
-
message: "#^PHPDoc type array of property App\\\\Providers\\\\AuthServiceProvider\\:\\:\\$policies is not covariant with PHPDoc type array\\<class\\-string, class\\-string\\> of overridden property Illuminate\\\\Foundation\\\\Support\\\\Providers\\\\AuthServiceProvider\\:\\:\\$policies\\.$#"
count: 1
path: app/Providers/AuthServiceProvider.php
-
message: "#^Property App\\\\Providers\\\\AuthServiceProvider\\:\\:\\$policies type has no value type specified in iterable type array\\.$#"
count: 1
path: app/Providers/AuthServiceProvider.php
-
message: "#^Method Database\\\\Factories\\\\ActivityFactory\\:\\:definition\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@ -541,3 +532,18 @@ parameters:
message: "#^Parameter \\#1 \\.\\.\\.\\$parts of method App\\\\Member\\\\FilterScope\\:\\:combinations\\(\\) expects array\\<int, int\\>, array\\<int, array\\<int, int\\>\\> given\\.$#"
count: 1
path: app/Member/FilterScope.php
-
message: "#^Call to function is_null\\(\\) with array will always evaluate to false\\.$#"
count: 1
path: app/Form/Models/Form.php
-
message: "#^Unable to resolve the template type TKey in call to function collect$#"
count: 1
path: app/Form/Fields/NamiField.php
-
message: "#^Unable to resolve the template type TValue in call to function collect$#"
count: 1
path: app/Form/Fields/NamiField.php

View File

@ -9,3 +9,4 @@
@import 'table';
@import 'bool';
@import 'form';
@import 'editor';

View File

@ -1,3 +1,7 @@
::selection {
@apply bg-blue-800;
}
.button-group > div {
padding-left: 0 !important;
padding-right: 0 !important;

83
resources/css/editor.css vendored Normal file
View File

@ -0,0 +1,83 @@
.ce-inline-toolbar,
.codex-editor--narrow .ce-toolbox,
.ce-conversion-toolbar,
.ce-settings,
.ce-settings__button,
.ce-toolbar__settings-btn,
.cdx-button,
.ce-popover,
.ce-toolbar__plus:hover {
@apply bg-primary-700;
color: inherit;
}
.ce-inline-tool-input {
@apply bg-primary-700 text-primary-200 placeholder-primary-500;
}
.ce-block--selected {
@apply bg-gray-800;
}
h2.ce-header {
@apply font-semibold text-white text-xl mt-2;
}
h3.ce-header {
@apply font-semibold text-white text-lg mt-2;
}
h4.ce-header {
@apply font-semibold text-white mt-2;
}
.ce-block--selected .ce-block__content {
@apply bg-gray-800;
}
.ce-conversion-tool__icon,
.ce-popover-item__icon {
@apply bg-primary-800;
}
.ce-popover-item__title {
@apply text-primary-200;
}
.ce-popover-item:hover:not(.ce-popover-item--no-hover) {
@apply bg-primary-800;
}
.ce-inline-tool,
.ce-conversion-toolbar__label,
.ce-toolbox__button,
.cdx-settings-button,
.ce-toolbar__plus {
color: inherit;
}
.codex-editor ::selection {
@apply bg-blue-800;
}
.cdx-settings-button:hover,
.ce-settings__button:hover,
.ce-toolbox__button--active,
.ce-toolbox__button:hover,
.cdx-button:hover,
.ce-inline-toolbar__dropdown:hover,
.ce-inline-tool:hover,
.ce-popover__item:hover,
.ce-toolbar__settings-btn:hover {
background-color: #439a86;
color: inherit;
}
.cdx-notify--error {
background: #fb5d5d !important;
}
.cdx-notify__cross::after,
.cdx-notify__cross::before {
background: white;
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 503.664 503.664" style="enable-background:new 0 0 503.664 503.664" xml:space="preserve"><path d="M467.98 39.376H35.792C16.112 39.376.1 55.384.1 75.064L0 369.52c0 19.676 16.004 35.748 35.684 35.748h141.4v35.408h-27.612c-2.172 0-3.864 1.848-3.864 4.02v15.532c0 2.172 1.688 4.06 3.864 4.06h204c2.164 0 4.584-1.888 4.584-4.06v-15.532c0-2.172-2.42-4.02-4.584-4.02H326.58v-35.408h141.304c19.664 0 35.688-16.072 35.688-35.748l.092-294.424c.004-19.684-16.004-35.72-35.684-35.72zM251.34 377.524c-6.524 0-11.804-5.284-11.804-11.804 0-6.516 5.28-11.8 11.804-11.8 6.52 0 11.804 5.284 11.804 11.8 0 6.52-5.28 11.804-11.804 11.804zm220.812-50.944H31.512V70.848h440.64V326.58z"/></svg>

After

Width:  |  Height:  |  Size: 723 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="124.813" height="124.813" style="enable-background:new 0 0 124.813 124.813" xml:space="preserve"><path d="m48.083 80.355-1.915 11.374a4.158 4.158 0 0 0 1.65 4.05 4.15 4.15 0 0 0 4.361.32l10.226-5.338L72.631 96.1a4.184 4.184 0 0 0 1.924.472c.859 0 1.716-.269 2.439-.792a4.152 4.152 0 0 0 1.651-4.05l-1.913-11.374 8.234-8.077a4.159 4.159 0 0 0 1.044-4.247 4.152 4.152 0 0 0-3.341-2.823l-11.41-1.692-5.139-10.329a4.147 4.147 0 0 0-3.716-2.303 4.16 4.16 0 0 0-3.718 2.303l-5.134 10.329-11.41 1.691a4.147 4.147 0 0 0-3.339 2.823 4.147 4.147 0 0 0 1.042 4.247l8.238 8.077z"/><path d="M111.443 13.269H98.378V6.022A6.022 6.022 0 0 0 92.355 0H91.4a6.021 6.021 0 0 0-6.021 6.022v7.247H39.282V6.022A6.022 6.022 0 0 0 33.261 0h-.956a6.021 6.021 0 0 0-6.021 6.022v7.247H13.371C6.538 13.269.977 18.828.977 25.663v86.757c0 6.831 5.561 12.394 12.394 12.394h98.073c6.832 0 12.394-5.562 12.394-12.394V25.663c-.001-6.835-5.563-12.394-12.395-12.394zm-1.617 97.534H14.988V43.268h94.838v67.535z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 503.604 503.604" style="enable-background:new 0 0 503.604 503.604" xml:space="preserve"><path d="M337.324 0H167.192c-28.924 0-53.5 23.584-53.5 52.5v398.664c0 28.916 24.056 52.44 52.98 52.44l170.412-.184c28.92 0 52.58-23.528 52.58-52.448l.248-398.5C389.908 23.452 366.364 0 337.324 0zM227.68 31.476h49.36c4.336 0 7.868 3.52 7.868 7.868 0 4.348-3.532 7.868-7.868 7.868h-49.36a7.865 7.865 0 0 1-7.868-7.868 7.865 7.865 0 0 1 7.868-7.868zm-29.66 2.504c2.916-2.912 8.224-2.952 11.136 0a7.973 7.973 0 0 1 2.324 5.588c0 2.048-.864 4.088-2.324 5.548-1.452 1.46-3.504 2.32-5.548 2.32-2.084 0-4.088-.86-5.588-2.32-1.452-1.456-2.28-3.5-2.28-5.548-.004-2.088.828-4.132 2.28-5.588zm52.752 454.028c-12.984 0-23.544-10.568-23.544-23.548 0-12.984 10.56-23.548 23.544-23.548s23.544 10.564 23.544 23.548c0 12.98-10.564 23.548-23.544 23.548zm114.716-63.1H141.232V74.756h224.256v350.152z"/></svg>

After

Width:  |  Height:  |  Size: 929 B

View File

@ -1,18 +1 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<polygon points="51.2,353.28 0,512 158.72,460.8 "/>
</g>
</g>
<g>
<g>
<rect x="89.73" y="169.097" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -95.8575 260.3719)" width="353.277" height="153.599"/>
</g>
</g>
<g>
<g>
<path d="M504.32,79.36L432.64,7.68c-10.24-10.24-25.6-10.24-35.84,0l-23.04,23.04l107.52,107.52l23.04-23.04 C514.56,104.96,514.56,89.6,504.32,79.36z"/>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M51.2 353.28 0 512l158.72-51.2zm35.96-36.788L336.96 66.69 445.57 175.3l-249.8 249.802zM504.32 79.36 432.64 7.68c-10.24-10.24-25.6-10.24-35.84 0l-23.04 23.04 107.52 107.52 23.04-23.04c10.24-10.24 10.24-25.6 0-35.84z"/></svg>

Before

Width:  |  Height:  |  Size: 660 B

After

Width:  |  Height:  |  Size: 357 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 503.592 503.592" style="enable-background:new 0 0 503.592 503.592" xml:space="preserve"><path d="M428.636 27.852C428.636 12.508 416.144 0 400.792 0H102.66C87.312 0 74.828 12.504 74.828 27.852l.132 447.892c0 15.352 12.488 27.848 27.832 27.848h298.136c15.352 0 27.836-12.496 27.836-27.848l-.128-447.892zm-196.276 3.62h38.884c4.36 0 7.868 3.52 7.868 7.868a7.863 7.863 0 0 1-7.868 7.872H232.36c-4.348 0-7.868-3.524-7.868-7.872s3.52-7.868 7.868-7.868zm-29.396 2.44c2.916-2.948 8.184-2.948 11.136 0 1.452 1.46 2.324 3.504 2.324 5.552 0 2.08-.872 4.084-2.324 5.544-1.46 1.496-3.504 2.324-5.584 2.324a7.747 7.747 0 0 1-5.552-2.324c-1.452-1.456-2.316-3.464-2.316-5.544 0-2.048.868-4.092 2.316-5.552zm47.252 454.216c-12.98 0-23.544-10.564-23.544-23.544 0-12.984 10.568-23.548 23.544-23.548 12.988 0 23.544 10.564 23.544 23.548s-10.552 23.544-23.544 23.544zM94.424 424.904V74.752h314.744v350.152H94.424z"/></svg>

After

Width:  |  Height:  |  Size: 954 B

4
resources/js/app.js vendored
View File

@ -50,6 +50,10 @@ createInertiaApp({
requireModules(import.meta.glob('./components/form/*.vue'), app, 'f');
requireModules(import.meta.glob('./components/ui/*.vue'), app, 'ui');
requireModules(import.meta.glob('./components/page/*.vue', {eager: true}), app, 'page');
app.component(
'f-singlefile',
defineAsyncComponent(() => import('!/medialibrary-helper/assets/components/SingleFile.vue'))
);
app.provide('axios', app.config.globalProperties.axios);
app.mount(el);

View File

@ -0,0 +1,98 @@
<template>
<div>
<span v-if="label" class="font-semibold text-gray-400" :class="labelClass(size)">{{ label }}<span v-show="required" class="text-red-800">&nbsp;*</span></span>
<div class="relative w-full h-full">
<div :id="id" :class="[defaultFieldClass, fieldClass(size)]"></div>
<div v-if="hint" v-tooltip="hint" class="absolute right-0 top-0 mr-2 mt-2">
<ui-sprite src="info-button" class="w-5 h-5 text-indigo-200"></ui-sprite>
</div>
</div>
</div>
</template>
<script setup>
import {debounce} from 'lodash';
import {onMounted, ref} from 'vue';
import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';
import Paragraph from '@editorjs/paragraph';
import NestedList from '@editorjs/nested-list';
import useFieldSize from '../../composables/useFieldSize.js';
const emit = defineEmits(['update:modelValue']);
const {labelClass, fieldClass, defaultFieldClass} = useFieldSize();
const props = defineProps({
required: {
type: Boolean,
default: false,
},
size: {
default: null,
},
rows: {
default: function () {
return 4;
},
},
id: {
required: true,
},
hint: {
default: null,
},
modelValue: {
default: undefined,
},
label: {
default: false,
},
placeholder: {
default: '',
},
});
const editor = ref(null);
onMounted(async () => {
editor.value = new EditorJS({
placeholder: props.placeholder,
holder: props.id,
minHeight: 0,
defaultBlock: 'paragraph',
data: JSON.parse(JSON.stringify(props.modelValue)),
tools: {
paragraph: {
class: Paragraph,
shortcut: 'CTRL+P',
inlineToolbar: true,
config: {
preserveBlank: true,
placeholder: 'Absatz',
},
},
heading: {
class: Header,
shortcut: 'CTRL+H',
inlineToolbar: true,
config: {
placeholder: 'Überschrift',
levels: [2, 3, 4],
defaultLevel: 2,
},
},
list: {
class: NestedList,
shortcut: 'CTRL+L',
inlineToolbar: true,
},
},
onChange: debounce(async (api, event) => {
const data = await editor.value.save();
emit('update:modelValue', data);
}, 500),
});
await editor.value.isReady;
console.log('Editor is ready');
});
</script>

View File

@ -85,8 +85,8 @@ export default {
return Array.isArray(this.options)
? this.options
: map(this.options, (value, key) => {
return { name: value, id: key };
});
return {name: value, id: key};
});
},
},
mounted() {

View File

@ -5,8 +5,20 @@
<span v-show="required" class="text-red-800">&nbsp;*</span>
</span>
<div class="real-field-wrap size-sm" :class="sizes[size].field">
<input :name="name" :type="type" :value="transformedValue" :disabled="disabled" :placeholder="placeholder"
@keypress="$emit('keypress', $event)" @input="onInput" @change="onChange" @focus="onFocus" @blur="onBlur" />
<input
:name="name"
:type="type"
:value="transformedValue"
:disabled="disabled"
:placeholder="placeholder"
:min="min"
:max="max"
@keypress="$emit('keypress', $event)"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
/>
<div v-if="hint" class="info-wrap">
<div v-tooltip="hint">
<ui-sprite src="info-button" class="info-button"></ui-sprite>
@ -281,6 +293,12 @@ export default {
default: false,
type: Boolean,
},
min: {
default: () => '',
},
max: {
default: () => '',
},
name: {},
},
data: function () {
@ -324,7 +342,7 @@ export default {
},
created() {
if (typeof this.modelValue === 'undefined') {
this.$emit('input', this.default === undefined ? '' : this.default);
this.$emit('update:modelValue', this.default === undefined ? '' : this.default);
}
},
methods: {

View File

@ -1,104 +1,54 @@
<template>
<label class="flex flex-col relative">
<span
v-if="label && !inset"
class="font-semibold text-gray-400"
:class="{
'text-xs': size == 'sm',
'text-sm': size === null,
}"
>{{ label }}<span v-show="required" class="text-red-800">&nbsp;*</span></span
>
<span
v-if="label && inset"
class="absolute top-0 left-0 -mt-2 px-1 ml-3 inset-bg font-semibold text-gray-700"
:class="{
'text-xs': size == 'sm',
'text-sm': size === null,
}"
>{{ label }}<span v-show="required" class="text-red-800">&nbsp;*</span></span
>
<textarea
v-text="value"
@input="trigger"
:placeholder="placeholder"
class="h-full outline-none bg-gray-700 border-gray-600 border-solid"
:rows="rows"
:class="{
'rounded-lg text-sm border-2 p-2 text-gray-300': size === null,
'rounded-lg py-2 px-2 text-xs border-2 text-gray-300': size == 'sm',
}"
></textarea>
<div v-if="hint" v-tooltip="hint" class="absolute right-0 top-0 mr-2 mt-2">
<ui-sprite src="info-button" class="w-5 h-5 text-indigo-200"></ui-sprite>
<label class="flex flex-col">
<span v-if="label" class="font-semibold text-gray-400" :class="labelClass(size)">{{ label }}<span v-show="required" class="text-red-800">&nbsp;*</span></span>
<div class="relative w-full h-full">
<textarea :placeholder="placeholder" class="h-full w-full outline-none" :class="[defaultFieldClass, fieldClass(size)]" :rows="rows" @input="trigger" v-text="modelValue"></textarea>
<div v-if="hint" v-tooltip="hint" class="absolute right-0 top-0 mr-2 mt-2">
<ui-sprite src="info-button" class="w-5 h-5 text-indigo-200"></ui-sprite>
</div>
</div>
</label>
</template>
<script>
export default {
data: function () {
return {
focus: false,
};
},
props: {
required: {
type: Boolean,
default: false,
},
inset: {
default: false,
type: Boolean,
},
size: {
default: null,
},
rows: {
default: function () {
return 4;
},
},
id: {
required: true,
},
hint: {
default: null,
},
value: {
default: undefined,
},
mask: {
default: undefined,
},
label: {
default: false,
},
type: {
required: false,
default: function () {
return 'text';
},
},
placeholder: {
default: '',
},
},
methods: {
trigger(v) {
this.$emit('input', v.target.value);
},
},
created() {
if (typeof this.value === 'undefined') {
this.$emit('input', '');
}
},
};
</script>
<script setup>
import useFieldSize from '../../composables/useFieldSize.js';
const emit = defineEmits(['update:modelValue']);
<style scope>
.inset-bg {
background: linear-gradient(to bottom, hsl(247.5, 66.7%, 97.6%) 0%, hsl(247.5, 66.7%, 97.6%) 41%, hsl(0deg 0% 100%) 41%, hsl(180deg 0% 100%) 100%);
const {labelClass, fieldClass, defaultFieldClass} = useFieldSize();
const props = defineProps({
required: {
type: Boolean,
default: false,
},
size: {
default: null,
},
rows: {
default: function () {
return 4;
},
},
id: {
required: true,
},
hint: {
default: null,
},
modelValue: {
default: undefined,
},
label: {
default: false,
},
placeholder: {
default: '',
},
});
function trigger(v) {
emit('update:modelValue', v.target.value);
}
</style>
if (typeof props.modelValue === 'undefined') {
emit('update:modelValue', '');
}
</script>

View File

@ -0,0 +1,23 @@
<template>
<div class="p-6 bg-gray-700 border-r border-gray-600 flex-none w-maxc flex flex-col justify-between">
<div class="grid gap-1">
<a v-for="(item, index) in entries" :key="index" href="#" class="rounded py-1 px-3 text-gray-400"
:class="index === modelValue ? `bg-gray-600` : ''" @click.prevent="openMenu(index)" v-text="item.title"></a>
</div>
<slot name="bottom"></slot>
</div>
</template>
<script>
export default {
props: {
modelValue: {},
entries: {},
},
methods: {
openMenu(index) {
this.$emit('update:modelValue', index);
},
},
};
</script>

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>

Some files were not shown because too many files have changed in this diff Show More