From f63f6015ce38539f10b36db0e1fc539a031e59a2 Mon Sep 17 00:00:00 2001 From: Norman Huth Date: Sun, 25 Jan 2026 04:52:36 +0100 Subject: [PATCH 1/2] fix(views): add structured data for shopping list and improve schema compliance - Generate `recipeIngredient` using PHP `array_map` for consistency - Add JSON-LD script for enhanced SEO and structured data handling - Refactor `` schema attributes for proper tagging as Recipe --- resources/views/web/bring-export.blade.php | 41 +++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/resources/views/web/bring-export.blade.php b/resources/views/web/bring-export.blade.php index 565d9c3e..0fc604a3 100644 --- a/resources/views/web/bring-export.blade.php +++ b/resources/views/web/bring-export.blade.php @@ -4,22 +4,31 @@ {{ __('Shopping List') }} - hfresh.info + @php + $ingredientStrings = array_map( + fn($i) => ($i['amount'] !== '' ? $i['amount'] . ' ' : '') . $i['name'], + $ingredients + ); + @endphp + - -
-

{{ __('Shopping List') }}

- - -
+ +

{{ __('Shopping List') }}

+ From 6874850ebfe5adef8498fea42e3d03aa188534b5 Mon Sep 17 00:00:00 2001 From: Norman Huth Date: Sun, 25 Jan 2026 10:25:30 +0100 Subject: [PATCH 2/2] feat(filter-share): implement filter sharing functionality across pages - Add `FilterShare` model, migration, and controller to handle shared filters - Create reusable `WithFilterShareTrait` for Livewire components - Extend `RecipeIndex` and `RecipeRandom` components with filter sharing - Add `FilterSharePageEnum` to manage routing for shared filters - Update UI to support generating and copying share URLs - Include tests for filter sharing behavior and validation --- CHANGELOG.md | 8 + app/Enums/FilterSharePageEnum.php | 22 ++ .../Controllers/FilterShareController.php | 87 ++++++ .../Web/Concerns/WithFilterShareTrait.php | 247 ++++++++++++++++++ .../Concerns/WithRecipeFilterOptionsTrait.php | 62 ++--- app/Livewire/Web/Recipes/RecipeIndex.php | 34 ++- app/Livewire/Web/Recipes/RecipeRandom.php | 10 + app/Models/FilterShare.php | 57 ++++ ...1_25_000010_create_filter_shares_table.php | 30 +++ resources/views/flux/icon/share-2.blade.php | 47 ++++ .../components/layouts/localized.blade.php | 1 + .../livewire/recipes/recipe-index.blade.php | 37 +++ .../livewire/recipes/recipe-random.blade.php | 37 +++ .../partials/recipes/filter-modal.blade.php | 61 +++-- routes/web-localized.php | 3 + tests/Feature/FilterShareTest.php | 154 +++++++++++ 16 files changed, 845 insertions(+), 52 deletions(-) create mode 100644 app/Enums/FilterSharePageEnum.php create mode 100644 app/Http/Controllers/FilterShareController.php create mode 100644 app/Livewire/Web/Concerns/WithFilterShareTrait.php create mode 100644 app/Models/FilterShare.php create mode 100644 database/migrations/2026_01_25_000010_create_filter_shares_table.php create mode 100644 resources/views/flux/icon/share-2.blade.php create mode 100644 tests/Feature/FilterShareTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fb408355..bf614e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [3.14.0] - 2026-01-25 + +### Added + +- **Filter Share Links** - Share current filter settings via URL + - Share button on Recipes and Random Recipes pages + - Shareable links preserve all active filters (ingredients, tags, allergens, difficulty, time ranges, etc.) + ## [3.13.0] - 2026-01-25 ### Added diff --git a/app/Enums/FilterSharePageEnum.php b/app/Enums/FilterSharePageEnum.php new file mode 100644 index 00000000..a0386cc1 --- /dev/null +++ b/app/Enums/FilterSharePageEnum.php @@ -0,0 +1,22 @@ + 'localized.recipes.index', + self::Random => 'localized.recipes.random', + self::Menus => 'localized.menus.index', + }; + } +} diff --git a/app/Http/Controllers/FilterShareController.php b/app/Http/Controllers/FilterShareController.php new file mode 100644 index 00000000..ccdaa695 --- /dev/null +++ b/app/Http/Controllers/FilterShareController.php @@ -0,0 +1,87 @@ +country_id !== $country->id, 404); + + $page = FilterSharePageEnum::tryFrom($filterShare->page); + + abort_if($page === null, 404); + + $this->applyFiltersToSession($filterShare, $country); + + // Pass through URL params (search, page, sort) + $queryParams = []; + + if ($request->filled('search')) { + $queryParams['search'] = $request->query('search'); + } + + if ($request->filled('page') && (int) $request->query('page') > 1) { + $queryParams['page'] = (int) $request->query('page'); + } + + if ($request->filled('sort')) { + $queryParams['sort'] = $request->query('sort'); + } + + $redirectUrl = localized_route($page->routeName()); + + if ($queryParams !== []) { + $redirectUrl .= '?' . http_build_query($queryParams); + } + + return redirect($redirectUrl); + } + + /** + * Apply the filter share's filters to the session. + */ + protected function applyFiltersToSession(FilterShare $filterShare, Country $country): void + { + $filters = $filterShare->filters; + $prefix = sprintf('recipe_filter_%d_', $country->id); + + $mapping = [ + 'has_pdf' => 'has_pdf', + 'show_canonical' => 'show_canonical', + 'excluded_allergens' => 'excluded_allergens', + 'ingredients' => 'ingredients', + 'ingredient_match' => 'ingredient_match', + 'excluded_ingredients' => 'excluded_ingredients', + 'tags' => 'tags', + 'excluded_tags' => 'excluded_tags', + 'labels' => 'labels', + 'excluded_labels' => 'excluded_labels', + 'difficulty' => 'difficulty', + 'prep_time' => 'prep_time', + 'total_time' => 'total_time', + ]; + + foreach ($mapping as $filterKey => $sessionKey) { + if (array_key_exists($filterKey, $filters)) { + session()->put($prefix . $sessionKey, $filters[$filterKey]); + } + } + } +} diff --git a/app/Livewire/Web/Concerns/WithFilterShareTrait.php b/app/Livewire/Web/Concerns/WithFilterShareTrait.php new file mode 100644 index 00000000..3e912d64 --- /dev/null +++ b/app/Livewire/Web/Concerns/WithFilterShareTrait.php @@ -0,0 +1,247 @@ + $excludedAllergenIds + * @property array $ingredientIds + * @property string $ingredientMatchMode + * @property array $excludedIngredientIds + * @property array $tagIds + * @property array $excludedTagIds + * @property array $labelIds + * @property array $excludedLabelIds + * @property array $difficultyLevels + * @property array{0: int, 1: int}|null $prepTimeRange + * @property array{0: int, 1: int}|null $totalTimeRange + */ +trait WithFilterShareTrait +{ + public string $shareUrl = ''; + + /** + * Prepare the share URL (called when share button is clicked). + */ + public function prepareShareUrl(): void + { + $this->shareUrl = $this->activeFilterCount > 0 + ? $this->createFilterShare() + : $this->getDirectShareUrl(); + } + + /** + * Get the current page URL (no filters, no DB). + */ + public function getDirectShareUrl(): string + { + $params = $this->collectUrlParamsForShare(); + $url = localized_route($this->getFilterSharePage()->routeName()); + + return $params !== [] ? $url . '?' . http_build_query($params) : $url; + } + + /** + * Generate filter share URL (only called when filters are active). + */ + public function generateFilterShareUrl(): void + { + $this->shareUrl = $this->createFilterShare(); + } + + /** + * Create a filter share and return the URL. + */ + protected function createFilterShare(): string + { + $filters = $this->collectFiltersForShare(); + ksort($filters); + $page = $this->getFilterSharePage()->value; + + // Check for existing identical filter share (JSONB = comparison) + $filterShare = FilterShare::where('country_id', $this->countryId) + ->where('page', $page) + ->whereRaw('filters = ?::jsonb', [json_encode($filters, JSON_THROW_ON_ERROR)]) + ->first(); + + if ($filterShare === null) { + $filterShare = new FilterShare([ + 'page' => $page, + 'filters' => $filters, + ]); + $filterShare->country()->associate($this->country); + $filterShare->save(); + } + + $url = localized_route('localized.filter-share', ['id' => $filterShare->id]); + + $urlParams = $this->collectUrlParamsForShare(); + if ($urlParams !== []) { + $url .= '?' . http_build_query($urlParams); + } + + return $url; + } + + /** + * Collect URL parameters for sharing (search, page, sort). + * + * @return array + */ + protected function collectUrlParamsForShare(): array + { + $params = []; + + if ($this->search !== '') { + $params['search'] = $this->search; + } + + if ($this->getSharePage() > 1) { + $params['page'] = $this->getSharePage(); + } + + if ($this->sortBy !== '' && $this->sortBy !== $this->getDefaultSort()) { + $params['sort'] = $this->sortBy; + } + + return $params; + } + + /** + * Get the default sort value. + */ + protected function getDefaultSort(): string + { + return ''; + } + + /** + * Get the current page number for sharing (override in paginated components). + */ + protected function getSharePage(): int + { + return 1; + } + + /** + * Get the filter share page enum for this component. + */ + abstract protected function getFilterSharePage(): FilterSharePageEnum; + + /** + * Collect the current filters for sharing. + * + * @return array + */ + protected function collectFiltersForShare(): array + { + $filters = []; + + $this->collectBooleanFilters($filters); + $this->collectArrayFilters($filters); + $this->collectTimeRangeFilters($filters); + + return $filters; + } + + /** + * Collect boolean filters. + * + * @param array $filters + */ + protected function collectBooleanFilters(array &$filters): void + { + if ($this->filterHasPdf) { + $filters['has_pdf'] = true; + } + + if ($this->filterShowCanonical) { + $filters['show_canonical'] = true; + } + + $filters['ingredient_match'] = $this->ingredientMatchMode; + } + + /** + * Collect array filters. + * + * @param array $filters + */ + protected function collectArrayFilters(array &$filters): void + { + $arrayMappings = [ + 'excludedAllergenIds' => 'excluded_allergens', + 'ingredientIds' => 'ingredients', + 'excludedIngredientIds' => 'excluded_ingredients', + 'tagIds' => 'tags', + 'excludedTagIds' => 'excluded_tags', + 'labelIds' => 'labels', + 'excludedLabelIds' => 'excluded_labels', + 'difficultyLevels' => 'difficulty', + ]; + + foreach ($arrayMappings as $property => $filterKey) { + if ($this->{$property} !== []) { + $values = $this->{$property}; + sort($values); + $filters[$filterKey] = $values; + } + } + } + + /** + * Collect time range filters. + * + * @param array $filters + */ + protected function collectTimeRangeFilters(array &$filters): void + { + if ($this->isPrepTimeFilterActive()) { + $filters['prep_time'] = $this->prepTimeRange; + } + + if ($this->isTotalTimeFilterActive()) { + $filters['total_time'] = $this->totalTimeRange; + } + } + + /** + * Check if prep time filter is active (not default values). + */ + protected function isPrepTimeFilterActive(): bool + { + if ($this->prepTimeRange === null) { + return false; + } + + $default = $this->getDefaultPrepTimeRange(); + + return $default === null || $this->prepTimeRange !== $default; + } + + /** + * Check if total time filter is active (not default values). + */ + protected function isTotalTimeFilterActive(): bool + { + if ($this->totalTimeRange === null) { + return false; + } + + $default = $this->getDefaultTotalTimeRange(); + + return $default === null || $this->totalTimeRange !== $default; + } +} diff --git a/app/Livewire/Web/Recipes/Concerns/WithRecipeFilterOptionsTrait.php b/app/Livewire/Web/Recipes/Concerns/WithRecipeFilterOptionsTrait.php index 708e435a..3cddd450 100644 --- a/app/Livewire/Web/Recipes/Concerns/WithRecipeFilterOptionsTrait.php +++ b/app/Livewire/Web/Recipes/Concerns/WithRecipeFilterOptionsTrait.php @@ -65,74 +65,74 @@ public function labels(): Collection } /** - * Search ingredients for the current country. + * Get ingredient options (selected + search results). * * @return Collection */ #[Computed] - public function ingredientResults(): Collection + public function ingredientOptions(): Collection { + $selected = $this->ingredientIds !== [] + ? Ingredient::whereIn('id', $this->ingredientIds)->get() + : new Collection(); + if ($this->ingredientSearch === '') { - return new Collection(); + return $selected; } - return Ingredient::where('country_id', $this->countryId) + $results = Ingredient::where('country_id', $this->countryId) ->active() ->whereLike('name', sprintf('%%%s%%', $this->ingredientSearch)) ->whereNotIn('id', $this->ingredientIds) ->orderBy('name') ->limit(20) ->get(); - } - - /** - * Get selected ingredients. - * - * @return Collection - */ - #[Computed] - public function selectedIngredients(): Collection - { - if ($this->ingredientIds === []) { - return new Collection(); - } - return Ingredient::whereIn('id', $this->ingredientIds)->get(); + return $selected->concat($results); } /** - * Search ingredients to exclude for the current country. + * Get excluded ingredient options (selected + search results). * * @return Collection */ #[Computed] - public function excludedIngredientResults(): Collection + public function excludedIngredientOptions(): Collection { + $selected = $this->excludedIngredientIds !== [] + ? Ingredient::whereIn('id', $this->excludedIngredientIds)->get() + : new Collection(); + if ($this->excludedIngredientSearch === '') { - return new Collection(); + return $selected; } - return Ingredient::where('country_id', $this->countryId) + $results = Ingredient::where('country_id', $this->countryId) ->active() ->whereLike('name', sprintf('%%%s%%', $this->excludedIngredientSearch)) ->whereNotIn('id', $this->excludedIngredientIds) ->orderBy('name') ->limit(20) ->get(); + + return $selected->concat($results); } /** - * Get selected excluded ingredients. - * - * @return Collection + * Check if there are search results for ingredients (excluding selected). */ - #[Computed] - public function selectedExcludedIngredients(): Collection + public function hasIngredientSearchResults(): bool { - if ($this->excludedIngredientIds === []) { - return new Collection(); - } + return $this->ingredientSearch !== '' && + $this->ingredientOptions->count() > count($this->ingredientIds); + } - return Ingredient::whereIn('id', $this->excludedIngredientIds)->get(); + /** + * Check if there are search results for excluded ingredients (excluding selected). + */ + public function hasExcludedIngredientSearchResults(): bool + { + return $this->excludedIngredientSearch !== '' && + $this->excludedIngredientOptions->count() > count($this->excludedIngredientIds); } } diff --git a/app/Livewire/Web/Recipes/RecipeIndex.php b/app/Livewire/Web/Recipes/RecipeIndex.php index 4a9de263..4156da4f 100644 --- a/app/Livewire/Web/Recipes/RecipeIndex.php +++ b/app/Livewire/Web/Recipes/RecipeIndex.php @@ -2,10 +2,12 @@ namespace App\Livewire\Web\Recipes; +use App\Enums\FilterSharePageEnum; use App\Enums\IngredientMatchModeEnum; use App\Enums\RecipeSortEnum; use App\Enums\ViewModeEnum; use App\Livewire\AbstractComponent; +use App\Livewire\Web\Concerns\WithFilterShareTrait; use App\Livewire\Web\Concerns\WithLocalizedContextTrait; use App\Livewire\Web\Recipes\Concerns\WithRecipeFilterOptionsTrait; use App\Models\Menu; @@ -22,6 +24,7 @@ #[Layout('web::components.layouts.localized')] class RecipeIndex extends AbstractComponent { + use WithFilterShareTrait; use WithLocalizedContextTrait; use WithPagination; use WithRecipeFilterOptionsTrait; @@ -37,7 +40,8 @@ class RecipeIndex extends AbstractComponent public string $viewMode = ''; - public string $sortBy = ''; + #[Url(as: 'sort', except: 'hellofresh_created_at-desc')] + public string $sortBy = 'hellofresh_created_at-desc'; public bool $filterHasPdf = false; @@ -87,7 +91,6 @@ public function mount(?Menu $menu = null): void $this->menu = $this->resolveMenu($menu); $this->selectedMenuWeek = $this->menu?->year_week; $this->viewMode = session('view_mode', ViewModeEnum::Grid->value); - $this->sortBy = session($this->filterSessionKey('sort'), RecipeSortEnum::NewestFirst->value); $this->filterHasPdf = session($this->filterSessionKey('has_pdf'), false); $this->filterShowCanonical = session($this->filterSessionKey('show_canonical'), false); $this->excludedAllergenIds = session($this->filterSessionKey('excluded_allergens'), []); @@ -193,6 +196,16 @@ protected function filterSessionKey(string $key): string return sprintf('recipe_filter_%d_%s', $this->countryId, $key); } + /** + * Get the filter share page enum for this component. + */ + protected function getFilterSharePage(): FilterSharePageEnum + { + return $this->menu instanceof Menu + ? FilterSharePageEnum::Menus + : FilterSharePageEnum::Recipes; + } + /** * Get the current sort enum. */ @@ -201,6 +214,22 @@ protected function getSortEnum(): RecipeSortEnum return RecipeSortEnum::tryFrom($this->sortBy) ?? RecipeSortEnum::NewestFirst; } + /** + * Get the default sort value. + */ + protected function getDefaultSort(): string + { + return RecipeSortEnum::NewestFirst->value; + } + + /** + * Get the current page number for sharing. + */ + protected function getSharePage(): int + { + return $this->getPage(); + } + /** * Handle property updates and persist to session. */ @@ -250,7 +279,6 @@ public function updated(string $property): void protected function getSessionMapping(): array { return [ - 'sortBy' => 'sort', 'filterHasPdf' => 'has_pdf', 'filterShowCanonical' => 'show_canonical', 'excludedAllergenIds' => 'excluded_allergens', diff --git a/app/Livewire/Web/Recipes/RecipeRandom.php b/app/Livewire/Web/Recipes/RecipeRandom.php index 057e4075..66041015 100644 --- a/app/Livewire/Web/Recipes/RecipeRandom.php +++ b/app/Livewire/Web/Recipes/RecipeRandom.php @@ -4,6 +4,7 @@ namespace App\Livewire\Web\Recipes; +use App\Enums\FilterSharePageEnum; use App\Models\Recipe; use Illuminate\Contracts\View\View as ViewInterface; use Illuminate\Database\Eloquent\Builder; @@ -71,6 +72,15 @@ public function toggleTag(int $id): void unset($this->randomRecipes); } + /** + * Get the filter share page enum for this component. + */ + #[Override] + protected function getFilterSharePage(): FilterSharePageEnum + { + return FilterSharePageEnum::Random; + } + /** * Get the view / view contents that represent the component. */ diff --git a/app/Models/FilterShare.php b/app/Models/FilterShare.php new file mode 100644 index 00000000..0c5dde51 --- /dev/null +++ b/app/Models/FilterShare.php @@ -0,0 +1,57 @@ + $filters + */ +class FilterShare extends Model +{ + use HasUuids; + + public $timestamps = false; + + protected $fillable = [ + 'page', + 'filters', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'filters' => 'array', + 'created_at' => 'datetime', + ]; + } + + /** + * Boot the model. + */ + #[Override] + protected static function booted(): void + { + static::creating(function (FilterShare $filterShare): void { + $filterShare->created_at = now(); + }); + } + + /** + * Get the country that owns this filter share. + * + * @return BelongsTo + */ + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } +} diff --git a/database/migrations/2026_01_25_000010_create_filter_shares_table.php b/database/migrations/2026_01_25_000010_create_filter_shares_table.php new file mode 100644 index 00000000..5aed4ed1 --- /dev/null +++ b/database/migrations/2026_01_25_000010_create_filter_shares_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignId('country_id')->constrained()->cascadeOnDelete(); + $table->string('page', 20); + $table->jsonb('filters'); + $table->timestamp('created_at', 3); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('filter_shares'); + } +}; diff --git a/resources/views/flux/icon/share-2.blade.php b/resources/views/flux/icon/share-2.blade.php new file mode 100644 index 00000000..adc88948 --- /dev/null +++ b/resources/views/flux/icon/share-2.blade.php @@ -0,0 +1,47 @@ +@blaze + +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +if ($variant === 'solid') { + throw new \Exception('The "solid" variant is not supported in Lucide.'); +} + +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + + + diff --git a/resources/views/web/components/layouts/localized.blade.php b/resources/views/web/components/layouts/localized.blade.php index e2a57252..b2c23bb1 100644 --- a/resources/views/web/components/layouts/localized.blade.php +++ b/resources/views/web/components/layouts/localized.blade.php @@ -100,6 +100,7 @@ class="flex items-center gap-ui rounded-full bg-green-500 px-4 py-3 text-white s }); } }); + diff --git a/resources/views/web/livewire/recipes/recipe-index.blade.php b/resources/views/web/livewire/recipes/recipe-index.blade.php index e1157816..cc6b1d38 100644 --- a/resources/views/web/livewire/recipes/recipe-index.blade.php +++ b/resources/views/web/livewire/recipes/recipe-index.blade.php @@ -134,4 +134,41 @@ @endif + + @if ($this->activeFilterCount === 0) +
+ + + {{ __('Share') }} + + +
+ + +
+ {{ __('Share Link') }} + +
+
+ @else +
+ + + {{ __('Share') }} + + +
+ + +
+ {{ __('Share Link') }} +
+ +
+
+ +
+
+
+ @endif diff --git a/resources/views/web/livewire/recipes/recipe-random.blade.php b/resources/views/web/livewire/recipes/recipe-random.blade.php index a10f6b69..6a9d8dba 100644 --- a/resources/views/web/livewire/recipes/recipe-random.blade.php +++ b/resources/views/web/livewire/recipes/recipe-random.blade.php @@ -76,4 +76,41 @@ @endif @endif + + @if ($this->activeFilterCount === 0) +
+ + + {{ __('Share') }} + + +
+ + +
+ {{ __('Share Link') }} + +
+
+ @else +
+ + + {{ __('Share') }} + + +
+ + +
+ {{ __('Share Link') }} +
+ +
+
+ +
+
+
+ @endif diff --git a/resources/views/web/partials/recipes/filter-modal.blade.php b/resources/views/web/partials/recipes/filter-modal.blade.php index ba7f8ace..82a02995 100644 --- a/resources/views/web/partials/recipes/filter-modal.blade.php +++ b/resources/views/web/partials/recipes/filter-modal.blade.php @@ -75,19 +75,14 @@ wire:model.live="ingredientIds" variant="combobox" multiple + clearable :placeholder="__('Search ingredients...')" > - + - @foreach ($this->selectedIngredients as $ingredient) - - {{ $ingredient->name }} - - @endforeach - - @foreach ($this->ingredientResults as $ingredient) + @foreach ($this->ingredientOptions as $ingredient) {{ $ingredient->name }} @@ -95,7 +90,7 @@ - {{ __('No ingredients found.') }} + {{ $ingredientSearch !== '' ? __('No ingredients found.') : __('Type to search...') }} @@ -105,20 +100,15 @@ wire:model.live="excludedIngredientIds" variant="combobox" multiple + clearable :label="__('Without Ingredients')" :placeholder="__('Search ingredients...')" > - + - @foreach ($this->selectedExcludedIngredients as $ingredient) - - {{ $ingredient->name }} - - @endforeach - - @foreach ($this->excludedIngredientResults as $ingredient) + @foreach ($this->excludedIngredientOptions as $ingredient) {{ $ingredient->name }} @@ -126,7 +116,7 @@ - {{ __('No ingredients found.') }} + {{ __('Type to search...') }} @@ -136,6 +126,7 @@ wire:model.live="tagIds" multiple searchable + clearable :label="__('With Tags')" placeholder="{{ __('Select tags...') }}" > @@ -144,12 +135,19 @@ {{ $tag->name }} @endforeach + + + + {{ __('Type to search...') }} + + @@ -158,6 +156,12 @@ {{ $tag->name }} @endforeach + + + + {{ __('Type to search...') }} + + @endif @@ -166,6 +170,7 @@ wire:model.live="labelIds" multiple searchable + clearable :label="__('With Labels')" placeholder="{{ __('Select labels...') }}" > @@ -174,12 +179,19 @@ {{ $label->name }} @endforeach + + + + {{ __('Type to search...') }} + + @@ -188,6 +200,12 @@ {{ $label->name }} @endforeach + + + + {{ __('Type to search...') }} + + @endif @@ -196,6 +214,7 @@ wire:model.live="excludedAllergenIds" multiple searchable + clearable :label="__('Exclude Allergens')" placeholder="{{ __('Select allergens...') }}" > @@ -204,6 +223,12 @@ {{ $allergen->name }} @endforeach + + + + {{ __('Type to search...') }} + + @endif diff --git a/routes/web-localized.php b/routes/web-localized.php index 15136baf..424c9f97 100644 --- a/routes/web-localized.php +++ b/routes/web-localized.php @@ -1,6 +1,7 @@ where(['slug' => '.*', 'recipe' => '[0-9]+']) ->name('recipes.show'); +Route::get('s/{id}', [FilterShareController::class, '__invoke'])->name('filter-share'); + Route::get('shopping-list', ShoppingListIndex::class)->name('shopping-list.index'); Route::get('shopping-list/print', ShoppingListIndex::class)->name('shopping-list.print'); Route::get('shopping-list/bring', BringExportController::class)->name('shopping-list.bring'); diff --git a/tests/Feature/FilterShareTest.php b/tests/Feature/FilterShareTest.php new file mode 100644 index 00000000..8a363e17 --- /dev/null +++ b/tests/Feature/FilterShareTest.php @@ -0,0 +1,154 @@ +country = Country::factory()->create([ + 'code' => 'US', + 'locales' => ['en'], + 'prep_min' => 5, + 'prep_max' => 60, + 'total_min' => 15, + 'total_max' => 120, + ]); + + app()->bind('current.country', fn (): Country => $this->country); + app()->setLocale('en'); + } + + #[Test] + public function filter_share_can_be_created(): void + { + $filterShare = new FilterShare([ + 'page' => FilterSharePageEnum::Recipes->value, + 'filters' => ['has_pdf' => true, 'difficulty' => [1, 2]], + ]); + $filterShare->country()->associate($this->country); + $filterShare->save(); + + $this->assertNotNull($filterShare->id); + $this->assertEquals($this->country->id, $filterShare->country_id); + $this->assertEquals(FilterSharePageEnum::Recipes->value, $filterShare->page); + $this->assertEquals(['has_pdf' => true, 'difficulty' => [1, 2]], $filterShare->filters); + } + + #[Test] + public function filter_share_belongs_to_country(): void + { + $filterShare = new FilterShare([ + 'page' => FilterSharePageEnum::Recipes->value, + 'filters' => ['has_pdf' => true], + ]); + $filterShare->country()->associate($this->country); + $filterShare->save(); + + $this->assertTrue($filterShare->country->is($this->country)); + } + + #[Test] + public function filter_share_has_uuid_primary_key(): void + { + $filterShare = new FilterShare([ + 'page' => FilterSharePageEnum::Recipes->value, + 'filters' => ['has_pdf' => true], + ]); + $filterShare->country()->associate($this->country); + $filterShare->save(); + + $this->assertSame(36, strlen((string) $filterShare->id)); + $this->assertMatchesRegularExpression('/^[0-9a-f-]{36}$/', $filterShare->id); + } + + #[Test] + public function filter_share_has_created_at_timestamp(): void + { + $filterShare = new FilterShare([ + 'page' => FilterSharePageEnum::Recipes->value, + 'filters' => [], + ]); + $filterShare->country()->associate($this->country); + $filterShare->save(); + + $this->assertNotNull($filterShare->created_at); + } + + #[Test] + public function recipe_index_can_create_filter_share(): void + { + Livewire::test(RecipeIndex::class) + ->set('filterHasPdf', true) + ->set('difficultyLevels', [1, 2]) + ->call('generateFilterShareUrl') + ->assertSet('shareUrl', fn (string $url): bool => str_contains($url, '/s/')); + + $this->assertDatabaseHas('filter_shares', [ + 'country_id' => $this->country->id, + 'page' => FilterSharePageEnum::Recipes->value, + ]); + + $filterShare = FilterShare::latest('created_at')->first(); + $this->assertTrue($filterShare->filters['has_pdf']); + $this->assertEquals([1, 2], $filterShare->filters['difficulty']); + } + + #[Test] + public function recipe_random_creates_filter_share_with_random_page(): void + { + Livewire::test(RecipeRandom::class) + ->set('filterHasPdf', true) + ->call('generateFilterShareUrl') + ->assertSet('shareUrl', fn (string $url): bool => str_contains($url, '/s/')); + + $this->assertDatabaseHas('filter_shares', [ + 'country_id' => $this->country->id, + 'page' => FilterSharePageEnum::Random->value, + ]); + } + + #[Test] + public function filter_share_only_includes_active_filters(): void + { + Livewire::test(RecipeIndex::class) + ->set('filterHasPdf', true) + ->set('filterShowCanonical', false) + ->set('excludedAllergenIds', []) + ->call('generateFilterShareUrl'); + + $filterShare = FilterShare::latest('created_at')->first(); + + $this->assertTrue($filterShare->filters['has_pdf']); + $this->assertArrayNotHasKey('show_canonical', $filterShare->filters); + $this->assertArrayNotHasKey('excluded_allergens', $filterShare->filters); + } + + #[Test] + public function filter_share_page_enum_returns_correct_route_names(): void + { + $this->assertSame('localized.recipes.index', FilterSharePageEnum::Recipes->routeName()); + $this->assertSame('localized.recipes.random', FilterSharePageEnum::Random->routeName()); + $this->assertSame('localized.menus.index', FilterSharePageEnum::Menus->routeName()); + } +}