diff --git a/demo/app/Sharp/Categories/CategoryList.php b/demo/app/Sharp/Categories/CategoryList.php index 6a51f0904..fbf604305 100644 --- a/demo/app/Sharp/Categories/CategoryList.php +++ b/demo/app/Sharp/Categories/CategoryList.php @@ -8,7 +8,7 @@ use Code16\Sharp\EntityList\Fields\EntityListField; use Code16\Sharp\EntityList\Fields\EntityListFieldsContainer; use Code16\Sharp\EntityList\SharpEntityList; -use Code16\Sharp\Utils\Filters\CheckFilter; +use Code16\Sharp\Filters\CheckFilter; use Illuminate\Contracts\Support\Arrayable; class CategoryList extends SharpEntityList diff --git a/demo/app/Sharp/DummyGlobalFilter.php b/demo/app/Sharp/DummyGlobalFilter.php index 43e5a7bfc..2523278c5 100644 --- a/demo/app/Sharp/DummyGlobalFilter.php +++ b/demo/app/Sharp/DummyGlobalFilter.php @@ -2,7 +2,7 @@ namespace App\Sharp; -use Code16\Sharp\Utils\Filters\GlobalRequiredFilter; +use Code16\Sharp\Filters\GlobalRequiredFilter; class DummyGlobalFilter extends GlobalRequiredFilter { diff --git a/demo/app/Sharp/Posts/PostList.php b/demo/app/Sharp/Posts/PostList.php index 9b0d79cdb..cbaee4edf 100644 --- a/demo/app/Sharp/Posts/PostList.php +++ b/demo/app/Sharp/Posts/PostList.php @@ -3,6 +3,7 @@ namespace App\Sharp\Posts; use App\Models\Post; +use App\Models\PostAttachment; use App\Sharp\Entities\PostEntity; use App\Sharp\Posts\Commands\BulkPublishPostsCommand; use App\Sharp\Posts\Commands\ComposeEmailWithPostsWizardCommand; @@ -12,12 +13,13 @@ use App\Sharp\Utils\Filters\AuthorFilter; use App\Sharp\Utils\Filters\CategoryFilter; use App\Sharp\Utils\Filters\PeriodFilter; +use App\Sharp\Utils\Filters\PostAttachmentFilter; use App\Sharp\Utils\Filters\StateFilter; use Code16\Sharp\EntityList\Fields\EntityListField; use Code16\Sharp\EntityList\Fields\EntityListFieldsContainer; use Code16\Sharp\EntityList\Fields\EntityListStateField; use Code16\Sharp\EntityList\SharpEntityList; -use Code16\Sharp\Utils\Filters\DateRangeFilterValue; +use Code16\Sharp\Filters\DateRange\DateRangeFilterValue; use Code16\Sharp\Utils\Links\LinkToEntityList; use Code16\Sharp\Utils\PageAlerts\PageAlert; use Code16\Sharp\Utils\Transformers\Attributes\Eloquent\SharpTagsTransformer; @@ -86,6 +88,7 @@ protected function getFilters(): ?array StateFilter::class, AuthorFilter::class, CategoryFilter::class, + PostAttachmentFilter::class, PeriodFilter::class, ]; } @@ -147,6 +150,14 @@ function (Builder $builder, $categories) { }); }, ) + ->when( + $this->queryParams->filterFor(PostAttachmentFilter::class), + function (Builder $builder, int $attachmentId) { + $builder->whereHas('attachments', function (Builder $builder) use ($attachmentId) { + $builder->where('title', PostAttachment::find($attachmentId)->title); + }); + }, + ) // Handle search words ->when( diff --git a/demo/app/Sharp/Utils/Filters/AuthorFilter.php b/demo/app/Sharp/Utils/Filters/AuthorFilter.php index f1c9d4d41..4032edacf 100644 --- a/demo/app/Sharp/Utils/Filters/AuthorFilter.php +++ b/demo/app/Sharp/Utils/Filters/AuthorFilter.php @@ -3,9 +3,9 @@ namespace App\Sharp\Utils\Filters; use App\Models\User; -use Code16\Sharp\EntityList\Filters\EntityListSelectFilter; +use Code16\Sharp\Filters\SelectFilter; -class AuthorFilter extends EntityListSelectFilter +class AuthorFilter extends SelectFilter { public function buildFilterConfig(): void { @@ -16,6 +16,7 @@ public function values(): array { return User::whereHas('posts') ->orderBy('name') + ->get() ->pluck('name', 'id') ->map(fn ($name, $id) => auth()->id() === $id ? "$name (me)" : $name) ->toArray(); diff --git a/demo/app/Sharp/Utils/Filters/CategoryFilter.php b/demo/app/Sharp/Utils/Filters/CategoryFilter.php index 7ab17e2eb..59cdf4184 100644 --- a/demo/app/Sharp/Utils/Filters/CategoryFilter.php +++ b/demo/app/Sharp/Utils/Filters/CategoryFilter.php @@ -3,9 +3,9 @@ namespace App\Sharp\Utils\Filters; use App\Models\Category; -use Code16\Sharp\EntityList\Filters\EntityListSelectMultipleFilter; +use Code16\Sharp\Filters\SelectMultipleFilter; -class CategoryFilter extends EntityListSelectMultipleFilter +class CategoryFilter extends SelectMultipleFilter { public function buildFilterConfig(): void { diff --git a/demo/app/Sharp/Utils/Filters/PeriodFilter.php b/demo/app/Sharp/Utils/Filters/PeriodFilter.php index 9b955a893..b9c4fc94e 100644 --- a/demo/app/Sharp/Utils/Filters/PeriodFilter.php +++ b/demo/app/Sharp/Utils/Filters/PeriodFilter.php @@ -2,9 +2,9 @@ namespace App\Sharp\Utils\Filters; -use Code16\Sharp\EntityList\Filters\EntityListDateRangeFilter; +use Code16\Sharp\Filters\DateRangeFilter; -class PeriodFilter extends EntityListDateRangeFilter +class PeriodFilter extends DateRangeFilter { public function buildFilterConfig(): void { diff --git a/demo/app/Sharp/Utils/Filters/PeriodRequiredFilter.php b/demo/app/Sharp/Utils/Filters/PeriodRequiredFilter.php index 8804c6e6e..ef01d716b 100644 --- a/demo/app/Sharp/Utils/Filters/PeriodRequiredFilter.php +++ b/demo/app/Sharp/Utils/Filters/PeriodRequiredFilter.php @@ -2,9 +2,9 @@ namespace App\Sharp\Utils\Filters; -use Code16\Sharp\Dashboard\Filters\DashboardDateRangeRequiredFilter; +use Code16\Sharp\Filters\DateRangeRequiredFilter; -class PeriodRequiredFilter extends DashboardDateRangeRequiredFilter +class PeriodRequiredFilter extends DateRangeRequiredFilter { public function buildFilterConfig(): void { diff --git a/demo/app/Sharp/Utils/Filters/PostAttachmentFilter.php b/demo/app/Sharp/Utils/Filters/PostAttachmentFilter.php new file mode 100644 index 000000000..d614687b2 --- /dev/null +++ b/demo/app/Sharp/Utils/Filters/PostAttachmentFilter.php @@ -0,0 +1,32 @@ +configureLabel('Attachment'); + } + + public function values(string $query): array + { + return PostAttachment::query() + ->orderBy('title') + ->when($query, function ($builder) use ($query) { + $builder->where('title', 'like', "%$query%"); + }) + ->get() + ->pluck('title', 'id') + ->unique() + ->toArray(); + } + + public function valueLabelFor(string $id): string + { + return PostAttachment::find($id)->title ?? ''; + } +} diff --git a/demo/app/Sharp/Utils/Filters/StateFilter.php b/demo/app/Sharp/Utils/Filters/StateFilter.php index 2d8397a7a..0b50afe9c 100644 --- a/demo/app/Sharp/Utils/Filters/StateFilter.php +++ b/demo/app/Sharp/Utils/Filters/StateFilter.php @@ -2,9 +2,9 @@ namespace App\Sharp\Utils\Filters; -use Code16\Sharp\EntityList\Filters\EntityListSelectFilter; +use Code16\Sharp\Filters\SelectFilter; -class StateFilter extends EntityListSelectFilter +class StateFilter extends SelectFilter { public function buildFilterConfig(): void { diff --git a/demo/database/factories/PostAttachmentFactory.php b/demo/database/factories/PostAttachmentFactory.php index 255c680f9..4f5219446 100644 --- a/demo/database/factories/PostAttachmentFactory.php +++ b/demo/database/factories/PostAttachmentFactory.php @@ -2,14 +2,17 @@ namespace Database\Factories; +use Database\Seeders\WithTextFixtures; use Illuminate\Database\Eloquent\Factories\Factory; class PostAttachmentFactory extends Factory { + use WithTextFixtures; + public function definition() { return [ - 'title' => $this->faker->sentence, + 'title' => $this->faker->randomElement(static::$attachmentTitles), 'is_link' => $this->faker->boolean, 'link_url' => $this->faker->url, ]; diff --git a/demo/database/seeders/WithTextFixtures.php b/demo/database/seeders/WithTextFixtures.php index ea87d0b73..46ee38163 100644 --- a/demo/database/seeders/WithTextFixtures.php +++ b/demo/database/seeders/WithTextFixtures.php @@ -149,4 +149,14 @@ trait WithTextFixtures 'The chair sat in the corner where it had been for over 25 years. The only difference was there was someone actually sitting in it. How long had it been since someone had done that? Ten years or more he imagined. Yet there was no denying the presence in the chair now.', "Things aren't going well at all with mom today. She is just a limp noodle and wants to sleep all the time. I sure hope that things get better soon.", ]; + protected static array $attachmentTitles = [ + 'Newspaper article', + 'Charts', + 'PDF specifications', + 'Book preview', + 'Code example', + 'Photo of my lamborghini', + 'Photo of my cat', + 'Sales growth chart', + ]; } diff --git a/docs/guide/filters.md b/docs/guide/filters.md index 2f5be5602..185f46993 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -12,10 +12,10 @@ php artisan sharp:make:entity-list-filter [--required,--multiple,-- ## Write the filter class -First, we need to write a class which extends `Code16\Sharp\EntityList\Filters\EntityListSelectFilter`, and therefore declare a `values()` function. This function must return an `[{id} => {label}]` array. For instance, with Eloquent: +First, we need to write a class which extends `Code16\Sharp\Filters\SelectFilter`, and therefore declare a `values()` function. This function must return an `[{id} => {label}]` array. For instance, with Eloquent: ```php -class ProductCategoryFilter extends EntityListSelectFilter +class ProductCategoryFilter extends SelectFilter { public function values(): array { @@ -31,7 +31,7 @@ class ProductCategoryFilter extends EntityListSelectFilter You can implement the optional `buildFilterConfig()` method to configure the filter: ```php -class ProductCategoryFilter extends EntityListSelectFilter +class ProductCategoryFilter extends SelectFilter { public function buildFilterConfig(): void { @@ -94,7 +94,7 @@ class ProductList extends SharpEntityList ## Multiple filter -First, notice that you can have as many filters as you want for an EntityList. The "multiple filter" here designate something else: allowing the user to select more than one value for a filter. To achieve this, make your filter extend `Code16\Sharp\EntityList\Filters\EntityListSelectMultipleFilter`. +First, notice that you can have as many filters as you want for an EntityList. The "multiple filter" here designate something else: allowing the user to select more than one value for a filter. To achieve this, make your filter extend `Code16\Sharp\Filters\SelectMultipleFilter`. In this case, with Eloquent for instance, your might have to modify your code to ensure that you have an array (Sharp will return either null, and id or an array of id, depending on the user selection): @@ -120,7 +120,7 @@ Note that a filter can't be required AND multiple. ## Date range filter -You might find useful to filter list elements on a specific date range. Date range filters enable you to show only data that meets a given time period. To implement such a filter, your filter class must extend `Code16\Sharp\EntityList\Filters\EntityListDateRangeFilter`. +You might find useful to filter list elements on a specific date range. Date range filters enable you to show only data that meets a given time period. To implement such a filter, your filter class must extend `Code16\Sharp\Filters\DateRangeFilter`. Then you need to adjust the query with selected range; in this case, with Eloquent for instance, you might add a condition like: @@ -148,7 +148,7 @@ You can define the date display format (default is `MM-DD-YYYY`, using [Carbon i With `configureShowPresets()`, a list of buttons is displayed allowing the user to quickly select a date range. ```php -class ProductCreationDateFilter extends EntityListDateRangeFilter +class ProductCreationDateFilter extends DateRangeFilter { public function buildFilterConfig(): void { @@ -161,14 +161,47 @@ class ProductCreationDateFilter extends EntityListDateRangeFilter } ``` +## Autocomplete remote filter + +If you want to use a remote filter, you can use the `Code16\Sharp\Filters\AutocompleteRemoteFilter` class. It is very similar to the `Code16\Sharp\Filters\SelectFilter` class, but it uses a remote endpoint to fetch the values. + +```php +class ProductCategoryFilter extends AutocompleteRemoteFilter +{ + public function buildFilterConfig(): void + { + $this + ->configureLabel('Category') + ->configureDebounceDelay(200) // 300ms per default + ->configureSearchMinChars(2); // 1 per default, set 0 to search directly on opening the filter + } + + public function values(string $query): array + { + return ProductCategory::orderBy('label') + ->where('label', 'like', "%$query%") + ->pluck('label', 'id') + ->toArray(); + } + + public function valueLabelFor(string $id): ?string + { + return ProductCategory::find($id)?->label; + } +} +``` + +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. + + ## Required filters -It is sometimes useful to have a filter which can't be null: to achieve this you need to extend the right "Required" subclass (`EntityListSelectRequiredFilter` or `EntityListDateRangeRequiredFilter`), and define a proper default value. +It is sometimes useful to have a filter which can't be null: to achieve this you need to extend the right "Required" subclass (`SelectRequiredFilter` or `DateRangeRequiredFilter`), and define a proper default value. Example for a select filter: ```php -class ProductCategoryFilter extends EntityListSelectRequiredFilter +class ProductCategoryFilter extends SelectRequiredFilter { public function defaultValue(): mixed { @@ -184,7 +217,7 @@ Note that a filter can't be required AND multiple. Example for a date range filter: ```php -class ProductCreationDateFilter extends EntityListDateRangeRequiredFilter +class ProductCreationDateFilter extends DateRangeRequiredFilter { public function defaultValue(): array { @@ -211,7 +244,7 @@ public function buildFilterConfig(): void ## Check filter -In case of a filter that is just a matter on true / false ("only show admins" for example), just make your filter class extend `Code16\Sharp\EntityList\Filters\EntityListCheckFilter`. +In case of a filter that is just a matter on true / false ("only show admins" for example), just make your filter class extend `Code16\Sharp\Filters\CheckFilter`. ## Master filter @@ -272,14 +305,14 @@ class OrderList extends SharpEntityList ## Filters for Dashboards -[Dashboards](building-dashboard.md) also can take advantage of filters; the API the same, but base classes are specific: `Code16\Sharp\Dashboard\Filters\DashboardSelectFilter`, `Code16\Sharp\Dashboard\Filters\DashboardDateRangeFilter`,`Code16\Sharp\Dashboard\DashboardCheckFilter` and so on. +[Dashboards](building-dashboard.md) also can take advantage of filters; the API the same, but base classes are specific: `Code16\Sharp\Filters\SelectFilter`, `Code16\Sharp\Filters\DateRangeFilter`,`Code16\Sharp\Filters\CheckFilter` and so on. ## Global menu Filters You may want to "scope" the entire data set: an example of this could be a user which can manage several organizations. Instead of adding a filter on almost every Entity List, in this case, you can define a global filter, which will appear on top of the global menu. To achieve this, first write the filter class, like any filter, except it must -extend `\Code16\Sharp\Utils\Filters\GlobalRequiredFilter` — meaning it must be a required filter. +extend `\Code16\Sharp\Filters\GlobalRequiredFilter` — meaning it must be a required filter. ```php class OrganizationGlobalFilter extends GlobalRequiredFilter diff --git a/resources/js/Pages/Dashboard/Dashboard.vue b/resources/js/Pages/Dashboard/Dashboard.vue index a2bcf8f6a..93a8b9caf 100644 --- a/resources/js/Pages/Dashboard/Dashboard.vue +++ b/resources/js/Pages/Dashboard/Dashboard.vue @@ -29,7 +29,6 @@ DialogTrigger } from "@/components/ui/dialog"; import { Filter } from "lucide-vue-next"; - import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; const props = defineProps<{ @@ -108,6 +107,7 @@ :filter="filter" :value="filters.currentValues[filter.key]" :valuated="filters.isValuated([filter])" + :entity-key="dashboardKey" inline @input="onFilterChange(filter, $event)" /> @@ -140,6 +140,7 @@ :filter="filter" :value="filters.currentValues[filter.key]" :valuated="filters.isValuated([filter])" + :entity-key="dashboardKey" @input="onFilterChange(filter, $event)" /> @@ -205,6 +206,7 @@ :filter="filter" :value="filters.currentValues[filter.key]" :valuated="filters.isValuated([filter])" + :entity-key="dashboardKey" inline @input="onFilterChange(filter, $event)" /> @@ -237,6 +239,7 @@ :filter="filter" :value="filters.currentValues[filter.key]" :valuated="filters.isValuated([filter])" + :entity-key="dashboardKey" @input="onFilterChange(filter, $event)" /> diff --git a/resources/js/composables/useRemoteAutocomplete.ts b/resources/js/composables/useRemoteAutocomplete.ts new file mode 100644 index 000000000..4d7e91bd7 --- /dev/null +++ b/resources/js/composables/useRemoteAutocomplete.ts @@ -0,0 +1,72 @@ +import { AxiosError, AxiosResponse, isCancel } from "axios"; +import { Ref, ref } from "vue"; + + +export function useRemoteAutocomplete( + post: (props: ReturnType) => Promise, + options: { + minLength: number; + debounceDelay: number; + } +) { + let abortController: AbortController | null = null; + let timeout = null; + let loadingTimeout = null; + const results = ref([]); + const loading = ref(false); + + function postProps(query: string) { + return { + query, + signal: abortController.signal, + onSuccess: (response: AxiosResponse) => { + clearTimeout(loadingTimeout); + loading.value = false; + return response; + }, + onError: (e: AxiosError) => { + if(isCancel(e)) { + clearTimeout(loadingTimeout); + } + return Promise.reject(e); + } + } + } + + function search(query: string) { + clearTimeout(loadingTimeout); + loadingTimeout = setTimeout(() => { + loading.value = true; + }, 200); + abortController?.abort(); + abortController = new AbortController(); + + return post(postProps(query)) + .then(r => results.value = r); + } + + return { + results, + loading, + async search(query: string, immediate?: boolean) { + return new Promise((resolve, reject) => { + clearTimeout(timeout); + if(query.length >= options.minLength) { + if(!results.value.length) { + loading.value = true; + } + if(immediate) { + search(query).then(resolve, reject) + } else { + timeout = setTimeout(() => search(query).then(resolve, reject), options.debounceDelay) + } + } else { + clearTimeout(timeout); + loading.value = false; + results.value = []; + resolve([] as T); + } + }); + } + } +} diff --git a/resources/js/entity-list/components/EntityList.vue b/resources/js/entity-list/components/EntityList.vue index 491ca2d38..cfd6776dd 100644 --- a/resources/js/entity-list/components/EntityList.vue +++ b/resources/js/entity-list/components/EntityList.vue @@ -543,6 +543,7 @@ :value="filters.currentValues[filter.key]" :disabled="reordering" :valuated="filters.isValuated([filter])" + :entity-key="entityKey" @input="onFilterChange(filter, $event)" /> @@ -586,6 +587,7 @@ :value="filters.currentValues[filter.key]" :disabled="reordering || selecting" :valuated="filters.isValuated([filter])" + :entity-key="entityKey" inline @input="onFilterChange(filter, $event)" /> diff --git a/resources/js/filters/FilterManager.ts b/resources/js/filters/FilterManager.ts index 6cba9b4b8..0ca736551 100644 --- a/resources/js/filters/FilterManager.ts +++ b/resources/js/filters/FilterManager.ts @@ -37,7 +37,7 @@ export class FilterManager { } nextValues(filter: FilterData, value: ParsedValue): FilterValues { - if(filter.type === 'select' && filter.master) { + if((filter.type === 'select' || filter.type === 'autocompleteRemote') && filter.master) { return { ...Object.fromEntries(Object.entries(this.currentValues).map(([key, value]) => [key, null])), [filter.key]: value, diff --git a/resources/js/filters/components/Filter.vue b/resources/js/filters/components/Filter.vue index 3a54aec2f..33ed4775c 100644 --- a/resources/js/filters/components/Filter.vue +++ b/resources/js/filters/components/Filter.vue @@ -5,9 +5,11 @@ import SelectFilter from "./filters/SelectFilter.vue"; import type { Component } from "vue"; import { FilterProps } from "@/filters/types"; + import AutocompleteRemoteFilter from "@/filters/components/filters/AutocompleteRemoteFilter.vue"; const props = defineProps>(); const components: Record = { + 'autocompleteRemote': AutocompleteRemoteFilter, 'check': CheckFilter, 'daterange': DateRangeFilter, 'select': SelectFilter, diff --git a/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue b/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue new file mode 100644 index 000000000..8722d14eb --- /dev/null +++ b/resources/js/filters/components/filters/AutocompleteRemoteFilter.vue @@ -0,0 +1,186 @@ + + + diff --git a/resources/js/filters/components/filters/AutocompleteRemoteFilterValue.vue b/resources/js/filters/components/filters/AutocompleteRemoteFilterValue.vue new file mode 100644 index 000000000..d936c4aff --- /dev/null +++ b/resources/js/filters/components/filters/AutocompleteRemoteFilterValue.vue @@ -0,0 +1,16 @@ + + + diff --git a/resources/js/filters/components/filters/SelectButton.vue b/resources/js/filters/components/filters/SelectButton.vue new file mode 100644 index 000000000..07d3f218b --- /dev/null +++ b/resources/js/filters/components/filters/SelectButton.vue @@ -0,0 +1,42 @@ + + + diff --git a/resources/js/filters/components/filters/SelectFilter.vue b/resources/js/filters/components/filters/SelectFilter.vue index 5946b88db..181980b5d 100644 --- a/resources/js/filters/components/filters/SelectFilter.vue +++ b/resources/js/filters/components/filters/SelectFilter.vue @@ -20,12 +20,13 @@ import { computed, ref } from "vue"; import SelectFilterValue from "@/filters/components/filters/SelectFilterValue.vue"; import { FilterEmits, FilterProps } from "@/filters/types"; + import SelectButton from "@/filters/components/filters/SelectButton.vue"; const props = defineProps>(); const emit = defineEmits>(); const open = ref(false); - const valuated = computed(() => Array.isArray(props.value) ? props.value.length : props.value != null); + const hasValue = computed(() => Array.isArray(props.value) ? props.value.length : props.value != null); function isSelected(selectValue: SelectFilterData['values'][0]) { return Array.isArray(props.value) @@ -54,37 +55,15 @@ -