fix(ui): per-page skeletons, photo Alpine scope, tasks modal dispatch

Bug fixes (browser console + UX):

1. Photo /photo: "Alpine Expression Error: preview is not defined" during
   Lazy hydration. Placeholder now pre-declares the same x-data scope as
   the real view (file, preview, status) so Alpine bindings on children
   resolve from first paint and survive the morph.

2. Tasks Start button: "Unable to resolve dependency [Parameter #0 $component]
   in LivewireUI\\Modal\\Modal". Livewire 4 server-side dispatch with
   ->to('livewire-ui-modal') drops named payload args. Switched to
   $this->js("Livewire.dispatch('openModal', {...})") which emits a
   browser-side dispatch that wire-elements reliably picks up.

3. Skeleton was generic across all pages. Replaced single
   components.skeleton.page placeholder with page-specific placeholders that
   mirror each view's actual layout (hero header, stat-tile grid,
   subject-card grid, table rows, sub-nav, etc.).

   New skeleton primitives:
     components/skeleton/header.blade.php
     components/skeleton/stat-tile.blade.php
     components/skeleton/subject-card.blade.php
     components/skeleton/table-row.blade.php
     components/skeleton/card.blade.php

   Per-page placeholders:
     livewire/{dashboard,subjects,tasks,photo-help,rewards,progress,
               notifications,settings}/placeholder.blade.php

   Each placeholder() now returns its feature-local view; each placeholder
   has a unique data-testid (dashboard-skeleton, tasks-skeleton, etc.)

Tests:
- PerPageSkeletonTest (18 assertions): every page returns its own
  placeholder view, each has unique testid, photo-help placeholder
  declares preview in x-data.
- TasksStartButtonTest updated: assertScriptRan -> assertion against
  Livewire effects xjs key (correct Livewire 4 API).
- LazySkeletonTest removed (superseded by PerPageSkeletonTest).

Verification:
- 156 tests passed, 0 failed, 0 skipped (390 assertions)
- All 8 routes return 302 unauth (auth middleware correct)
- All 7 modal aliases resolve correctly via livewire.finder
- Build artifacts: manifest.json, logo/favicon.svg, manifest.webmanifest
  all reachable

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
Boban Blaskovic 2026-05-22 21:51:28 +02:00
parent 7e6d0d4ab4
commit a6c44fd169
24 changed files with 241 additions and 80 deletions

View File

@ -14,7 +14,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 5]); return view('livewire.dashboard.placeholder');
} }

View File

@ -15,7 +15,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 4]); return view('livewire.notifications.placeholder');
} }

View File

@ -13,7 +13,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 3]); return view('livewire.photo-help.placeholder');
} }
public function render(): View public function render(): View

View File

@ -13,7 +13,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 4]); return view('livewire.progress.placeholder');
} }
public function render(): View public function render(): View

View File

@ -13,7 +13,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 4]); return view('livewire.rewards.placeholder');
} }
public function render(): View public function render(): View

View File

@ -14,7 +14,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 4]); return view('livewire.settings.placeholder');
} }

View File

@ -13,7 +13,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 4]); return view('livewire.subjects.placeholder');
} }
public function render(): View public function render(): View

View File

@ -15,7 +15,7 @@ class Index extends Component
{ {
public static function placeholder(array $params = []): View public static function placeholder(array $params = []): View
{ {
return view('components.skeleton.page', ['rows' => 5]); return view('livewire.tasks.placeholder');
} }
@ -54,10 +54,15 @@ class Index extends Component
} }
// Future: redirect to a real lesson runner. For now open detail modal. // Future: redirect to a real lesson runner. For now open detail modal.
// Must target wire-elements modal component explicitly — Livewire 4 // Use $this->js() to emit a browser-side Livewire dispatch — server-side
// server-side dispatch does not auto-broadcast to other components. // $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named
$this->dispatch('openModal', component: 'tasks.modals.detail', arguments: ['task' => $task]) // payload args, causing "$component dependency unresolved" in Modal::openModal.
->to('livewire-ui-modal'); $payload = json_encode([
'component' => 'tasks.modals.detail',
'arguments' => ['task' => $task],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$this->js("Livewire.dispatch('openModal', {$payload})");
} }
public function repeatTask(string $key): void public function repeatTask(string $key): void

View File

