Skip to content

Commit 644787b

Browse files
committed
Add free-text filter chip for text-like custom fields
1 parent 49edc45 commit 644787b

11 files changed

Lines changed: 174 additions & 26 deletions

packages/builder/README.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ Navigation: **Fields → Field Groups**
462462
| **Visibility** | Per context: admin, frontend, API (`visible_*` toggles) |
463463
| **Conditional logic** | Show/hide rules based on sibling field values (root, group, repeater, flexible layout) |
464464
| **Table column** | Optional list-column settings for scalar, media, and relation fields — see [Table columns](#table-columns) |
465-
| **Table filter** | Optional list-filter for select, radio, button_group, toggle, and single relation fields — see [Table filters](#table-filters) |
465+
| **Table filter** | Optional list-filter chip for select, radio, button_group, toggle, single relation, and text-like fields — see [Table filters](#table-filters) |
466466

467467
Repeater rows are collapsed by default and show type, key, and required flag in the label.
468468

@@ -523,17 +523,20 @@ public static function table(Table $table): Table
523523

524524
## Table filters
525525

526-
Opt-in Filament list filters for custom fields. Filtering runs as subqueries on `builder_field_values` — the consumer model needs no filter scopes.
526+
Opt-in Filament list filter chips for custom fields. Filtering runs as subqueries on `builder_field_values` — the consumer model needs no filter scopes.
527527

528-
### Supported field types
528+
One filter chip per filterable field, compiled from the **Show in table filter** toggle:
529+
530+
| Field type | Filter chip |
531+
|------------|-------------|
532+
| `select`, `radio`, `button_group` | `SelectFilter` (options from field definition) |
533+
| `toggle` | `TernaryFilter` (yes / no / any) |
534+
| `relation` (single) | `SelectFilter` (searchable, preloaded) |
535+
| `text`, `textarea`, `email`, `url`, `rich_text` | `Filter` (free-text "contains" search) |
529536

530-
| Field type | Filter UI | Requirements |
531-
|------------|-----------|--------------|
532-
| `select`, `radio`, `button_group` | `SelectFilter` (options from field definition) | At least one option |
533-
| `toggle` | `TernaryFilter` (yes / no / any) ||
534-
| `relation` | `SelectFilter` (searchable, preloaded) | `related_entity` set, `multiple` off |
537+
Choice fields need at least one option; relation fields need `related_entity` set and `multiple` off.
535538

536-
**Not filterable:** all other types (text, number, media, compound fields, multiselect, etc.).
539+
**Not filterable at all:** number, range, date, datetime, media, multiselect/checkbox list, and compound fields (group, repeater, clone, flexible content) — no dedicated filter chip is compiled for these.
537540

538541
### Admin setup
539542

@@ -585,11 +588,11 @@ Other resources with `HasCustomFields` do **not** get filters automatically —
585588
FieldGroup (show_in_filter = true)
586589
→ DefinitionRegistry
587590
→ TableFilterCompiler
588-
→ SelectFilter / TernaryFilter
591+
→ SelectFilter / TernaryFilter / Filter (text)
589592
→ CustomFieldTableFilterQuery (subquery on builder_field_values)
590593
```
591594

592-
Only fields marked `settings.show_in_filter` in active, location-matched groups are compiled. Filter names use the field key (`fuel`, `accident_free`, …).
595+
Only fields marked `settings.show_in_filter` in active, location-matched groups are compiled. Filter (chip) names use the field key (`fuel`, `accident_free`, …).
593596

594597
### Troubleshooting
595598

@@ -1118,7 +1121,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc
11181121
| Image/gallery/file (with `moox/media`) | library filtered, save/load works, `media_usables` updated |
11191122
| Relation field on a group | searchable select, save/load IDs, API shows labels |
11201123
| Table column on scalar/toggle/media/relation field | column appears (toggle via column picker if hidden by default) |
1121-
| Table filter on select/radio/button_group/toggle/relation | filter chip on list pages narrows matching records |
1124+
| Table filter on select/radio/button_group/toggle/relation/text-like field | filter chip on list pages narrows matching records |
11221125
| Conditional logic (show/hide) | field visibility updates live; hidden required fields do not block save |
11231126
| `?lang=` on translatable resource | values stored per `locale` column |
11241127

@@ -1145,7 +1148,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc
11451148
- **Per-context visibility**`visible_admin`, `visible_api`, `visible_frontend` (admin + API wired)
11461149
- **Location rules** — entity, record type, record status, user role, taxonomy term IDs (admin constraints + import/export)
11471150
- **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`)
1148-
- **Table filters** — opt-in list filters for select, radio, button_group, toggle, and single relation fields (`TableFilterCompiler`, `CustomFieldTableFilterQuery`)
1151+
- **Table filters** — opt-in filter chips for select, radio, button_group, toggle, single relation, and text-like fields (`TableFilterCompiler`, `CustomFieldTableFilterQuery`)
11491152
- **Field width grid** — 12-column layout per field (`FieldWidth`)
11501153
- **Sidebar placement**`main` vs `sidebar` field group slots
11511154
- **Translations** — definition + value locales (Astrotomic + `builder_field_values.locale`)
@@ -1155,7 +1158,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc
11551158
**v1 limitations (known):**
11561159

11571160
- Location rules: no template/parent params yet; record/taxonomy/status rules need a saved record (ignored on create)
1158-
- Table filters: no centralized filter builder / preset groups yet (per-field opt-in only)
1161+
- Table filters: number, range, date, datetime, media, multiselect/checkbox list, and compound fields have no filter chip yet
11591162
- Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only)
11601163
- Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models)
11611164

packages/builder/resources/lang/de/builder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
'show_in_table' => 'In Tabelle anzeigen',
136136
'show_in_table_helper' => 'Fügt dieses Feld als Spalte auf den Listen-Seiten hinzu. Standardmäßig ausgeblendet; über den Spalten-Umschalter aktivierbar.',
137137
'show_in_filter' => 'Als Listenfilter',
138-
'show_in_filter_helper' => 'Fügt einen Listenfilter für Auswahl-, Wahr/falsch- und Beziehungsfelder hinzu. Standardmäßig aus.',
138+
'show_in_filter_helper' => 'Fügt einen Listenfilter für Auswahl-, Wahr/falsch-, Beziehungs- und Textfelder hinzu. Standardmäßig aus.',
139139
'show_in_filter_relation_multiple_helper' => 'Listenfilter gibt es nur für einfache Beziehungsfelder (ohne Mehrfachauswahl).',
140140
'show_in_filter_relation_entity_helper' => 'Zuerst „Beziehung zu“ wählen, dann den Listenfilter aktivieren.',
141141
'show_in_filter_choice_options_helper' => 'Mindestens eine Option hinzufügen, bevor der Listenfilter aktiviert werden kann.',

packages/builder/resources/lang/en/builder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135
'show_in_table' => 'Show in table',
136136
'show_in_table_helper' => 'Adds this field as a column on entity list pages. Hidden by default; enable via the column toggle.',
137137
'show_in_filter' => 'Show in table filter',
138-
'show_in_filter_helper' => 'Adds a list filter for select, toggle, and single relation fields. Off by default.',
138+
'show_in_filter_helper' => 'Adds a list filter for select, toggle, relation, and text fields. Off by default.',
139139
'show_in_filter_relation_multiple_helper' => 'List filters are only available for single relation fields. Disable “Multiple” first.',
140140
'show_in_filter_relation_entity_helper' => 'Choose a related entity before enabling the list filter.',
141141
'show_in_filter_choice_options_helper' => 'Add at least one option before enabling the list filter.',

packages/builder/src/Compiler/TableFilterCompiler.php

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace Moox\Builder\Compiler;
66

7+
use Filament\Forms\Components\TextInput;
78
use Filament\Tables\Filters\BaseFilter;
9+
use Filament\Tables\Filters\Filter;
810
use Filament\Tables\Filters\SelectFilter;
911
use Filament\Tables\Filters\TernaryFilter;
1012
use Illuminate\Database\Eloquent\Builder;
@@ -54,15 +56,14 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null):
5456
return [];
5557
}
5658

57-
$fields = $this->storableFieldCollector
59+
$filterableFields = $this->storableFieldCollector
5860
->definitionsFromList($fieldGroups->flatMap(fn (FieldGroupDefinition $group): Collection => $group->fields))
59-
->filter(fn (FieldDefinition $field): bool => $field->showInFilter()
60-
&& FilterableFieldTypes::supports($field))
61+
->filter(fn (FieldDefinition $field): bool => $field->showInFilter())
6162
->values();
6263

6364
$filters = [];
6465

65-
foreach ($fields as $field) {
66+
foreach ($filterableFields->filter(fn (FieldDefinition $field): bool => FilterableFieldTypes::supports($field)) as $field) {
6667
$filter = $this->compileFilter($field, $entity, $modelClass);
6768

6869
if ($filter !== null) {
@@ -85,10 +86,28 @@ protected function compileFilter(
8586
'toggle' => $this->compileToggleFilter($field, $entity, $modelClass),
8687
'select', 'radio', 'button_group' => $this->compileChoiceFilter($field, $entity, $modelClass),
8788
'relation' => $this->compileRelationFilter($field, $entity, $modelClass),
89+
'text', 'textarea', 'email', 'url', 'rich_text' => $this->compileTextFilter($field),
8890
default => null,
8991
};
9092
}
9193

94+
/**
95+
* A simple "contains" search filter chip for text-like fields.
96+
*/
97+
protected function compileTextFilter(FieldDefinition $field): Filter
98+
{
99+
return Filter::make($field->name)
100+
->label($field->label)
101+
->schema([
102+
TextInput::make('value')
103+
->label($field->label),
104+
])
105+
->query(fn (Builder $query, array $data): Builder => $this->filterQuery->applyContains($query, $field, $data['value'] ?? null))
106+
->indicateUsing(fn (array $data): ?string => filled($data['value'] ?? null)
107+
? "{$field->label}: {$data['value']}"
108+
: null);
109+
}
110+
92111
/**
93112
* @param class-string<Model&InteractsWithCustomFields> $modelClass
94113
*/

packages/builder/src/Resources/FieldGroupResource.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use Moox\Builder\Support\FieldGroupPlacement;
4141
use Moox\Builder\Support\FieldValidationRules;
4242
use Moox\Builder\Support\FieldWidth;
43+
use Moox\Builder\Support\FilterableFieldTypes;
4344
use Moox\Builder\Support\LocationConstraintOptions;
4445
use Moox\Builder\Support\TypedValueColumns;
4546

@@ -672,7 +673,7 @@ protected static function fieldTypeSupportsFilter(?string $type): bool
672673
return false;
673674
}
674675

675-
return in_array($type, ['select', 'radio', 'button_group', 'toggle', 'relation'], true);
676+
return FilterableFieldTypes::supportsType($type);
676677
}
677678

678679
protected static function fieldHasFilterableChoiceOptions(callable $get): bool

packages/builder/src/Support/CustomFieldTableFilterQuery.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ public function applyEquals(
3636
return $query->where($field->name, $this->normalizeValue($field, $value));
3737
}
3838

39+
/**
40+
* Case-insensitive "contains" search, for the free-text filter chip on
41+
* text-like custom fields.
42+
*/
43+
public function applyContains(Builder $query, FieldDefinition $field, mixed $value): Builder
44+
{
45+
if (! filled($value)) {
46+
return $query;
47+
}
48+
49+
return $query->where($field->name, 'like', '%'.$value.'%');
50+
}
51+
3952
/**
4053
* @param class-string<Model&InteractsWithCustomFields> $modelClass
4154
*/

