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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions CARD-VIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Card View

An opt-in alternative to the default table row layout that displays results as a grid of cards.

## Basic Setup

Override `cardLayout()` in any `TableBuilder` or `QueryBuilder` component to enable the card view toggle:

```php
use ACTTraining\QueryBuilder\Support\CardLayout;

class CoursesTable extends TableBuilder
{
public function cardLayout(): ?CardLayout
{
return CardLayout::make()
->columns(3)
->view('courses.card');
}
}
```

This adds the grid/list toggle buttons to the toolbar. Without overriding `cardLayout()`, nothing changes — the toggle is hidden by default.

## Card Blade View

Create the Blade partial referenced in `->view()`. It receives `$row` (the Eloquent model) and `$imageUrl` (resolved image URL or null):

```blade
{{-- resources/views/courses/card.blade.php --}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
@if($imageUrl)
<div class="aspect-video overflow-hidden">
<img src="{{ $imageUrl }}" alt="" class="w-full h-full object-cover" />
</div>
@endif

<div class="p-4">
<p class="text-sm text-orange-500 font-medium">{{ $row->category->name }}</p>
<h3 class="text-lg font-bold text-gray-900 mt-1">{{ $row->title }}</h3>

<div class="mt-3 space-y-1 text-sm text-gray-600">
<p>{{ $row->duration }} hours</p>
<p>{{ $row->location }}</p>
<p>{{ $row->delivery_method }}</p>
</div>
</div>
</div>
```

## CardLayout Options

| Method | Description | Default |
|--------|-------------|---------|
| `->columns(int)` | Number of grid columns at `lg` breakpoint | `3` |
| `->image(string)` | Dot-notation key for the image URL on the model (e.g. `'photo_url'` or `'media.url'`) | `null` (no image) |
| `->placeholder(string)` | Fallback image URL when the model's image is null | `null` |
| `->view(string)` | Blade view to render each card | `'query-builder::card'` (basic default) |

## Examples

### Cards with images and a placeholder

```php
public function cardLayout(): ?CardLayout
{
return CardLayout::make()
->image('featured_image_url')
->placeholder('/img/placeholder.jpg')
->columns(3)
->view('courses.card');
}
```

### Cards without images (text-only)

```php
public function cardLayout(): ?CardLayout
{
return CardLayout::make()
->columns(4)
->view('contacts.card');
}
```

### Using the default card template

No custom view needed — renders image + model ID:

```php
public function cardLayout(): ?CardLayout
{
return CardLayout::make()
->image('avatar_url');
}
```

## What still works in card mode

- **Pagination** — unchanged
- **Search** — unchanged
- **Filters / criteria** — unchanged
- **Sorting** — applied at the query level, so cards render in sorted order
- **Row click** — if `isClickable()` is true, clicking a card triggers the same action as clicking a table row
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
],
"require": {
"php": "^8.2",
"livewire/livewire": "^v3.2.6",
"livewire/livewire": "^v3.2.6|^4.0",
"secondnetwork/blade-tabler-icons": "^3.24",
"spatie/laravel-package-tools": "^1.14.0"
},
Expand Down
5 changes: 4 additions & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,8 @@ parameters:
tmpDir: build/phpstan
checkOctaneCompatibility: true
checkModelProperties: true
checkMissingIterableValueType: false
ignoreErrors:
-
identifier: missingType.iterableValue
reportUnmatched: false

25 changes: 25 additions & 0 deletions resources/views/card-grid.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@php
$cardLayout = $this->cardLayout();
$gridCols = $cardLayout->getGridColumns();
@endphp

<div class="grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-{{ $gridCols }} p-4">
@foreach($this->rows as $row)
@php
$imageUrl = $cardLayout->getImageUrl($row);
$cardView = $cardLayout->getCardView();
@endphp

<div
wire:key="{{ $this->identifier() }}-card-{{ $row->id }}"
@if($this->isClickable())
{!! $this->renderRowClick($row->id) !!}
class="cursor-pointer h-full"
@else
class="h-full"
@endif
>
@include($cardView, ['row' => $row, 'imageUrl' => $imageUrl])
</div>
@endforeach
</div>
15 changes: 15 additions & 0 deletions resources/views/card.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
@if($imageUrl)
<div class="aspect-video overflow-hidden">
<img
src="{{ $imageUrl }}"
alt=""
class="w-full h-full object-cover"
/>
</div>
@endif

<div class="p-4">
<p class="text-sm text-gray-500">{{ $row->id }}</p>
</div>
</div>
30 changes: 30 additions & 0 deletions resources/views/components/view-toggle.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="inline-flex rounded-md shadow-sm">
<button
type="button"
wire:click="toggleViewMode"
@class([
'inline-flex items-center px-3 py-2 text-sm font-medium rounded-l-md border',
'bg-white text-gray-500 hover:bg-gray-50 border-gray-300' => $this->viewMode !== 'cards',
'bg-gray-100 text-gray-900 border-gray-300 z-10' => $this->viewMode === 'cards',
])
title="Card view"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
</button>
<button
type="button"
wire:click="toggleViewMode"
@class([
'inline-flex items-center px-3 py-2 text-sm font-medium rounded-r-md border -ml-px',
'bg-white text-gray-500 hover:bg-gray-50 border-gray-300' => $this->viewMode !== 'table',
'bg-gray-100 text-gray-900 border-gray-300 z-10' => $this->viewMode === 'table',
])
title="Table view"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
</button>
</div>
8 changes: 8 additions & 0 deletions resources/views/query-table.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<div class="p-4 flex items-center gap-2 justify-between bg-gray-50">

<div class="p-4 flex items-center gap-2">
@if($this->isCardViewEnabled())
@include('query-builder::components.view-toggle')
@endif

@if($this->isSearchVisible())
@include('query-builder::components.search')
@endif
Expand All @@ -49,6 +53,9 @@

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

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

@if($this->isPaginated() && $this->rows->hasPages())
<div class="border-b border-gray-200 shadow-sm">
Expand Down
19 changes: 19 additions & 0 deletions resources/views/table.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
@if($this->isSearchVisible() && $this->searchableColumnsSet() && ! $this->areActionsVisible())
@if($this->isFiltered() || $this->isSearchActive() || $this->rows->count() > 0)
<div class="p-4 flex items-center gap-2 w-full">
@if($this->isCardViewEnabled())
@include('query-builder::components.view-toggle')
@endif

@include('query-builder::components.search')
</div>
@endif
Expand Down Expand Up @@ -43,6 +47,20 @@

<div id="{{ $this->identifier() }}">
@if($this->rows->count())

@if($this->isCardMode())
@include('query-builder::card-grid')

@if($this->isPaginated() && $this->rows->hasPages())
<div class="px-6 py-2">
@if($this->scroll() === true)
{{ $this->rows->links() }}
@else
{{ $this->rows->links(data: ['scrollTo' => $this->scroll()]) }}
@endif
</div>
@endif
@else
<div class="relative overflow-x-auto overflow-y-auto">
<table class="w-full text-sm text-left text-gray-500" wire:key="{{ $this->identifier() }}">
<thead class="text-xs text-gray-700 bg-gray-50">
Expand Down Expand Up @@ -210,6 +228,7 @@ class="flex justify-center items-center absolute inset-0"
@endif

</div>
@endif

@else
<div>
Expand Down
2 changes: 2 additions & 0 deletions src/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use ACTTraining\QueryBuilder\Support\Collection\CriteriaCollection;
use ACTTraining\QueryBuilder\Support\Concerns\WithActions;
use ACTTraining\QueryBuilder\Support\Concerns\WithCaching;
use ACTTraining\QueryBuilder\Support\Concerns\WithCardView;
use ACTTraining\QueryBuilder\Support\Concerns\WithColumns;
use ACTTraining\QueryBuilder\Support\Concerns\WithFilters;
use ACTTraining\QueryBuilder\Support\Concerns\WithIdentifier;
Expand Down Expand Up @@ -36,6 +37,7 @@ abstract class QueryBuilder extends Component
{
use WithActions;
use WithCaching;
use WithCardView;
use WithColumns;
use WithFilters;
use WithIdentifier;
Expand Down
76 changes: 76 additions & 0 deletions src/Support/CardLayout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace ACTTraining\QueryBuilder\Support;

class CardLayout
{
protected ?string $imageKey = null;

protected ?string $placeholderImage = null;

protected int $gridColumns = 3;

protected string $cardView = 'query-builder::card';

public static function make(): self
{
return new self;
}

public function image(string $key): static
{
$this->imageKey = $key;

return $this;
}

public function placeholder(string $url): static
{
$this->placeholderImage = $url;

return $this;
}

public function columns(int $columns): static
{
$this->gridColumns = $columns;

return $this;
}

public function view(string $view): static
{
$this->cardView = $view;

return $this;
}

public function getImageKey(): ?string
{
return $this->imageKey;
}

public function getPlaceholderImage(): ?string
{
return $this->placeholderImage;
}

public function getGridColumns(): int
{
return $this->gridColumns;
}

public function getCardView(): string
{
return $this->cardView;
}

public function getImageUrl(mixed $row): ?string
{
if (! $this->imageKey) {
return null;
}

return data_get($row, $this->imageKey) ?? $this->placeholderImage;
}
}
30 changes: 30 additions & 0 deletions src/Support/Concerns/WithCardView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace ACTTraining\QueryBuilder\Support\Concerns;

use ACTTraining\QueryBuilder\Support\CardLayout;

trait WithCardView
{
public string $viewMode = 'table';

public function cardLayout(): ?CardLayout
{
return null;
}

public function toggleViewMode(): void
{
$this->viewMode = $this->viewMode === 'table' ? 'cards' : 'table';
}

public function isCardViewEnabled(): bool
{
return $this->cardLayout() !== null;
}

public function isCardMode(): bool
{
return $this->isCardViewEnabled() && $this->viewMode === 'cards';
}
}
Loading
Loading