Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 50 additions & 25 deletions app/Livewire/Pages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|int,bool>
Expand All @@ -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]`化していないため、ページネーション
* リンク(プレーンな`<a href>`によるページ遷移)をクリックすると検索条件は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<int, Page>
*/
#[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),
]);
}

/**
Expand Down
9 changes: 6 additions & 3 deletions resources/views/livewire/pages.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700" wire:click="onConditionUpdate">検索</button>
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700" wire:click="clear">リセット</button>
</div>
@error('keyword')
<p class="text-xs text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror

<div class="my-4">
{{ $pages->onEachSide(1)->links('tailwind_custom') }}
{{ $this->pages->onEachSide(1)->links('tailwind_custom') }}
</div>

<ul>
@forelse ($pages as $page)
@forelse ($this->pages as $page)
<li class="my-2">
<div>
@foreach ($page->paks as $pak)
Expand All @@ -42,6 +45,6 @@
@endforelse
</ul>
<div class="my-4">
{{ $pages->onEachSide(1)->links('tailwind_custom') }}
{{ $this->pages->onEachSide(1)->links('tailwind_custom') }}
</div>
</div>
128 changes: 128 additions & 0 deletions tests/Feature/Livewire/PagesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Livewire;

use App\Enums\PakSlug;
use App\Enums\SiteName;
use App\Livewire\Pages;
use App\Models\Page;
use App\Models\Pak;
use App\Models\RawPage;
use Livewire\Livewire;
use Tests\Feature\TestCase;

final class PagesTest extends TestCase
{
public function test_renders_successfully(): void
{
Livewire::test(Pages::class)
->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);
}
}
Loading