Skip to content

Add filtering functionality to Kanban board #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
80 changes: 80 additions & 0 deletions resources/views/livewire/kanban-board-filters.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<div class="mb-4 p-4 bg-white rounded-lg shadow space-y-4">
<div class="flex flex-wrap gap-2">
@foreach($config->getFilterableFields() as $field => $config)
<div class="flex-shrink-0">
@if($config['type'] === 'select')
<div>
<label for="filter-{{ $field }}" class="block text-sm font-medium text-gray-700">
{{ $config['label'] ?? ucfirst($field) }}
</label>
<select
id="filter-{{ $field }}"
wire:model.live="filters.{{ $field }}"
wire:change="applyFilter('{{ $field }}', $event.target.value)"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-50 text-sm"
>
<option value="">{{ __('All') }}</option>
@foreach($this->getFilterOptions($field) as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
@elseif($config['type'] === 'text')
<div>
<label for="filter-{{ $field }}" class="block text-sm font-medium text-gray-700">
{{ $config['label'] ?? ucfirst($field) }}
</label>
<input
type="text"
id="filter-{{ $field }}"
wire:model.live.debounce.300ms="filters.{{ $field }}"
wire:change="applyFilter('{{ $field }}', $event.target.value)"
placeholder="{{ __('Search') }}..."
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-50 text-sm"
/>
</div>
@elseif($config['type'] === 'date')
<div>
<label for="filter-{{ $field }}" class="block text-sm font-medium text-gray-700">
{{ $config['label'] ?? ucfirst($field) }}
</label>
<input
type="date"
id="filter-{{ $field }}"
wire:model.live="filters.{{ $field }}"
wire:change="applyFilter('{{ $field }}', $event.target.value)"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring focus:ring-primary-500 focus:ring-opacity-50 text-sm"
/>
</div>
@endif
</div>
@endforeach
</div>

@if(count($filters))
<div class="flex items-center justify-between">
<div class="flex flex-wrap gap-2">
@foreach($filters as $field => $value)
@if($value)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-sm font-medium bg-primary-100 text-primary-800">
{{ $config->getFilterableFields()[$field]['label'] ?? ucfirst($field) }}: {{ $this->formatFilterValue($field, $value) }}
<button type="button" wire:click="removeFilter('{{ $field }}')" class="ml-1 inline-flex text-primary-400 hover:text-primary-600">
<span class="sr-only">{{ __('Remove filter') }}</span>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</span>
@endif
@endforeach
</div>

<button
wire:click="resetFilters"
class="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
{{ __('Clear All Filters') }}
</button>
</div>
@endif
</div>
13 changes: 13 additions & 0 deletions resources/views/livewire/kanban-board.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ class="ff-board"
}
})"
>
<!-- Filter UI Section -->
@if(count($config->getFilterableFields()))
<div class="ff-board__filters">
@include('flowforge::livewire.kanban-board-filters')
</div>
@endif

<!-- Active Filters Indicator -->
@if(count($filters))
<div class="mb-4 px-4 py-2 bg-primary-50 rounded-md text-sm text-primary-700">
{{ __('Viewing filtered results.') }} <button wire:click="resetFilters" class="underline">{{ __('Clear all filters') }}</button>
</div>
@endif

<!-- Board Content -->
<div class="ff-board__content">
Expand Down
132 changes: 132 additions & 0 deletions src/Concerns/QueryHandlingTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,136 @@ public function getColumnItemsCount(string | int $columnId): int
->where($columnField, $columnId)
->count();
}

/**
* Get all items with filtering applied.
*
* @param array $filters Associative array of field => value filters
*/
public function getFilteredItems(array $filters = []): Collection
{
$query = $this->newQuery();
$orderField = $this->config->getOrderField();

// Apply all filters to the query
$query = $this->applyFiltersToQuery($query, $filters);

if ($orderField !== null) {
$query->orderBy($orderField);
}

$models = $query->get();

return $this->formatCardsForDisplay($models);
}

/**
* Get items for a specific column with filtering.
*
* @param string|int $columnId The column ID
* @param int $limit The number of items to return
* @param array $filters Associative array of field => value filters
*/
public function getFilteredItemsForColumn(string | int $columnId, int $limit = 10, array $filters = []): Collection
{
$columnField = $this->config->getColumnField();
$orderField = $this->config->getOrderField();

$query = $this->newQuery()->where($columnField, $columnId);

// Apply all filters to the query
$query = $this->applyFiltersToQuery($query, $filters);

if ($orderField !== null) {
$query->orderBy($orderField);
}

$models = $query->limit($limit)->get();

return $this->formatCardsForDisplay($models);
}

