Say that a click landed, and make moving one actually move it
"Man klickt und sieht nicht was passiert." Every action in this console is a round trip, and a round trip with no sign of itself reads as a click that missed — so the answer is in three places, from the general to the specific. A thin bar at the top of the window for any Livewire request, in app.js. Delayed by 140 ms, because a request that answers faster than that is perceived as immediate and a bar flashing on every keystroke of a .live field is worse than none. It creeps to 70 % and only completes on the answer: a bar that reaches the end while the answer is still on its way is a lie about the thing it exists to report. Released on failure as well — the request somebody must not be left waiting on is precisely the one that went wrong — and on wire:navigate, which is not a request hook at all and would otherwise leave the bar behind on a page that is gone. Per button: a pressed state on the way down, disabled while its own request is out, scoped with wire:target to that button's own call so a row does not grey itself out because a neighbour is moving. The edit button carries its own spinner, because opening the modal is a request made by the MODAL component — wire:loading on this page never sees it. It clears when the package announces the modal is up, and on a six second timer as well, so a request that never answers cannot leave a row spinning for ever. And the reordering was not only slow to look at, it was wrong. It added or subtracted a fixed amount: with the seeded values ten apart that usually landed right, and sometimes landed on a value another template already had — or on the floor along with three others. Equal sort values fall back to the name, so a click could reorder something else, or nothing, which is indistinguishable from a click that never registered. It swaps with the neighbour now: exactly one place, or nothing at the ends, both of which are what an operator expects to see. Where two shared a value the swap leaves them distinct, or the next click would do nothing again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus v1.3.40
parent
173f05c1bd
commit
57c6912987
|
|
@ -78,16 +78,61 @@ class MailTemplates extends Component
|
|||
}
|
||||
}
|
||||
|
||||
/** Move one up or down the list an operator picks from. */
|
||||
public function move(string $uuid, int $by): void
|
||||
/**
|
||||
* Swap one with its neighbour.
|
||||
*
|
||||
* It used to add or subtract a fixed amount, which is not the same thing:
|
||||
* with the seeded values ten apart, ±15 usually landed correctly — and
|
||||
* sometimes landed on a value another template already had, or on the 0
|
||||
* floor along with three others. Equal `sort` values fall back to the name,
|
||||
* so a click could reorder something else, or visibly nothing, which is
|
||||
* indistinguishable from a click that never registered.
|
||||
*
|
||||
* A swap always moves exactly one place, or does nothing at the ends, and
|
||||
* both of those are what the operator expects to see.
|
||||
*/
|
||||
public function move(string $uuid, int $direction): void
|
||||
{
|
||||
$this->authorize('customers.manage');
|
||||
|
||||
$template = MailTemplate::query()->where('uuid', $uuid)->first();
|
||||
|
||||
if ($template !== null) {
|
||||
$template->update(['sort' => max(0, $template->sort + $by)]);
|
||||
if ($template === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The one immediately above or below in the order the page shows —
|
||||
// read through the same ordering, so the swap agrees with the list.
|
||||
$neighbour = MailTemplate::query()
|
||||
->when($direction < 0, fn ($q) => $q
|
||||
->where(fn ($w) => $w->where('sort', '<', $template->sort)
|
||||
->orWhere(fn ($t) => $t->where('sort', $template->sort)->where('name', '<', $template->name)))
|
||||
->orderByDesc('sort')->orderByDesc('name'))
|
||||
->when($direction > 0, fn ($q) => $q
|
||||
->where(fn ($w) => $w->where('sort', '>', $template->sort)
|
||||
->orWhere(fn ($t) => $t->where('sort', $template->sort)->where('name', '>', $template->name)))
|
||||
->orderBy('sort')->orderBy('name'))
|
||||
->first();
|
||||
|
||||
if ($neighbour === null) {
|
||||
return; // already at the end; nothing to swap with
|
||||
}
|
||||
|
||||
// Distinct values, even where the two were equal before: leaving them
|
||||
// equal would put the order back in the hands of the name.
|
||||
$mine = $template->sort;
|
||||
$theirs = $neighbour->sort;
|
||||
|
||||
if ($mine === $theirs) {
|
||||
// Moving UP means taking the lower value, so the one the mover
|
||||
// receives is below the neighbour's — not above it. The other way
|
||||
// round swaps them into the order they were already in, which looks
|
||||
// exactly like the bug this method was rewritten to fix.
|
||||
$theirs = $mine + ($direction < 0 ? -1 : 1);
|
||||
}
|
||||
|
||||
$template->update(['sort' => $theirs]);
|
||||
$neighbour->update(['sort' => $mine]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -131,8 +131,96 @@ function recoverFrom419() {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
// A thin bar at the top of the window while a Livewire request is in flight.
|
||||
//
|
||||
// Reported as "man klickt und sieht nicht was passiert": every action in the
|
||||
// console is a round trip, and a round trip with no sign of itself reads as a
|
||||
// click that missed. wire:loading fixes that per element, one element at a time,
|
||||
// and only where somebody remembered to add it — so this is the floor underneath
|
||||
// all of them.
|
||||
//
|
||||
// Deliberately delayed: a request that answers in 80 ms does not need a bar, and
|
||||
// flashing one for every keystroke on a .live field is worse than nothing.
|
||||
// SHOW_AFTER_MS is the threshold below which a person perceives the response as
|
||||
// immediate anyway.
|
||||
const BUSY_SHOW_AFTER_MS = 140;
|
||||
const BUSY_FADE_MS = 220;
|
||||
|
||||
function requestIndicator() {
|
||||
let bar = null;
|
||||
let showTimer = null;
|
||||
let inFlight = 0;
|
||||
|
||||
const element = () => {
|
||||
if (bar !== null) return bar;
|
||||
|
||||
bar = document.createElement('div');
|
||||
// Inline styles, not a class: this has to work on the login page, on the
|
||||
// 503 page, and anywhere else the app's stylesheet may not have loaded
|
||||
// the utilities it would otherwise need.
|
||||
bar.style.cssText = [
|
||||
'position:fixed', 'top:0', 'left:0', 'height:2px', 'width:0%',
|
||||
'background:#c2560a', 'z-index:2147483647', 'pointer-events:none',
|
||||
'transition:width .25s ease-out,opacity .22s linear', 'opacity:0',
|
||||
].join(';');
|
||||
bar.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(bar);
|
||||
|
||||
return bar;
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
const el = element();
|
||||
el.style.opacity = '1';
|
||||
// Not to 100: the bar must never claim to be finished while the answer
|
||||
// is still on its way. It creeps and then completes.
|
||||
el.style.width = '70%';
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (bar === null) return;
|
||||
bar.style.width = '100%';
|
||||
bar.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
if (inFlight === 0 && bar !== null) bar.style.width = '0%';
|
||||
}, BUSY_FADE_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
inFlight++;
|
||||
if (showTimer !== null) return;
|
||||
showTimer = window.setTimeout(() => {
|
||||
showTimer = null;
|
||||
if (inFlight > 0) show();
|
||||
}, BUSY_SHOW_AFTER_MS);
|
||||
},
|
||||
// Called for success AND failure: a request that fails is exactly the
|
||||
// one somebody must not be left waiting on.
|
||||
stop() {
|
||||
inFlight = Math.max(0, inFlight - 1);
|
||||
if (inFlight > 0) return;
|
||||
|
||||
if (showTimer !== null) {
|
||||
window.clearTimeout(showTimer);
|
||||
showTimer = null;
|
||||
}
|
||||
|
||||
hide();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const busy = requestIndicator();
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.Livewire.hook('request', ({ fail }) => {
|
||||
window.Livewire.hook('request', ({ fail, respond }) => {
|
||||
busy.start();
|
||||
// respond fires for every answer, whatever its status; fail() below
|
||||
// handles the ones worth acting on. Both paths must release the bar,
|
||||
// and respond runs first — so stopping here covers the failures too.
|
||||
respond(() => busy.stop());
|
||||
|
||||
fail(({ status, preventDefault }) => {
|
||||
// 503: the application is in maintenance mode, which during a
|
||||
// deployment is not a fault — it is the event being watched.
|
||||
|
|
@ -159,6 +247,11 @@ document.addEventListener('livewire:init', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// A navigation (wire:navigate) is not a request hook, and the bar would
|
||||
// otherwise sit there after the page it belonged to is gone.
|
||||
document.addEventListener('livewire:navigating', () => busy.start());
|
||||
document.addEventListener('livewire:navigated', () => busy.stop());
|
||||
|
||||
// Connection banner ("Keine Verbindung" / "Verbindung wiederhergestellt" —
|
||||
// see connectionBanner() below): navigator.onLine and the browser's
|
||||
// online/offline events are both used, but neither is trusted alone —
|
||||
|
|
|
|||
|
|
@ -84,7 +84,17 @@
|
|||
@else
|
||||
<div class="grid gap-3 lg:grid-cols-2 animate-rise [animation-delay:120ms]">
|
||||
@foreach ($templates as $template)
|
||||
{{-- `opening` is local to the row: fetching the modal is a round
|
||||
trip, and a click with nothing to show for it reads as a
|
||||
click that missed. It clears when the modal is up (the
|
||||
package announces that) and on a timer as well, so a request
|
||||
that never answers cannot leave the row spinning for ever. --}}
|
||||
<div wire:key="tpl-{{ $template->uuid }}"
|
||||
x-data="{ opening: false }"
|
||||
x-on:modal-opened.window="opening = false"
|
||||
x-on:modalclosed.window="opening = false"
|
||||
wire:loading.class="opacity-60"
|
||||
wire:target="move('{{ $template->uuid }}', -1), move('{{ $template->uuid }}', 1), toggleActive('{{ $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,
|
||||
|
|
@ -105,29 +115,43 @@
|
|||
<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. --}}
|
||||
{{-- Every button reports its own click: pressed state on
|
||||
the way down, disabled while the request is out. The
|
||||
global bar at the top of the window says that SOMETHING
|
||||
is happening; these say which thing. --}}
|
||||
<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') }}">
|
||||
<button type="button" wire:click="move('{{ $template->uuid }}', -1)"
|
||||
wire:loading.attr="disabled" wire:target="move('{{ $template->uuid }}', -1)"
|
||||
class="rounded p-1.5 text-faint transition-colors hover:bg-surface-hover hover:text-ink active:bg-accent-subtle active:text-accent-text disabled:opacity-40"
|
||||
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') }}">
|
||||
<button type="button" wire:click="move('{{ $template->uuid }}', 1)"
|
||||
wire:loading.attr="disabled" wire:target="move('{{ $template->uuid }}', 1)"
|
||||
class="rounded p-1.5 text-faint transition-colors hover:bg-surface-hover hover:text-ink active:bg-accent-subtle active:text-accent-text disabled:opacity-40"
|
||||
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"
|
||||
wire:loading.attr="disabled" wire:target="toggleActive('{{ $template->uuid }}')"
|
||||
class="rounded p-1.5 text-faint transition-colors hover:bg-surface-hover hover:text-warning active:bg-warning-bg disabled:opacity-40"
|
||||
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. --}}
|
||||
list row is the height jump the rule exists to stop.
|
||||
|
||||
The spinner is this button's own: opening the modal
|
||||
is a Livewire request made by the MODAL component,
|
||||
so wire:loading on this page never sees it. --}}
|
||||
<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" />
|
||||
x-bind:disabled="opening"
|
||||
x-on:click="opening = true; setTimeout(() => opening = false, 6000); $dispatch('openModal', { component: 'admin.edit-mail-template', arguments: { uuid: '{{ $template->uuid }}' } })"
|
||||
class="rounded p-1.5 text-muted transition-colors hover:bg-surface-hover hover:text-ink active:bg-accent-subtle active:text-accent-text disabled:opacity-60"
|
||||
aria-label="{{ __('templates.edit') }}" title="{{ __('templates.edit') }}">
|
||||
<x-ui.icon name="pencil" class="size-4" x-show="!opening" />
|
||||
<x-ui.icon name="refresh" class="size-4 animate-spin text-accent-text" x-show="opening" x-cloak />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Models\SentMail;
|
|||
use App\Models\Subscription;
|
||||
use App\Services\Mail\MailTemplateRenderer;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
|
|
@ -302,3 +303,84 @@ it('leaves an edited template alone when the seed runs again', function () {
|
|||
expect($template->fresh()->body)->toBe('Meine eigene Fassung.')
|
||||
->and(MailTemplate::query()->where('name', 'Anfrage bestätigen')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ---- Moving one, and seeing that it moved ----
|
||||
|
||||
it('swaps a template with its neighbour rather than nudging a number', function () {
|
||||
// It used to add or subtract a fixed amount, which is not the same thing:
|
||||
// with values ten apart that usually landed right, and sometimes landed on a
|
||||
// value another template already had — equal sort falls back to the name, so
|
||||
// a click could reorder something else, or visibly nothing at all, which is
|
||||
// indistinguishable from a click that never registered.
|
||||
MailTemplate::query()->delete();
|
||||
|
||||
$first = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 10]);
|
||||
$second = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 20]);
|
||||
$third = MailTemplate::create(['name' => 'C', 'subject' => 'S', 'body' => 'B', 'sort' => 30]);
|
||||
|
||||
$order = fn () => MailTemplate::query()->orderBy('sort')->orderBy('name')->pluck('name')->all();
|
||||
|
||||
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(MailTemplates::class);
|
||||
|
||||
$page->call('move', $third->uuid, -1);
|
||||
expect($order())->toBe(['A', 'C', 'B']);
|
||||
|
||||
$page->call('move', $third->uuid, -1);
|
||||
expect($order())->toBe(['C', 'A', 'B']);
|
||||
|
||||
$page->call('move', $third->uuid, 1);
|
||||
expect($order())->toBe(['A', 'C', 'B']);
|
||||
});
|
||||
|
||||
it('does nothing at the ends instead of pretending', function () {
|
||||
// Both are what an operator expects to see: the top one cannot go up. A
|
||||
// number that silently changed without the order changing was the bug.
|
||||
MailTemplate::query()->delete();
|
||||
|
||||
$top = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 10]);
|
||||
$bottom = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 20]);
|
||||
|
||||
$page = Livewire::actingAs(operator('Owner'), 'operator')->test(MailTemplates::class);
|
||||
$page->call('move', $top->uuid, -1);
|
||||
$page->call('move', $bottom->uuid, 1);
|
||||
|
||||
expect(MailTemplate::query()->orderBy('sort')->pluck('name')->all())->toBe(['A', 'B'])
|
||||
->and($top->fresh()->sort)->toBe(10)
|
||||
->and($bottom->fresh()->sort)->toBe(20);
|
||||
});
|
||||
|
||||
it('separates two templates that shared a sort value', function () {
|
||||
// Equal values put the order in the hands of the name, so a swap between
|
||||
// them has to leave them distinct or the next click does nothing.
|
||||
MailTemplate::query()->delete();
|
||||
|
||||
$a = MailTemplate::create(['name' => 'A', 'subject' => 'S', 'body' => 'B', 'sort' => 5]);
|
||||
$b = MailTemplate::create(['name' => 'B', 'subject' => 'S', 'body' => 'B', 'sort' => 5]);
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(MailTemplates::class)
|
||||
->call('move', $b->uuid, -1);
|
||||
|
||||
expect($a->fresh()->sort)->not->toBe($b->fresh()->sort)
|
||||
->and(MailTemplate::query()->orderBy('sort')->orderBy('name')->pluck('name')->all())->toBe(['B', 'A']);
|
||||
});
|
||||
|
||||
it('says a click landed, on the button and at the top of the window', function () {
|
||||
// Reported as "man klickt und sieht nicht was passiert". Every action here is
|
||||
// a round trip; a round trip with no sign of itself reads as a click that
|
||||
// missed. The bar is the floor under every page, the per-button state says
|
||||
// WHICH thing is happening.
|
||||
$page = File::get(resource_path('views/livewire/admin/mail-templates.blade.php'));
|
||||
|
||||
expect($page)->toContain('wire:loading.attr="disabled"')
|
||||
->toContain('animate-spin')
|
||||
// Scoped to the one button, not to the whole page: every row disabling
|
||||
// itself because a neighbour is moving is its own kind of confusing.
|
||||
->toContain('wire:target="move(');
|
||||
|
||||
expect(File::get(resource_path('js/app.js')))
|
||||
->toContain('BUSY_SHOW_AFTER_MS')
|
||||
// Released on failure too — the request somebody must not be left
|
||||
// waiting on is exactly the one that went wrong.
|
||||
->toContain('respond(() => busy.stop())');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue