Compare commits

..

6 Commits

Author SHA1 Message Date
philipp lang 6bcea543fb Fix namespace
continuous-integration/drone/push Build is failing Details
2025-01-26 17:58:43 +01:00
philipp lang 9ca06fd064 Pass initial value to editor function call 2025-01-26 17:58:43 +01:00
philipp lang 9a6eba5fd9 Merge class attributes to form text field 2025-01-26 17:58:43 +01:00
philipp lang d41f24fdea Add Lazy Loading prevention to app when testing 2025-01-26 17:58:43 +01:00
philipp lang da567e0a73 Fix form tests 2025-01-26 17:58:43 +01:00
philipp lang 3ba29b9f5e Add contribution index page 2025-01-26 17:58:31 +01:00
22 changed files with 112 additions and 350 deletions

View File

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

View File

@ -32,10 +32,6 @@ class AppServiceProvider extends ServiceProvider
Blade::componentNamespace('App\\View\\Mail', 'mail-view');
Model::preventLazyLoading(!app()->isProduction());
Blade::directive('rawAttribute', function ($expression) {
return '<?php if ($attributes->has("' . $expression . '.raw")): ?> <?php echo $attributes->get("' . $expression . '.raw"); ?> <?php else: ?> <?php echo "`".e($attributes->get("' . $expression . '"))."`"; ?> <?php endif ?>';
});
}
/**

View File

@ -27,7 +27,7 @@ class Editor extends Component
return <<<'HTML'
<div class="flex flex-col group {{$heightClass}}">
@if ($label)
<x-form::label :required="$required" :value="$label" />
<x-form::label :required="$required">{{$label}}</x-form::label>
@endif
<div class="relative w-full h-full">
@ -41,7 +41,7 @@ class Editor extends Component
@updated="$wire.{{$attributes->wire('model')->value}} = $event.detail"
x-data="editor($wire.{{$attributes->wire('model')->value}})"
id="{{$id}}"
{{$attributes->except(['label', ':label'])}}
{{$attributes}}
></div>
<x-ui::errors :for="$name" />
@if($hint)

View File

@ -3,17 +3,15 @@
namespace App\View\Form;
use App\View\Traits\HasFormDimensions;
use App\View\Traits\RendersAlpine;
use Illuminate\View\Component;
class Label extends Component
{
use HasFormDimensions;
use RendersAlpine;
public function __construct(
public bool $required = false,
public string $value = ''
) {
}
@ -21,11 +19,10 @@ class Label extends Component
{
return <<<'HTML'
<span class="font-semibold leading-none text-gray-400 group-[.size-default]:text-sm group-[.size-sm]:text-xs">
<span x-text="{!! $asAlpineString($attributes, 'value') !!}"></span>
<template x-if="{!! $asAlpineBool($attributes, 'required') !!}">
{{ $slot }}
@if ($required)
<span class="text-red-800">&nbsp;*</span>
</template>
{{$attributes}}
@endif
</span>
HTML;
}

View File

@ -29,10 +29,10 @@ class Lever extends Component
return <<<'HTML'
<label class="flex flex-col items-start group {{$heightClass}} " for="{{$id}}" style="{{$heightVars}}">
@if ($label)
<x-form::label :required="$required" :value="$label" />
<x-form::label :required="$required">{{$label}}</x-form::label>
@endif
<span class="relative flex-none flex h-[var(--height)] @if($hint) pr-8 @endif">
<input id="{{$id}}" type="checkbox" name="{{$name}}" value="{{$value}}" @if($disabled) disabled="disabled" @endif class="absolute peer opacity-0" {{ $attributes->except('label.raw') }} />
<input id="{{$id}}" type="checkbox" name="{{$name}}" value="{{$value}}" @if($disabled) disabled="disabled" @endif class="absolute peer opacity-0" {{ $attributes }} />
<span class="relative cursor-pointer h-full w-[calc(var(--height)*2)] rounded peer-focus:bg-red-500 duration-300 bg-gray-700 peer-checked:bg-primary-700"></span>
<span class="absolute h-full top-0 left-0 flex-none flex justify-center items-center aspect-square">
<x-ui::sprite

View File

@ -29,11 +29,11 @@ class Select extends Component
return <<<'HTML'
<label class="flex flex-col group {{$heightClass}}" for="{{$id}}" style="{{$heightVars}}">
@if ($label)
<x-form::label :required="$required" :value="$label"></x-form::label>
<x-form::label :required="$required">{{$label}}</x-form::label>
@endif
<div class="relative flex-none flex">
<select {{$attributes->except(['label', ':label'])}} @if($disabled) disabled @endif name="{{$name}}" id="{{$id}}"
<select {{$attributes}} @if($disabled) disabled @endif name="{{$name}}" id="{{$id}}"
class="
w-full h-[var(--height)] border-gray-600 border-solid text-gray-300 bg-gray-700 leading-none rounded-lg
group-[.size-default]:border-2 group-[.size-sm]:border

View File

@ -3,14 +3,14 @@
namespace App\View\Form;
use App\View\Traits\HasFormDimensions;
use App\View\Traits\RendersAlpine;
use Illuminate\View\Component;
class Text extends Component
{
use HasFormDimensions;
use RendersAlpine;
public string $id;
public function __construct(
public string $name,
@ -18,23 +18,17 @@ class Text extends Component
public ?string $hint = null,
public bool $required = false,
public string $label = '',
public string $type = 'text',
public string $id = '',
public string $type = 'text'
) {
if (!$id) {
$this->id = str()->uuid()->toString();
}
}
public function render()
{
return <<<'HTML'
<label {{ $attributes->merge(['class' => 'flex flex-col group '.$heightClass])->only('class') }}
@if ($attributes->has(':id')) :for="{{$attributes->get(':id')}}" @else for="{{$id}}" @endif
style="{{$heightVars}}"
>
@if ($hasAlpineAttribute($attributes, 'label'))
<x-form::label {{$attributes->merge(['value' => 'lalala', 'required' => false]) }} ></x-form::label>
<label {{ $attributes->merge(['class' => 'flex flex-col group '.$heightClass])->only('class') }} for="{{$id}}" style="{{$heightVars}}">
@if ($label)
<x-form::label :required="$required">{{$label}}</x-form::label>
@endif
<div class="relative flex-none flex">
<input
@ -48,7 +42,7 @@ class Text extends Component
group-[.size-default]:text-sm group-[.size-sm]:text-xs
group-[.size-default]:p-2 group-[.size-sm]:p-1
"
{{ $attributes->except(['class', 'label', ':label']) }}
{{ $attributes->except('class') }}
/>
<x-ui::errors :for="$name" />
@if($hint)

View File

@ -1,59 +0,0 @@
<?php
namespace App\View\Traits;
use Illuminate\View\ComponentAttributeBag;
trait RendersAlpine
{
public function hasAlpineAttribute(ComponentAttributeBag $attributes, string $attribute): bool
{
return $this->{$attribute} || ($attributes->has(':' . $attribute) && $attributes->get(':' . $attribute));
}
public function asAlpineString(ComponentAttributeBag $attributes, string $tagName): string
{
$rawTag = ':' . $tagName;
if ($attributes->has($rawTag) && $attributes->get($rawTag)) {
return $attributes->get($rawTag);
}
$output = e($this->{$tagName});
return str($output)->swap([
'`' => '\\`',
'$' => '\\$',
'{' => '\\{',
'}' => '\\}',
])->wrap('`');
}
public function asAlpineBool(ComponentAttributeBag $attributes, string $tagName): string
{
$rawTag = ':' . $tagName;
if ($attributes->has($rawTag)) {
return $attributes->get($rawTag);
}
return $this->{$tagName} ? 'true' : 'false';
}
public function renderAlpineFallbackAttribute(ComponentAttributeBag $attributes, string $tagName, string $prop): string
{
return ':label="aaa"';
return new ComponentAttributeBag([]);
return $attributes;
}
public function ca(ComponentAttributeBag $attributes): ComponentAttributeBag
{
return new ComponentAttributeBag([
'::value' => $attributes->get(':label'),
]);
return ':value="$label" ::value="{!!$attributes->get(":label")!!}"';
}
}

View File

@ -104,8 +104,6 @@ services:
meilisearch:
image: getmeili/meilisearch:v1.6
ports:
- '7700:7700'
volumes:
- ./data/meilisearch:/meili_data
env_file:

View File

@ -2,63 +2,14 @@
namespace Modules\Contribution\Components;
use App\Contribution\ContributionFactory;
use App\Country;
use Illuminate\Support\Collection;
use Livewire\Component;
class FillList extends Component
{
public Collection $countries;
public ?int $country = null;
public Collection $compilers;
public string $eventName = '';
public string $dateFrom = '';
public string $dateUntil = '';
public string $zipLocation = '';
public function mount(): void
{
$this->countries = Country::select('name', 'id')->get();
$this->country = Country::firstWhere('name', 'Deutschland')->id;
$this->compilers = app(ContributionFactory::class)->compilerSelect();
}
public function render()
{
return <<<'HTML'
<x-page::layout title="Zuschüsse" menu="contribution">
<form target="_BLANK" class="max-w-4xl w-full mx-auto gap-6 grid-cols-2 grid p-6">
<x-form::text name="event_name" wire:model="eventName" class="col-span-full" label="Veranstaltungs-Name" required></x-form::text>
<x-form::text name="date_until" wire:model="dateUntil" type="date" label="Datum bis" required></x-form::text>
<x-form::text name="date_until" wire:model="dateUntil" type="date" label="Datum bis"></x-form::text>
<x-form::text name="date_from" wire:model="dateFrom" type="date" label="Datum von" required></x-form::text>
<x-form::text name="zip_location" wire:model="zipLocation" label="PLZ / Ort" required></x-form::text>
<x-form::select id="country" wire:model="country" :options="$countries" name="country" label="Land" required></x-form::select>
<x-ui::box class="col-span-2" title="Mitglieder finden" x-data="memberSearch()">
<span x-text="JSON.stringify(members)"></span>
<div class="mt-2 grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-2 col-span-2">
<x-form::text name="search_text" ref="searchInput" x-model="searchString" class="col-span-2" label="Suchen …" size="sm" @keydown.enter.prevent="onSubmitFirstMemberResult"></x-form::text>
<template x-for="member in searchHelper.getHits()" :key="member.id">
<x-form::lever
name="members"
::label="member.fullname"
::id="member.id"
::value="member.id"
size="sm"
ref="input"
x-model="members"
@keydown.enter.prevent="onSubmitMemberResult($event.target.value)"
></x-form::lever>
</template>
</div>
</x-ui::box>
<button v-for="(compiler, index) in compilers" :key="index" class="btn btn-primary mt-3 inline-block" @click.prevent="submit(compiler.class)" v-text="compiler.title"></button>
</form>
</x-page::layout>
HTML;
}

View File

@ -2,8 +2,6 @@
namespace Modules\Contribution\Components;
use App\Contribution\Documents\RdpNrwDocument;
use App\Country;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Livewire\Livewire;
@ -20,16 +18,7 @@ it('displays page', function () {
->assertSeeLivewire(FillList::class);
});
it('displays fields', function () {
$country = Country::factory()->create(['name' => 'Deutschland']);
it('loads component', function () {
Livewire::test(FillList::class)
->assertSet('compilers.0.class', RdpNrwDocument::class)
->assertSet('compilers.0.title', 'Für RdP NRW erstellen')
->assertSet('countries.0.name', 'Deutschland')
->assertSet('countries.0.id', $country->id)
->assertSet('country', $country->id)
->assertSee('Veranstaltungs-Name')
->assertSee('Datum von')
->assertSee('Deutschland');
->assertSee('Zuschüsse');
});

7
package-lock.json generated
View File

@ -39,7 +39,6 @@
},
"devDependencies": {
"accounting": "^0.4.1",
"alpinejs-axios": "^1.0.8",
"autoprefixer": "^10.4.17",
"axios": "^1.6.6",
"dayjs": "^1.11.10",
@ -1157,12 +1156,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alpinejs-axios": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/alpinejs-axios/-/alpinejs-axios-1.0.8.tgz",
"integrity": "sha512-6B6oxLiXvEsNUnhXT8/H8q/CPF4CThYp3s9Pw/rI/lCpiBPSBNLBuL0hgLn6VeuhLIUKL9DN3IYYWWsKWXzpKg==",
"dev": true
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",

View File

@ -12,7 +12,6 @@
},
"devDependencies": {
"accounting": "^0.4.1",
"alpinejs-axios": "^1.0.8",
"autoprefixer": "^10.4.17",
"axios": "^1.6.6",
"dayjs": "^1.11.10",

View File

@ -62,4 +62,22 @@ async function submit(compiler) {
var payload = btoa(encodeURIComponent(JSON.stringify(values.value)));
window.open(`/contribution-generate?payload=${payload}`);
}
function onSubmitMemberResult(selected) {
if (values.value.members.find((m) => m === selected.id) !== undefined) {
values.value.members = values.value.members.filter((m) => m !== selected.id);
} else {
values.value.members.push(selected.id);
}
clearSearch();
searchInput.value.$el.querySelector('input').focus();
}
function onSubmitFirstMemberResult() {
if (results.value.hits.length === 0) {
clearSearch();
return;
}
onSubmitMemberResult(results.value.hits[0]);
}
</script>

View File

@ -1,16 +1,12 @@
import {Livewire, Alpine} from '../../vendor/livewire/livewire/dist/livewire.esm';
import Tooltip from '@ryangjchandler/alpine-tooltip';
import axios from 'axios';
import '../css/app.css';
import 'tippy.js/dist/tippy.css';
import 'tippy.js/animations/shift-toward.css';
import '../css/tooltip.css';
import {error, success} from './toastify.js';
import editor from './editor.js';
import memberSearch from './memberSearch.js';
window.axios = axios;
Alpine.plugin(
Tooltip.defaultProps({
@ -23,7 +19,6 @@ window.addEventListener('success', (event) => success(event.detail[0]));
document.addEventListener('alpine:init', () => {
Alpine.data('editor', editor);
Alpine.data('memberSearch', memberSearch);
});
Livewire.start();

View File

@ -1,35 +0,0 @@
import searchHelper from './searchHelper.js';
export default () => ({
members: [],
searchHelper: searchHelper([], {}),
onSubmitFirstMemberResult() {
if (this.searchHelper.hasNoHits()) {
this.searchHelper.clear();
return;
}
this.onSubmitMemberResult(this.searchHelper.firstHit().id.toString());
},
onSubmitMemberResult(id) {
if (this.members.find((m) => m === id) !== undefined) {
this.members = this.members.filter((m) => m !== id);
} else {
this.members.push(id);
}
this.searchHelper.clear();
this.$root.querySelector('input').focus();
},
set searchString(v) {
this.searchHelper.setInput(v);
},
get searchString() {
return this.searchHelper.getInput();
},
});

View File

@ -1,58 +0,0 @@
export default (params = [], options = {}) => ({
params: params,
options: options,
results: {
hits: [],
},
realSearchString: '',
async search(text, filters = [], options = {}) {
var response = await axios.post(
import.meta.env.MODE === 'development' ? 'http://localhost:7700/indexes/members/search' : '/indexes/members/search',
{
q: text,
filter: filters,
sort: ['lastname:asc', 'firstname:asc'],
...options,
},
{headers: {Authorization: 'Bearer ' + document.querySelector('meta[name="meilisearch_key"]').content}}
);
return response.data;
},
async setInput(v) {
this.realSearchString = v;
if (!v.length) {
this.results = {hits: []};
return;
}
this.results = await this.search(v, this.params, this.options);
},
getInput() {
return this.realSearchString;
},
clear() {
this.setInput('');
},
getHits() {
return this.results.hits;
},
hasHits() {
return this.results.hits.length > 0;
},
hasNoHits() {
return !this.hasHits();
},
firstHit() {
return this.results.hits[0];
},
});

View File

@ -103,6 +103,7 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::post('/api/member/search', SearchAction::class)->name('member.search');
// ------------------------------- Contributions -------------------------------
Route::get('/contribution', ContributionFormAction::class)->name('contribution.form');
Route::get('/contribution-generate', ContributionGenerateAction::class)->name('contribution.generate');
Route::post('/contribution-validate', ContributionValidateAction::class)->name('contribution.validate');

View File

@ -0,0 +1,36 @@
<?php
namespace Tests\Feature\Contribution;
use App\Contribution\Documents\RdpNrwDocument;
use App\Country;
use App\Member\Member;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class IndexTest extends TestCase
{
use DatabaseTransactions;
public function testItHasContributionIndex(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$country = Country::factory()->create(['name' => 'Deutschland']);
Member::factory()->defaults()->create(['firstname' => 'Max', 'lastname' => 'Muster']);
Member::factory()->defaults()->create(['firstname' => 'Jane', 'lastname' => 'Muster']);
$response = $this->get('/contribution');
$this->assertInertiaHas([
'class' => RdpNrwDocument::class,
'title' => 'Für RdP NRW erstellen',
], $response, 'compilers.0');
$this->assertInertiaHas([
'id' => $country->id,
'name' => $country->name,
], $response, 'countries.0');
$this->assertInertiaHas([
'country' => $country->id,
], $response, 'data');
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace Tests\Unit\View\Form;
use Illuminate\Support\Facades\Blade;
use Tests\TestCase;
uses(TestCase::class);
it('renders component', function ($component, $params, $expected = '', $notExpected = '') {
$rendered = Blade::render($component, $params);
if ($expected) {
expect($rendered)->toContain($expected);
}
if ($notExpected) {
expect($rendered)->not()->toContain($notExpected);
}
})->with([
'component parameter' => ['<x-form::label :value="$label" />', ['label' => 'the label'], 'x-text="`the label`"'],
'component parameter with double quotes' => ['<x-form::label :value="$label" />', ['label' => 'the "label"'], 'x-text="`the &quot;label&quot;`"'],
'component parameter with ``' => ['<x-form::label value="the `label`" />', [], 'x-text="`the \\`label\\``"'],
'escape dollars' => ['<x-form::label value="the $label" />', [], 'x-text="`the \\$label`"'],
'escape kl' => ['<x-form::label value="the {label}" />', [], 'x-text="`the \\{label\\}`"'],
'raw php string' => ['<x-form::label value="the label" />', [], 'x-text="`the label`"'],
'raw js string' => ['<x-form::label ::value="theLabel" />', [], 'x-text="theLabel"'],
'raw js string with tag' => ['<x-form::label ::value="the<Label" />', [], 'x-text="the<Label"'],
'raw js string empty' => ['<x-form::label value="lala" ::value="" />', [], 'x-text="`lala`"'],
'renders required' => ['<x-form::label value="" required/>', [], 'x-if="true"'],
'renders required' => ['<x-form::label value="" />', [], 'x-if="false"'],
]);

View File

@ -1,31 +0,0 @@
<?php
namespace Tests\Unit\View\Form;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ViewErrorBag;
use Tests\TestCase;
uses(TestCase::class);
it('renders component', function ($component, $params, $expected = '', $notExpected = '') {
View::share(['errors' => new ViewErrorBag()]);
$rendered = Blade::render($component, $params);
if ($expected) {
expect($rendered)->toContain($expected);
}
if ($notExpected) {
expect($rendered)->not()->toContain($notExpected);
}
})->with([
'php string' => ['<x-form::lever name="name" label="the label" />', [], 'x-text="`the label`"'],
'php string with escaping' => ['<x-form::lever name="name" label="the <label>" />', [], 'x-text="`the &lt;label&gt;`"'],
'php string with var' => ['<x-form::lever name="name" :label="$label" />', ['label' => 'the <label>'], 'x-text="`the &lt;label&gt;`"'],
'js plain string' => ['<x-form::lever name="name" ::label="post.name" />', [], 'x-text="post.name"'],
'js plain string with <' => ['<x-form::lever name="name" ::label="post.<name" />', [], 'x-text="post.<name"'],
'raw id' => ['<x-form::lever name="name" ::id="post.id" />', [], ':id="post.id"'],
'raw id with for' => ['<x-form::lever name="name" ::id="post.id" />', [], ':for="post.id"'],
]);

View File

@ -1,26 +0,0 @@
<?php
namespace Tests\Unit\View\Form;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ViewErrorBag;
use Tests\TestCase;
uses(TestCase::class);
it('renders component', function ($component, $params, $expected) {
View::share(['errors' => new ViewErrorBag()]);
$rendered = Blade::render($component, $params);
dd($rendered);
expect($rendered)->toContain($expected);
})->with([
'php string' => ['<x-form::text name="name" label="the label" />', [], 'x-text="`the label`"'],
// 'php string with escaping' => ['<x-form::text name="name" label="the <label>" />', [], 'x-text="`the &lt;label&gt;`"'],
// 'php string with var' => ['<x-form::text name="name" :label="$label" />', ['label' => 'the <label>'], 'x-text="`the &lt;label&gt;`"'],
// 'js plain string' => ['<x-form::text name="name" ::label="post.name" />', [], 'x-text="post.name"'],
// 'js plain string with <' => ['<x-form::text name="name" ::label="post.<name" />', [], 'x-text="post.<name"'],
// 'raw id' => ['<x-form::text name="name" ::id="post.id" />', [], ':id="post.id"'],
// 'raw id with for' => ['<x-form::text name="name" ::id="post.id" />', [], ' :for="post.id"'],
]);