diff --git a/app/Livewire/Dashboard/Index.php b/app/Livewire/Dashboard/Index.php index 88dd339..0f08fd8 100644 --- a/app/Livewire/Dashboard/Index.php +++ b/app/Livewire/Dashboard/Index.php @@ -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'); } diff --git a/app/Livewire/Notifications/Index.php b/app/Livewire/Notifications/Index.php index 3dbff69..353518e 100644 --- a/app/Livewire/Notifications/Index.php +++ b/app/Livewire/Notifications/Index.php @@ -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'); } diff --git a/app/Livewire/PhotoHelp/Index.php b/app/Livewire/PhotoHelp/Index.php index d524d61..0b22fee 100644 --- a/app/Livewire/PhotoHelp/Index.php +++ b/app/Livewire/PhotoHelp/Index.php @@ -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 diff --git a/app/Livewire/Progress/Index.php b/app/Livewire/Progress/Index.php index a2aa60b..3d8443e 100644 --- a/app/Livewire/Progress/Index.php +++ b/app/Livewire/Progress/Index.php @@ -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 diff --git a/app/Livewire/Rewards/Index.php b/app/Livewire/Rewards/Index.php index 67bdd0f..a2db27c 100644 --- a/app/Livewire/Rewards/Index.php +++ b/app/Livewire/Rewards/Index.php @@ -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 diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index b5f5862..afc0a18 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -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'); } diff --git a/app/Livewire/Subjects/Index.php b/app/Livewire/Subjects/Index.php index 1afc2eb..e003b0d 100644 --- a/app/Livewire/Subjects/Index.php +++ b/app/Livewire/Subjects/Index.php @@ -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 diff --git a/app/Livewire/Tasks/Index.php b/app/Livewire/Tasks/Index.php index 6b6c35d..bcc32b2 100644 --- a/app/Livewire/Tasks/Index.php +++ b/app/Livewire/Tasks/Index.php @@ -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 diff --git a/resources/views/components/skeleton/card.blade.php b/resources/views/components/skeleton/card.blade.php new file mode 100644 index 0000000..5529599 --- /dev/null +++ b/resources/views/components/skeleton/card.blade.php @@ -0,0 +1,16 @@ +@props([ + 'lines' => 3, + 'header' => true, +]) + +
merge(['class' => 'bg-bg-soft border border-line rounded-xl p-5 shadow-sm animate-pulse flex flex-col gap-3']) }}> + @if($header) +
+
+
+
+ @endif + @for($i = 0; $i < (int) $lines; $i++) +
+ @endfor +
diff --git a/resources/views/components/skeleton/header.blade.php b/resources/views/components/skeleton/header.blade.php new file mode 100644 index 0000000..cc8b7e9 --- /dev/null +++ b/resources/views/components/skeleton/header.blade.php @@ -0,0 +1,14 @@ +@props([ + 'cta' => true, +]) + +
merge(['class' => 'flex justify-between items-end gap-6 animate-pulse']) }}> +
+
+
+
+
+ @if($cta) +
+ @endif +
diff --git a/resources/views/components/skeleton/stat-tile.blade.php b/resources/views/components/skeleton/stat-tile.blade.php new file mode 100644 index 0000000..b7bea24 --- /dev/null +++ b/resources/views/components/skeleton/stat-tile.blade.php @@ -0,0 +1,5 @@ +
merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}> +
+
+
+
diff --git a/resources/views/components/skeleton/subject-card.blade.php b/resources/views/components/skeleton/subject-card.blade.php new file mode 100644 index 0000000..3c095e9 --- /dev/null +++ b/resources/views/components/skeleton/subject-card.blade.php @@ -0,0 +1,10 @@ +
merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm animate-pulse']) }}> +
+
+
+
+
+
+
+
+
diff --git a/resources/views/components/skeleton/table-row.blade.php b/resources/views/components/skeleton/table-row.blade.php new file mode 100644 index 0000000..38a838e --- /dev/null +++ b/resources/views/components/skeleton/table-row.blade.php @@ -0,0 +1,11 @@ +
merge(['class' => 'grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 animate-pulse']) }}> +
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/livewire/dashboard/placeholder.blade.php b/resources/views/livewire/dashboard/placeholder.blade.php new file mode 100644 index 0000000..7b5752b --- /dev/null +++ b/resources/views/livewire/dashboard/placeholder.blade.php @@ -0,0 +1,22 @@ +
+ + +
+ @for($i = 0; $i < 4; $i++)@endfor +
+ +
+ +
+ @for($i = 0; $i < 4; $i++)@endfor +
+
+ +
+ +
+ + +
+
+
diff --git a/resources/views/livewire/notifications/placeholder.blade.php b/resources/views/livewire/notifications/placeholder.blade.php new file mode 100644 index 0000000..0e6d293 --- /dev/null +++ b/resources/views/livewire/notifications/placeholder.blade.php @@ -0,0 +1,13 @@ +
+ + +
+ @for($i = 0; $i < 2; $i++) +
+ @endfor +
+ +
+ @for($i = 0; $i < 5; $i++)@endfor +
+
diff --git a/resources/views/livewire/photo-help/placeholder.blade.php b/resources/views/livewire/photo-help/placeholder.blade.php new file mode 100644 index 0000000..82c1217 --- /dev/null +++ b/resources/views/livewire/photo-help/placeholder.blade.php @@ -0,0 +1,12 @@ +{{-- Photo placeholder mirrors page x-data to avoid Alpine "preview is not defined" race during hydration --}} +
+ + +
+ +
+ + +
+
+
diff --git a/resources/views/livewire/progress/placeholder.blade.php b/resources/views/livewire/progress/placeholder.blade.php new file mode 100644 index 0000000..4d05b7c --- /dev/null +++ b/resources/views/livewire/progress/placeholder.blade.php @@ -0,0 +1,14 @@ +
+ + +
+ @for($i = 0; $i < 4; $i++)@endfor +
+ +
+ + +
+ + +
diff --git a/resources/views/livewire/rewards/placeholder.blade.php b/resources/views/livewire/rewards/placeholder.blade.php new file mode 100644 index 0000000..d7ed525 --- /dev/null +++ b/resources/views/livewire/rewards/placeholder.blade.php @@ -0,0 +1,6 @@ +
+ +
+ @for($i = 0; $i < 8; $i++)@endfor +
+
diff --git a/resources/views/livewire/settings/placeholder.blade.php b/resources/views/livewire/settings/placeholder.blade.php new file mode 100644 index 0000000..524f4eb --- /dev/null +++ b/resources/views/livewire/settings/placeholder.blade.php @@ -0,0 +1,15 @@ +
+ + +
+ {{-- Sub-nav --}} +
+ @for($i = 0; $i < 5; $i++) +
+ @endfor +
+ + {{-- Section card --}} + +
+
diff --git a/resources/views/livewire/subjects/placeholder.blade.php b/resources/views/livewire/subjects/placeholder.blade.php new file mode 100644 index 0000000..dc9d078 --- /dev/null +++ b/resources/views/livewire/subjects/placeholder.blade.php @@ -0,0 +1,6 @@ +
+ +
+ @for($i = 0; $i < 8; $i++)@endfor +
+
diff --git a/resources/views/livewire/tasks/placeholder.blade.php b/resources/views/livewire/tasks/placeholder.blade.php new file mode 100644 index 0000000..b853dac --- /dev/null +++ b/resources/views/livewire/tasks/placeholder.blade.php @@ -0,0 +1,15 @@ +
+ + + {{-- Filter pills --}} +
+ @for($i = 0; $i < 3; $i++) +
+ @endfor +
+ + {{-- Task table --}} +
+ @for($i = 0; $i < 5; $i++)@endfor +
+
diff --git a/tests/Feature/Livewire/LazySkeletonTest.php b/tests/Feature/Livewire/LazySkeletonTest.php deleted file mode 100644 index 1fd8d9b..0000000 --- a/tests/Feature/Livewire/LazySkeletonTest.php +++ /dev/null @@ -1,59 +0,0 @@ -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'); - }); -} diff --git a/tests/Feature/Livewire/PerPageSkeletonTest.php b/tests/Feature/Livewire/PerPageSkeletonTest.php new file mode 100644 index 0000000..04bc15a --- /dev/null +++ b/tests/Feature/Livewire/PerPageSkeletonTest.php @@ -0,0 +1,55 @@ + ['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}"); + } +}); diff --git a/tests/Feature/Livewire/TasksStartButtonTest.php b/tests/Feature/Livewire/TasksStartButtonTest.php index 1dd4e74..51e3d7a 100644 --- a/tests/Feature/Livewire/TasksStartButtonTest.php +++ b/tests/Feature/Livewire/TasksStartButtonTest.php @@ -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 () {