commit c4540cd3029a48825f02bc9efae928007f5eef40 Author: upstream Date: Sat Oct 10 12:44:35 2026 +0200 Upstream filamentphp/filament state of the touched files before 4650cecc5a diff --git a/packages/tables/src/Concerns/CanSortRecords.php b/packages/tables/src/Concerns/CanSortRecords.php new file mode 100644 index 0000000..0704145 --- /dev/null +++ b/packages/tables/src/Concerns/CanSortRecords.php @@ -0,0 +1,171 @@ +getTableSortColumn()) { + $direction ??= match ($this->getTableSortDirection()) { + 'asc' => 'desc', + 'desc' => null, + default => 'asc', + }; + } else { + $direction ??= 'asc'; + } + + $this->tableSort = $direction ? "{$column}:{$direction}" : null; + + $this->updatedTableSort(); + } + + public function getTableSortColumn(): ?string + { + if (blank($this->tableSort)) { + return null; + } + + return (string) str($this->tableSort)->before(':'); + } + + public function getTableSortDirection(): ?string + { + if (blank($this->tableSort)) { + return null; + } + + if (! str($this->tableSort)->contains(':')) { + return 'asc'; + } + + return match ((string) str($this->tableSort)->after(':')) { + 'asc' => 'asc', + 'desc' => 'desc', + default => null, + }; + } + + public function updatedTableSort(): void + { + if ($this->getTable()->persistsSortInSession()) { + session()->put( + $this->getTableSortSessionKey(), + $this->tableSort, + ); + } + + $this->resetPage(); + } + + public function updatedTableSortDirection(): void + { + if ($this->getTable()->persistsSortInSession()) { + session()->put( + $this->getTableSortSessionKey(), + $this->tableSort, + ); + } + + $this->resetPage(); + } + + protected function applySortingToTableQuery(Builder $query): Builder + { + if ($this->getTable()->isGroupsOnly()) { + return $query; + } + + if ($this->isTableReordering()) { + return $query->orderBy($this->getTable()->getReorderColumn(), $this->getTable()->getReorderDirection()); + } + + $tableSortColumn = $this->getTableSortColumn(); + + if ( + $tableSortColumn && + $column = $this->getTable()->getSortableVisibleColumn($tableSortColumn) + ) { + $sortDirection = $this->getTableSortDirection() === 'desc' ? 'desc' : 'asc'; + + $column->applySort($query, $sortDirection); + } + + $sortDirection = ($this->getTable()->getDefaultSortDirection() ?? $this->getTableSortDirection()) === 'desc' ? 'desc' : 'asc'; + $defaultSort = $this->getTable()->getDefaultSort($query, $sortDirection); + + if ( + is_string($defaultSort) && + ($defaultSort !== $tableSortColumn) && + ($sortColumn = $this->getTable()->getSortableVisibleColumn($defaultSort)) + ) { + $sortColumn->applySort($query, $sortDirection); + } elseif ( + is_string($defaultSort) && + $defaultSort !== $tableSortColumn + ) { + $query->orderBy($defaultSort, $sortDirection); + } + + if ($defaultSort instanceof Builder) { + $query = $defaultSort; + } + + if (! $this->getTable()->hasDefaultKeySort()) { + return $query; + } + + $qualifiedKeyName = $query->getModel()->getQualifiedKeyName(); + + foreach ($query->getQuery()->orders ?? [] as $order) { + if (($order['column'] ?? null) === $qualifiedKeyName) { + return $query; + } + + if ( + is_string($order['column'] ?? null) && + str($order['column'] ?? null)->afterLast('.')->is( + str($qualifiedKeyName)->afterLast('.') + ) + ) { + return $query; + } + } + + return $query->orderBy($qualifiedKeyName, $sortDirection); + } + + /** + * @deprecated Override the `table()` method to configure the table. + */ + protected function getDefaultTableSortColumn(): ?string + { + return null; + } + + /** + * @deprecated Override the `table()` method to configure the table. + */ + protected function getDefaultTableSortDirection(): ?string + { + return null; + } + + public function getTableSortSessionKey(): string + { + return $this->getTableSessionKey('sort'); + } + + /** + * @deprecated Override the `table()` method to configure the table. + */ + protected function shouldPersistTableSortInSession(): bool + { + return false; + } +} diff --git a/tests/src/Fixtures/Livewire/UsersTable.php b/tests/src/Fixtures/Livewire/UsersTable.php new file mode 100644 index 0000000..63eda5e --- /dev/null +++ b/tests/src/Fixtures/Livewire/UsersTable.php @@ -0,0 +1,97 @@ +query(User::query()) + ->groups(fn () => [ + Tables\Grouping\Group::make('name'), + Tables\Grouping\Group::make('profile.bio'), + Tables\Grouping\Group::make('profile.company.name'), + Tables\Grouping\Group::make('profile.setting.theme'), + Tables\Grouping\Group::make('image.url'), + Tables\Grouping\Group::make('setting.theme'), + Tables\Grouping\Group::make('setting.language.name'), + Tables\Grouping\Group::make('setting.languageWithTrashed.name'), + Tables\Grouping\Group::make('company.name'), + Tables\Grouping\Group::make('publishedPost.title'), + ]) + ->columns([ + Tables\Columns\TextColumn::make('name') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('email') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('profile.bio') + ->label('Profile Bio') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('profile.company.name') + ->label('Company') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('profile.setting.theme') + ->label('Theme') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('profile.setting.language') + ->label('Language') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('image.url') + ->label('Image URL') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('setting.theme') + ->label('Setting Theme (HasOneThrough)') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('setting.language') + ->label('Setting Language (HasOneThrough)') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('setting.language.name') + ->label('Setting Language Name (HasOneThrough -> BelongsTo)') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('setting.languageWithTrashed.name') + ->label('Setting Language With Trashed (HasOneThrough -> BelongsTo with withTrashed)') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('company.name') + ->label('Company (BelongsToThrough)') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('publishedPost.title') + ->label('Published Post Title (HasOne with where())') + ->sortable() + ->searchable(), + Tables\Columns\TextColumn::make('posts_count') + ->counts('posts'), + ]); + } + + public function render(): View + { + return view('livewire.table'); + } +} diff --git a/tests/src/Tables/ColumnTest.php b/tests/src/Tables/ColumnTest.php new file mode 100644 index 0000000..198f971 --- /dev/null +++ b/tests/src/Tables/ColumnTest.php @@ -0,0 +1,2307 @@ +count(10)->create(); + + livewire(PostsTable::class) + ->assertCanRenderTableColumn('title'); + }); + + it('can render text column with relationship', function (): void { + Post::factory()->count(10)->create(); + + livewire(PostsTable::class) + ->assertCanRenderTableColumn('author.name'); + }); + + it('can sort records', function (): void { + Post::factory()->count(10)->create(); + + $sortedAsc = Post::query()->orderBy('title')->orderBy('id')->get(); + $sortedDesc = Post::query()->orderByDesc('title')->orderBy('id')->get(); + + livewire(PostsTable::class) + ->sortTable('title') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('title', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with relationship', function (): void { + Post::factory()->count(10)->create(); + + $sortedAsc = Post::query() + ->orderBy( + User::query() + ->select('name') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + User::query() + ->select('name') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nested relationship', function (): void { + foreach (['Birch', 'Maple', 'Birch', 'Cedar', 'Ash'] as $teamName) { + Post::factory()->state([ + 'author_id' => User::factory()->state([ + 'team_id' => Team::factory()->state(['name' => $teamName]), + ]), + ])->create(); + } + + $sortedAsc = Post::query() + ->orderBy( + Team::query() + ->select('teams.name') + ->whereColumn('teams.id', 'users.team_id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Team::query() + ->select('teams.name') + ->whereColumn('teams.id', 'users.team_id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderByDesc('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.team.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.team.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records by multiple columns in the order they are passed', function (): void { + $posts = collect([ + ['rating' => 1, 'title' => 'Charlie'], + ['rating' => 2, 'title' => 'Alpha'], + ['rating' => 1, 'title' => 'Bravo'], + ['rating' => 2, 'title' => 'Delta'], + ])->map(fn (array $attributes): Post => Post::factory()->create($attributes)); + + livewire(PostsTable::class) + ->sortTable('rating_and_title') + ->assertCanSeeTableRecords([$posts[2], $posts[0], $posts[1], $posts[3]], inOrder: true) + ->sortTable('rating_and_title', 'desc') + ->assertCanSeeTableRecords([$posts[3], $posts[1], $posts[0], $posts[2]], inOrder: true); + }); + + it('can sort records with JSON column', function (): void { + $posts = Post::factory()->count(10)->state(fn (): array => [ + 'json' => ['foo' => Str::random()], + ])->create(); + + // Get database-sorted results to match actual query behavior + $sortedAsc = Post::query()->orderBy('json->foo')->orderBy('id')->get(); + $sortedDesc = Post::query()->orderByDesc('json->foo')->orderBy('id')->get(); + + livewire(PostsTable::class) + ->sortTable('json.foo') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('json.foo', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nested JSON column', function (): void { + $posts = Post::factory()->count(10)->state(fn (): array => [ + 'json' => ['bar' => ['baz' => Str::random()]], + ])->create(); + + // Get database-sorted results to match actual query behavior + $sortedAsc = Post::query()->orderBy('json->bar->baz')->orderBy('id')->get(); + $sortedDesc = Post::query()->orderByDesc('json->bar->baz')->orderBy('id')->get(); + + livewire(PostsTable::class) + ->sortTable('json.bar.baz') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('json.bar.baz', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with relationship JSON column', function (): void { + $posts = Post::factory()->count(10)->state(fn (): array => [ + 'author_id' => User::factory()->state(['json' => ['foo' => Str::random()]]), + ])->create(); + + // Get database-sorted results using subquery orderBy + $sortedAsc = Post::query() + ->orderBy( + User::query() + ->select('json->foo') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + User::query() + ->select('json->foo') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.json.foo') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.json.foo', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with relationship nested JSON column', function (): void { + $posts = Post::factory()->count(10)->state(fn (): array => [ + 'author_id' => User::factory()->state(['json' => ['bar' => ['baz' => Str::random()]]]), + ])->create(); + + // Get database-sorted results using subquery orderBy + $sortedAsc = Post::query() + ->orderBy( + User::query() + ->select('json->bar->baz') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + User::query() + ->select('json->bar->baz') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.json.bar.baz') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.json.bar.baz', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); +}); + +describe('searching', function (): void { + it('can search records', function (): void { + $posts = Post::factory()->count(10)->create(); + + $title = $posts->first()->title; + + livewire(PostsTable::class) + ->searchTable($title) + ->assertCanSeeTableRecords($posts->where('title', $title)) + ->assertCanNotSeeTableRecords($posts->where('title', '!=', $title)); + }); + + it('treats LIKE wildcard characters as literal text when searching records', function (): void { + $literalMatch = Post::factory()->create(['title' => 'Before café!_100% After']); + $wildcardMatch = Post::factory()->create(['title' => 'Before café!X100 anything After']); + + livewire(PostsTable::class) + ->searchTable('café!_100%') + ->assertCanSeeTableRecords([$literalMatch]) + ->assertCanNotSeeTableRecords([$wildcardMatch]); + }); + + it('can search individual column records', function (): void { + $posts = Post::factory()->count(10)->create(); + + $content = $posts->first()->content; + + livewire(PostsTable::class) + ->searchTableColumns(['content' => $content]) + ->assertCanSeeTableRecords($posts->where('content', $content)) + ->assertCanNotSeeTableRecords($posts->where('content', '!=', $content)); + }); + + it('can search posts with relationship', function (): void { + $posts = Post::factory()->count(10)->create(); + + $author = $posts->first()->author->name; + + livewire(PostsTable::class) + ->searchTable($author) + ->assertCanSeeTableRecords($posts->where('author.name', $author)) + ->assertCanNotSeeTableRecords($posts->where('author.name', '!=', $author)); + }); + + it('can search posts with JSON column', function (): void { + $search = Str::random(); + + $matchingPosts = Post::factory()->count(5)->create([ + 'json' => ['foo' => $search], + ]); + + $notMatchingPosts = Post::factory()->count(5)->create([ + 'json' => ['foo' => Str::random()], + ]); + + livewire(PostsTable::class) + ->searchTable($search) + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($notMatchingPosts); + }); + + it('can search posts with nested JSON column', function (): void { + $search = Str::random(); + + $matchingPosts = Post::factory()->count(5)->create([ + 'json' => ['bar' => ['baz' => $search]], + ]); + + $notMatchingPosts = Post::factory()->count(5)->create([ + 'json' => ['bar' => ['baz' => Str::random()]], + ]); + + livewire(PostsTable::class) + ->searchTable($search) + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($notMatchingPosts); + }); + + it('can search posts with relationship JSON column', function (): void { + $search = Str::random(); + + $matchingAuthor = User::factory() + ->create(['json' => ['foo' => $search]]); + + $matchingPosts = Post::factory() + ->for($matchingAuthor, 'author') + ->count(5) + ->create(); + + $notMatchingPosts = Post::factory() + ->count(5) + ->create(); + + livewire(PostsTable::class) + ->searchTable($search) + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($notMatchingPosts); + }); + + it('can search posts with relationship nested JSON column', function (): void { + $search = Str::random(); + + $matchingAuthor = User::factory() + ->create(['json' => ['bar' => ['baz' => $search]]]); + + $matchingPosts = Post::factory() + ->for($matchingAuthor, 'author') + ->count(5) + ->create(); + + $notMatchingPosts = Post::factory() + ->count(5) + ->create(); + + livewire(PostsTable::class) + ->searchTable($search) + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($notMatchingPosts); + }); + + it('can search individual column records with relationship', function (): void { + $posts = Post::factory()->count(10)->create(); + + $authorEmail = $posts->first()->author->email; + + livewire(PostsTable::class) + ->searchTableColumns(['author.email' => $authorEmail]) + ->assertCanSeeTableRecords($posts->where('author.email', $authorEmail)) + ->assertCanNotSeeTableRecords($posts->where('author.email', '!=', $authorEmail)); + }); + + it('can search multiple individual columns', function (): void { + $posts = Post::factory()->count(10)->create(); + + $content = $posts->first()->content; + $authorEmail = $posts->first()->author->email; + + livewire(PostsTable::class) + ->searchTableColumns([ + 'content' => $content, + 'author.email' => $authorEmail, + ]) + ->assertCanSeeTableRecords($posts->where('author.email', $authorEmail)) + ->assertCanNotSeeTableRecords($posts->where('author.email', '!=', $authorEmail)); + }); + + it('can use `splitIndividualSearchTerms(false)` on a column to disable search term splitting for its individual search', function (): void { + $exactPhrasePost = Post::factory()->create(['content' => 'ipsum dolor']); + $separateWordsPost = Post::factory()->create(['content' => 'ipsum sit dolor']); + + livewire(PostsTableWithColumnIndividualSearchTermSplittingDisabled::class) + ->searchTableColumns(['content' => 'ipsum dolor']) + ->assertCanSeeTableRecords([$exactPhrasePost]) + ->assertCanNotSeeTableRecords([$separateWordsPost]); + }); + + it('splits search terms for an individual column search by default, when the column does not define `splitIndividualSearchTerms()`', function (): void { + $exactPhrasePost = Post::factory()->create(['title' => 'ipsum dolor']); + $separateWordsPost = Post::factory()->create(['title' => 'ipsum sit dolor']); + + livewire(PostsTableWithColumnIndividualSearchTermSplittingDisabled::class) + ->searchTableColumns(['title' => 'ipsum dolor']) + ->assertCanSeeTableRecords([$exactPhrasePost, $separateWordsPost]); + }); + + it('can use `splitIndividualSearchTerms()` on a column to enable search term splitting for its individual search when the table uses `splitSearchTerms(false)`', function (): void { + $exactPhrasePost = Post::factory()->create(['content' => 'ipsum dolor']); + $separateWordsPost = Post::factory()->create(['content' => 'ipsum sit dolor']); + + livewire(PostsTableWithColumnIndividualSearchTermSplittingEnabled::class) + ->searchTableColumns(['content' => 'ipsum dolor']) + ->assertCanSeeTableRecords([$exactPhrasePost, $separateWordsPost]); + }); + + it('does not split search terms for an individual column search when the table uses `splitSearchTerms(false)` and the column does not define `splitIndividualSearchTerms()`', function (): void { + $exactPhrasePost = Post::factory()->create(['title' => 'ipsum dolor']); + $separateWordsPost = Post::factory()->create(['title' => 'ipsum sit dolor']); + + livewire(PostsTableWithColumnIndividualSearchTermSplittingEnabled::class) + ->searchTableColumns(['title' => 'ipsum dolor']) + ->assertCanSeeTableRecords([$exactPhrasePost]) + ->assertCanNotSeeTableRecords([$separateWordsPost]); + }); + + it('does not leak the `orWhere` of a non-split individual column search across multiple columns past an active filter', function (): void { + // The searchable column matches on both `title` and `content`. With `splitSearchTerms(false)`, + // the constraint emits `where(title LIKE ?)->orWhere(content LIKE ?)`. If that pair is not + // wrapped in its own `where()` group, the trailing `orWhere` escapes the filter group and + // re-exposes records the filter excluded. + $search = Str::random(); + + $publishedMatch = Post::factory()->create([ + 'is_published' => true, + 'content' => $search, + ]); + + $unpublishedMatch = Post::factory()->create([ + 'is_published' => false, + 'content' => $search, + ]); + + livewire(PostsTableWithNonSplitMultiColumnIndividualSearch::class) + ->filterTable('is_published') + ->searchTableColumns(['title' => $search]) + ->assertCanSeeTableRecords([$publishedMatch]) + ->assertCanNotSeeTableRecords([$unpublishedMatch]); + }); + + it('seeds individually searchable columns whose names collide with JS array properties on mount so `tableColumnSearches` serializes as a JSON object', function (): void { + livewire(PostsTableWithReservedJsPropertyColumnSearch::class) + ->assertSet('tableColumnSearches', ['length' => '', 'sort' => '']); + }); + + it('does not seed individually searchable columns whose names do not collide with JS array properties', function (): void { + livewire(PostsTableWithReservedJsPropertyColumnSearch::class) + ->assertSet('tableColumnSearches', fn (array $tableColumnSearches): bool => ! array_key_exists('title', $tableColumnSearches)); + }); + + it('does not remove a reserved key from `tableColumnSearches` when clearing an individual column search value', function (): void { + livewire(PostsTableWithReservedJsPropertyColumnSearch::class) + ->set('tableColumnSearches.length', 'foo') + ->assertSet('tableColumnSearches.length', 'foo') + ->set('tableColumnSearches.length', '') + ->assertSet('tableColumnSearches.length', ''); + }); + + it('removes a non-reserved key from `tableColumnSearches` when clearing an individual column search value', function (): void { + livewire(PostsTableWithReservedJsPropertyColumnSearch::class) + ->set('tableColumnSearches.title', 'foo') + ->assertSet('tableColumnSearches.title', 'foo') + ->set('tableColumnSearches.title', '') + ->assertSet('tableColumnSearches', fn (array $tableColumnSearches): bool => ! array_key_exists('title', $tableColumnSearches)); + }); + + it('keeps reserved keys present in `tableColumnSearches` after resetting all column searches', function (): void { + livewire(PostsTableWithReservedJsPropertyColumnSearch::class) + ->set('tableColumnSearches.length', 'foo') + ->set('tableColumnSearches.sort', 'bar') + ->call('resetTableColumnSearches') + ->assertSet('tableColumnSearches', ['length' => '', 'sort' => '']); + }); + + it('renders, responsively hides, and clears individual column search inputs with associated labels for columns named after JavaScript array properties in the browser', function (): void { + retry(10, function (): void { + Artisan::call('filament:assets'); + + $this->actingAs(User::factory()->create()); + + Post::factory()->count(3)->create(); + + visit('/individual-column-search-browser-test') + ->assertValue('.fi-ta-individual-search-cell-length input', '') + ->assertValue('.fi-ta-individual-search-cell-sort input', '') + ->assertValue('.fi-ta-individual-search-cell-title input', '') + ->resize(375, 812) + ->assertScript("(() => { const expectedLabels = { length: 'Length', sort: 'Sort', title: 'Title' }; return Object.entries(expectedLabels).every(([column, label]) => document.querySelector('.fi-ta-individual-search-cell-' + column + ' input').labels[0]?.textContent.trim() === label) })()", true) + ->fill('.fi-ta-individual-search-cell-length input', 'foo') + ->wait(1) + ->assertValue('.fi-ta-individual-search-cell-length input', 'foo') + ->fill('.fi-ta-individual-search-cell-length input', '') + ->wait(1) + ->assertValue('.fi-ta-individual-search-cell-length input', '') + ->resize(700, 812) + ->assertScript("document.querySelector('.fi-ta-individual-search-row').checkVisibility()", false) + ->resize(800, 812) + ->assertScript("document.querySelector('.fi-ta-individual-search-row').checkVisibility()", true) + ->assertNoSmoke() + ->assertNoAccessibilityIssues(); + + visit('/individual-column-search-browser-test') + ->inDarkMode() + ->resize(375, 812) + ->assertNoAccessibilityIssues(); + }); + }); + + it('scopes column manager checkbox ids to each table in the browser', function (): void { + retry(10, function (): void { + Artisan::call('filament:assets'); + + $this->actingAs(User::factory()->create()); + + Post::factory()->count(3)->create(); + + visit('/column-manager-browser-test') + ->assertPresent('#first-table .fi-ta-header-cell-title') + ->assertPresent('#second-table .fi-ta-header-cell-title') + // Open the first table's column manager too, so its checkboxes are also rendered when the second manager's labels are clicked. + ->click('#first-table button[aria-label="Column manager"]') + ->click('#second-table button[aria-label="Column manager"]') + // Every rendered column manager checkbox id must be unique, otherwise a label's `for` can activate a checkbox in another table's manager. + ->assertScript('new Set(Array.from(document.querySelectorAll(\'.fi-ta-col-manager-label input[type="checkbox"]\')).map((checkbox) => checkbox.id)).size === document.querySelectorAll(\'.fi-ta-col-manager-label input[type="checkbox"]\').length', true) + // Clicking the `Title` label must toggle the checkbox in this table's column manager, not the checkbox of the other table's manager that shares the column name. + ->click('#second-table .fi-ta-col-manager-label[for$="-title"]') + ->wait(1) + ->assertMissing('#second-table .fi-ta-header-cell-title') + ->assertPresent('#first-table .fi-ta-header-cell-title') + // Restore the toggled column, since the column manager persists in the session and a retried attempt must start from the default state. + ->click('#second-table .fi-ta-col-manager-label[for$="-title"]') + ->wait(1) + ->assertPresent('#second-table .fi-ta-header-cell-title') + ->assertNoSmoke() + ->assertNoAccessibilityIssues(); + + visit('/column-manager-browser-test') + ->inDarkMode() + ->click('#second-table button[aria-label="Column manager"]') + ->assertNoAccessibilityIssues(); + }); + }); +}); + +describe('column properties and assertions', function (): void { + it('can hide a column', function (): void { + livewire(PostsTable::class) + ->assertTableColumnVisible('visible') + ->assertTableColumnHidden('hidden'); + }); + + it('can call a column action', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->callTableColumnAction('title', $post) + ->assertDispatched('title-action-called'); + }); + + it('can call a column action object', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->callTableAction('column-action-object', $post) + ->assertDispatched('column-action-object-called'); + }); + + it('can state whether a column has the correct value with Eloquent records', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('with_state', 'correct state', $post) + ->assertTableColumnStateNotSet('with_state', 'incorrect state', $post); + }); + + it('can state whether a column has the correct value with custom data', function (): void { + livewire(CustomDataTable::class) + ->assertTableColumnStateSet('title', 'Second item', 2) + ->assertTableColumnStateNotSet('title', 'incorrect state', 2); + }); + + it('can state whether a column with `counts()` has the correct value', function (): void { + $user = User::factory() + ->has(Post::factory()->count(3)) + ->create(); + + livewire(UsersTable::class) + ->assertTableColumnStateSet('posts_count', 3, $user) + ->assertTableColumnStateNotSet('posts_count', 0, $user); + }); + + it('can state whether a column has the correct formatted value with Eloquent records', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnFormattedStateSet('formatted_state', 'formatted state', $post) + ->assertTableColumnFormattedStateNotSet('formatted_state', 'incorrect formatted state', $post); + }); + + it('can state whether a column has the correct formatted value with custom data', function (): void { + livewire(CustomDataTable::class) + ->assertTableColumnFormattedStateSet('formatted_state', 'formatted state', 1) + ->assertTableColumnFormattedStateNotSet('formatted_state', 'incorrect formatted state', 1); + }); + + it('can output JSON values', function (): void { + $post = Post::factory()->create([ + 'json' => ['foo' => 'bar'], + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('json.foo', 'bar', $post); + }); + + it('can output nested JSON values', function (): void { + $post = Post::factory()->create([ + 'json' => ['bar' => ['baz' => 'qux']], + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('json.bar.baz', 'qux', $post); + }); + + it('can output relationship JSON values', function (): void { + $post = Post::factory()->create([ + 'author_id' => User::factory()->state([ + 'json' => ['foo' => 'bar'], + ]), + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('author.json.foo', 'bar', $post); + }); + + it('can output relationship nested JSON values', function (): void { + $post = Post::factory()->create([ + 'author_id' => User::factory()->state([ + 'json' => ['bar' => ['baz' => 'qux']], + ]), + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('author.json.bar.baz', 'qux', $post); + }); + + it('can output values in a JSON array column of objects', function (): void { + $post = Post::factory()->create([ + 'json_array_of_objects' => [ + ['value' => 'foo'], + ['value' => 'bar'], + ['value' => 'baz'], + ], + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('json_array_of_objects.*.value', ['foo', 'bar', 'baz'], $post); + }); + + it('can output values in a JSON column with a non-relationship accessor method', function (): void { + $post = Post::factory()->create([ + 'config' => ['setting' => 'foo'], + ]); + + livewire(PostsTable::class) + ->assertTableColumnStateSet('config.setting', 'foo', $post); + }); + + it('can state whether a column has extra attributes with Eloquent records', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnHasExtraAttributes('extra_attributes', ['class' => 'text-danger-500'], $post) + ->assertTableColumnDoesNotHaveExtraAttributes('extra_attributes', ['class' => 'text-primary-500'], $post); + }); + + it('can state whether a column has extra attributes with custom data', function (): void { + livewire(CustomDataTable::class) + ->assertTableColumnHasExtraAttributes('extra_attributes', ['class' => 'text-danger-500'], 1) + ->assertTableColumnDoesNotHaveExtraAttributes('extra_attributes', ['class' => 'text-primary-500'], 1); + }); + + it('can state whether a column has a description with Eloquent records', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnHasDescription('with_description', 'description below', $post) + ->assertTableColumnHasDescription('with_description', 'description above', $post, 'above') + ->assertTableColumnDoesNotHaveDescription('with_description', 'incorrect description below', $post) + ->assertTableColumnDoesNotHaveDescription('with_description', 'incorrect description above', $post, 'above'); + }); + + it('can state whether a column has a description with custom data', function (): void { + livewire(CustomDataTable::class) + ->assertTableColumnHasDescription('with_description', 'description below', 1) + ->assertTableColumnHasDescription('with_description', 'description above', 1, 'above') + ->assertTableColumnDoesNotHaveDescription('with_description', 'incorrect description below', 1) + ->assertTableColumnDoesNotHaveDescription('with_description', 'incorrect description above', 1, 'above'); + }); + + it('can state whether a select column has options with Eloquent records', function (): void { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableSelectColumnHasOptions('with_options', ['red' => 'Red', 'blue' => 'Blue'], $post) + ->assertTableSelectColumnDoesNotHaveOptions('with_options', ['one' => 'One', 'two' => 'Two'], $post); + }); + + it('can state whether a select column has options with custom data', function (): void { + livewire(CustomDataTable::class) + ->assertTableSelectColumnHasOptions('with_options', ['red' => 'Red', 'blue' => 'Blue'], 2) + ->assertTableSelectColumnDoesNotHaveOptions('with_options', ['one' => 'One', 'two' => 'Two'], 2); + }); + + it('can assert that a column exists with the given configuration', function (): void { + $publishedPost = Post::factory()->create([ + 'is_published' => true, + ]); + + livewire(PostsTable::class) + ->assertTableColumnExists('title2', function (TextColumn $column) { + return $column->isSortable() && + $column->isSearchable() && + $column->getPrefix() == 'published'; + }, $publishedPost); + + $unpublishedPost = Post::factory()->create([ + 'is_published' => false, + ]); + + livewire(PostsTable::class) + ->assertTableColumnExists('title2', function (TextColumn $column) { + return $column->getPrefix() == 'unpublished'; + }, $unpublishedPost); + + $this->expectException('PHPUnit\Framework\ExpectationFailedException'); + $this->expectExceptionMessage('Failed asserting that a column with the name [title] and provided configuration exists on the [' . PostsTable::class . '] component'); + + livewire(PostsTable::class) + ->assertTableColumnExists('title', function (TextColumn $column) { + return $column->isTime(); + }, $publishedPost); + }); + + it('can automatically detect boolean cast attribute in icon column', function (): void { + $post = Post::factory() + ->create(['is_published' => false]); + + livewire(PostsTable::class) + ->assertTableColumnExists('is_published', function (IconColumn $column) { + return $column->isBoolean(); + }, $post); + }); + + it('can toggle all table columns', function (): void { + Post::factory()->create(); + + livewire(PostsTable::class) + ->assertSuccessful() + ->assertCountTableRecords(1) + ->assertDontSeeText('Toggleable column state') + ->toggleAllTableColumns() + ->assertSeeText('Toggleable column state') + ->toggleAllTableColumns(false) + ->assertDontSeeText('Toggleable column state'); + }); +}); + +describe('relationship columns', function (): void { + it('can output the state of a nested relationship through a `MorphTo` relationship', function (): void { + $team = Team::factory()->create(['name' => 'Team Alpha']); + $user = User::factory()->create([ + 'name' => 'Alice', + 'team_id' => $team->id, + ]); + $image = Image::factory()->for($user, 'imageable')->create(); + + livewire(ImagesTable::class) + ->assertTableColumnStateSet('imageable.team.name', 'Team Alpha', $image) + ->assertTableColumnStateNotSet('imageable.team.name', 'Alice', $image) + ->assertTableColumnFormattedStateSet('imageable.team.name', 'Team Alpha', $image) + ->assertTableColumnFormattedStateNotSet('imageable.team.name', 'Alice', $image); + }); + + it('can output the state of multiple nested relationships through the same `MorphTo` relationship', function (): void { + $company = Company::factory()->create(['name' => 'Acme Corporation']); + $team = Team::factory()->create([ + 'name' => 'Team Alpha', + 'company_id' => $company->id, + ]); + $user = User::factory()->create([ + 'name' => 'Alice', + 'team_id' => $team->id, + ]); + $image = Image::factory()->for($user, 'imageable')->create(); + + livewire(ImagesTable::class) + ->assertTableColumnStateSet('imageable.team.name', 'Team Alpha', $image) + ->assertTableColumnStateSet('imageable.company.name', 'Acme Corporation', $image); + }); + + it('can output the state of nested relationships through a `MorphTo` relationship with mixed related model types', function (): void { + $userCompany = Company::factory()->create(['name' => 'Acme Corporation']); + $team = Team::factory()->create(['company_id' => $userCompany->id]); + $user = User::factory()->create([ + 'name' => 'Alice', + 'team_id' => $team->id, + ]); + $userImage = Image::factory()->for($user, 'imageable')->create(); + + $profileCompany = Company::factory()->create(['name' => 'Globex Corporation']); + $profile = Profile::factory()->create([ + 'user_id' => $user->id, + 'company_id' => $profileCompany->id, + ]); + $profileImage = Image::factory()->for($profile, 'imageable')->create(); + + livewire(ImagesTable::class) + ->assertTableColumnStateSet('imageable.company.name', 'Acme Corporation', $userImage) + ->assertTableColumnStateSet('imageable.company.name', 'Globex Corporation', $profileImage); + }); + + it('can search and sort by relationship column when both tables have the same column name', function (): void { + $teamAlpha = Team::factory()->create(['name' => 'Team Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Team Beta']); + + $userAlice = User::factory()->create([ + 'name' => 'Alice', + 'team_id' => $teamAlpha->id, + ]); + + $userBob = User::factory()->create([ + 'name' => 'Bob', + 'team_id' => $teamBeta->id, + ]); + + livewire(UsersWithTeamTable::class) + ->searchTable('Alice') + ->sortTable('team.name') + ->assertCanSeeTableRecords([$userAlice]) + ->assertCanNotSeeTableRecords([$userBob]); + }); + + it('can search and sort by nested relationship column when tables have the same column name, with a qualified column', function (): void { + // Create teams (teams table has 'name' column) + $teamAlpha = Team::factory()->create(['name' => 'Team Alpha']); + $teamBeta = Team::factory()->create(['name' => 'Team Beta']); + $teamGamma = Team::factory()->create(['name' => 'Team Gamma']); + + // Create users with names and team assignments (users table has 'name' column) + $userAlice = User::factory()->create([ + 'name' => 'Alice', + 'team_id' => $teamAlpha->id, + ]); + + $userBob = User::factory()->create([ + 'name' => 'Bob', + 'team_id' => $teamBeta->id, + ]); + + $userCharlie = User::factory()->create([ + 'name' => 'Charlie', + 'team_id' => $teamGamma->id, + ]); + + // Create posts with those authors (posts table has searchable 'title' column) + $postByAlice = Post::factory()->create([ + 'author_id' => $userAlice->id, + 'title' => 'Alice\'s Post', + ]); + + $postByBob = Post::factory()->create([ + 'author_id' => $userBob->id, + 'title' => 'Bob\'s Post', + ]); + + $postByCharlie = Post::factory()->create([ + 'author_id' => $userCharlie->id, + 'title' => 'Charlie\'s Post', + ]); + + livewire(PostsTableWithQualifiedColumns::class) + ->searchTable('Alice') + ->sortTable('author.team.name') + ->assertCanSeeTableRecords([$postByAlice]) + ->assertCanNotSeeTableRecords([$postByBob, $postByCharlie]); + }); + + it('can sort records with `BelongsTo` -> `HasOne` relationship', function (): void { + Post::factory()->count(5)->state(fn (): array => [ + 'author_id' => User::factory()->has( + Profile::factory()->state(fn (): array => [ + 'bio' => fake()->sentence(), + ]), + 'profile' + ), + ])->create(); + + $sortedAsc = Post::query() + ->orderBy( + Profile::query() + ->select('bio') + ->whereColumn('profiles.user_id', 'users.id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Profile::query() + ->select('bio') + ->whereColumn('profiles.user_id', 'users.id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.profile.bio') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.profile.bio', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `BelongsTo` -> `HasOne` -> `BelongsTo` relationship', function (): void { + Post::factory()->count(5)->state(fn (): array => [ + 'author_id' => User::factory()->has( + Profile::factory()->state(fn (): array => [ + 'company_id' => Company::factory(), + ]), + 'profile' + ), + ])->create(); + + $sortedAsc = Post::query() + ->orderBy( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'profiles.company_id') + ->join('profiles', 'profiles.company_id', '=', 'companies.id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'profiles.company_id') + ->join('profiles', 'profiles.company_id', '=', 'companies.id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.profile.company.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.profile.company.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can search records with `BelongsTo` -> `HasOne` relationship', function (): void { + $searchBio = 'unique bio for testing'; + + $matchingUser = User::factory()->has( + Profile::factory()->state([ + 'bio' => $searchBio, + ]), + 'profile' + )->create(); + + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchBio) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can search records with `BelongsTo` -> `HasOne` -> `BelongsTo` relationship', function (): void { + $searchCompany = 'Acme Corporation Testing'; + + $company = Company::factory()->create([ + 'name' => $searchCompany, + ]); + + $matchingUser = User::factory()->has( + Profile::factory()->for($company, 'company'), + 'profile' + )->create(); + + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchCompany) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with `HasOne` -> `BelongsTo` relationship', function (): void { + for ($i = 0; $i < 5; $i++) { + $company = Company::factory()->create(['name' => 'Company ' . chr(65 + $i)]); // A, B, C, D, E + User::factory()->has( + Profile::factory()->for($company, 'company'), + 'profile' + )->create(); + } + + $sortedAsc = User::query() + ->orderBy( + Company::query() + ->select('name') + ->whereColumn('companies.id', 'profiles.company_id') + ->join('profiles', 'profiles.company_id', '=', 'companies.id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Company::query() + ->select('name') + ->whereColumn('companies.id', 'profiles.company_id') + ->join('profiles', 'profiles.company_id', '=', 'companies.id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('profile.company.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('profile.company.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `HasOne` -> `HasOne` relationship', function (): void { + $themes = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + + foreach ($themes as $theme) { + User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $theme]), + 'setting' + ), + 'profile' + )->create(); + } + + $sortedAsc = User::query() + ->orderBy( + Setting::query() + ->select('theme') + ->whereColumn('settings.profile_id', 'profiles.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Setting::query() + ->select('theme') + ->whereColumn('settings.profile_id', 'profiles.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('profile.setting.theme') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('profile.setting.theme', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can search records with `HasOne` -> `BelongsTo` relationship', function (): void { + $searchCompany = 'TechCorp Testing Inc'; + + $company = Company::factory()->create(['name' => $searchCompany]); + + $matchingUser = User::factory()->has( + Profile::factory()->for($company, 'company'), + 'profile' + )->create(); + + $nonMatchingUsers = User::factory()->count(3)->create(); + + livewire(UsersTable::class) + ->searchTable($searchCompany) + ->assertCanSeeTableRecords([$matchingUser]) + ->assertCanNotSeeTableRecords($nonMatchingUsers); + }); + + it('can search records with `HasOne` -> `HasOne` relationship', function (): void { + $searchTheme = 'midnight-purple'; + + $matchingUser = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $searchTheme]), + 'setting' + ), + 'profile' + )->create(); + + $nonMatchingUsers = User::factory()->count(3)->create(); + + livewire(UsersTable::class) + ->searchTable($searchTheme) + ->assertCanSeeTableRecords([$matchingUser]) + ->assertCanNotSeeTableRecords($nonMatchingUsers); + }); + + it('can sort records with `MorphOne` relationship', function (): void { + $urls = ['alpha.jpg', 'beta.jpg', 'gamma.jpg', 'delta.jpg', 'epsilon.jpg']; + + foreach ($urls as $url) { + $user = User::factory()->create(); + Image::factory()->create([ + 'url' => $url, + 'imageable_type' => User::class, + 'imageable_id' => $user->id, + ]); + } + + $sortedAsc = User::query() + ->orderBy( + Image::query() + ->select('url') + ->whereColumn('images.imageable_id', 'users.id') + ->where('images.imageable_type', User::class) + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Image::query() + ->select('url') + ->whereColumn('images.imageable_id', 'users.id') + ->where('images.imageable_type', User::class) + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('image.url') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('image.url', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `BelongsTo` -> `MorphOne` relationship', function (): void { + $urls = ['alpha.jpg', 'beta.jpg', 'gamma.jpg', 'delta.jpg', 'epsilon.jpg']; + + foreach ($urls as $url) { + $user = User::factory()->create(); + Image::factory()->create([ + 'url' => $url, + 'imageable_type' => User::class, + 'imageable_id' => $user->id, + ]); + Post::factory()->create(['author_id' => $user->id]); + } + + $sortedAsc = Post::query() + ->orderBy( + Image::query() + ->select('url') + ->whereColumn('images.imageable_id', 'users.id') + ->where('images.imageable_type', User::class) + ->join('users', 'users.id', '=', 'images.imageable_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Image::query() + ->select('url') + ->whereColumn('images.imageable_id', 'users.id') + ->where('images.imageable_type', User::class) + ->join('users', 'users.id', '=', 'images.imageable_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.image.url') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.image.url', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `BelongsTo` -> `HasOne` -> `MorphOne` relationship', function (): void { + $altTexts = ['alt alpha', 'alt beta', 'alt gamma', 'alt delta', 'alt epsilon']; + + foreach ($altTexts as $altText) { + $user = User::factory()->create(); + $profile = Profile::factory()->create(['user_id' => $user->id]); + Image::factory()->create([ + 'alt_text' => $altText, + 'imageable_type' => Profile::class, + 'imageable_id' => $profile->id, + ]); + Post::factory()->create(['author_id' => $user->id]); + } + + $sortedAsc = Post::query() + ->orderBy( + Image::query() + ->select('alt_text') + ->whereColumn('images.imageable_id', 'profiles.id') + ->where('images.imageable_type', Profile::class) + ->join('profiles', 'profiles.id', '=', 'images.imageable_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Image::query() + ->select('alt_text') + ->whereColumn('images.imageable_id', 'profiles.id') + ->where('images.imageable_type', Profile::class) + ->join('profiles', 'profiles.id', '=', 'images.imageable_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.profile.image.alt_text') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.profile.image.alt_text', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can search records with `MorphOne` relationship', function (): void { + $searchUrl = 'unique-image-url.jpg'; + + $matchingUser = User::factory()->create(); + Image::factory()->create([ + 'url' => $searchUrl, + 'imageable_type' => User::class, + 'imageable_id' => $matchingUser->id, + ]); + + $nonMatchingUsers = User::factory()->count(3)->create(); + + livewire(UsersTable::class) + ->searchTable($searchUrl) + ->assertCanSeeTableRecords([$matchingUser]) + ->assertCanNotSeeTableRecords($nonMatchingUsers); + }); + + it('can search records with `BelongsTo` -> `MorphOne` relationship', function (): void { + $searchUrl = 'unique-author-image.jpg'; + + $matchingUser = User::factory()->create(); + Image::factory()->create([ + 'url' => $searchUrl, + 'imageable_type' => User::class, + 'imageable_id' => $matchingUser->id, + ]); + + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchUrl) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can search records with `BelongsTo` -> `HasOne` -> `MorphOne` relationship', function (): void { + $searchAltText = 'unique profile image alt text'; + + $matchingUser = User::factory()->create(); + $matchingProfile = Profile::factory()->create(['user_id' => $matchingUser->id]); + Image::factory()->create([ + 'alt_text' => $searchAltText, + 'imageable_type' => Profile::class, + 'imageable_id' => $matchingProfile->id, + ]); + + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchAltText) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with nullable `BelongsTo` relationship', function (): void { + // Create posts with authors + $userAlice = User::factory()->create(['name' => 'Alice']); + $userBob = User::factory()->create(['name' => 'Bob']); + + $postWithAlice = Post::factory()->create(['author_id' => $userAlice->id]); + $postWithBob = Post::factory()->create(['author_id' => $userBob->id]); + + // Create posts without authors (null relationship) + $postWithoutAuthor1 = Post::factory()->create(['author_id' => null]); + $postWithoutAuthor2 = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postWithAlice, $postWithBob, $postWithoutAuthor1, $postWithoutAuthor2]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(PostsTable::class) + ->sortTable('author.name') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.name', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can sort records with nullable `HasOne` relationship', function (): void { + // Create users with profiles + $userWithProfile1 = User::factory()->has( + Profile::factory()->state(['bio' => 'Alpha bio']), + 'profile' + )->create(); + + $userWithProfile2 = User::factory()->has( + Profile::factory()->state(['bio' => 'Beta bio']), + 'profile' + )->create(); + + // Create users without profiles (null relationship) + $userWithoutProfile1 = User::factory()->create(); + $userWithoutProfile2 = User::factory()->create(); + + $allUsers = collect([$userWithProfile1, $userWithProfile2, $userWithoutProfile1, $userWithoutProfile2]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(UsersTable::class) + ->sortTable('profile.bio') + ->assertCanSeeTableRecords($allUsers) + ->sortTable('profile.bio', 'desc') + ->assertCanSeeTableRecords($allUsers); + }); + + it('can sort records with nullable `MorphOne` relationship', function (): void { + // Create users with images + $userWithImage1 = User::factory()->create(); + Image::factory()->create([ + 'url' => 'alpha.jpg', + 'imageable_type' => User::class, + 'imageable_id' => $userWithImage1->id, + ]); + + $userWithImage2 = User::factory()->create(); + Image::factory()->create([ + 'url' => 'beta.jpg', + 'imageable_type' => User::class, + 'imageable_id' => $userWithImage2->id, + ]); + + // Create users without images (null relationship) + $userWithoutImage1 = User::factory()->create(); + $userWithoutImage2 = User::factory()->create(); + + $allUsers = collect([$userWithImage1, $userWithImage2, $userWithoutImage1, $userWithoutImage2]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(UsersTable::class) + ->sortTable('image.url') + ->assertCanSeeTableRecords($allUsers) + ->sortTable('image.url', 'desc') + ->assertCanSeeTableRecords($allUsers); + }); + + it('can sort records with nullable nested `BelongsTo` -> `HasOne` relationship', function (): void { + // Post with author that has profile + $userWithProfile = User::factory()->has( + Profile::factory()->state(['bio' => 'Alpha bio']), + 'profile' + )->create(); + $postWithAuthorAndProfile = Post::factory()->create(['author_id' => $userWithProfile->id]); + + // Post with author but no profile + $userWithoutProfile = User::factory()->create(); + $postWithAuthorNoProfile = Post::factory()->create(['author_id' => $userWithoutProfile->id]); + + // Post with no author + $postWithoutAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postWithAuthorAndProfile, $postWithAuthorNoProfile, $postWithoutAuthor]); + + // Just verify sorting doesn't crash with nullable nested relationships + livewire(PostsTable::class) + ->sortTable('author.profile.bio') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.profile.bio', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can sort records with nullable nested `BelongsTo` -> `HasOne` -> `BelongsTo` relationship', function (): void { + // Post with author->profile->company + $company = Company::factory()->create(['name' => 'Acme Corp']); + $userWithProfileAndCompany = User::factory()->has( + Profile::factory()->for($company, 'company'), + 'profile' + )->create(); + $postComplete = Post::factory()->create(['author_id' => $userWithProfileAndCompany->id]); + + // Post with author and profile but no company + $userWithProfileNoCompany = User::factory()->has( + Profile::factory()->state(['company_id' => null]), + 'profile' + )->create(); + $postNoCompany = Post::factory()->create(['author_id' => $userWithProfileNoCompany->id]); + + // Post with author but no profile + $userNoProfile = User::factory()->create(); + $postNoProfile = Post::factory()->create(['author_id' => $userNoProfile->id]); + + // Post with no author + $postNoAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postComplete, $postNoCompany, $postNoProfile, $postNoAuthor]); + + // Just verify sorting doesn't crash with nullable nested relationships + livewire(PostsTable::class) + ->sortTable('author.profile.company.name') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.profile.company.name', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can sort records with nullable nested `BelongsTo` -> `MorphOne` relationship', function (): void { + // Post with author that has image + $userWithImage = User::factory()->create(); + Image::factory()->create([ + 'url' => 'alpha.jpg', + 'imageable_type' => User::class, + 'imageable_id' => $userWithImage->id, + ]); + $postWithAuthorAndImage = Post::factory()->create(['author_id' => $userWithImage->id]); + + // Post with author but no image + $userWithoutImage = User::factory()->create(); + $postWithAuthorNoImage = Post::factory()->create(['author_id' => $userWithoutImage->id]); + + // Post with no author + $postWithoutAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postWithAuthorAndImage, $postWithAuthorNoImage, $postWithoutAuthor]); + + // Just verify sorting doesn't crash with nullable nested relationships + livewire(PostsTable::class) + ->sortTable('author.image.url') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.image.url', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can search records with nullable `BelongsTo` relationship', function (): void { + $userAlice = User::factory()->create(['name' => 'Alice Unique']); + + $matchingPost = Post::factory()->create(['author_id' => $userAlice->id]); + $postWithoutAuthor = Post::factory()->create(['author_id' => null]); + $nonMatchingPost = Post::factory()->create(); + + livewire(PostsTable::class) + ->searchTable('Alice Unique') + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords([$postWithoutAuthor, $nonMatchingPost]); + }); + + it('can search records with nullable `HasOne` relationship', function (): void { + $userWithProfile = User::factory()->has( + Profile::factory()->state(['bio' => 'Unique bio search']), + 'profile' + )->create(); + + $userWithoutProfile = User::factory()->create(); + $otherUsers = User::factory()->count(2)->create(); + + livewire(UsersTable::class) + ->searchTable('Unique bio search') + ->assertCanSeeTableRecords([$userWithProfile]) + ->assertCanNotSeeTableRecords([$userWithoutProfile, ...$otherUsers]); + }); + + it('can sort records with `BelongsToThrough` relationship', function (): void { + Post::factory()->count(5)->state(fn (): array => [ + 'author_id' => User::factory()->state([ + 'team_id' => Team::factory(), + ]), + ])->create(); + + $sortedAsc = Post::query() + ->orderBy( + Team::query() + ->select('teams.name') + ->whereColumn('teams.id', 'users.team_id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Team::query() + ->select('teams.name') + ->whereColumn('teams.id', 'users.team_id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('team.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('team.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable `BelongsToThrough` relationship', function (): void { + // Post with author that has team + $teamAlpha = Team::factory()->create(['name' => 'Alpha Team']); + $userWithTeam = User::factory()->create(['team_id' => $teamAlpha->id]); + $postWithTeam = Post::factory()->create(['author_id' => $userWithTeam->id]); + + // Post with author but no team + $userWithoutTeam = User::factory()->create(['team_id' => null]); + $postWithAuthorNoTeam = Post::factory()->create(['author_id' => $userWithoutTeam->id]); + + // Post with no author + $postWithoutAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postWithTeam, $postWithAuthorNoTeam, $postWithoutAuthor]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(PostsTable::class) + ->sortTable('team.name') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('team.name', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can search records with `BelongsToThrough` relationship', function (): void { + $searchTeam = 'Unique Team Name'; + + $team = Team::factory()->create(['name' => $searchTeam]); + $matchingUser = User::factory()->create(['team_id' => $team->id]); + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchTeam) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with nested `BelongsToThrough` -> `BelongsTo` relationship', function (): void { + Post::factory()->count(5)->state(fn (): array => [ + 'author_id' => User::factory()->state([ + 'team_id' => Team::factory()->state([ + 'company_id' => Company::factory(), + ]), + ]), + ])->create(); + + $sortedAsc = Post::query() + ->orderBy( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'teams.company_id') + ->join('teams', 'teams.company_id', '=', 'companies.id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'teams.company_id') + ->join('teams', 'teams.company_id', '=', 'companies.id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('team.company.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('team.company.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable nested `BelongsToThrough` -> `BelongsTo` relationship', function (): void { + // Post with team and company + $company = Company::factory()->create(['name' => 'Acme Corp']); + $teamWithCompany = Team::factory()->create(['company_id' => $company->id]); + $userWithTeamAndCompany = User::factory()->create(['team_id' => $teamWithCompany->id]); + $postComplete = Post::factory()->create(['author_id' => $userWithTeamAndCompany->id]); + + // Post with team but no company + $teamWithoutCompany = Team::factory()->create(['company_id' => null]); + $userWithTeamNoCompany = User::factory()->create(['team_id' => $teamWithoutCompany->id]); + $postNoCompany = Post::factory()->create(['author_id' => $userWithTeamNoCompany->id]); + + // Post with author but no team + $userWithoutTeam = User::factory()->create(['team_id' => null]); + $postNoTeam = Post::factory()->create(['author_id' => $userWithoutTeam->id]); + + // Post with no author + $postNoAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postComplete, $postNoCompany, $postNoTeam, $postNoAuthor]); + + // Just verify sorting doesn't crash with nullable nested relationships + livewire(PostsTable::class) + ->sortTable('team.company.name') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('team.company.name', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can search records with nested `BelongsToThrough` -> `BelongsTo` relationship', function (): void { + $searchCompany = 'Unique Company Name'; + + $company = Company::factory()->create(['name' => $searchCompany]); + $team = Team::factory()->create(['company_id' => $company->id]); + $matchingUser = User::factory()->create(['team_id' => $team->id]); + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchCompany) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with `BelongsTo` -> `BelongsToThrough` relationship', function (): void { + Post::factory()->count(5)->state(fn (): array => [ + 'author_id' => User::factory()->state([ + 'team_id' => Team::factory()->state([ + 'company_id' => Company::factory(), + ]), + ]), + ])->create(); + + $sortedAsc = Post::query() + ->orderBy( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'teams.company_id') + ->join('teams', 'teams.company_id', '=', 'companies.id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Company::query() + ->select('companies.name') + ->whereColumn('companies.id', 'teams.company_id') + ->join('teams', 'teams.company_id', '=', 'companies.id') + ->join('users', 'users.team_id', '=', 'teams.id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.company.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.company.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable `BelongsTo` -> `BelongsToThrough` relationship', function (): void { + // Post with author, team, and company + $company = Company::factory()->create(['name' => 'Acme Corp']); + $teamWithCompany = Team::factory()->create(['company_id' => $company->id]); + $userComplete = User::factory()->create(['team_id' => $teamWithCompany->id]); + $postComplete = Post::factory()->create(['author_id' => $userComplete->id]); + + // Post with author and team but no company + $teamWithoutCompany = Team::factory()->create(['company_id' => null]); + $userNoCompany = User::factory()->create(['team_id' => $teamWithoutCompany->id]); + $postNoCompany = Post::factory()->create(['author_id' => $userNoCompany->id]); + + // Post with author but no team + $userNoTeam = User::factory()->create(['team_id' => null]); + $postNoTeam = Post::factory()->create(['author_id' => $userNoTeam->id]); + + // Post with no author + $postNoAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postComplete, $postNoCompany, $postNoTeam, $postNoAuthor]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(PostsTable::class) + ->sortTable('author.company.name') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.company.name', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can search records with `BelongsTo` -> `BelongsToThrough` relationship', function (): void { + $searchCompany = 'Specific Company Name'; + + $company = Company::factory()->create(['name' => $searchCompany]); + $team = Team::factory()->create(['company_id' => $company->id]); + $matchingUser = User::factory()->create(['team_id' => $team->id]); + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchCompany) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with `HasOneThrough` relationship', function (): void { + $themes = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + + foreach ($themes as $theme) { + User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $theme]), + 'setting' + ), + 'profile' + )->create(); + } + + $sortedAsc = User::query() + ->orderBy( + Setting::query() + ->select('theme') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Setting::query() + ->select('theme') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('setting.theme') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('setting.theme', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable `HasOneThrough` relationship', function (): void { + // User with profile and setting + $userWithSetting = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => 'dark']), + 'setting' + ), + 'profile' + )->create(); + + // User with profile but no setting + $userWithProfileNoSetting = User::factory()->has( + Profile::factory(), + 'profile' + )->create(); + + // User without profile (no setting possible) + $userWithoutProfile = User::factory()->create(); + + $allUsers = collect([$userWithSetting, $userWithProfileNoSetting, $userWithoutProfile]); + + // Just verify sorting doesn't crash with nullable relationships + livewire(UsersTable::class) + ->sortTable('setting.theme') + ->assertCanSeeTableRecords($allUsers) + ->sortTable('setting.theme', 'desc') + ->assertCanSeeTableRecords($allUsers); + }); + + it('can search records with `HasOneThrough` relationship', function (): void { + $searchTheme = 'unique-theme-for-testing'; + + $matchingUser = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $searchTheme]), + 'setting' + ), + 'profile' + )->create(); + + $nonMatchingUsers = User::factory()->count(3)->create(); + + livewire(UsersTable::class) + ->searchTable($searchTheme) + ->assertCanSeeTableRecords([$matchingUser]) + ->assertCanNotSeeTableRecords($nonMatchingUsers); + }); + + it('can sort records with `HasOneThrough` relationship using different column', function (): void { + $languages = ['de', 'en', 'es', 'fr', 'it']; + + foreach ($languages as $language) { + User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language' => $language]), + 'setting' + ), + 'profile' + )->create(); + } + + $sortedAsc = User::query() + ->orderBy( + Setting::query() + ->select('language') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Setting::query() + ->select('language') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('setting.language') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('setting.language', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can search records with `HasOneThrough` relationship using different column', function (): void { + $searchLanguage = 'unique-language'; + + $matchingUser = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language' => $searchLanguage]), + 'setting' + ), + 'profile' + )->create(); + + $nonMatchingUsers = User::factory()->count(3)->create(); + + livewire(UsersTable::class) + ->searchTable($searchLanguage) + ->assertCanSeeTableRecords([$matchingUser]) + ->assertCanNotSeeTableRecords($nonMatchingUsers); + }); + + it('can sort records with `BelongsTo` -> `HasOneThrough` relationship', function (): void { + $themes = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + + foreach ($themes as $theme) { + $user = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $theme]), + 'setting' + ), + 'profile' + )->create(); + + Post::factory()->create(['author_id' => $user->id]); + } + + $sortedAsc = Post::query() + ->orderBy( + Setting::query() + ->select('theme') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Setting::query() + ->select('theme') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.setting.theme') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.setting.theme', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable `BelongsTo` -> `HasOneThrough` relationship', function (): void { + // Post with author that has setting + $userWithSetting = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => 'dark']), + 'setting' + ), + 'profile' + )->create(); + $postWithSetting = Post::factory()->create(['author_id' => $userWithSetting->id]); + + // Post with author with profile but no setting + $userWithProfileNoSetting = User::factory()->has( + Profile::factory(), + 'profile' + )->create(); + $postNoSetting = Post::factory()->create(['author_id' => $userWithProfileNoSetting->id]); + + // Post with author but no profile + $userNoProfile = User::factory()->create(); + $postNoProfile = Post::factory()->create(['author_id' => $userNoProfile->id]); + + // Post with no author + $postNoAuthor = Post::factory()->create(['author_id' => null]); + + $allPosts = collect([$postWithSetting, $postNoSetting, $postNoProfile, $postNoAuthor]); + + // Just verify sorting doesn't crash with nullable nested relationships + livewire(PostsTable::class) + ->sortTable('author.setting.theme') + ->assertCanSeeTableRecords($allPosts) + ->sortTable('author.setting.theme', 'desc') + ->assertCanSeeTableRecords($allPosts); + }); + + it('can search records with `BelongsTo` -> `HasOneThrough` relationship', function (): void { + $searchTheme = 'unique-post-author-theme'; + + $matchingUser = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['theme' => $searchTheme]), + 'setting' + ), + 'profile' + )->create(); + + $matchingPost = Post::factory()->for($matchingUser, 'author')->create(); + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTable::class) + ->searchTable($searchTheme) + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); + }); + + it('can sort records with `HasOneThrough` -> `BelongsTo` relationship', function (): void { + $languageNames = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon']; + + foreach ($languageNames as $languageName) { + $language = Language::factory()->create(['name' => $languageName]); + User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language_id' => $language->id]), + 'setting' + ), + 'profile' + )->create(); + } + + $sortedAsc = User::query() + ->orderBy( + Language::query() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Language::query() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('setting.language.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('setting.language.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with nullable `HasOneThrough` -> `BelongsTo` relationship', function (): void { + // User with full chain + $language = Language::factory()->create(['name' => 'English']); + $userWithLanguage = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language_id' => $language->id]), + 'setting' + ), + 'profile' + )->create(); + + // User with profile and setting but no language + $userWithSettingNoLanguage = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language_id' => null]), + 'setting' + ), + 'profile' + )->create(); + + // User with profile but no setting + $userWithProfileNoSetting = User::factory()->has( + Profile::factory(), + 'profile' + )->create(); + + // User without profile + $userWithoutProfile = User::factory()->create(); + + $allUsers = collect([$userWithLanguage, $userWithSettingNoLanguage, $userWithProfileNoSetting, $userWithoutProfile]); + + livewire(UsersTable::class) + ->sortTable('setting.language.name') + ->assertCanSeeTableRecords($allUsers) + ->sortTable('setting.language.name', 'desc') + ->assertCanSeeTableRecords($allUsers); + }); + + it('can sort records with `BelongsTo` -> `HasOneThrough` -> `BelongsTo` relationship', function (): void { + $languageNames = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon']; + + foreach ($languageNames as $languageName) { + $language = Language::factory()->create(['name' => $languageName]); + $user = User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language_id' => $language->id]), + 'setting' + ), + 'profile' + )->create(); + Post::factory()->create(['author_id' => $user->id]); + } + + $sortedAsc = Post::query() + ->orderBy( + Language::query() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + $sortedDesc = Post::query() + ->orderByDesc( + Language::query() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->join('users', 'users.id', '=', 'profiles.user_id') + ->whereColumn('users.id', 'posts.author_id') + ->limit(1) + ) + ->orderBy('posts.id') + ->get(); + + livewire(PostsTable::class) + ->sortTable('author.setting.language.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('author.setting.language.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `HasOne` relationship that uses a custom `where()` constraint', function (): void { + // User 1: has both published and unpublished posts; should sort by published + $user1 = User::factory()->create(); + Post::factory()->create(['author_id' => $user1->id, 'is_published' => false, 'title' => 'Z Unpublished']); + Post::factory()->create(['author_id' => $user1->id, 'is_published' => true, 'title' => 'A Published']); + + // User 2: only has unpublished posts; should sort as null + $user2 = User::factory()->create(); + Post::factory()->create(['author_id' => $user2->id, 'is_published' => false, 'title' => 'B Unpublished']); + + // User 3: has a published post + $user3 = User::factory()->create(); + Post::factory()->create(['author_id' => $user3->id, 'is_published' => true, 'title' => 'M Published']); + + $sortedAsc = User::query() + ->orderBy( + Post::query() + ->select('posts.title') + ->whereColumn('posts.author_id', 'users.id') + ->where('posts.is_published', true) + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Post::query() + ->select('posts.title') + ->whereColumn('posts.author_id', 'users.id') + ->where('posts.is_published', true) + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('publishedPost.title') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('publishedPost.title', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); + + it('can sort records with `HasOneThrough` -> `BelongsTo` relationship that uses `withTrashed()` when the related model is soft-deleted', function (): void { + $alphaLanguage = Language::factory()->create(['name' => 'Alpha']); + $betaLanguage = Language::factory()->create(['name' => 'Beta']); + $gammaLanguage = Language::factory()->create(['name' => 'Gamma']); + + foreach ([$alphaLanguage, $betaLanguage, $gammaLanguage] as $language) { + User::factory()->has( + Profile::factory()->has( + Setting::factory()->state(['language_id' => $language->id]), + 'setting' + ), + 'profile' + )->create(); + } + + // Soft-delete the Gamma language + $gammaLanguage->delete(); + + $sortedAsc = User::query() + ->orderBy( + Language::query() + ->withTrashed() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + $sortedDesc = User::query() + ->orderByDesc( + Language::query() + ->withTrashed() + ->select('languages.name') + ->join('settings', 'settings.language_id', '=', 'languages.id') + ->join('profiles', 'profiles.id', '=', 'settings.profile_id') + ->whereColumn('profiles.user_id', 'users.id') + ->limit(1) + ) + ->orderBy('users.id') + ->get(); + + livewire(UsersTable::class) + ->sortTable('setting.languageWithTrashed.name') + ->assertCanSeeTableRecords($sortedAsc, inOrder: true) + ->sortTable('setting.languageWithTrashed.name', 'desc') + ->assertCanSeeTableRecords($sortedDesc, inOrder: true); + }); +}); + +it('can search records using table-level searchable columns', function (): void { + $posts = Post::factory()->count(10)->create(); + + $title = $posts->first()->title; + + livewire(PostsTableWithTableSearchableColumns::class) + ->searchTable($title) + ->assertCanSeeTableRecords($posts->where('title', $title)) + ->assertCanNotSeeTableRecords($posts->where('title', '!=', $title)); +}); + +it('can search records using table-level searchable columns with relationship', function (): void { + $posts = Post::factory()->count(10)->create(); + + $author = $posts->first()->author->name; + + livewire(PostsTableWithTableSearchableColumns::class) + ->searchTable($author) + ->assertCanSeeTableRecords($posts->where('author.name', $author)) + ->assertCanNotSeeTableRecords($posts->where('author.name', '!=', $author)); +}); + +it('can search records using table-level searchable columns case insensitively', function (): void { + $matchingPost = Post::factory()->create([ + 'title' => 'Test Post Title', + ]); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTableWithTableSearchableColumns::class) + ->searchTable('test post title') + ->assertCanSeeTableRecords([$matchingPost]) + ->assertCanNotSeeTableRecords($nonMatchingPosts); +}); + +it('can search records using table-level searchable columns with relationship case insensitively', function (): void { + $matchingAuthor = User::factory()->create(['name' => 'John Smith']); + + $matchingPosts = Post::factory() + ->for($matchingAuthor, 'author') + ->count(3) + ->create(); + + $nonMatchingPosts = Post::factory()->count(3)->create(); + + livewire(PostsTableWithTableSearchableColumns::class) + ->searchTable('john smith') + ->assertCanSeeTableRecords($matchingPosts) + ->assertCanNotSeeTableRecords($nonMatchingPosts); +});