@ -0,0 +1,16 @@
@props([
'lines' => 3,
'header' => true,
])
<div {{ $attributes->merge(['class' => 'bg-bg-soft border border-line rounded-xl p-5 shadow-sm animate-pulse flex flex-col gap-3']) }}>
@if($header)
<div class="flex justify-between items-center mb-2">
<div class="h-4 bg-line rounded w-1/3"></div>
<div class="h-5 bg-line rounded w-16"></div>
</div>
@endif
@for($i = 0; $i < (int) $lines; $i++)
<div class="h-3 bg-line rounded" style="width: {{ rand(60, 95) }}%;"></div>
@endfor
</div>

View File

@ -0,0 +1,14 @@
@props([
'cta' => true,
])
<div {{ $attributes->merge(['class' => 'flex justify-between items-end gap-6 animate-pulse']) }}>
<div class="flex-1 max-w-xl flex flex-col gap-3">
<div class="h-2 bg-line rounded w-20"></div>
<div class="h-8 bg-line rounded w-3/5"></div>
<div class="h-3 bg-line rounded w-4/5"></div>
</div>
@if($cta)
<div class="h-10 w-40 bg-line rounded-lg shrink-0"></div>
@endif
</div>

View File

@ -0,0 +1,5 @@
<div {{ $attributes->merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}>
<div class="h-2 bg-line rounded w-20"></div>
<div class="h-8 bg-line rounded w-24 mt-3"></div>
<div class="h-2 bg-line rounded w-32 mt-2"></div>
</div>

View File

@ -0,0 +1,10 @@
<div {{ $attributes->merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}>
<div class="w-10 h-10 rounded-lg bg-line mb-3"></div>
<div class="h-3 bg-line rounded w-3/5 mb-2"></div>
<div class="h-2 bg-line rounded w-4/5"></div>
<div class="h-1.5 rounded-full bg-line mt-3.5"></div>
<div class="flex justify-between mt-2">
<div class="h-2 bg-line rounded w-20"></div>
<div class="h-2 bg-line rounded w-10"></div>
</div>
</div>

View File

@ -0,0 +1,11 @@
<div {{ $attributes->merge(['class' => 'grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 animate-pulse']) }}>
<div class="w-10 h-10 rounded-lg bg-line"></div>
<div class="flex flex-col gap-2 min-w-0">
<div class="h-3 bg-line rounded w-3/5"></div>
<div class="h-2 bg-line rounded w-2/5"></div>
</div>
<div class="flex items-center gap-3">
<div class="h-3 bg-line rounded w-16"></div>
<div class="h-7 bg-line rounded-lg w-20"></div>
</div>
</div>

View File

@ -0,0 +1,22 @@
<div class="flex flex-col gap-8" data-testid="dashboard-skeleton">
<x-skeleton.header />
<section class="grid grid-cols-4 gap-4">
@for($i = 0; $i < 4; $i++)<x-skeleton.stat-tile />@endfor
</section>
<section class="flex flex-col gap-4">
<x-skeleton.header :cta="false" />
<div class="grid grid-cols-4 gap-4">
@for($i = 0; $i < 4; $i++)<x-skeleton.subject-card />@endfor
</div>
</section>
<section class="grid grid-cols-3 gap-5">
<x-skeleton.card class="col-span-2" :lines="5" />
<div class="flex flex-col gap-5">
<x-skeleton.card :lines="3" />
<x-skeleton.card :lines="3" />
</div>
</section>
</div>

View File

@ -0,0 +1,13 @@
<div class="flex flex-col gap-6" data-testid="notifications-skeleton">
<x-skeleton.header />
<div class="flex gap-2 animate-pulse">
@for($i = 0; $i < 2; $i++)
<div class="h-7 w-24 rounded-full bg-line"></div>
@endfor
</div>
<div class="bg-bg-soft border border-line rounded-xl shadow-sm flex flex-col">
@for($i = 0; $i < 5; $i++)<x-skeleton.table-row class="p-4 py-0" />@endfor
</div>
</div>

View File

@ -0,0 +1,12 @@
{{-- Photo placeholder mirrors page x-data to avoid Alpine "preview is not defined" race during hydration --}}
<div class="flex flex-col gap-6" x-data="{ file: null, preview: null, status: 'idle' }" data-testid="photo-help-skeleton">
<x-skeleton.header />
<div class="grid grid-cols-3 gap-5">
<x-skeleton.card class="col-span-2" :lines="5" :header="false" />
<div class="flex flex-col gap-4">
<x-skeleton.card :lines="2" />
<x-skeleton.card :lines="2" />
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
<div class="flex flex-col gap-6" data-testid="progress-skeleton">
<x-skeleton.header />
<section class="grid grid-cols-4 gap-4">
@for($i = 0; $i < 4; $i++)<x-skeleton.stat-tile />@endfor
</section>
<section class="grid grid-cols-3 gap-5">
<x-skeleton.card class="col-span-2" :lines="6" />
<x-skeleton.card :lines="4" />
</section>
<x-skeleton.card :lines="6" />
</div>

View File

@ -0,0 +1,6 @@
<div class="flex flex-col gap-6" data-testid="rewards-skeleton">
<x-skeleton.header />
<section class="grid grid-cols-4 gap-4">
@for($i = 0; $i < 8; $i++)<x-skeleton.subject-card />@endfor
</section>
</div>

View File

@ -0,0 +1,15 @@
<div class="flex flex-col gap-6" data-testid="settings-skeleton">
<x-skeleton.header :cta="false" />
<div class="grid grid-cols-[200px_1fr] gap-6">
{{-- Sub-nav --}}
<div class="flex flex-col gap-1 animate-pulse">
@for($i = 0; $i < 5; $i++)
<div class="h-9 bg-line rounded-lg"></div>
@endfor
</div>
{{-- Section card --}}
<x-skeleton.card :lines="6" />
</div>
</div>

View File

@ -0,0 +1,6 @@
<div class="flex flex-col gap-6" data-testid="subjects-skeleton">
<x-skeleton.header />
<section class="grid grid-cols-4 gap-4">
@for($i = 0; $i < 8; $i++)<x-skeleton.subject-card />@endfor
</section>
</div>

View File

@ -0,0 +1,15 @@
<div class="flex flex-col gap-6" data-testid="tasks-skeleton">
<x-skeleton.header />
{{-- Filter pills --}}
<div class="flex gap-2 animate-pulse">
@for($i = 0; $i < 3; $i++)
<div class="h-7 w-24 rounded-full bg-line"></div>
@endfor
</div>
{{-- Task table --}}
<div class="bg-bg-soft border border-line rounded-xl p-5 shadow-sm flex flex-col">
@for($i = 0; $i < 5; $i++)<x-skeleton.table-row />@endfor
</div>
</div>

View File

@ -1,59 +0,0 @@
<?php
use App\Livewire\Dashboard\Index as DashboardIndex;
use App\Livewire\Notifications\Index as NotificationsIndex;
use App\Livewire\PhotoHelp\Index as PhotoHelpIndex;
use App\Livewire\Progress\Index as ProgressIndex;
use App\Livewire\Rewards\Index as RewardsIndex;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\Subjects\Index as SubjectsIndex;
use App\Livewire\Tasks\Index as TasksIndex;
use Livewire\Attributes\Lazy;
/**
* Verifies all main Livewire pages declare #[Lazy] + a placeholder() that
* returns a server-rendered skeleton. The skeleton is what users see during
* page hydration prevents blank flash.
*/
$pages = [
DashboardIndex::class,
SubjectsIndex::class,
TasksIndex::class,
PhotoHelpIndex::class,
RewardsIndex::class,
ProgressIndex::class,
NotificationsIndex::class,
SettingsIndex::class,
];
it('skeleton page component file exists', function () {
expect(file_exists(base_path('resources/views/components/skeleton/page.blade.php')))->toBeTrue();
});
it('skeleton template has animate-pulse + data-testid="page-skeleton"', function () {
$tpl = file_get_contents(base_path('resources/views/components/skeleton/page.blade.php'));
expect($tpl)->toContain('animate-pulse');
expect($tpl)->toContain('data-testid="page-skeleton"');
expect($tpl)->toContain('bg-line');
});
foreach ($pages as $pageClass) {
it("{$pageClass} declares #[Lazy] attribute", function () use ($pageClass) {
$ref = new ReflectionClass($pageClass);
$attrs = $ref->getAttributes(Lazy::class);
expect($attrs)->not->toBeEmpty("Class {$pageClass} must have #[Lazy] attribute");
});
it("{$pageClass} has placeholder() static method returning skeleton view", function () use ($pageClass) {
$ref = new ReflectionClass($pageClass);
expect($ref->hasMethod('placeholder'))->toBeTrue("Class {$pageClass} must define placeholder()");
$method = $ref->getMethod('placeholder');
expect($method->isStatic())->toBeTrue("{$pageClass}::placeholder() must be static");
$view = $pageClass::placeholder([]);
expect($view)->toBeInstanceOf(Illuminate\Contracts\View\View::class);
expect($view->getName())->toBe('components.skeleton.page');
});
}

View File

@ -0,0 +1,55 @@
<?php
/**
* Each Livewire page must declare its OWN page-specific placeholder view
* (not a generic skeleton) so the loading shape matches the final layout
* and Alpine x-data states (e.g. PhotoHelp `preview`) are present from
* the very first paint.
*/
use App\Livewire\Dashboard\Index as DashboardIndex;
use App\Livewire\Notifications\Index as NotificationsIndex;
use App\Livewire\PhotoHelp\Index as PhotoHelpIndex;
use App\Livewire\Progress\Index as ProgressIndex;
use App\Livewire\Rewards\Index as RewardsIndex;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\Subjects\Index as SubjectsIndex;
use App\Livewire\Tasks\Index as TasksIndex;
$placeholderMap = [
DashboardIndex::class => ['view' => 'livewire.dashboard.placeholder', 'marker' => 'dashboard-skeleton'],
SubjectsIndex::class => ['view' => 'livewire.subjects.placeholder', 'marker' => 'subjects-skeleton'],
TasksIndex::class => ['view' => 'livewire.tasks.placeholder', 'marker' => 'tasks-skeleton'],
PhotoHelpIndex::class => ['view' => 'livewire.photo-help.placeholder', 'marker' => 'photo-help-skeleton'],
RewardsIndex::class => ['view' => 'livewire.rewards.placeholder', 'marker' => 'rewards-skeleton'],
ProgressIndex::class => ['view' => 'livewire.progress.placeholder', 'marker' => 'progress-skeleton'],
NotificationsIndex::class => ['view' => 'livewire.notifications.placeholder', 'marker' => 'notifications-skeleton'],
SettingsIndex::class => ['view' => 'livewire.settings.placeholder', 'marker' => 'settings-skeleton'],
];
foreach ($placeholderMap as $class => $meta) {
it("{$class} returns its OWN placeholder view ({$meta['view']})", function () use ($class, $meta) {
$view = $class::placeholder([]);
expect($view->getName())->toBe($meta['view']);
});
it("placeholder for {$class} has unique data-testid (\"{$meta['marker']}\")", function () use ($class, $meta) {
$html = $class::placeholder([])->render();
expect($html)->toContain($meta['marker']);
});
}
it('photo-help placeholder pre-declares Alpine x-data with preview state', function () {
// Fixes "Alpine Expression Error: preview is not defined" during Lazy
// hydration — placeholder must mount the same x-data scope as the real view.
$html = PhotoHelpIndex::placeholder([])->render();
expect($html)->toContain('x-data');
expect($html)->toContain('preview');
});
it('skeleton blade components exist for composition', function () {
foreach (['stat-tile', 'subject-card', 'table-row', 'header', 'card'] as $component) {
$path = "resources/views/components/skeleton/{$component}.blade.php";
expect(file_exists(base_path($path)))->toBeTrue("Missing skeleton component: {$path}");
}
});

View File

@ -66,16 +66,17 @@ it('startTask action runs and adds key to startedKeys', function () {
->assertSet('startedKeys', ['t1']); ->assertSet('startedKeys', ['t1']);
}); });
it('startTask dispatches openModal event targeted at livewire-ui-modal', function () { it('startTask emits browser-side Livewire.dispatch via $this->js()', function () {
Livewire::test(TasksIndex::class) $tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
->call('startTask', 't1')
->assertDispatched('openModal');
});
it('startTask dispatch targets livewire-ui-modal with component=tasks.modals.detail', function () { // Livewire 4 stores js() output in component effects under 'xjs' key
Livewire::test(TasksIndex::class) $effects = $tester->effects ?? [];
->call('startTask', 't1') $scripts = $effects['xjs'] ?? $effects['js'] ?? [];
->assertDispatchedTo('livewire-ui-modal', 'openModal'); $allScripts = implode("\n", is_array($scripts) ? array_map(fn($s) => is_array($s) ? json_encode($s) : (string) $s, $scripts) : [(string) $scripts]);
expect($allScripts)->toContain("Livewire.dispatch('openModal'");
expect($allScripts)->toContain('tasks.modals.detail');
expect($allScripts)->toContain('t1');
}); });
it('startTask ignores unknown task keys', function () { it('startTask ignores unknown task keys', function () {