Skip to content

Commit bd144b4

Browse files
committed
Add from/until range filter for number, range, date, and datetime custom fields
1 parent 644787b commit bd144b4

9 files changed

Lines changed: 175 additions & 8 deletions

File tree

packages/builder/README.md

Lines changed: 7 additions & 5 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 chip for select, radio, button_group, toggle, single relation, and text-like fields — see [Table filters](#table-filters) |
465+
| **Table filter** | Optional list-filter chip for select, radio, button_group, toggle, single relation, text-like, and number/range/date/datetime 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

@@ -533,10 +533,11 @@ One filter chip per filterable field, compiled from the **Show in table filter**
533533
| `toggle` | `TernaryFilter` (yes / no / any) |
534534
| `relation` (single) | `SelectFilter` (searchable, preloaded) |
535535
| `text`, `textarea`, `email`, `url`, `rich_text` | `Filter` (free-text "contains" search) |
536+
| `number`, `range`, `date`, `datetime` | `Filter` (inclusive "from"/"until" range) |
536537

537-
Choice fields need at least one option; relation fields need `related_entity` set and `multiple` off.
538+
Choice fields need at least one option; relation fields need `related_entity` set and `multiple` off. Range filters accept either bound alone (open-ended) or both.
538539

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.
540+
**Not filterable at all:** media, multiselect/checkbox list, and compound fields (group, repeater, clone, flexible content) — their values are stored as JSON, not a single comparable value, so no dedicated filter chip is compiled for these.
540541

541542
### Admin setup
542543

@@ -1122,6 +1123,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc
11221123
| Relation field on a group | searchable select, save/load IDs, API shows labels |
11231124
| Table column on scalar/toggle/media/relation field | column appears (toggle via column picker if hidden by default) |
11241125
| Table filter on select/radio/button_group/toggle/relation/text-like field | filter chip on list pages narrows matching records |
1126+
| Table filter on number/range/date/datetime field | from/until range filter narrows matching records; either bound alone also works |
11251127
| Conditional logic (show/hide) | field visibility updates live; hidden required fields do not block save |
11261128
| `?lang=` on translatable resource | values stored per `locale` column |
11271129

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

11601162
- Location rules: no template/parent params yet; record/taxonomy/status rules need a saved record (ignored on create)
1161-
- Table filters: number, range, date, datetime, media, multiselect/checkbox list, and compound fields have no filter chip yet
1163+
- Table filters: media, multiselect/checkbox list, and compound fields (JSON-backed values) have no filter chip yet
11621164
- Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only)
11631165
- Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models)
11641166

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
'save_failed' => 'Feldgruppe konnte nicht gespeichert werden',
8787
],
8888

89+
'filters' => [
90+
'from' => 'Von',
91+
'until' => 'Bis',
92+
],
93+
8994
'field' => [
9095
'label' => 'Feldbezeichnung',
9196
'label_helper' => 'Sichtbarer Name im Formular.',
@@ -135,7 +140,7 @@
135140
'show_in_table' => 'In Tabelle anzeigen',
136141
'show_in_table_helper' => 'Fügt dieses Feld als Spalte auf den Listen-Seiten hinzu. Standardmäßig ausgeblendet; über den Spalten-Umschalter aktivierbar.',
137142
'show_in_filter' => 'Als Listenfilter',
138-
'show_in_filter_helper' => 'Fügt einen Listenfilter für Auswahl-, Wahr/falsch-, Beziehungs- und Textfelder hinzu. Standardmäßig aus.',
143+
'show_in_filter_helper' => 'Fügt einen Listenfilter hinzu: Auswahl/Wahr-falsch/Beziehung als Auswahlfilter, Text als Freitextsuche, Zahl/Bereich/Datum/Datum-Zeit als Von-bis-Filter. Standardmäßig aus.',
139144
'show_in_filter_relation_multiple_helper' => 'Listenfilter gibt es nur für einfache Beziehungsfelder (ohne Mehrfachauswahl).',
140145
'show_in_filter_relation_entity_helper' => 'Zuerst „Beziehung zu“ wählen, dann den Listenfilter aktivieren.',
141146
'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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
'save_failed' => 'Field group could not be saved',
8787
],
8888

89+
'filters' => [
90+
'from' => 'From',
91+
'until' => 'Until',
92+
],
93+
8994
'field' => [
9095
'label' => 'Field label',
9196
'label_helper' => 'Visible name in the form.',
@@ -135,7 +140,7 @@
135140
'show_in_table' => 'Show in table',
136141
'show_in_table_helper' => 'Adds this field as a column on entity list pages. Hidden by default; enable via the column toggle.',
137142
'show_in_filter' => 'Show in table filter',
138-
'show_in_filter_helper' => 'Adds a list filter for select, toggle, relation, and text fields. Off by default.',
143+
'show_in_filter_helper' => 'Adds a list filter: select/toggle/relation as a picker, text as a "contains" search, number/range/date/datetime as a from–until range. Off by default.',
139144
'show_in_filter_relation_multiple_helper' => 'List filters are only available for single relation fields. Disable “Multiple” first.',
140145
'show_in_filter_relation_entity_helper' => 'Choose a related entity before enabling the list filter.',
141146
'show_in_filter_choice_options_helper' => 'Add at least one option before enabling the list filter.',

packages/builder/src/Compiler/TableFilterCompiler.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Moox\Builder\Compiler;
66

7+
use Filament\Forms\Components\DatePicker;
8+
use Filament\Forms\Components\DateTimePicker;
79
use Filament\Forms\Components\TextInput;
810
use Filament\Tables\Filters\BaseFilter;
911
use Filament\Tables\Filters\Filter;
@@ -87,6 +89,7 @@ protected function compileFilter(
8789
'select', 'radio', 'button_group' => $this->compileChoiceFilter($field, $entity, $modelClass),
8890
'relation' => $this->compileRelationFilter($field, $entity, $modelClass),
8991
'text', 'textarea', 'email', 'url', 'rich_text' => $this->compileTextFilter($field),
92+
'number', 'range', 'date', 'datetime' => $this->compileRangeFilter($field),
9093
default => null,
9194
};
9295
}
@@ -108,6 +111,44 @@ protected function compileTextFilter(FieldDefinition $field): Filter
108111
: null);
109112
}
110113

114+
/**
115+
* An inclusive "from"/"until" range filter chip for number, range,
116+
* date, and datetime fields, since an exact-match chip rarely makes
117+
* sense for these types.
118+
*/
119+
protected function compileRangeFilter(FieldDefinition $field): Filter
120+
{
121+
[$fromInput, $untilInput] = match ($field->type) {
122+
'date' => [DatePicker::make('from')->native(false), DatePicker::make('until')->native(false)],
123+
'datetime' => [DateTimePicker::make('from')->native(false), DateTimePicker::make('until')->native(false)],
124+
default => [TextInput::make('from')->numeric(), TextInput::make('until')->numeric()],
125+
};
126+
127+
return Filter::make($field->name)
128+
->label($field->label)
129+
->schema([
130+
$fromInput->label(__('builder::builder.filters.from')),
131+
$untilInput->label(__('builder::builder.filters.until')),
132+
])
133+
->query(fn (Builder $query, array $data): Builder => $this->filterQuery->applyRange(
134+
$query,
135+
$field,
136+
$data['from'] ?? null,
137+
$data['until'] ?? null,
138+
))
139+
->indicateUsing(function (array $data) use ($field): ?string {
140+
$from = $data['from'] ?? null;
141+
$until = $data['until'] ?? null;
142+
143+
return match (true) {
144+
filled($from) && filled($until) => "{$field->label}: {$from}{$until}",
145+
filled($from) => "{$field->label}: ≥ {$from}",
146+
filled($until) => "{$field->label}: ≤ {$until}",
147+
default => null,
148+
};
149+
});
150+
}
151+
111152
/**
112153
* @param class-string<Model&InteractsWithCustomFields> $modelClass
113154
*/

packages/builder/src/Support/CustomFieldTableFilterQuery.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ public function applyContains(Builder $query, FieldDefinition $field, mixed $val
4949
return $query->where($field->name, 'like', '%'.$value.'%');
5050
}
5151

52+
/**
53+
* Inclusive "from"/"until" bounds, for the range filter chip on
54+
* number, range, date, and datetime custom fields. Both bounds are
55+
* optional and combine with AND when both are given.
56+
*/
57+
public function applyRange(Builder $query, FieldDefinition $field, mixed $from, mixed $until): Builder
58+
{
59+
if (filled($from)) {
60+
$query->where($field->name, '>=', $from);
61+
}
62+
63+
if (filled($until)) {
64+
$query->where($field->name, '<=', $until);
65+
}
66+
67+
return $query;
68+
}
69+
5270
/**
5371
* @param class-string<Model&InteractsWithCustomFields> $modelClass
5472
*/

packages/builder/src/Support/FilterableFieldTypes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static function supports(FieldDefinition $field): bool
1616
'relation' => ! RelationValueRules::isMultiple($field)
1717
&& filled($field->config['related_entity'] ?? null),
1818
'text', 'textarea', 'email', 'url', 'rich_text' => true,
19+
'number', 'range', 'date', 'datetime' => true,
1920
default => false,
2021
};
2122
}
@@ -30,6 +31,7 @@ public static function supportsType(string $type): bool
3031
return match ($type) {
3132
'select', 'radio', 'button_group', 'toggle', 'relation' => true,
3233
'text', 'textarea', 'email', 'url', 'rich_text' => true,
34+
'number', 'range', 'date', 'datetime' => true,
3335
default => false,
3436
};
3537
}

packages/builder/tests/Unit/FieldGroupResourceFilterVisibilityTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ function fieldTypeSupportsFilter(?string $type): bool
2222
})->with([
2323
'select', 'radio', 'button_group', 'toggle', 'relation',
2424
'text', 'textarea', 'email', 'url', 'rich_text',
25+
'number', 'range', 'date', 'datetime',
2526
]);
2627

2728
it('does not offer the toggle for field types with no filter support', function (string $type): void {
2829
expect(fieldTypeSupportsFilter($type))->toBeFalse();
2930
})->with([
3031
'multiselect', 'checkbox_list', 'image', 'gallery', 'file',
3132
'group', 'clone', 'repeater', 'flexible_content', 'password',
32-
'number', 'range', 'date', 'datetime',
3333
]);
3434

3535
it('does not offer the toggle when no type is selected yet', function (): void {

packages/builder/tests/Unit/FilterableFieldTypesTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@
8282
expect(FilterableFieldTypes::supports($field))->toBeTrue();
8383
})->with(['text', 'textarea', 'email', 'url', 'rich_text']);
8484

85+
it('supports number, range, date, and datetime fields via the range filter', function (string $type): void {
86+
$field = FieldDefinition::fromArray([
87+
'name' => 'mileage',
88+
'label' => 'Mileage',
89+
'type' => $type,
90+
]);
91+
92+
expect(FilterableFieldTypes::supports($field))->toBeTrue();
93+
})->with(['number', 'range', 'date', 'datetime']);
94+
8595
it('does not support non-filterable field types', function (): void {
8696
$field = FieldDefinition::fromArray([
8797
'name' => 'notes',

packages/builder/tests/Unit/TableFilterCompilerTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,90 @@
290290
expect(TestItemResource::customFieldFilters())->toBe([]);
291291
});
292292

293+
it('filters list queries by number custom fields via the range filter chip', function (): void {
294+
$group = FieldGroup::query()->create([
295+
'name' => 'Mileage filter',
296+
'slug' => 'mileage-filter',
297+
'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]],
298+
'active' => true,
299+
]);
300+
301+
Field::query()->create([
302+
'field_group_id' => $group->getKey(),
303+
'name' => 'mileage',
304+
'label' => 'Mileage',
305+
'type' => 'number',
306+
'settings' => ['show_in_filter' => true],
307+
'sort' => 0,
308+
'validation' => ['required' => false, 'rules' => []],
309+
]);
310+
311+
Cache::forget(DefinitionRegistry::CACHE_KEY);
312+
TestItem::flushCustomFieldDefinitionCache();
313+
314+
$filters = TestItemResource::customFieldFilters();
315+
$rangeFilter = collect($filters)->first(fn ($filter) => $filter instanceof Filter);
316+
317+
expect($rangeFilter)->not->toBeNull()
318+
->and($rangeFilter->getName())->toBe('mileage');
319+
320+
$lowMileage = TestItem::query()->create(['title' => 'Low mileage car']);
321+
$highMileage = TestItem::query()->create(['title' => 'High mileage car']);
322+
323+
app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $lowMileage, [
324+
'mileage' => 10000,
325+
]);
326+
app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $highMileage, [
327+
'mileage' => 90000,
328+
]);
329+
330+
$filtered = $rangeFilter->apply(TestItem::query(), ['from' => 5000, 'until' => 50000])->pluck('id')->all();
331+
332+
expect($filtered)->toBe([$lowMileage->getKey()]);
333+
});
334+
335+
it('filters list queries by date custom fields via the range filter chip using only one bound', function (): void {
336+
$group = FieldGroup::query()->create([
337+
'name' => 'Registered filter',
338+
'slug' => 'registered-filter',
339+
'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]],
340+
'active' => true,
341+
]);
342+
343+
Field::query()->create([
344+
'field_group_id' => $group->getKey(),
345+
'name' => 'registered_at',
346+
'label' => 'Registered at',
347+
'type' => 'date',
348+
'settings' => ['show_in_filter' => true],
349+
'sort' => 0,
350+
'validation' => ['required' => false, 'rules' => []],
351+
]);
352+
353+
Cache::forget(DefinitionRegistry::CACHE_KEY);
354+
TestItem::flushCustomFieldDefinitionCache();
355+
356+
$filters = TestItemResource::customFieldFilters();
357+
$rangeFilter = collect($filters)->first(fn ($filter) => $filter instanceof Filter);
358+
359+
expect($rangeFilter)->not->toBeNull()
360+
->and($rangeFilter->getName())->toBe('registered_at');
361+
362+
$older = TestItem::query()->create(['title' => 'Older car']);
363+
$newer = TestItem::query()->create(['title' => 'Newer car']);
364+
365+
app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $older, [
366+
'registered_at' => '2020-01-01',
367+
]);
368+
app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $newer, [
369+
'registered_at' => '2024-01-01',
370+
]);
371+
372+
$filtered = $rangeFilter->apply(TestItem::query(), ['from' => '2023-01-01'])->pluck('id')->all();
373+
374+
expect($filtered)->toBe([$newer->getKey()]);
375+
});
376+
293377
it('does not build filters for multiple relation fields', function (): void {
294378
$group = FieldGroup::query()->create([
295379
'name' => 'Multiple relation filter',

0 commit comments

Comments
 (0)