/**
* Get the count of filtered items in a specific column.
*
* @param string|int $columnId The column ID
* @param array $filters Associative array of field => value filters
*/
public function getFilteredColumnItemsCount(string | int $columnId, array $filters = []): int
{
$columnField = $this->config->getColumnField();

$query = $this->newQuery()->where($columnField, $columnId);

// Apply all filters to the query
$query = $this->applyFiltersToQuery($query, $filters);

return $query->count();
}

/**
* Apply filters to a query builder instance.
*
* @param Builder $query The query builder
* @param array $filters Associative array of field => value filters
* @return Builder The modified query builder
*/
protected function applyFiltersToQuery(Builder $query, array $filters): Builder
{
foreach ($filters as $field => $value) {
if ($value === null || $value === '') {
continue;
}

// Get field configuration if available
$fieldConfig = $this->config->getFilterableFields()[$field] ?? null;

// If no field configuration is available, use simple equals comparison
if (!$fieldConfig || !isset($fieldConfig['operator'])) {
$query->where($field, $value);
continue;
}

// Handle different operators based on field configuration
switch ($fieldConfig['operator']) {
case 'contains':
$query->where($field, 'LIKE', "%{$value}%");
break;
case 'starts_with':
$query->where($field, 'LIKE', "{$value}%");
break;
case 'ends_with':
$query->where($field, 'LIKE', "%{$value}");
break;
case 'greater_than':
$query->where($field, '>', $value);
break;
case 'less_than':
$query->where($field, '<', $value);
break;
case 'greater_than_or_equal':
$query->where($field, '>=', $value);
break;
case 'less_than_or_equal':
$query->where($field, '<=', $value);
break;
case 'in':
if (is_array($value)) {
$query->whereIn($field, $value);
}
break;
case 'not_in':
if (is_array($value)) {
$query->whereNotIn($field, $value);
}
break;
default:
// Default to equals
$query->where($field, $value);
break;
}
}

return $query;
}
}
16 changes: 15 additions & 1 deletion src/Config/KanbanConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* @method self withOrderField(string|null $orderField) Set the field name for maintaining card order
* @method self withCardLabel(string|null $cardLabel) Set the label for individual cards
* @method self withPluralCardLabel(string|null $pluralCardLabel) Set the plural label for collection of cards
* @method self withFilterableFields(array $filterableFields) Set the fields that can be used for filtering cards
*/
final readonly class KanbanConfig implements Wireable
{
Expand All @@ -43,6 +44,7 @@ public function __construct(
private ?string $orderField = null,
private ?string $cardLabel = null,
private ?string $pluralCardLabel = null,
private array $filterableFields = [],
) {}

/**
Expand Down Expand Up @@ -161,6 +163,16 @@ public function getPluralCardLabel(): string
return $this->pluralCardLabel ?? Str::plural($this->getSingularCardLabel());
}

/**
* Get the fields that can be used for filtering cards.
*
* @return array<string, array> Map of field names to their filter configuration
*/
public function getFilterableFields(): array
{
return $this->filterableFields;
}

/**
* Get the default form schema for creating cards.
*
Expand Down Expand Up @@ -279,6 +291,7 @@ public function toLivewire(): array
'orderField' => $this->orderField,
'cardLabel' => $this->cardLabel,
'pluralCardLabel' => $this->pluralCardLabel,
'filterableFields' => $this->filterableFields,
];
}

Expand All @@ -296,7 +309,8 @@ public static function fromLivewire($value): KanbanConfig
$value['cardAttributeIcons'],
$value['orderField'],
$value['cardLabel'],
$value['pluralCardLabel']
$value['pluralCardLabel'],
$value['filterableFields'] ?? []
);
}
}
24 changes: 24 additions & 0 deletions src/Contracts/KanbanAdapterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,28 @@ public function getColumnItemsCount(string | int $columnId): int;
* @param array<int, mixed> $recordIds The record IDs in their new order
*/
public function updateRecordsOrderAndColumn(string | int $columnId, array $recordIds): bool;

/**
* Get items for a specific column with filtering.
*
* @param string|int $columnId The column ID
* @param int $limit The number of items to return
* @param array $filters Associative array of field => value filters
*/
public function getFilteredItemsForColumn(string | int $columnId, int $limit = 10, array $filters = []): Collection;

/**
* Get all items with filtering applied.
*
* @param array $filters Associative array of field => value filters
*/
public function getFilteredItems(array $filters = []): Collection;

/**
* Get the count of filtered items in a specific column.
*
* @param string|int $columnId The column ID
* @param array $filters Associative array of field => value filters
*/
public function getFilteredColumnItemsCount(string | int $columnId, array $filters = []): int;
}
Loading