Skip to content

Commit ad3a148

Browse files
feat: deterministic record lookups, configurable search minimum, and an option-count search threshold [3.x] (#205)
* feat: add selects config block for picker behavior * fix: order record lookup results deterministically * fix: make the record-select minimum search length configurable end to end * feat: only show select search above the configured option threshold * docs: document the selects config block and the search-box opt-out * perf: default record lookups to the model key instead of updated_at
1 parent 2a76adb commit ad3a148

14 files changed

Lines changed: 490 additions & 28 deletions

File tree

config/custom-fields.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,33 @@
108108
'description_max_length' => 255,
109109
],
110110

111+
/*
112+
|--------------------------------------------------------------------------
113+
| Select & Record Lookup Behavior
114+
|--------------------------------------------------------------------------
115+
|
116+
| searchable_threshold controls when option-backed selects render a search
117+
| box. Set it to 0 to always show one, which is the pre-3.8 behavior.
118+
|
119+
| record_lookup governs the record-select field's initial page and search.
120+
| order_column null means the model's key, which is backed by the primary
121+
| key index and so costs no more than an unordered query. Naming a column
122+
| instead (for example 'updated_at' for most-recently-touched-first) is
123+
| supported, but on a large lookup table an unindexed column makes every
124+
| render sort the whole tenant, so index it before you switch.
125+
|
126+
*/
127+
'selects' => [
128+
'searchable_threshold' => 10,
129+
130+
'record_lookup' => [
131+
'order_column' => null,
132+
'order_direction' => 'desc',
133+
'limit' => 50,
134+
'min_search_length' => 2,
135+
],
136+
],
137+
111138
/*
112139
|--------------------------------------------------------------------------
113140
| Database Configuration

docs/content/2.essentials/1.configuration.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,46 @@ The page must extend `CustomFieldsManagementPage`, and it replaces the packaged
175175
rather than sitting alongside it — only one management page is registered on the panel,
176176
so there is no second route to the same screen.
177177

178+
### Select Behavior
179+
180+
Controls when option-backed selects render a search box, and how the record-select field
181+
orders and pages its lookups:
182+
183+
```php
184+
'selects' => [
185+
'searchable_threshold' => 10,
186+
187+
'record_lookup' => [
188+
'order_column' => null,
189+
'order_direction' => 'desc',
190+
'limit' => 50,
191+
'min_search_length' => 2,
192+
],
193+
],
194+
```
195+
196+
| Key | Default | Effect |
197+
|---|---|---|
198+
| `searchable_threshold` | `10` | Select and multi-select fields render a search box only when they have more options than this. Set it to `0` to always render one. |
199+
| `record_lookup.order_column` | `null` | Column the record-select orders its initial page and search results by. `null` means the model's key, which is backed by the primary key index. Name a real column to override. |
200+
| `record_lookup.order_direction` | `'desc'` | Direction for that column. The model key is always applied after it, so rows sharing a value keep a fixed order. |
201+
| `record_lookup.limit` | `50` | Rows fetched for the initial page and for each search. |
202+
| `record_lookup.min_search_length` | `2` | Characters required before a filtered lookup query is issued. Below it, the field shows the unfiltered first page. Both the server and the field's JavaScript read this value. |
203+
204+
::alert{type="info"}
205+
Before 3.8 every select rendered a search box, including a three-option status field. Set
206+
`searchable_threshold` to `0` to restore that behavior exactly.
207+
::
208+
209+
The default exists to make the initial page deterministic without paying for it. Measured on a
210+
50,000-row lookup table, ordering by the model key plans as an index scan and costs the same as
211+
the unordered query it replaces, while ordering by an unindexed `updated_at` sorts the whole
212+
tenant on every render (roughly 176x the time and 205x the buffers). If you prefer
213+
most-recently-touched-first, set `order_column` to `'updated_at'` and index that column.
214+
215+
When `order_column` is set to `updated_at` and the looked-up model returns `false` from
216+
`usesTimestamps()`, the model key is used instead, so the order is always deterministic.
217+
178218
### Database Configuration
179219

180220
Customize table names and paths:

resources/views/forms/record-select-input.blade.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
$emptyStateLabel = $getEmptyStateLabel();
1010
$placeholder = $getPlaceholder() ?? __('Search records...');
1111
$key = $getKey();
12+
$minSearchLength = $getMinSearchLength();
13+
$shortSearchMessage = __('Type at least :count characters to search', ['count' => $minSearchLength]);
1214
1315
// Get initial records data for selected values
1416
$state = $getState() ?? [];
@@ -39,6 +41,7 @@ class="fi-fo-record-select-input-wrp"
3941
recordsCache: @js($initialRecords),
4042
initialOptions: @js(array_values($initialOptions)),
4143
maxVisibleValues: @js($maxVisiblePills),
44+
minSearchLength: @js($minSearchLength),
4245
selectedSnapshot: [],
4346
activeIndex: -1,
4447
documentClickListener: null,
@@ -50,7 +53,7 @@ class="fi-fo-record-select-input-wrp"
5053
this.state = this.state.filter(v => v && v !== '');
5154
5255
this.$watch('search', (value) => {
53-
if (value.trim().length >= 2) {
56+
if (value.trim().length >= this.minSearchLength) {
5457
this.performSearch();
5558
} else {
5659
this.searchResults = [];
@@ -143,8 +146,8 @@ class="fi-fo-record-select-input-wrp"
143146
get sortedOptions() {
144147
const searchLower = this.search.toLowerCase().trim();
145148
146-
// If searching (>=2 chars) and have server results, use those
147-
if (searchLower.length >= 2 && this.searchResults.length > 0) {
149+
// If searching (at or above the minimum) and have server results, use those
150+
if (searchLower.length >= this.minSearchLength && this.searchResults.length > 0) {
148151
return this.sortBySelected([...this.searchResults]);
149152
}
150153
@@ -179,11 +182,11 @@ class="fi-fo-record-select-input-wrp"
179182
180183
get emptyStateMessage() {
181184
const searchLength = this.search.trim().length;
182-
if (searchLength >= 2) {
185+
if (searchLength >= this.minSearchLength) {
183186
return '{{ __('No records found') }}';
184187
}
185188
if (searchLength > 0) {
186-
return '{{ __('Type at least 2 characters to search') }}';
189+
return @js($shortSearchMessage);
187190
}
188191
if (this.initialOptions.length === 0) {
189192
return '{{ __('No records available') }}';
@@ -194,7 +197,7 @@ class="fi-fo-record-select-input-wrp"
194197
async performSearch() {
195198
const query = this.search.trim();
196199
197-
if (query.length < 2) {
200+
if (query.length < this.minSearchLength) {
198201
this.searchResults = [];
199202
return;
200203
}

src/Filament/Integration/Base/AbstractFormComponent.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,26 @@ protected function getCustomFieldOptions(CustomField $customField): array
377377
return $customField->options->pluck('name', 'id')->all();
378378
}
379379

380+
/**
381+
* Whether an option-backed select should render a search box.
382+
*
383+
* Filtering for static options happens client-side, so this is a readability
384+
* choice rather than a performance one. A threshold of 0 always shows the box,
385+
* which is the pre-3.8 behavior.
386+
*
387+
* @param array<int|string, string> $options
388+
*/
389+
protected function shouldBeSearchable(array $options): bool
390+
{
391+
$threshold = (int) config('custom-fields.selects.searchable_threshold', 10);
392+
393+
if ($threshold <= 0) {
394+
return true;
395+
}
396+
397+
return count($options) > $threshold;
398+
}
399+
380400
/**
381401
* Create the specific Filament field component.
382402
*

src/Filament/Integration/Components/Forms/MultiSelectComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function create(CustomField $customField): Select
1919

2020
$field = Select::make($customField->getFieldName())
2121
->multiple()
22-
->searchable()
22+
->searchable($this->shouldBeSearchable($options))
2323
->options($options);
2424

2525
if ($this->hasColorOptionsEnabled($customField)) {

src/Filament/Integration/Components/Forms/RecordSelectInput/RecordSelectInputComponent.php

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ public function getMaxVisiblePills(): int
151151
return $this->evaluate($this->maxVisiblePills);
152152
}
153153

154+
/**
155+
* Characters required before the field issues a filtered lookup query.
156+
*
157+
* The view reads the same value, so raising it cannot leave the client
158+
* asking for a filtered search the server answers with an unfiltered page.
159+
*/
160+
public function getMinSearchLength(): int
161+
{
162+
return (int) config('custom-fields.selects.record_lookup.min_search_length', 2);
163+
}
164+
154165
/**
155166
* Get entity configuration for the lookup type.
156167
*/
@@ -168,7 +179,7 @@ public function getEntityConfiguration(): ?EntityConfigurationData
168179
/**
169180
* Prepare entity query with common attributes.
170181
*
171-
* @return array{entity: EntityConfigurationData, query: Builder, keyName: string, titleAttribute: string, avatarConfig: ?AvatarConfiguration}|null
182+
* @return array{entity: EntityConfigurationData, model: Model, query: Builder, keyName: string, titleAttribute: string, avatarConfig: ?AvatarConfiguration}|null
172183
*/
173184
private function prepareEntityQuery(): ?array
174185
{
@@ -178,15 +189,58 @@ private function prepareEntityQuery(): ?array
178189
return null;
179190
}
180191

192+
$model = $entity->createModelInstance();
193+
181194
return [
182195
'entity' => $entity,
183-
'query' => $entity->newQuery(),
184-
'keyName' => $entity->createModelInstance()->getKeyName(),
196+
'model' => $model,
197+
'query' => $model->newQuery(),
198+
'keyName' => $model->getKeyName(),
185199
'titleAttribute' => $entity->getPrimaryAttribute(),
186200
'avatarConfig' => $entity->getAvatarConfiguration(),
187201
];
188202
}
189203

204+
/**
205+
* Apply a deterministic order to a lookup query.
206+
*
207+
* Without one, LIMIT returns arbitrary rows and the initial page can change
208+
* between renders. The default is the model key: it is backed by the primary
209+
* key index, so ordering costs no more than the unordered query it replaces.
210+
* Measured on a 50k-row table, ordering by an unindexed column instead costs
211+
* roughly 176x the time and 205x the buffers, because every render sorts the
212+
* whole tenant.
213+
*
214+
* A configured column is trusted and the model key is appended to it, so rows
215+
* sharing a value still come back in a fixed sequence. Column existence is
216+
* resolved without a schema query: a runtime Schema::hasColumn() call would be
217+
* a per-request round trip. The one exception is the documented 'updated_at',
218+
* which falls back to the key on a model that opts out of timestamps.
219+
*/
220+
private function applyLookupOrder(Builder $query, Model $model): Builder
221+
{
222+
$column = config('custom-fields.selects.record_lookup.order_column');
223+
$direction = (string) config('custom-fields.selects.record_lookup.order_direction', 'desc');
224+
$key = $model->getQualifiedKeyName();
225+
226+
if (! is_string($column) || $column === '') {
227+
return $query->orderBy($key, $direction);
228+
}
229+
230+
if ($column === 'updated_at' && ! $model->usesTimestamps()) {
231+
return $query->orderBy($key, $direction);
232+
}
233+
234+
return $query
235+
->orderBy($query->qualifyColumn($column), $direction)
236+
->orderBy($key, $direction);
237+
}
238+
239+
private function lookupLimit(): int
240+
{
241+
return (int) config('custom-fields.selects.record_lookup.limit', 50);
242+
}
243+
190244
/**
191245
* Search for records matching the query.
192246
*
@@ -200,7 +254,7 @@ public function searchRecords(string $search): array
200254
return [];
201255
}
202256

203-
['entity' => $entity, 'query' => $query, 'keyName' => $keyName, 'titleAttribute' => $titleAttribute, 'avatarConfig' => $avatarConfig] = $prepared;
257+
['entity' => $entity, 'model' => $model, 'query' => $query, 'keyName' => $keyName, 'titleAttribute' => $titleAttribute, 'avatarConfig' => $avatarConfig] = $prepared;
204258
$searchAttributes = $entity->getSearchAttributes();
205259

206260
// Try to use resource's search if available
@@ -228,7 +282,9 @@ public function searchRecords(string $search): array
228282
});
229283
}
230284

231-
$records = $query->limit(50)->get();
285+
$records = $this->applyLookupOrder($query, $model)
286+
->limit($this->lookupLimit())
287+
->get();
232288

233289
return $this->formatRecordsForJs($records, $keyName, $titleAttribute, $avatarConfig);
234290
}
@@ -272,9 +328,11 @@ public function getInitialOptions(): array
272328
return [];
273329
}
274330

275-
['query' => $query, 'keyName' => $keyName, 'titleAttribute' => $titleAttribute, 'avatarConfig' => $avatarConfig] = $prepared;
331+
['model' => $model, 'query' => $query, 'keyName' => $keyName, 'titleAttribute' => $titleAttribute, 'avatarConfig' => $avatarConfig] = $prepared;
276332

277-
$records = $query->limit(50)->get();
333+
$records = $this->applyLookupOrder($query, $model)
334+
->limit($this->lookupLimit())
335+
->get();
278336

279337
return $this->formatRecordsForJs($records, $keyName, $titleAttribute, $avatarConfig);
280338
}
@@ -324,8 +382,11 @@ private function getAvatarUrl(Model $record, ?AvatarConfiguration $avatarConfig)
324382
#[Renderless]
325383
public function getSearchResultsForJs(string $search): array
326384
{
327-
if (mb_strlen($search) < 2) {
328-
return [];
385+
// Below the minimum, show the unfiltered first page rather than nothing.
386+
// Returning [] renders as "no results", which reads as broken for a
387+
// one-character search, and is wrong for single-character CJK names.
388+
if (mb_strlen($search) < $this->getMinSearchLength()) {
389+
return array_values($this->getInitialOptions());
329390
}
330391

331392
return array_values($this->searchRecords($search));

src/Filament/Integration/Components/Forms/SelectComponent.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public function create(CustomField $customField): Select
1818
$options = $this->getCustomFieldOptions($customField);
1919

2020
$field = Select::make($customField->getFieldName())
21-
->searchable()
21+
->searchable($this->shouldBeSearchable($options))
2222
->options($options);
2323

2424
if ($this->hasColorOptionsEnabled($customField)) {

0 commit comments

Comments
 (0)