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
parent
7e6d0d4ab4
commit
a6c44fd169
|
|
@ -14,7 +14,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 5]);
|
||||
return view('livewire.dashboard.placeholder');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 4]);
|
||||
return view('livewire.notifications.placeholder');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 3]);
|
||||
return view('livewire.photo-help.placeholder');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 4]);
|
||||
return view('livewire.progress.placeholder');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 4]);
|
||||
return view('livewire.rewards.placeholder');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 4]);
|
||||
return view('livewire.settings.placeholder');
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Index extends Component
|
|||
{
|
||||
public static function placeholder(array $params = []): View
|
||||
{
|
||||
return view('components.skeleton.page', ['rows' => 4]);
|
||||
return view('livewire.subjects.placeholder');
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class Index extends Component
|
|||
{
|
||||
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.
|
||||
// Must target wire-elements modal component explicitly — Livewire 4
|
||||
// server-side dispatch does not auto-broadcast to other components.
|
||||
$this->dispatch('openModal', component: 'tasks.modals.detail', arguments: ['task' => $task])
|
||||
->to('livewire-ui-modal');
|
||||
// Use $this->js() to emit a browser-side Livewire dispatch — server-side
|
||||
// $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named
|
||||
// payload args, causing "$component dependency unresolved" in Modal::openModal.
|
||||
$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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
});
|
||||
|
|
@ -66,16 +66,17 @@ it('startTask action runs and adds key to startedKeys', function () {
|
|||
->assertSet('startedKeys', ['t1']);
|
||||
});
|
||||
|
||||
it('startTask dispatches openModal event targeted at livewire-ui-modal', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 't1')
|
||||
->assertDispatched('openModal');
|
||||
});
|
||||
it('startTask emits browser-side Livewire.dispatch via $this->js()', function () {
|
||||
$tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
|
||||
|
||||
it('startTask dispatch targets livewire-ui-modal with component=tasks.modals.detail', function () {
|
||||
Livewire::test(TasksIndex::class)
|
||||
->call('startTask', 't1')
|
||||
->assertDispatchedTo('livewire-ui-modal', 'openModal');
|
||||
// Livewire 4 stores js() output in component effects under 'xjs' key
|
||||
$effects = $tester->effects ?? [];
|
||||
$scripts = $effects['xjs'] ?? $effects['js'] ?? [];
|
||||
$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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue