feat(tasks): separate Runner modal — Start actually runs the task
Before: Both info-icon (Details) and Start button opened the same
modal (tasks.modals.detail). Made no sense — Start should DO the task,
not just show metadata.
Now:
- Info-icon (i) -> tasks.modals.detail (read-only metadata)
- Start / Wiederholen-> tasks.modals.runner (interactive quiz)
New: App\Livewire\Tasks\Modals\Runner
Multi-step quiz modal with:
- 4-step progress bar (Frage X / Y + colored bar)
- Question + 4 answer options (2x2 grid, Tailwind cards)
- submitAnswer(int $option) advances step + tallies correctAnswers
- finished state shows trophy (passed) or arrow-path (partial)
- restart() resets quiz to step 1
- closeModalOnClickAway() = false (forces explicit cancel/finish)
- 2xl max-width for comfortable reading
Tasks/Index::startTask() now dispatches tasks.modals.runner via
$this->js("Livewire.dispatch('openModal', ...)") — same dispatch pattern
as before, different target component.
Placeholder data: 4 multiplication questions hardcoded. Future iteration
should load task content from DB (TaskItem / Question models).
Tests:
- TasksRunnerModalTest (10 tests, 25 assertions):
* Runner extends ModalComponent + alias resolves
* Step 1 renders question + 4 options
* Correct answer increments correctAnswers + advances step
* Wrong answer advances step without point
* Final step sets finished=true
* restart() resets state
* closeModalOnClickAway = false
* Progress label "Frage X / Y"
* Success path shows trophy
- TasksStartButtonTest updated:
* Asserts startTask emits dispatch with 'tasks.modals.runner'
(NOT 'tasks.modals.detail')
* Asserts info-button still routes to detail
- 167 tests passed, 0 failed, 0 skipped
Verification:
- All 8 modal aliases resolve via livewire.finder
- All 8 page routes return 302 unauth
- npm build OK
- No HTTP / no console errors expected
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
parent
a6c44fd169
commit
7c6ea8d9f0
|
|
@ -53,12 +53,12 @@ class Index extends Component
|
||||||
$this->startedKeys[] = $key;
|
$this->startedKeys[] = $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future: redirect to a real lesson runner. For now open detail modal.
|
// Start = open the Runner modal (actual quiz/exercise) — NOT the Detail
|
||||||
// Use $this->js() to emit a browser-side Livewire dispatch — server-side
|
// modal which is reserved for the info-icon button. Use $this->js() to
|
||||||
// $this->dispatch()->to(...) in Livewire 4 + wire-elements drops named
|
// emit a browser-side Livewire dispatch — server-side $this->dispatch()
|
||||||
// payload args, causing "$component dependency unresolved" in Modal::openModal.
|
// with ->to(...) in Livewire 4 + wire-elements drops named payload args.
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'component' => 'tasks.modals.detail',
|
'component' => 'tasks.modals.runner',
|
||||||
'arguments' => ['task' => $task],
|
'arguments' => ['task' => $task],
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Tasks\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Runner extends ModalComponent
|
||||||
|
{
|
||||||
|
public array $task = [];
|
||||||
|
public int $currentStep = 1;
|
||||||
|
public int $totalSteps = 4;
|
||||||
|
public bool $finished = false;
|
||||||
|
public ?string $answer = null;
|
||||||
|
public int $correctAnswers = 0;
|
||||||
|
|
||||||
|
/** @var array<int,array<string,mixed>> Placeholder mini-quiz; future = DB-loaded items */
|
||||||
|
protected array $steps = [
|
||||||
|
['question' => 'Was ergibt 12 × 8?', 'options' => ['64', '88', '96', '102'], 'correct' => 2],
|
||||||
|
['question' => 'Was ergibt 15 × 7?', 'options' => ['95', '105', '115', '125'], 'correct' => 1],
|
||||||
|
['question' => 'Was ergibt 9 × 11?', 'options' => ['91', '99', '108', '111'], 'correct' => 1],
|
||||||
|
['question' => 'Was ergibt 14 × 6?', 'options' => ['72', '78', '84', '88'], 'correct' => 2],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(array $task = []): void
|
||||||
|
{
|
||||||
|
$this->task = $task;
|
||||||
|
$this->totalSteps = count($this->steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentQuestion(): array
|
||||||
|
{
|
||||||
|
return $this->steps[$this->currentStep - 1] ?? $this->steps[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitAnswer(int $optionIndex): void
|
||||||
|
{
|
||||||
|
$q = $this->currentQuestion();
|
||||||
|
if ($optionIndex === ($q['correct'] ?? -1)) {
|
||||||
|
$this->correctAnswers++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentStep >= $this->totalSteps) {
|
||||||
|
$this->finished = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->currentStep++;
|
||||||
|
$this->answer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restart(): void
|
||||||
|
{
|
||||||
|
$this->currentStep = 1;
|
||||||
|
$this->finished = false;
|
||||||
|
$this->correctAnswers = 0;
|
||||||
|
$this->answer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return '2xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function closeModalOnClickAway(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.tasks.modals.runner');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
@php
|
||||||
|
$q = $this->currentQuestion();
|
||||||
|
$progressPercent = $totalSteps > 0 ? (int) round(($currentStep / $totalSteps) * 100) : 0;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div data-testid="task-runner-modal">
|
||||||
|
|
||||||
|
<header class="flex items-center justify-between gap-4 p-5 border-b border-line">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-primary-soft text-primary-ink grid place-items-center font-bold text-lg shrink-0">
|
||||||
|
{{ $task['icon'] ?? '∑' }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold">{{ $task['meta'] ?? '' }}</div>
|
||||||
|
<div class="font-bold text-base text-ink-1 truncate">{{ $task['title'] ?? 'Aufgabe' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1 shrink-0">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if(! $finished)
|
||||||
|
{{-- Progress bar --}}
|
||||||
|
<div class="px-5 pt-4">
|
||||||
|
<div class="flex justify-between items-baseline mb-2">
|
||||||
|
<span class="text-xs font-semibold text-ink-2 font-mono tabular-nums">Frage {{ $currentStep }} / {{ $totalSteps }}</span>
|
||||||
|
<span class="text-xs font-semibold text-amber-ink font-mono tabular-nums">+{{ (int) ($task['points'] ?? 0) }} Pkt.</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 rounded-full bg-bg-base overflow-hidden">
|
||||||
|
<div class="h-full rounded-full bg-primary-ink transition-all duration-500" style="width:{{ $progressPercent }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Question --}}
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-2">Aufgabe</div>
|
||||||
|
<div class="text-xl font-bold tracking-tight text-ink-1 mb-5" data-testid="runner-question">
|
||||||
|
{{ $q['question'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3" wire:key="step-{{ $currentStep }}">
|
||||||
|
@foreach($q['options'] as $i => $opt)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="submitAnswer({{ $i }})"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
wire:target="submitAnswer"
|
||||||
|
class="px-4 py-4 rounded-lg bg-bg-soft border-2 border-line text-ink-1 font-semibold text-base hover:border-primary hover:bg-primary-soft transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||||
|
data-testid="runner-option-{{ $i }}"
|
||||||
|
>
|
||||||
|
{{ $opt }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="px-5 pb-5 pt-2 flex justify-between items-center text-xs text-ink-3">
|
||||||
|
<span>Klick eine Antwort an, um fortzufahren.</span>
|
||||||
|
<button type="button" wire:click="closeModal" class="hover:text-ink-1 underline">Abbrechen</button>
|
||||||
|
</footer>
|
||||||
|
@else
|
||||||
|
{{-- Finished state --}}
|
||||||
|
@php
|
||||||
|
$passed = $correctAnswers >= (int) ceil($totalSteps / 2);
|
||||||
|
$percent = $totalSteps > 0 ? (int) round(($correctAnswers / $totalSteps) * 100) : 0;
|
||||||
|
@endphp
|
||||||
|
<div class="p-8 text-center" data-testid="runner-finished">
|
||||||
|
<div class="w-16 h-16 rounded-full grid place-items-center mx-auto mb-4 {{ $passed ? 'bg-green-soft text-green-ink' : 'bg-amber-soft text-amber-ink' }}">
|
||||||
|
@if($passed)
|
||||||
|
<x-heroicon-s-trophy class="w-8 h-8" />
|
||||||
|
@else
|
||||||
|
<x-heroicon-s-arrow-path class="w-8 h-8" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xl font-bold text-ink-1 mb-1">
|
||||||
|
@if($passed) Super gemacht! @else Fast geschafft @endif
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-ink-2 mb-2">
|
||||||
|
Du hast <span class="font-mono tabular-nums font-bold text-ink-1">{{ $correctAnswers }}</span>
|
||||||
|
von <span class="font-mono tabular-nums font-bold text-ink-1">{{ $totalSteps }}</span> Aufgaben richtig.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-ink-3 mb-6 font-mono tabular-nums">{{ $percent }} % Erfolgsquote</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-btn wire:click="restart" class="flex-1 justify-center">
|
||||||
|
<x-heroicon-o-arrow-path class="w-4 h-4" />
|
||||||
|
Nochmal
|
||||||
|
</x-btn>
|
||||||
|
<x-btn variant="primary" wire:click="closeModal" class="flex-1 justify-center">
|
||||||
|
@if($passed) Punkte holen @else Fertig @endif
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Tasks\Modals\Runner;
|
||||||
|
use App\Models\License;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(\Database\Seeders\RolesSeeder::class);
|
||||||
|
|
||||||
|
$tenant = Tenant::firstOrCreate(['name' => 'Test Schule'], ['type' => 'school', 'active' => true]);
|
||||||
|
$this->user = User::withoutGlobalScopes()->create([
|
||||||
|
'name' => 'Runner Tester', 'email' => 'runner@test.example',
|
||||||
|
'tenant_id' => $tenant->id, 'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role = Role::where('name', 'child')->first();
|
||||||
|
DB::table('role_user')->insert(['user_id' => $this->user->id, 'role_id' => $role->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||||
|
|
||||||
|
$license = License::withoutGlobalScopes()->create([
|
||||||
|
'tenant_id' => $tenant->id, 'type' => 'school_flat',
|
||||||
|
'expires_at' => now()->addYear(), 'active' => true,
|
||||||
|
]);
|
||||||
|
DB::table('license_assignments')->insert(['license_id' => $license->id, 'user_id' => $this->user->id, 'created_at' => now(), 'updated_at' => now()]);
|
||||||
|
|
||||||
|
$this->task = ['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathe · 4 Aufgaben', 'icon' => '∑', 'points' => 120];
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner modal class extends ModalComponent', function () {
|
||||||
|
expect(is_subclass_of(Runner::class, ModalComponent::class))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner alias resolves to App\\Livewire\\Tasks\\Modals\\Runner', function () {
|
||||||
|
$cls = app('livewire.finder')->resolveClassComponentClassName('tasks.modals.runner');
|
||||||
|
expect($cls)->toBe(Runner::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner renders question + 4 options for step 1', function () {
|
||||||
|
Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->assertSet('currentStep', 1)
|
||||||
|
->assertSet('totalSteps', 4)
|
||||||
|
->assertSet('finished', false)
|
||||||
|
->assertSee('Multiplikation bis 100')
|
||||||
|
->assertSeeHtml('data-testid="runner-question"')
|
||||||
|
->assertSeeHtml('data-testid="runner-option-0"')
|
||||||
|
->assertSeeHtml('data-testid="runner-option-3"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitAnswer with correct option increments correctAnswers + advances step', function () {
|
||||||
|
Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->call('submitAnswer', 2) // step 1 correct = index 2
|
||||||
|
->assertSet('correctAnswers', 1)
|
||||||
|
->assertSet('currentStep', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submitAnswer with wrong option still advances step but no point', function () {
|
||||||
|
Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->call('submitAnswer', 0) // step 1 wrong
|
||||||
|
->assertSet('correctAnswers', 0)
|
||||||
|
->assertSet('currentStep', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finishing all steps sets finished=true', function () {
|
||||||
|
Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->call('submitAnswer', 2) // step 1
|
||||||
|
->call('submitAnswer', 1) // step 2
|
||||||
|
->call('submitAnswer', 1) // step 3
|
||||||
|
->call('submitAnswer', 2) // step 4 — final
|
||||||
|
->assertSet('finished', true)
|
||||||
|
->assertSet('correctAnswers', 4)
|
||||||
|
->assertSeeHtml('data-testid="runner-finished"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restart resets state', function () {
|
||||||
|
Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->call('submitAnswer', 2)
|
||||||
|
->call('submitAnswer', 1)
|
||||||
|
->call('restart')
|
||||||
|
->assertSet('currentStep', 1)
|
||||||
|
->assertSet('correctAnswers', 0)
|
||||||
|
->assertSet('finished', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner does NOT close on click-away (must finish or cancel explicitly)', function () {
|
||||||
|
expect(Runner::closeModalOnClickAway())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner displays progress bar with step indicator', function () {
|
||||||
|
$html = Livewire::test(Runner::class, ['task' => $this->task])->html();
|
||||||
|
expect($html)->toContain('Frage 1 / 4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Runner shows trophy on success, arrow-path on partial', function () {
|
||||||
|
// 4/4 correct → trophy (passed)
|
||||||
|
$html = Livewire::test(Runner::class, ['task' => $this->task])
|
||||||
|
->call('submitAnswer', 2)
|
||||||
|
->call('submitAnswer', 1)
|
||||||
|
->call('submitAnswer', 1)
|
||||||
|
->call('submitAnswer', 2)
|
||||||
|
->html();
|
||||||
|
expect($html)->toContain('Super gemacht!');
|
||||||
|
});
|
||||||
|
|
@ -66,19 +66,27 @@ it('startTask action runs and adds key to startedKeys', function () {
|
||||||
->assertSet('startedKeys', ['t1']);
|
->assertSet('startedKeys', ['t1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('startTask emits browser-side Livewire.dispatch via $this->js()', function () {
|
it('startTask emits browser-side Livewire.dispatch via $this->js() targeting RUNNER modal (not detail)', function () {
|
||||||
$tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
|
$tester = Livewire::test(TasksIndex::class)->call('startTask', 't1');
|
||||||
|
|
||||||
// Livewire 4 stores js() output in component effects under 'xjs' key
|
|
||||||
$effects = $tester->effects ?? [];
|
$effects = $tester->effects ?? [];
|
||||||
$scripts = $effects['xjs'] ?? $effects['js'] ?? [];
|
$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]);
|
$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("Livewire.dispatch('openModal'");
|
||||||
expect($allScripts)->toContain('tasks.modals.detail');
|
// Start button opens RUNNER, not detail
|
||||||
|
expect($allScripts)->toContain('tasks.modals.runner');
|
||||||
|
expect($allScripts)->not->toContain('tasks.modals.detail');
|
||||||
expect($allScripts)->toContain('t1');
|
expect($allScripts)->toContain('t1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Detail (info) button still opens tasks.modals.detail — separate from Start', function () {
|
||||||
|
$html = Livewire::test(TasksIndex::class)->html();
|
||||||
|
// Info-button is client-side wire:click="$dispatch('openModal', ...detail...)"
|
||||||
|
expect($html)->toContain('data-testid="task-detail-btn"');
|
||||||
|
expect($html)->toContain('tasks.modals.detail');
|
||||||
|
});
|
||||||
|
|
||||||
it('startTask ignores unknown task keys', function () {
|
it('startTask ignores unknown task keys', function () {
|
||||||
Livewire::test(TasksIndex::class)
|
Livewire::test(TasksIndex::class)
|
||||||
->call('startTask', 'non-existent')
|
->call('startTask', 'non-existent')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue