Skip to content

Commit 5faea4a

Browse files
Merge pull request #104 from ACT-Training/feature/issue-103-card-view-mode
Add card view mode to TableBuilder
2 parents 4286423 + 1135b73 commit 5faea4a

11 files changed

Lines changed: 315 additions & 1 deletion

File tree

CARD-VIEW.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Card View
2+
3+
An opt-in alternative to the default table row layout that displays results as a grid of cards.
4+
5+
## Basic Setup
6+
7+
Override `cardLayout()` in any `TableBuilder` or `QueryBuilder` component to enable the card view toggle:
8+
9+
```php
10+
use ACTTraining\QueryBuilder\Support\CardLayout;
11+
12+
class CoursesTable extends TableBuilder
13+
{
14+
public function cardLayout(): ?CardLayout
15+
{
16+
return CardLayout::make()
17+
->columns(3)
18+
->view('courses.card');
19+
}
20+
}
21+
```
22+
23+
This adds the grid/list toggle buttons to the toolbar. Without overriding `cardLayout()`, nothing changes — the toggle is hidden by default.
24+
25+
## Card Blade View
26+
27+
Create the Blade partial referenced in `->view()`. It receives `$row` (the Eloquent model) and `$imageUrl` (resolved image URL or null):
28+
29+
```blade
30+
{{-- resources/views/courses/card.blade.php --}}
31+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
32+
@if($imageUrl)
33+
<div class="aspect-video overflow-hidden">
34+
<img src="{{ $imageUrl }}" alt="" class="w-full h-full object-cover" />
35+
</div>
36+
@endif
37+
38+
<div class="p-4">
39+
<p class="text-sm text-orange-500 font-medium">{{ $row->category->name }}</p>
40+
<h3 class="text-lg font-bold text-gray-900 mt-1">{{ $row->title }}</h3>
41+
42+
<div class="mt-3 space-y-1 text-sm text-gray-600">
43+
<p>{{ $row->duration }} hours</p>
44+
<p>{{ $row->location }}</p>
45+
<p>{{ $row->delivery_method }}</p>
46+
</div>
47+
</div>
48+
</div>
49+
```
50+
51+
## CardLayout Options
52+
53+
| Method | Description | Default |
54+
|--------|-------------|---------|
55+
| `->columns(int)` | Number of grid columns at `lg` breakpoint | `3` |
56+
| `->image(string)` | Dot-notation key for the image URL on the model (e.g. `'photo_url'` or `'media.url'`) | `null` (no image) |
57+
| `->placeholder(string)` | Fallback image URL when the model's image is null | `null` |
58+
| `->view(string)` | Blade view to render each card | `'query-builder::card'` (basic default) |
59+
60+
## Examples
61+
62+
### Cards with images and a placeholder
63+
64+
```php
65+
public function cardLayout(): ?CardLayout
66+
{
67+
return CardLayout::make()
68+
->image('featured_image_url')
69+
->placeholder('/img/placeholder.jpg')
70+
->columns(3)
71+
->view('courses.card');
72+
}
73+
```
74+
75+
### Cards without images (text-only)
76+
77+
```php
78+
public function cardLayout(): ?CardLayout
79+
{
80+
return CardLayout::make()
81+
->columns(4)
82+
->view('contacts.card');
83+
}
84+
```
85+
86+
### Using the default card template
87+
88+
No custom view needed — renders image + model ID:
89+
90+
```php
91+
public function cardLayout(): ?CardLayout
92+
{
93+
return CardLayout::make()
94+
->image('avatar_url');
95+
}
96+
```
97+
98+
## What still works in card mode
99+
100+
- **Pagination** — unchanged
101+
- **Search** — unchanged
102+
- **Filters / criteria** — unchanged
103+
- **Sorting** — applied at the query level, so cards render in sorted order
104+
- **Row click** — if `isClickable()` is true, clicking a card triggers the same action as clicking a table row

phpstan.neon.dist

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@ parameters:
1010
tmpDir: build/phpstan
1111
checkOctaneCompatibility: true
1212
checkModelProperties: true
13-
checkMissingIterableValueType: false
13+
ignoreErrors:
14+
-
15+
identifier: missingType.iterableValue
16+
reportUnmatched: false
1417

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
@php
2+
$cardLayout = $this->cardLayout();
3+
$gridCols = $cardLayout->getGridColumns();
4+
@endphp
5+
6+
<div class="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-{{ $gridCols }} p-4">
7+
@foreach($this->rows as $row)
8+
@php
9+
$imageUrl = $cardLayout->getImageUrl($row);
10+
$cardView = $cardLayout->getCardView();
11+
@endphp
12+
13+
<div
14+
wire:key="{{ $this->identifier() }}-card-{{ $row->id }}"
15+
@if($this->isClickable())
16+
{!! $this->renderRowClick($row->id) !!}
17+
class="cursor-pointer h-full"
18+
@else
19+
class="h-full"
20+
@endif
21+
>
22+
@include($cardView, ['row' => $row, 'imageUrl' => $imageUrl])
23+
</div>
24+
@endforeach
25+
</div>

resources/views/card.blade.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
2+
@if($imageUrl)
3+
<div class="aspect-video overflow-hidden">
4+
<img
5+
src="{{ $imageUrl }}"
6+
alt=""
7+
class="w-full h-full object-cover"
8+
/>
9+
</div>
10+
@endif
11+
12+
<div class="p-4">
13+
<p class="text-sm text-gray-500">{{ $row->id }}</p>
14+
</div>
15+
</div>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<div class="inline-flex rounded-md shadow-sm">
2+
<button
3+
type="button"
4+
wire:click="toggleViewMode"
5+
@class([
6+
'inline-flex items-center px-3 py-2 text-sm font-medium rounded-l-md border',
7+
'bg-white text-gray-500 hover:bg-gray-50 border-gray-300' => $this->viewMode !== 'cards',
8+
'bg-gray-100 text-gray-900 border-gray-300 z-10' => $this->viewMode === 'cards',
9+
])
10+
title="Card view"
11+
>
12+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
13+
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
14+
</svg>
15+
</button>
16+
<button
17+
type="button"
18+
wire:click="toggleViewMode"
19+
@class([
20+
'inline-flex items-center px-3 py-2 text-sm font-medium rounded-r-md border -ml-px',
21+
'bg-white text-gray-500 hover:bg-gray-50 border-gray-300' => $this->viewMode !== 'table',
22+
'bg-gray-100 text-gray-900 border-gray-300 z-10' => $this->viewMode === 'table',
23+
])
24+
title="Table view"
25+
>
26+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
27+
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 010 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 010 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 010 2H4a1 1 0 01-1-1z" clip-rule="evenodd" />
28+
</svg>
29+
</button>
30+
</div>

resources/views/query-table.blade.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
<div class="p-4 flex items-center gap-2 justify-between bg-gray-50">
2626

2727
<div class="p-4 flex items-center gap-2">
28+
@if($this->isCardViewEnabled())
29+
@include('query-builder::components.view-toggle')
30+
@endif
31+
2832
@if($this->isSearchVisible())
2933
@include('query-builder::components.search')
3034
@endif
@@ -49,6 +53,9 @@
4953

5054
@if($this->rows->count())
5155

56+
@if($this->isCardMode())
57+
@include('query-builder::card-grid')
58+
@else
5259
<div id="{{ $this->identifier() }}" class="relative overflow-x-auto">
5360
<table class="w-full text-sm text-left text-gray-500">
5461
<thead class="text-xs text-gray-700 bg-gray-50">
@@ -173,6 +180,7 @@ class="sr-only ml-2 text-sm font-medium text-gray-900 dark:text-gray-300"></labe
173180
</tbody>
174181
</table>
175182
</div>
183+
@endif
176184

177185
@if($this->isPaginated() && $this->rows->hasPages())
178186
<div class="border-b border-gray-200 shadow-sm">

resources/views/table.blade.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
@if($this->isSearchVisible() && $this->searchableColumnsSet() && ! $this->areActionsVisible())
99
@if($this->isFiltered() || $this->isSearchActive() || $this->rows->count() > 0)
1010
<div class="p-4 flex items-center gap-2 w-full">
11+
@if($this->isCardViewEnabled())
12+
@include('query-builder::components.view-toggle')
13+
@endif
14+
1115
@include('query-builder::components.search')
1216
</div>
1317
@endif
@@ -43,6 +47,20 @@
4347

4448
<div id="{{ $this->identifier() }}">
4549
@if($this->rows->count())
50+
51+
@if($this->isCardMode())
52+
@include('query-builder::card-grid')
53+
54+
@if($this->isPaginated() && $this->rows->hasPages())
55+
<div class="px-6 py-2">
56+
@if($this->scroll() === true)
57+
{{ $this->rows->links() }}
58+
@else
59+
{{ $this->rows->links(data: ['scrollTo' => $this->scroll()]) }}
60+
@endif
61+
</div>
62+
@endif
63+
@else
4664
<div class="relative overflow-x-auto overflow-y-auto">
4765
<table class="w-full text-sm text-left text-gray-500" wire:key="{{ $this->identifier() }}">
4866
<thead class="text-xs text-gray-700 bg-gray-50">
@@ -210,6 +228,7 @@ class="flex justify-center items-center absolute inset-0"
210228
@endif
211229

212230
</div>
231+
@endif
213232

214233
@else
215234
<div>

src/QueryBuilder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use ACTTraining\QueryBuilder\Support\Collection\CriteriaCollection;
1010
use ACTTraining\QueryBuilder\Support\Concerns\WithActions;
1111
use ACTTraining\QueryBuilder\Support\Concerns\WithCaching;
12+
use ACTTraining\QueryBuilder\Support\Concerns\WithCardView;
1213
use ACTTraining\QueryBuilder\Support\Concerns\WithColumns;
1314
use ACTTraining\QueryBuilder\Support\Concerns\WithFilters;
1415
use ACTTraining\QueryBuilder\Support\Concerns\WithIdentifier;
@@ -36,6 +37,7 @@ abstract class QueryBuilder extends Component
3637
{
3738
use WithActions;
3839
use WithCaching;
40+
use WithCardView;
3941
use WithColumns;
4042
use WithFilters;
4143
use WithIdentifier;

src/Support/CardLayout.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace ACTTraining\QueryBuilder\Support;
4+
5+
class CardLayout
6+
{
7+
protected ?string $imageKey = null;
8+
9+
protected ?string $placeholderImage = null;
10+
11+
protected int $gridColumns = 3;
12+
13+
protected string $cardView = 'query-builder::card';
14+
15+
public static function make(): self
16+
{
17+
return new self;
18+
}
19+
20+
public function image(string $key): static
21+
{
22+
$this->imageKey = $key;
23+
24+
return $this;
25+
}
26+
27+
public function placeholder(string $url): static
28+
{
29+
$this->placeholderImage = $url;
30+
31+
return $this;
32+
}
33+
34+
public function columns(int $columns): static
35+
{
36+
$this->gridColumns = $columns;
37+
38+
return $this;
39+
}
40+
41+
public function view(string $view): static
42+
{
43+
$this->cardView = $view;
44+
45+
return $this;
46+
}
47+
48+
public function getImageKey(): ?string
49+
{
50+
return $this->imageKey;
51+
}
52+
53+
public function getPlaceholderImage(): ?string
54+
{
55+
return $this->placeholderImage;
56+
}
57+
58+
public function getGridColumns(): int
59+
{
60+
return $this->gridColumns;
61+
}
62+
63+
public function getCardView(): string
64+
{
65+
return $this->cardView;
66+
}
67+
68+
public function getImageUrl(mixed $row): ?string
69+
{
70+
if (! $this->imageKey) {
71+
return null;
72+
}
73+
74+
return data_get($row, $this->imageKey) ?? $this->placeholderImage;
75+
}
76+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace ACTTraining\QueryBuilder\Support\Concerns;
4+
5+
use ACTTraining\QueryBuilder\Support\CardLayout;
6+
7+
trait WithCardView
8+
{
9+
public string $viewMode = 'table';
10+
11+
public function cardLayout(): ?CardLayout
12+
{
13+
return null;
14+
}
15+
16+
public function toggleViewMode(): void
17+
{
18+
$this->viewMode = $this->viewMode === 'table' ? 'cards' : 'table';
19+
}
20+
21+
public function isCardViewEnabled(): bool
22+
{
23+
return $this->cardLayout() !== null;
24+
}
25+
26+
public function isCardMode(): bool
27+
{
28+
return $this->isCardViewEnabled() && $this->viewMode === 'cards';
29+
}
30+
}

0 commit comments

Comments
 (0)