Fix a cancel button that did nothing, and fit the answers on one screen
tests / pest (push) Failing after 10m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

The cancel button on the template modal was written with Alpine's
$dispatch, which fires a browser DOM event — and wire-elements/modal
listens for a LIVEWIRE event of that name. So it did nothing at all, while
saving worked, which is the combination that makes somebody click twice and
then close the tab. Every other modal in the repo already used
Livewire.dispatch; this one was the odd one out, and nothing would have
caught it, so there is now a scan that fails on the Alpine form. Narrowed
to Alpine handlers deliberately: wire:click="$dispatch('closeModal')" IS
Livewire's own dispatcher and works.

The list was one column of thirteen entries, each carrying a name, a
subject, two clamped lines of body and two full-width buttons — four
screens to find the one you want, with the body preview taking most of the
height while being the least useful part of it. The whole text is one click
away in the modal.

Now: two columns of one-line entries — name and subject, which is what
tells two similar answers apart — with the actions as icons that do not
compete with the name. The create form is folded away behind the button
that creates one, because a form that is always open is a screen of form
between the operator and the thing they came for. And the placeholder list
runs as a grid across the page instead of a tall sidebar that squeezed the
entries into half the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.39
nexxo 2026-07-29 22:50:16 +02:00
parent 7fd45b01fb
commit 173f05c1bd
4 changed files with 167 additions and 104 deletions

View File

@ -1 +1 @@
1.3.38
1.3.39

View File

@ -37,7 +37,11 @@
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="save">
{{ __('templates.save') }}
</x-ui.button>
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button>
{{-- Livewire.dispatch, not Alpine's $dispatch. Alpine's fires a
browser DOM event, and the modal listens for a LIVEWIRE event of
that name so the button did nothing at all. Every other modal
in this repo does it this way; this one was the odd one out. --}}
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('templates.cancel') }}</x-ui.button>
</div>
</form>
</div>

View File