packages/builder/src/Support/FilterableFieldTypes.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ public static function supports(FieldDefinition $field): bool
1515
'toggle' => true,
1616
'relation' => ! RelationValueRules::isMultiple($field)
1717
&& filled($field->config['related_entity'] ?? null),
18+
'text', 'textarea', 'email', 'url', 'rich_text' => true,
19+
default => false,
20+
};
21+
}
22+
23+
/**
24+
* Type-only check (no field instance required), e.g. to decide whether
25+
* the admin "Show in table filter" toggle should be offered at all,
26+
* before options or relation config are known.
27+
*/
28+
public static function supportsType(string $type): bool
29+
{
30+
return match ($type) {
31+
'select', 'radio', 'button_group', 'toggle', 'relation' => true,
32+
'text', 'textarea', 'email', 'url', 'rich_text' => true,
1833
default => false,
1934
};
2035
}

packages/builder/tests/Feature/HasCustomFieldsTest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Filament\Resources\Events\RecordSaved;
1010
use Filament\Resources\Pages\Page;
1111
use Filament\Schemas\Components\Section;
12+
use Filament\Tables\Filters\SelectFilter;
1213
use Illuminate\Support\Facades\Cache;
1314
use Illuminate\Support\Facades\Event;
1415
use Moox\Builder\Models\Field;
@@ -75,7 +76,10 @@
7576
Cache::forget(DefinitionRegistry::CACHE_KEY);
7677
TestItem::flushCustomFieldDefinitionCache();
7778

78-
expect(TestItemResource::customFieldFilters())->toHaveCount(1);
79+
$filters = TestItemResource::customFieldFilters();
80+
81+
expect($filters)->toHaveCount(1)
82+
->and($filters[0])->toBeInstanceOf(SelectFilter::class);
7983
});
8084

8185
it('returns no components when no field groups match the entity', function (): void {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__.'/../TestCase.php';
6+
7+
use Moox\Builder\Resources\FieldGroupResource;
8+
use Moox\Builder\Tests\TestCase;
9+
10+
uses(TestCase::class);
11+
12+
function fieldTypeSupportsFilter(?string $type): bool
13+
{
14+
$method = new ReflectionMethod(FieldGroupResource::class, 'fieldTypeSupportsFilter');
15+
$method->setAccessible(true);
16+
17+
return $method->invoke(null, $type);
18+
}
19+
20+
it('offers the "show in table filter" toggle for every compiler-supported field type', function (string $type): void {
21+
expect(fieldTypeSupportsFilter($type))->toBeTrue();
22+
})->with([
23+
'select', 'radio', 'button_group', 'toggle', 'relation',
24+
'text', 'textarea', 'email', 'url', 'rich_text',
25+
]);
26+
27+
it('does not offer the toggle for field types with no filter support', function (string $type): void {
28+
expect(fieldTypeSupportsFilter($type))->toBeFalse();
29+
})->with([
30+
'multiselect', 'checkbox_list', 'image', 'gallery', 'file',
31+
'group', 'clone', 'repeater', 'flexible_content', 'password',
32+
'number', 'range', 'date', 'datetime',
33+
]);
34+
35+
it('does not offer the toggle when no type is selected yet', function (): void {
36+
expect(fieldTypeSupportsFilter(null))->toBeFalse();
37+
});

packages/builder/tests/Unit/FilterableFieldTypesTest.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,21 @@
7272
expect(FilterableFieldTypes::supports($field))->toBeFalse();
7373
});
7474

75+
it('supports text-like fields', function (string $type): void {
76+
$field = FieldDefinition::fromArray([
77+
'name' => 'notes',
78+
'label' => 'Notes',
79+
'type' => $type,
80+
]);
81+
82+
expect(FilterableFieldTypes::supports($field))->toBeTrue();
83+
})->with(['text', 'textarea', 'email', 'url', 'rich_text']);
84+
7585
it('does not support non-filterable field types', function (): void {
7686
$field = FieldDefinition::fromArray([
7787
'name' => 'notes',
7888
'label' => 'Notes',
79-
'type' => 'text',
89+
'type' => 'repeater',
8090
]);
8191

8292
expect(FilterableFieldTypes::supports($field))->toBeFalse();

0 commit comments

Comments
 (0)