diff --git a/app/Livewire/Pages.php b/app/Livewire/Pages.php index deddb9a..c35d954 100644 --- a/app/Livewire/Pages.php +++ b/app/Livewire/Pages.php @@ -7,22 +7,23 @@ use App\Actions\SearchPage\SearchAction; use App\Enums\PakSlug; use App\Enums\SiteName; +use App\Models\Page; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\View\View; +use Illuminate\Pagination\Paginator; +use Livewire\Attributes\Computed; use Livewire\Attributes\Url; +use Livewire\Attributes\Validate; use Livewire\Component; -use Livewire\WithPagination; +use Livewire\Livewire; final class Pages extends Component { - use WithPagination; - + #[Validate('string|max:20')] public string $keyword = ''; - /** - * @var int|string|null - */ #[Url] - public $page = 1; + public int $page = 1; /** * @var array @@ -42,33 +43,57 @@ final class Pages extends Component SiteName::Portal->value => true, ]; - public function render(SearchAction $searchAction): View + /** + * `WithPagination`トレイトを外した分、ページネーションリンクの生成元URLを + * 実際のページURLに合わせる処理(本来はトレイトの`boot()`が担っていた)を + * ここで肩代わりする。これが無いと、Livewireのアクション経由でページ送り + * リンクを再生成した際にリンク先がLivewireの内部エンドポイント(POST専用) + * になってしまい、クリック時に405が返る。 + */ + public function boot(): void { - $this->resetPage(); - if (! is_numeric($this->page)) { - $this->page = 1; - } - - return view('livewire.pages', [ - 'pages' => $searchAction([ - 'keyword' => $this->keyword, - 'paks' => $this->selectedPaks(), - 'sites' => $this->selectedSites(), - 'page' => $this->page, - ]), - ]); + Paginator::currentPathResolver(fn (): string => Livewire::originalPath()); } - public function onConditionUpdate(SearchAction $searchAction): View + public function render(): View { + return view('livewire.pages'); + } - return $this->render($searchAction); + /** + * ページネーションリンクは通常のURL遷移(`?page=N`)で行われ、`#[Url] $page` が + * 「今何ページ目か」を表す唯一の状態源。検索条件(キーワード・pak・サイト)を + * 変えたときは、ここで明示的に1ページ目へ戻す。 + * + * 注意: `$keyword`/`$paks`/`$sites`は`#[Url]`化していないため、ページネーション + * リンク(プレーンな``によるページ遷移)をクリックすると検索条件はURLに + * 乗らず、デフォルト状態でコンポーネントが再マウントされる(既知の制限。今回の + * 修正はページ番号の二重管理を解消する範囲に限定しており、この制限自体はスコープ外)。 + */ + public function onConditionUpdate(): void + { + $this->page = 1; } public function clear(): void { - $this->resetPage(); - $this->reset('keyword', 'paks', 'sites'); + $this->reset('keyword', 'paks', 'sites', 'page'); + } + + /** + * @return LengthAwarePaginator + */ + #[Computed] + public function pages(): LengthAwarePaginator + { + return (new SearchAction)([ + 'keyword' => $this->keyword, + 'paks' => $this->selectedPaks(), + 'sites' => $this->selectedSites(), + // #[Url]は型不一致(TypeError)は弾くが、0以下の値はintとして許してしまうため、 + // ?page=-1 のような不正なURLでもここで1に丸める。 + 'page' => max(1, $this->page), + ]); } /** diff --git a/resources/views/livewire/pages.blade.php b/resources/views/livewire/pages.blade.php index 30bbd9a..2bc0ac5 100644 --- a/resources/views/livewire/pages.blade.php +++ b/resources/views/livewire/pages.blade.php @@ -16,13 +16,16 @@ + @error('keyword') +

{{ $message }}

+ @enderror
- {{ $pages->onEachSide(1)->links('tailwind_custom') }} + {{ $this->pages->onEachSide(1)->links('tailwind_custom') }}
    - @forelse ($pages as $page) + @forelse ($this->pages as $page)
  • @foreach ($page->paks as $pak) @@ -42,6 +45,6 @@ @endforelse
- {{ $pages->onEachSide(1)->links('tailwind_custom') }} + {{ $this->pages->onEachSide(1)->links('tailwind_custom') }}
diff --git a/tests/Feature/Livewire/PagesTest.php b/tests/Feature/Livewire/PagesTest.php new file mode 100644 index 0000000..bff6b44 --- /dev/null +++ b/tests/Feature/Livewire/PagesTest.php @@ -0,0 +1,128 @@ +assertOk(); + } + + public function test_default_state_selects_all_paks_and_sites(): void + { + Livewire::test(Pages::class) + ->assertSet('paks', [ + PakSlug::Pak64->value => true, + PakSlug::Pak128->value => true, + PakSlug::Pak128Jp->value => true, + ]) + ->assertSet('sites', [ + SiteName::Japan->value => true, + SiteName::Twitrans->value => true, + SiteName::Portal->value => true, + ]); + } + + public function test_condition_update_resets_page_to_one(): void + { + Livewire::test(Pages::class) + ->set('page', 3) + ->call('onConditionUpdate') + ->assertSet('page', 1); + } + + public function test_pagination_links_do_not_point_to_the_livewire_update_endpoint(): void + { + // WithPagination除去に伴い、ページネーションリンクの生成元パスをboot()で + // 明示的に補っている(Livewire::originalPath())。これが無いと、Livewireの + // アクション経由で再描画した際にリンク先がPOST専用の内部updateエンドポイント + // になり、通常のリンククリック(GET)が405になる回帰を防ぐテスト。 + $pak = Pak::factory()->create(['slug' => PakSlug::Pak128]); + // Fakerの乱数urlは60件生成すると衝突しうる(raw_pages/pagesのurlユニーク制約)ため、 + // テストの再現性を優先して明示的にユニークなurlを採番する。 + for ($i = 0; $i < 60; $i++) { + $page = Page::factory()->create([ + 'site_name' => SiteName::Japan, + 'url' => "https://example.test/page-{$i}", + 'raw_page_id' => RawPage::factory()->create(['url' => "https://example.test/raw-{$i}"])->id, + ]); + $page->paks()->attach($pak); + } + + $html = Livewire::test(Pages::class) + ->call('onConditionUpdate') + ->html(); + + $this->assertMatchesRegularExpression('#href="[^"]*\?page=2"#', $html); + $this->assertStringNotContainsString('/update?page=2', $html); + } + + public function test_clear_resets_keyword_paks_sites_and_page(): void + { + Livewire::test(Pages::class) + ->set('keyword', 'foo') + ->set('paks.'.PakSlug::Pak64->value, false) + ->set('sites.'.SiteName::Japan->value, false) + ->set('page', 3) + ->call('clear') + ->assertSet('keyword', '') + ->assertSet('page', 1) + ->assertSet('paks.'.PakSlug::Pak64->value, true) + ->assertSet('sites.'.SiteName::Japan->value, true); + } + + public function test_negative_page_is_clamped_to_one(): void + { + $results = Livewire::test(Pages::class) + ->set('page', -1) + ->instance() + ->pages; + + $this->assertSame(1, $results->currentPage()); + } + + public function test_keyword_over_max_length_fails_validation(): void + { + Livewire::test(Pages::class) + ->set('keyword', str_repeat('a', 21)) + ->assertHasErrors(['keyword' => 'max']); + } + + public function test_search_results_reflect_keyword_filter(): void + { + $pak = Pak::factory()->create(['slug' => PakSlug::Pak128]); + + $matching = Page::factory()->create([ + 'site_name' => SiteName::Japan, + 'title' => 'Steam Locomotive Addon', + ]); + $matching->paks()->attach($pak); + + $other = Page::factory()->create([ + 'site_name' => SiteName::Japan, + 'title' => 'Bus Addon', + ]); + $other->paks()->attach($pak); + + $testable = Livewire::test(Pages::class) + ->set('keyword', 'Locomotive'); + + $results = $testable->instance()->pages; + + $this->assertCount(1, $results); + $this->assertSame($matching->id, $results->first()->id); + } +}