@ -1,110 +1,137 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('templates.title') }}</h1>
<p class="mt-1 max-w-[75ch] text-sm text-muted">{{ __('templates.subtitle') }}</p>
{{--
Thirteen answers on one screen.
The first version was one column: name, subject, two clamped lines of body
and two full-width buttons per entry. Thirteen of those is four screens of
scrolling to find the one you want, and the body preview was the least
useful part of it the whole text is one click away in the modal anyway.
So: two columns of one-line entries, actions as icons that do not compete
with the name, the create form folded away behind a button, and the
placeholder list as a compact grid rather than a tall sidebar that pushed the
entries into half the window.
--}}
<div class="mx-auto max-w-[1120px] space-y-5" x-data="{ creating: false }">
<div class="flex flex-wrap items-start gap-4 animate-rise">
<div class="min-w-0">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('templates.title') }}</h1>
<p class="mt-1 max-w-[75ch] text-sm text-muted">{{ __('templates.subtitle') }}</p>
</div>
<x-ui.button variant="primary" class="ml-auto" x-on:click="creating = !creating">
<x-ui.icon name="plus" class="size-4" />{{ __('templates.new') }}
</x-ui.button>
</div>
<div class="grid gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="space-y-5">
{{-- The list. Order is the operator's own, because the order they
think in is neither alphabetical nor the order these happened to
be written in. --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise">
<h2 class="border-b border-line px-6 py-4 font-semibold text-ink">{{ __('templates.list') }}</h2>
@if ($templates->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('templates.empty') }}</p>
@else
<ul class="divide-y divide-line">
@foreach ($templates as $template)
<li wire:key="tpl-{{ $template->uuid }}" class="px-6 py-4">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span @class(['font-medium', 'text-ink' => $template->active, 'text-muted line-through' => ! $template->active])>
{{ $template->name }}
</span>
@if (! $template->active)
<x-ui.badge status="warning">{{ __('templates.retired') }}</x-ui.badge>
@endif
<span class="ml-auto flex items-center gap-1">
{{-- Two clicks, one value each, no height
change: R20's exception. --}}
<button type="button" wire:click="move('{{ $template->uuid }}', -15)"
class="rounded p-1.5 text-muted hover:text-ink" aria-label="{{ __('templates.up') }}">
<x-ui.icon name="chevron-down" class="size-4 rotate-180" />
</button>
<button type="button" wire:click="move('{{ $template->uuid }}', 15)"
class="rounded p-1.5 text-muted hover:text-ink" aria-label="{{ __('templates.down') }}">
<x-ui.icon name="chevron-down" class="size-4" />
</button>
</span>
</div>
<p class="mt-1 text-sm text-body">{{ $template->subject }}</p>
<p class="mt-1.5 line-clamp-2 text-xs leading-relaxed text-muted">{{ $template->body }}</p>
<div class="mt-3 flex flex-wrap gap-2">
{{-- R20: editing opens a modal. A five-line
textarea in a list row is the height jump
the rule exists to stop. --}}
<x-ui.button variant="secondary" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.edit-mail-template', arguments: { uuid: '{{ $template->uuid }}' } })">
<x-ui.icon name="pencil" class="size-4" />{{ __('templates.edit') }}
</x-ui.button>
<x-ui.button variant="ghost" size="sm" wire:click="toggleActive('{{ $template->uuid }}')">
{{ $template->active ? __('templates.retire') : __('templates.restore') }}
</x-ui.button>
</div>
</li>
@endforeach
</ul>
@endif
</div>
{{-- Creating. A form that IS the page rather than a modal R20
exempts exactly this, because nothing is being edited in place. --}}
<form wire:submit="create" class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<h2 class="font-semibold text-ink">{{ __('templates.new') }}</h2>
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" :placeholder="__('templates.name_hint')" />
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" :placeholder="__('templates.subject_hint')" />
</div>
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('templates.body') }}</label>
<textarea id="body" rows="8" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('templates.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="create">
<x-ui.icon name="plus" class="size-4" />{{ __('templates.create') }}
</x-ui.button>
</form>
{{-- Folded away by default. A form that is always open is a screen of form
between the operator and the thing they came for and creating a
template is the rarer of the two things done here. It stays a form that
IS the page (R20 exempts exactly that); only its visibility changed. --}}
<form wire:submit="create" x-show="creating" x-cloak x-transition
class="space-y-4 rounded-lg border border-accent-border bg-surface p-6 shadow-xs">
<div class="grid gap-4 sm:grid-cols-2">
<x-ui.input name="name" wire:model="name" :label="__('templates.name')" :placeholder="__('templates.name_hint')" />
<x-ui.input name="subject" wire:model="subject" :label="__('templates.subject')" :placeholder="__('templates.subject_hint')" />
</div>
{{-- The placeholders, rendered from the renderer's own list so a token
added there appears here without anybody writing it down twice. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms] lg:sticky lg:top-6">
<div>
<label for="body" class="block text-sm font-medium text-body">{{ __('templates.body') }}</label>
<textarea id="body" rows="8" wire:model="body"
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink placeholder:text-faint"
placeholder="{{ __('templates.body_hint') }}"></textarea>
@error('body')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="flex flex-wrap gap-2">
<x-ui.button type="submit" variant="primary" wire:loading.attr="disabled" wire:target="create">
{{ __('templates.create') }}
</x-ui.button>
<x-ui.button variant="secondary" x-on:click="creating = false">{{ __('templates.cancel') }}</x-ui.button>
</div>
</form>
{{-- The placeholders, as a grid across the page rather than a column beside
it. Rendered from the renderer's own list, so a token added there
appears here without being written down twice. --}}
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h2 class="font-semibold text-ink">{{ __('templates.placeholders') }}</h2>
<p class="text-xs leading-relaxed text-muted">{{ __('templates.placeholders_note') }}</p>
<dl class="space-y-3">
@foreach ($placeholders as $token => $explanation)
{{-- Assembled in PHP, not inline: Blade's echo tag ends at
the first }} it finds, so a literal one inside the
expression cuts the tag in half and compiles to
nonsense. Caught by the test that renders this page. --}}
@php $literal = '{{'.$token.'}}'; @endphp
<div>
<dt class="select-all font-mono text-xs font-semibold text-accent-text">{{ $literal }}</dt>
<dd class="mt-0.5 text-xs leading-relaxed text-muted">{{ __($explanation) }}</dd>
</div>
@endforeach
</dl>
<p class="border-t border-line pt-3 text-xs leading-relaxed text-muted">{{ __('templates.unknown_note') }}</p>
<p class="text-xs text-muted">{{ __('templates.placeholders_note') }}</p>
</div>
<dl class="mt-4 grid gap-x-6 gap-y-3 sm:grid-cols-2 xl:grid-cols-4">
@foreach ($placeholders as $token => $explanation)
{{-- Assembled in PHP, not inline: Blade's echo tag ends at the
first }} it finds, so a literal one inside the expression
cuts the tag in half and compiles to nonsense. --}}
@php $literal = '{{'.$token.'}}'; @endphp
<div class="min-w-0">
<dt class="select-all font-mono text-xs font-semibold text-accent-text">{{ $literal }}</dt>
<dd class="mt-0.5 text-xs leading-snug text-muted">{{ __($explanation) }}</dd>
</div>
@endforeach
</dl>
<p class="mt-4 border-t border-line pt-3 text-xs leading-relaxed text-muted">{{ __('templates.unknown_note') }}</p>
</div>
{{-- The entries. Two columns, one line each: what an operator scans for is
the name, and the subject is the only other thing that tells two similar
ones apart. The text itself is in the modal. --}}
@if ($templates->isEmpty())
<div class="rounded-lg border border-line bg-surface p-10 text-center shadow-xs animate-rise">
<p class="text-sm text-muted">{{ __('templates.empty') }}</p>
</div>
@else
<div class="grid gap-3 lg:grid-cols-2 animate-rise [animation-delay:120ms]">
@foreach ($templates as $template)
<div wire:key="tpl-{{ $template->uuid }}"
@class([
'group flex items-center gap-3 rounded-lg border bg-surface px-4 py-3 shadow-xs transition-colors',
'border-line hover:border-accent-border' => $template->active,
// A retired one stays visible and stays quiet.
'border-line bg-surface-2' => ! $template->active,
])>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2">
<span @class([
'truncate text-sm font-medium',
'text-ink' => $template->active,
'text-muted line-through' => ! $template->active,
])>{{ $template->name }}</span>
@if (! $template->active)
<span class="text-[10px] font-semibold uppercase tracking-wider text-warning">{{ __('templates.retired') }}</span>
@endif
</div>
<p class="truncate text-xs text-muted">{{ $template->subject }}</p>
</div>
{{-- Icons, and one word for the state change. Two full-width
buttons per entry was most of the height. --}}
<div class="flex shrink-0 items-center gap-0.5">
<button type="button" wire:click="move('{{ $template->uuid }}', -15)"
class="rounded p-1.5 text-faint hover:text-ink" aria-label="{{ __('templates.up') }}" title="{{ __('templates.up') }}">
<x-ui.icon name="chevron-down" class="size-4 rotate-180" />
</button>
<button type="button" wire:click="move('{{ $template->uuid }}', 15)"
class="rounded p-1.5 text-faint hover:text-ink" aria-label="{{ __('templates.down') }}" title="{{ __('templates.down') }}">
<x-ui.icon name="chevron-down" class="size-4" />
</button>
<button type="button" wire:click="toggleActive('{{ $template->uuid }}')"
class="rounded p-1.5 text-faint hover:text-warning"
aria-label="{{ $template->active ? __('templates.retire') : __('templates.restore') }}"
title="{{ $template->active ? __('templates.retire') : __('templates.restore') }}">
<x-ui.icon :name="$template->active ? 'x' : 'refresh'" class="size-4" />
</button>
{{-- R20: editing opens a modal. A five-line textarea in a
list row is the height jump the rule exists to stop. --}}
<button type="button"
x-on:click="$dispatch('openModal', { component: 'admin.edit-mail-template', arguments: { uuid: '{{ $template->uuid }}' } })"
class="rounded p-1.5 text-muted hover:text-ink" aria-label="{{ __('templates.edit') }}" title="{{ __('templates.edit') }}">
<x-ui.icon name="pencil" class="size-4" />
</button>
</div>
</div>
@endforeach
</div>
@endif
</div>

View File

@ -76,3 +76,35 @@ it('reaches every edit modal through openModal', function () {
expect($users)->toContain("component: 'edit-seat'");
});
it('closes every modal through the event the modal actually listens for', function () {
// Alpine's $dispatch fires a browser DOM event; wire-elements/modal listens
// for a LIVEWIRE event of that name. A cancel button written the Alpine way
// therefore does nothing at all — reported on the mail-template modal, where
// saving worked and cancelling did not. Every other modal in the repo was
// already correct, which is exactly why nobody noticed the odd one out.
$offenders = collect(File::allFiles(resource_path('views')))
->filter(fn ($file) => str_ends_with($file->getFilename(), '.blade.php'))
->filter(function ($file) {
// Blade comments stripped first: this file's own explanation of the
// wrong form must not read as an offence.
$body = preg_replace('/\{\{--.*?--\}\}/s', '', $file->getContents());
// ALPINE's $dispatch only. wire:click="$dispatch('closeModal')" is
// Livewire's own dispatcher and works — the broken form is the one
// bound through an Alpine handler, where $dispatch means the DOM.
return preg_match(
'/(?:x-on:click|@click)\s*=\s*"[^"]*\$dispatch\(\s*[\'"]closeModal/',
(string) $body,
) === 1;
})
->map(fn ($file) => $file->getRelativePathname())
->values()
->all();
expect($offenders)->toBe([]);
// And the working form is the one in use.
expect(File::get(resource_path('views/livewire/admin/edit-mail-template.blade.php')))
->toContain("Livewire.dispatch('closeModal')");
});