diff --git a/demo/app/Sharp/Categories/CategoryList.php b/demo/app/Sharp/Categories/CategoryList.php index 4627b4587..cfb3d74cd 100644 --- a/demo/app/Sharp/Categories/CategoryList.php +++ b/demo/app/Sharp/Categories/CategoryList.php @@ -4,6 +4,7 @@ use App\Models\Category; use App\Sharp\Categories\Commands\CleanUnusedCategoriesCommand; +use App\Sharp\Utils\Filters\PostFilter; use Code16\Sharp\EntityList\Eloquent\SimpleEloquentReorderHandler; use Code16\Sharp\EntityList\Fields\EntityListField; use Code16\Sharp\EntityList\Fields\EntityListFieldsContainer; @@ -50,16 +51,25 @@ public function buildFilterConfig(): void ->configureLabel('Orphan categories only.'); } }, + PostFilter::class, ]; } public function getListData(): array|Arrayable { - $categories = Category::withCount('posts') + $postIds = $this->queryParams->filterFor(PostFilter::class); + + $categories = Category::withCount([ + 'posts' => fn ($q) => $q->when($postIds, fn ($q) => $q->whereKey($postIds)), + ]) ->orderBy('order') ->when( $this->queryParams->filterFor('orphan'), fn ($q) => $q->having('posts_count', 0) + ) + ->when( + $postIds, + fn ($q) => $q->whereHas('posts', fn ($q) => $q->whereKey($postIds)) ); return $this->transform($categories->get()); diff --git a/demo/app/Sharp/Utils/Filters/PostFilter.php b/demo/app/Sharp/Utils/Filters/PostFilter.php new file mode 100644 index 000000000..cad9e4f6b --- /dev/null +++ b/demo/app/Sharp/Utils/Filters/PostFilter.php @@ -0,0 +1,39 @@ +configureLabel('Posts'); + } + + public function values(string $query): array + { + return Post::query() + ->when($query, function ($builder) use ($query) { + $builder->where(function ($builder) use ($query) { + $builder->where('title->en', 'like', "%$query%") + ->orWhere('title->fr', 'like', "%$query%"); + }); + }) + ->orderBy('title->en') + ->limit(20) + ->get() + ->mapWithKeys(fn (Post $post) => [$post->id => $post->getTranslation('title', 'en')]) + ->all(); + } + + public function valueLabelsFor(array $ids): array + { + return Post::query() + ->whereKey($ids) + ->get() + ->mapWithKeys(fn (Post $post) => [$post->id => $post->getTranslation('title', 'en')]) + ->all(); + } +} diff --git a/docs/guide/filters.md b/docs/guide/filters.md index e11b54254..3042013da 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -226,6 +226,52 @@ class ProductCategoryFilter extends AutocompleteRemoteFilter The `values()` method must return an `[{id} => {label}]` array. The `valueLabelFor()` method is used to display the label in the dropdown for the selected id. +### Multiple remote autocomplete filter + +For a large set of options, use `Code16\Sharp\Filters\AutocompleteRemoteMultipleFilter`. It combines remote searching with a multiple selection, without sending the complete option list to the browser. + +```php +use Code16\Sharp\Filters\AutocompleteRemoteMultipleFilter; + +class ProductFilter extends AutocompleteRemoteMultipleFilter +{ + public function buildFilterConfig(): void + { + $this + ->configureKey('products') + ->configureLabel('Products') + ->configureSearchMinChars(2) + ->configureDebounceDelay(300); + } + + public function values(string $query): array + { + return Product::query() + ->where('name', 'like', "%{$query}%") + ->limit(50) + ->pluck('name', 'id') + ->all(); + } + + public function valueLabelsFor(array $ids): array + { + return Product::query() + ->whereKey($ids) + ->pluck('name', 'id') + ->all(); + } +} +``` + +Both methods must return an `[id => label]` array. `values()` is called only for the typed search and should limit its result set. `valueLabelsFor()` is called once with the selected IDs when Sharp restores the filter from the URL or session; this avoids one query per selected value. + +In the Entity List, the selected value is an array of IDs: + +```php +if ($productIds = $this->queryParams->filterFor(ProductFilter::class)) { + $products->whereIn('id', $productIds); +} +``` ## Required filters diff --git a/resources/js/Pages/Dashboard/Dashboard.vue b/resources/js/Pages/Dashboard/Dashboard.vue index ff7eec47b..6578106d2 100644 --- a/resources/js/Pages/Dashboard/Dashboard.vue +++ b/resources/js/Pages/Dashboard/Dashboard.vue @@ -29,10 +29,12 @@ }); function onFilterChange(filter: FilterData, value: FilterData['value']) { + const filterValues = filters.nextValues(filter, value); + filters.setCurrentValues(filterValues); router.post( route('code16.sharp.dashboard.filters.store', { dashboardKey }), { - filterValues: filters.nextValues(filter, value), + filterValues, query: parseQuery(location.search), }, { preserveState: true, preserveScroll: true }, @@ -40,10 +42,12 @@ } function onFiltersReset(resettedFilters: FilterData[]) { + const filterValues = filters.defaultValues(resettedFilters); + filters.setCurrentValues(filterValues); router.post( route('code16.sharp.dashboard.filters.store', { dashboardKey }), { - filterValues: filters.defaultValues(resettedFilters), + filterValues, query: parseQuery(location.search), }, { preserveState: true, preserveScroll: true }, diff --git a/resources/js/Pages/EntityList/EntityList.vue b/resources/js/Pages/EntityList/EntityList.vue index 47105fc24..a0410e52f 100644 --- a/resources/js/Pages/EntityList/EntityList.vue +++ b/resources/js/Pages/EntityList/EntityList.vue @@ -49,10 +49,12 @@ } function onFilterChange(filter: FilterData, value: FilterData['value']) { + const filterValues = filters.nextValues(filter, value); + filters.setCurrentValues(filterValues); router.post( route('code16.sharp.list.filters.store', { entityKey }), { - filterValues: filters.nextValues(filter, value), + filterValues, query: entityList.value.query, }, { preserveState: true, preserveScroll: false } @@ -60,10 +62,12 @@ } function onReset() { + const filterValues = filters.defaultValues(filters.rootFilters); + filters.setCurrentValues(filterValues); router.post( route('code16.sharp.list.filters.store', { entityKey }), { - filterValues: filters.defaultValues(filters.rootFilters), + filterValues, query: { ...entityList.value.query, search: null }, }, { preserveState: true, preserveScroll: false } diff --git a/resources/js/filters/FilterManager.ts b/resources/js/filters/FilterManager.ts index 0ca736551..1d8275623 100644 --- a/resources/js/filters/FilterManager.ts +++ b/resources/js/filters/FilterManager.ts @@ -47,6 +47,16 @@ export class FilterManager { return { ...this.currentValues, [filter.key]: value }; } + /** + * Optimistically apply values before the server responds, so that successive changes don't build on stale values + */ + setCurrentValues(values: FilterValues) { + this.state.filterValues = { + ...this.filterValues, + current: { ...this.currentValues, ...values }, + }; + } + defaultValues(filters: FilterData[]) { return Object.fromEntries( filters.map(filter => [filter.key, this.filterValues.default[filter.key]]) diff --git a/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue b/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue index 8e21b0a0a..ff92045f1 100644 --- a/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue +++ b/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue @@ -1,6 +1,5 @@ diff --git a/resources/js/show/components/fields/Dashboard.vue b/resources/js/show/components/fields/Dashboard.vue index d33bbb728..2701a2476 100644 --- a/resources/js/show/components/fields/Dashboard.vue +++ b/resources/js/show/components/fields/Dashboard.vue @@ -90,11 +90,13 @@ } async function onFilterChange(filter: FilterData, value: FilterData['value']) { + const filterValues = filters.nextValues(filter, value); + filters.setCurrentValues(filterValues); const data = await api.post( route('code16.sharp.api.dashboard.filters.store', { dashboardKey: props.field.dashboardKey }), { query: dashboard.value.query, - filterValues: filters.nextValues(filter, value), + filterValues, hiddenFilters: props.field.hiddenFilters, } ) @@ -104,11 +106,13 @@ } async function onFiltersReset(resettedFilters: FilterData[]) { + const filterValues = filters.defaultValues(resettedFilters); + filters.setCurrentValues(filterValues); const data = await api.post( route('code16.sharp.api.dashboard.filters.store', { dashboardKey: props.field.dashboardKey }), { query: { ...dashboard.value.query, search: null }, - filterValues: filters.defaultValues(resettedFilters), + filterValues, hiddenFilters: props.field.hiddenFilters, } ) diff --git a/resources/js/show/components/fields/EntityList.vue b/resources/js/show/components/fields/EntityList.vue index 0df03d263..34ae6cdc4 100644 --- a/resources/js/show/components/fields/EntityList.vue +++ b/resources/js/show/components/fields/EntityList.vue @@ -93,11 +93,13 @@ } async function onFilterChange(filter: FilterData, value: FilterData['value']) { + const filterValues = filters.nextValues(filter, value); + filters.setCurrentValues(filterValues); const data = await api.post( route('code16.sharp.api.list.filters.store', { entityKey: props.field.entityListKey }), { query: entityList.value.query, - filterValues: filters.nextValues(filter, value), + filterValues, hiddenFilters: props.field.hiddenFilters, } ) @@ -107,11 +109,13 @@ } async function onFilterResetAll() { + const filterValues = filters.defaultValues(filters.rootFilters); + filters.setCurrentValues(filterValues); const data = await api.post( route('code16.sharp.api.list.filters.store', { entityKey: props.field.entityListKey }), { query: { ...entityList.value.query, search: null }, - filterValues: filters.defaultValues(filters.rootFilters), + filterValues, hiddenFilters: props.field.hiddenFilters, } ) diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 038f68dca..706022c5b 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -19,10 +19,14 @@ export type AreaGraphWidgetData = { opacity: number | null; }; export type AutocompleteRemoteFilterData = { - value?: { id: string | number; label: string }; + value?: + | { id: string | number; label: string } + | Array<{ id: string | number; label: string }> + | null; key: string; label: string | null; type: "autocompleteRemote"; + multiple: boolean; required: boolean; master: boolean; debounceDelay: number; diff --git a/src/Data/Filters/AutocompleteRemoteFilterData.php b/src/Data/Filters/AutocompleteRemoteFilterData.php index 485fb1e52..114251a43 100644 --- a/src/Data/Filters/AutocompleteRemoteFilterData.php +++ b/src/Data/Filters/AutocompleteRemoteFilterData.php @@ -13,7 +13,7 @@ final class AutocompleteRemoteFilterData extends Data { #[Optional] - #[LiteralTypeScriptType('{ id: string|number, label: string }')] + #[LiteralTypeScriptType('{ id: string|number, label: string } | Array<{ id: string|number, label: string }> | null')] public mixed $value; public function __construct( @@ -21,7 +21,7 @@ public function __construct( public ?string $label, #[LiteralTypeScriptType('"'.FilterType::AutocompleteRemote->value.'"')] public FilterType $type, - // public bool $multiple, + public bool $multiple, public bool $required, public bool $master, public int $debounceDelay, diff --git a/src/Filters/AutocompleteRemoteFilter.php b/src/Filters/AutocompleteRemoteFilter.php index 02a58b087..fe33d6a76 100644 --- a/src/Filters/AutocompleteRemoteFilter.php +++ b/src/Filters/AutocompleteRemoteFilter.php @@ -5,6 +5,7 @@ abstract class AutocompleteRemoteFilter extends Filter { use AutocompleteRemoteFilterTrait; + use AutocompleteRemoteSingleFilterTrait; public function defaultValue(): mixed { diff --git a/src/Filters/AutocompleteRemoteFilterTrait.php b/src/Filters/AutocompleteRemoteFilterTrait.php index 324cf2284..c8fb2099f 100644 --- a/src/Filters/AutocompleteRemoteFilterTrait.php +++ b/src/Filters/AutocompleteRemoteFilterTrait.php @@ -12,7 +12,6 @@ trait AutocompleteRemoteFilterTrait private bool $isMaster = false; private int $debounceDelay = 300; private int $searchMinChars = 1; - private array $cache = []; final public function configureMaster(bool $isMaster = true): self { @@ -35,40 +34,13 @@ final public function configureSearchMinChars(int $minChars): self return $this; } - public function fromQueryParam($value): mixed - { - if ($value) { - if ($this->cache[$value] ?? null) { - return $this->cache[$value]; - } - if ($label = $this->valueLabelFor($value)) { - return $this->cache[$value] = [ - 'id' => $value, - 'label' => $label, - ]; - } - } - - return null; - } - - public function toQueryParam($value): mixed - { - return is_array($value) ? $value['id'] : $value; - } - - public function defaultValue(): mixed - { - return null; - } - public function toArray(): array { return parent::buildArray([ 'type' => FilterType::AutocompleteRemote->value, 'master' => $this->isMaster, 'required' => $this instanceof AutocompleteRemoteRequiredFilter, - // 'multiple' => $this instanceof AutocompleteRemoteMultipleFilter, + 'multiple' => $this instanceof AutocompleteRemoteMultipleFilter, 'debounceDelay' => $this->debounceDelay, 'searchMinChars' => $this->searchMinChars, ]); @@ -88,12 +60,10 @@ final public function format(array $values) return $values; } - public function formatRawValue(mixed $value): mixed - { - return $value ? $value['id'] : null; - } - + /** + * Return the options matching the typed search, as [id => label]. + * + * @return array + */ abstract public function values(string $query): array; - - abstract public function valueLabelFor(string $id): ?string; } diff --git a/src/Filters/AutocompleteRemoteMultipleFilter.php b/src/Filters/AutocompleteRemoteMultipleFilter.php new file mode 100644 index 000000000..69c95691a --- /dev/null +++ b/src/Filters/AutocompleteRemoteMultipleFilter.php @@ -0,0 +1,76 @@ +formatIds(explode(',', $value)); + + if (! count($ids)) { + return []; + } + + return once(function () use ($ids) { + $labels = $this->valueLabelsFor($ids); + + return collect($ids) + ->filter(fn (string|int $id) => isset($labels[$id])) + ->map(fn (string|int $id) => [ + 'id' => $id, + 'label' => $labels[$id], + ]) + ->values() + ->all(); + }); + } + + public function toQueryParam($value): ?string + { + $ids = $this->formatIds(Arr::wrap($value)); + + return count($ids) + ? implode(',', $ids) + : null; + } + + public function defaultValue(): array + { + return []; + } + + public function formatRawValue(mixed $value): array + { + return $this->formatIds(Arr::wrap($value)); + } + + /** + * @return array + */ + private function formatIds(array $value): array + { + return collect($value) + ->map(fn ($item) => is_array($item) ? ($item['id'] ?? null) : $item) + ->filter(fn ($id) => is_string($id) && trim($id) !== '' || is_int($id)) + ->unique(fn (string|int $id) => (string) $id) + ->values() + ->all(); + } + + /** + * Return labels for all requested IDs in one batch, as [id => label]. + * + * @param array $ids + * @return array + */ + abstract public function valueLabelsFor(array $ids): array; +} diff --git a/src/Filters/AutocompleteRemoteRequiredFilter.php b/src/Filters/AutocompleteRemoteRequiredFilter.php index 28eede855..4b6aede73 100644 --- a/src/Filters/AutocompleteRemoteRequiredFilter.php +++ b/src/Filters/AutocompleteRemoteRequiredFilter.php @@ -5,6 +5,7 @@ abstract class AutocompleteRemoteRequiredFilter extends Filter { use AutocompleteRemoteFilterTrait; + use AutocompleteRemoteSingleFilterTrait; abstract public function defaultValue(): mixed; } diff --git a/src/Filters/AutocompleteRemoteSingleFilterTrait.php b/src/Filters/AutocompleteRemoteSingleFilterTrait.php new file mode 100644 index 000000000..67a721086 --- /dev/null +++ b/src/Filters/AutocompleteRemoteSingleFilterTrait.php @@ -0,0 +1,39 @@ +valueLabelFor($value)) { + return [ + 'id' => $value, + 'label' => $label, + ]; + } + + return null; + }); + } + + public function toQueryParam($value): mixed + { + return is_array($value) ? $value['id'] : $value; + } + + public function formatRawValue(mixed $value): mixed + { + return $value ? $value['id'] : null; + } + + abstract public function valueLabelFor(string $id): ?string; +} diff --git a/src/Filters/FilterContainer/Concerns/ProvidesFilterValuesToFront.php b/src/Filters/FilterContainer/Concerns/ProvidesFilterValuesToFront.php index 825a08bb9..4290da65f 100644 --- a/src/Filters/FilterContainer/Concerns/ProvidesFilterValuesToFront.php +++ b/src/Filters/FilterContainer/Concerns/ProvidesFilterValuesToFront.php @@ -2,6 +2,7 @@ namespace Code16\Sharp\Filters\FilterContainer\Concerns; +use Code16\Sharp\Filters\AutocompleteRemoteMultipleFilter; use Code16\Sharp\Filters\Filter; use Code16\Sharp\Filters\SelectMultipleFilter; use Illuminate\Support\Arr; @@ -36,9 +37,9 @@ public function getCurrentFilterValuesForFront(?array $query): array ->mapWithKeys(function (Filter $handler) use ($currentValues, $defaultValues) { $current = $currentValues[$handler->getKey()] ?? null; $default = $defaultValues[$handler->getKey()] ?? null; - if ($handler instanceof SelectMultipleFilter) { - $current = is_array($current) ? Arr::sort($current) : []; - $default = is_array($default) ? Arr::sort($default) : []; + if ($handler instanceof SelectMultipleFilter || $handler instanceof AutocompleteRemoteMultipleFilter) { + $current = Arr::sort($handler->formatRawValue($current)); + $default = Arr::sort($handler->formatRawValue($default)); } $isValuated = (string) $handler->toQueryParam($current) !== (string) $handler->toQueryParam($default); diff --git a/src/Filters/SelectFilterTrait.php b/src/Filters/SelectFilterTrait.php index 2ba0af81f..8b58c3bca 100644 --- a/src/Filters/SelectFilterTrait.php +++ b/src/Filters/SelectFilterTrait.php @@ -12,7 +12,6 @@ trait SelectFilterTrait private bool $isMaster = false; private bool $isSearchable = false; private array $searchKeys = ['label']; - private ?array $cachedValues = null; final public function isMaster(): bool { @@ -89,7 +88,7 @@ protected function formattedValues(): array public function cachedValues(): array { - return $this->cachedValues ??= $this->values(); + return once(fn () => $this->values()); } abstract public function values(): array; diff --git a/src/Http/Controllers/Api/ApiFilterAutocompleteController.php b/src/Http/Controllers/Api/ApiFilterAutocompleteController.php index d74eecc6e..49800b221 100644 --- a/src/Http/Controllers/Api/ApiFilterAutocompleteController.php +++ b/src/Http/Controllers/Api/ApiFilterAutocompleteController.php @@ -3,6 +3,8 @@ namespace Code16\Sharp\Http\Controllers\Api; use Code16\Sharp\Filters\AutocompleteRemoteFilter; +use Code16\Sharp\Filters\AutocompleteRemoteMultipleFilter; +use Code16\Sharp\Filters\AutocompleteRemoteRequiredFilter; use Code16\Sharp\Http\Controllers\Controller; use Code16\Sharp\Utils\Entities\SharpDashboardEntity; use Code16\Sharp\Utils\Entities\ValueObjects\EntityKey; @@ -21,7 +23,9 @@ public function index(string $globalFilter, EntityKey $entityKey, string $filter $filter = $entity->getListOrFail()->filterContainer()->findFilterHandler($filterHandlerKey); } - if (! $filter instanceof AutocompleteRemoteFilter) { + if (! $filter instanceof AutocompleteRemoteFilter + && ! $filter instanceof AutocompleteRemoteRequiredFilter + && ! $filter instanceof AutocompleteRemoteMultipleFilter) { return [ 'data' => [], ]; diff --git a/tests-e2e/site/app/Sharp/Filters/TestAutocompleteRemoteMultipleFilter.php b/tests-e2e/site/app/Sharp/Filters/TestAutocompleteRemoteMultipleFilter.php new file mode 100644 index 000000000..dca27058c --- /dev/null +++ b/tests-e2e/site/app/Sharp/Filters/TestAutocompleteRemoteMultipleFilter.php @@ -0,0 +1,36 @@ +configureLabel('Autocomplete remote multiple') + ->configureSearchMinChars(2); + } + + public function values(string $query): array + { + return collect($this->options()) + ->filter(fn ($option) => str_contains($option, $query)) + ->all(); + } + + public function valueLabelsFor(array $ids): array + { + return collect($this->options()) + ->only($ids) + ->all(); + } + + private function options(): array + { + return collect(range(1, 10)) + ->mapWithKeys(fn ($id) => [$id => "Option {$id}"]) + ->all(); + } +} diff --git a/tests-e2e/site/app/Sharp/TestModels/TestModelList.php b/tests-e2e/site/app/Sharp/TestModels/TestModelList.php index d90e0d740..e071f3ef0 100644 --- a/tests-e2e/site/app/Sharp/TestModels/TestModelList.php +++ b/tests-e2e/site/app/Sharp/TestModels/TestModelList.php @@ -22,6 +22,7 @@ use App\Sharp\Entities\TestModelRequiredEntity; use App\Sharp\Entities\TestModelTabsEntity; use App\Sharp\Filters\TestAutocompleteRemoteFilter; +use App\Sharp\Filters\TestAutocompleteRemoteMultipleFilter; use App\Sharp\Filters\TestCheckFilter; use App\Sharp\Filters\TestDateRangeFilter; use App\Sharp\Filters\TestDateRangeRequiredFilter; @@ -116,6 +117,7 @@ protected function getFilters(): array { return [ TestAutocompleteRemoteFilter::class, + TestAutocompleteRemoteMultipleFilter::class, TestCheckFilter::class, TestDateRangeFilter::class, TestDateRangeRequiredFilter::class, @@ -137,6 +139,9 @@ public function getListData(): array|Arrayable ->when($this->queryParams->filterFor(TestAutocompleteRemoteFilter::class), function (Builder $query, $value) { $query->where('select_dropdown', $value); }) + ->when($this->queryParams->filterFor(TestAutocompleteRemoteMultipleFilter::class), function (Builder $query, $value) { + $query->whereIn('select_dropdown', $value); + }) ->when($this->queryParams->filterFor(TestCheckFilter::class), function (Builder $query, $check) { $query->where('check', $check); }) diff --git a/tests-e2e/tests/entity-list.spec.ts b/tests-e2e/tests/entity-list.spec.ts index 099dff8e0..c1d776b60 100644 --- a/tests-e2e/tests/entity-list.spec.ts +++ b/tests-e2e/tests/entity-list.spec.ts @@ -309,6 +309,46 @@ function entityListSuite(test: TestType { + await init(); + await goto(); + + const trigger = page.getByRole('combobox', { name: 'Autocomplete remote multiple' }); + const dialog = page.getByRole('dialog'); + const input = dialog.getByRole('combobox', { name: 'Search...', exact: true }); + await trigger.click(); + await input.fill('O'); + await expect(dialog.getByText('Enter at least 2 characters to search...', { exact: true })).toBeVisible(); + + await input.fill('Option 1'); + await dialog.getByRole('option', { name: 'Option 1', exact: true }).click(); + await expect(dialog.getByRole('option', { name: 'Option 1', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText('1 item', { exact: true }).first()).toBeVisible(); + + await input.fill('Option 2'); + await dialog.getByRole('option', { name: 'Option 2', exact: true }).click(); + await expect(page.getByText('2 items', { exact: true }).first()).toBeVisible(); + // selected options are listed first and not repeated in the results + await expect(dialog.getByRole('option', { name: 'Option 2', exact: true })).toHaveCount(1); + await expect(dialog.getByRole('option', { name: 'Option 2', exact: true })).toHaveAttribute('aria-selected', 'true'); + await page.mouse.click(0, 0); + await expect(dialog).toBeHidden(); + await expect(trigger).toContainText('2 selected'); + + await reload(); + await expect(trigger).toContainText('2 selected'); + await trigger.click(); + await expect(dialog.getByRole('option', { name: 'Option 1', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(dialog.getByRole('option', { name: 'Option 2', exact: true })).toHaveAttribute('aria-selected', 'true'); + + await dialog.getByRole('option', { name: 'Option 1', exact: true }).click(); + await expect(dialog.getByRole('option', { name: 'Option 1', exact: true })).toHaveCount(0); + await expect(page.getByText('1 item', { exact: true }).first()).toBeVisible(); + + await dialog.getByRole('button', { name: 'Reset', exact: true }).click(); + await expect(dialog.getByRole('option', { name: 'Option 2', exact: true })).toHaveCount(0); + await expect(page.getByText('20 items', { exact: true }).first()).toBeVisible(); + }); }); test('search', async ({ page, init, goto, reload }) => { await init(); @@ -520,5 +560,3 @@ function entityListSuite(test: TestTypeconfigureKey('products'); + } + + public function values(string $query): array + { + expect($query)->toBe('product'); + + return [ + 1 => 'Product A', + 2 => 'Product B', + ]; + } + + public function valueLabelsFor(array $ids): array + { + return []; + } + }, + ]; + } + }); + + $this + ->postJson(route('code16.sharp.api.filters.autocomplete.index', [ + 'entityKey' => 'person', + 'filterHandlerKey' => 'products', + ]), [ + 'query' => 'product', + ]) + ->assertOk() + ->assertJson([ + 'data' => [ + ['id' => 1, 'label' => 'Product A'], + ['id' => 2, 'label' => 'Product B'], + ], + ]); +}); + it('allows to call an autocomplete remote filter endpoint for dashboard', function () { fakeShowFor('dashboard', new class() extends TestDashboard { diff --git a/tests/Http/FiltersInRequestTest.php b/tests/Http/FiltersInRequestTest.php index 6ea2cc656..0b66fbf66 100644 --- a/tests/Http/FiltersInRequestTest.php +++ b/tests/Http/FiltersInRequestTest.php @@ -1,5 +1,6 @@ labelCalls) extends AutocompleteRemoteMultipleFilter + { + public function __construct(private array &$labelCalls) {} + + public function buildFilterConfig(): void + { + $this->configureKey('job'); + } + + public function values(string $query): array + { + return []; + } + + public function valueLabelsFor(array $ids): array + { + $this->labelCalls[] = $ids; + + return collect($ids) + ->mapWithKeys(fn ($id) => [$id => ucfirst($id)]) + ->all(); + } + }, + ]; + } + + public function getListData(): array|Arrayable + { + $selectedJobs = $this->queryParams->filterFor('job'); + + expect($selectedJobs)->toBe(['physicist', 'physician']); + + return collect([ + ['id' => 1, 'name' => 'Marie Curie', 'job' => 'physicist'], + ['id' => 2, 'name' => 'Louis Pasteur', 'job' => 'physician'], + ]) + ->filter(fn ($item) => in_array($item['job'], $selectedJobs)) + ->values(); + } + }); + + $this + ->get(route('code16.sharp.list', [ + 'entityKey' => 'person', + 'filter_job' => 'physicist,physician', + ])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('entityList.filterValues.current.job', [ + ['id' => 'physicist', 'label' => 'Physicist'], + ['id' => 'physician', 'label' => 'Physician'], + ]) + ->count('entityList.data', 2) + ); + + expect($labelCalls)->toEqual([[ + 'physicist', + 'physician', + ]]); +}); + it('saves retained filters in the session when set', function () { fakeListFor('person', new class() extends PersonList { diff --git a/tests/Unit/EntityList/SharpEntityListFilterTest.php b/tests/Unit/EntityList/SharpEntityListFilterTest.php index 4002dbbc5..c1051a0f1 100644 --- a/tests/Unit/EntityList/SharpEntityListFilterTest.php +++ b/tests/Unit/EntityList/SharpEntityListFilterTest.php @@ -3,6 +3,7 @@ use Carbon\Carbon; use Code16\Sharp\EntityList\Filters\HiddenFilter; use Code16\Sharp\Filters\AutocompleteRemoteFilter; +use Code16\Sharp\Filters\AutocompleteRemoteMultipleFilter; use Code16\Sharp\Filters\CheckFilter; use Code16\Sharp\Filters\DateRange\DateRangePreset; use Code16\Sharp\Filters\DateRangeFilter; @@ -381,6 +382,50 @@ public function defaultValue(): mixed ]); }); +it('computes valuated state of a remote autocomplete multiple filter regardless of id type and order', function () { + $list = new class() extends FakeSharpEntityList + { + public function getFilters(): array + { + return [ + new class() extends AutocompleteRemoteMultipleFilter + { + public function buildFilterConfig(): void + { + $this->configureKey('products'); + } + + public function defaultValue(): array + { + return [2, 1]; + } + + public function values(string $query): array + { + return []; + } + + public function valueLabelsFor(array $ids): array + { + return collect($ids) + ->mapWithKeys(fn ($id) => [$id => "Product {$id}"]) + ->all(); + } + }, + ]; + } + }; + + $list->buildListConfig(); + + expect($list->filterContainer()->getCurrentFilterValuesForFront(null)['valuated']) + ->toEqual(['products' => false]) + ->and($list->filterContainer()->getCurrentFilterValuesForFront(['filter_products' => '1,2'])['valuated']) + ->toEqual(['products' => false]) + ->and($list->filterContainer()->getCurrentFilterValuesForFront(['filter_products' => '1,3'])['valuated']) + ->toEqual(['products' => true]); +}); + it('allows to declare a filter as retained and to set its default value', function () { $list = new class() extends FakeSharpEntityList { @@ -813,6 +858,7 @@ public function valueLabelFor(string $id): string 'type' => 'autocompleteRemote', 'master' => false, 'required' => false, + 'multiple' => false, 'debounceDelay' => 500, 'searchMinChars' => 2, ]); diff --git a/tests/Unit/Filters/AutocompleteRemoteMultipleFilterTest.php b/tests/Unit/Filters/AutocompleteRemoteMultipleFilterTest.php new file mode 100644 index 000000000..829929ac7 --- /dev/null +++ b/tests/Unit/Filters/AutocompleteRemoteMultipleFilterTest.php @@ -0,0 +1,97 @@ +configureKey('products') + ->configureLabel('Products') + ->configureSearchMinChars(2) + ->configureDebounceDelay(500); + } + + public function values(string $query): array + { + return []; + } + + public function valueLabelsFor(array $ids): array + { + $this->calls[] = $ids; + + return collect($ids) + ->reverse() + ->mapWithKeys(fn ($id) => [$id => "Product {$id}"]) + ->all(); + } + }; +} + +it('serializes a remote multiple autocomplete filter without loading its values', function () { + $calls = []; + $filter = makeAutocompleteRemoteMultipleFilter($calls); + $filter->buildFilterConfig(); + + expect($filter->toArray())->toEqual([ + 'key' => 'products', + 'label' => 'Products', + 'type' => 'autocompleteRemote', + 'master' => false, + 'required' => false, + 'multiple' => true, + 'debounceDelay' => 500, + 'searchMinChars' => 2, + ]) + ->and($calls)->toBeEmpty(); +}); + +it('restores all selected labels in one batch and keeps selection order', function () { + $calls = []; + $filter = makeAutocompleteRemoteMultipleFilter($calls); + + expect($filter->fromQueryParam('2,1,2'))->toEqual([ + ['id' => '2', 'label' => 'Product 2'], + ['id' => '1', 'label' => 'Product 1'], + ]) + ->and($calls)->toEqual([[ + '2', + '1', + ]]); +}); + +it('caches restored labels for the same selection', function () { + $calls = []; + $filter = makeAutocompleteRemoteMultipleFilter($calls); + + $filter->fromQueryParam('1,2'); + + expect($filter->fromQueryParam('1,2'))->toEqual([ + ['id' => '1', 'label' => 'Product 1'], + ['id' => '2', 'label' => 'Product 2'], + ]) + ->and($calls)->toEqual([ + ['1', '2'], + ]); +}); + +it('serializes selected options like other multiple filters and exposes raw IDs', function () { + $calls = []; + $filter = makeAutocompleteRemoteMultipleFilter($calls); + $value = [ + ['id' => 2, 'label' => 'Product 2'], + ['id' => '1', 'label' => 'Product 1'], + ['id' => 2, 'label' => 'Product 2'], + ]; + + expect($filter->toQueryParam($value))->toBe('2,1') + ->and($filter->formatRawValue($value))->toBe([2, '1']) + ->and($filter->fromQueryParam(null))->toBe([]) + ->and($filter->toQueryParam([]))->toBeNull() + ->and($filter->defaultValue())->toBe([]); +});