diff --git a/README.md b/README.md index 61a9606..1572b0a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,15 @@ resource: "@GallySyliusPlugin/Resources/config/shop_routing.yml" prefix: /{_locale} ``` + - The plugin uses `symfony/ux-live-component` (facet search/view-more, product & cart recommendations). + Check that the `ux_live_component` route is imported (it should already be there if you + `composer require`d `symfony/ux-live-component` directly at some point, via its Flex recipe; it is + not guaranteed if it only came in transitively through `sylius/sylius`). If `bin/console debug:router + ux_live_component` reports no match, create `config/routes/ux_live_component.yaml`: + ```yaml + _live_component: + resource: "@LiveComponentBundle/config/routes.php" + ``` - Implement the `Gally\SyliusPlugin\Model\GallyChannelInterface` and `Gally\SyliusPlugin\Model\GallyChannelTrait` in your Channel Entity `src/App/Entity/Channel/Channel.php`. ```php The `copyFiles()` call exposes `gally-sdk.global.js` as a standalone IIFE script > (available as `window.GallySDK`) for use in Twig templates via `{{ asset('build/app/shop/gally/gally-sdk.global.js') }}`. + - Register the plugin's Stimulus controllers in your app's root `assets/controllers.json`: + ```json + { + "controllers": { + "@gally/sylius-plugin": { + "range-slider": { + "main": "src/Resources/assets/shop/controllers/RangeSliderController.js", + "enabled": true, + "fetch": "eager" + }, + "search-autocomplete": { + "main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js", + "enabled": true, + "fetch": "eager" + }, + "filters-autosubmit": { + "main": "src/Resources/assets/shop/controllers/FiltersAutosubmitController.js", + "enabled": true, + "fetch": "eager" + } + } + } + } + ``` + > This step is required because `@gally/sylius-plugin` is installed as a local `path` repository + > rather than a real registered npm/Symfony UX package: Symfony Flex normally adds a package's + > Stimulus controllers to your app's `assets/controllers.json` automatically via that package's + > recipe when you run `composer require`, but local `path` packages have no such recipe. This + > entry must therefore mirror the `symfony.controllers` section of the plugin's own `package.json` + > and be kept in sync manually if a future version of the plugin adds, renames, or removes a + > controller. - Install JS dependencies and build assets: - **Without Docker** (from the Sylius root directory): ```shell diff --git a/package.json b/package.json index aaa7d6b..7152cf7 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,28 @@ "name": "@gally/sylius-plugin", "version": "2.3.0", "license": "OSL-3.0", + "symfony": { + "controllers": { + "range-slider": { + "main": "src/Resources/assets/shop/controllers/RangeSliderController.js", + "enabled": true, + "fetch": "eager" + }, + "search-autocomplete": { + "main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js", + "enabled": true, + "fetch": "eager" + }, + "filters-autosubmit": { + "main": "src/Resources/assets/shop/controllers/FiltersAutosubmitController.js", + "enabled": true, + "fetch": "eager" + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, "dependencies": { "@elastic-suite/gally-sdk": "2.2.2-alpha.0", "file-loader": "^6.2.0" diff --git a/src/Controller/Shop/FilterController.php b/src/Controller/Shop/FilterController.php deleted file mode 100644 index 19bf734..0000000 --- a/src/Controller/Shop/FilterController.php +++ /dev/null @@ -1,117 +0,0 @@ -, Gally Team - * @copyright 2022-present Smile - * @license Open Software License v. 3.0 (OSL-3.0) - */ - -declare(strict_types=1); - -namespace Gally\SyliusPlugin\Controller\Shop; - -use Gally\Sdk\Entity\Metadata; -use Gally\Sdk\Service\SearchManager; -use Gally\SyliusPlugin\Form\Type\Filter\GallyDynamicFilterType; -use Gally\SyliusPlugin\Grid\Filter\Type\SelectFilterType; -use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; -use Gally\SyliusPlugin\Search\FilterConverter; -use Sylius\Bundle\TaxonomyBundle\Doctrine\ORM\TaxonRepository; -use Sylius\Component\Channel\Context\ChannelContextInterface; -use Sylius\Component\Core\Model\ChannelInterface; -use Sylius\Component\Locale\Context\LocaleContextInterface; -use Sylius\Component\Taxonomy\Model\Taxon; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormFactoryInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; - -final class FilterController extends AbstractController -{ - /** - * @param TaxonRepository $taxonRepository - */ - public function __construct( - private CatalogProvider $catalogProvider, - private SearchManager $searchManager, - private ChannelContextInterface $channelContext, - private LocaleContextInterface $localeContext, - private TaxonRepository $taxonRepository, - private FormFactoryInterface $formFactory, - private FilterConverter $filterConverter, - ) { - } - - public function viewMore(Request $request, string $filterField): Response - { - /** @var string|null $search */ - $search = $request->get('search'); - /** @var array>|null $requestFilters */ - $requestFilters = $request->get('filters'); - $filters = $requestFilters['gally'] ?? []; - $gallyFilters = []; - foreach ($filters as $field => $value) { - $gallyFilter = $this->filterConverter->convert($field, $value); - if (null !== $gallyFilter) { - $gallyFilters[] = $gallyFilter; - } - } - - $choices = []; - /** @var ?Taxon $currentTaxon */ - $currentTaxon = null !== $request->get('taxon') ? $this->taxonRepository->find($request->get('taxon')) : null; - /** @var ChannelInterface $currentChannel */ - $currentChannel = $this->channelContext->getChannel(); - $currentLocaleCode = $this->localeContext->getLocaleCode(); - $currentLocale = $currentChannel->getDefaultLocale(); - if (null === $currentLocale) { - throw new \LogicException(sprintf('Missing default locale on channel %s', $currentChannel->getName())); - } - - foreach ($currentChannel->getLocales() as $locale) { - if ($currentLocaleCode === $locale->getCode()) { - $currentLocale = $locale; - break; - } - } - $currentLocalizedCatalog = $this->catalogProvider->buildLocalizedCatalog($currentChannel, $currentLocale); - $request = new \Gally\Sdk\GraphQl\Request( - $currentLocalizedCatalog, - new Metadata('product'), - false, - ['sku', 'source'], - 1, - 0, - $currentTaxon?->getCode(), - $search, - $gallyFilters, - ); - - $aggregationOptions = $this->searchManager->viewMoreProductFilterOption($request, $filterField); - - /** @var array$option */ - foreach ($aggregationOptions as $option) { - if (isset($option['label'])) { - $choices[$option['label']] = $option['value'] ?? ''; - } - } - - $options = [ - 'block_prefix' => 'sylius_gally_filter_checkbox', - 'choices' => $choices, - 'expanded' => true, - 'multiple' => true, - ]; - - $form = $this->formFactory->createNamed('criteria')->add('gally', GallyDynamicFilterType::class); - $form->get('gally')->add($filterField, SelectFilterType::class, $options); - $form->get('gally')->get($filterField)->setData($filters[$filterField] ?? null); - $html = $this->renderView('@GallySyliusPlugin/shop/grid/filter/gally_dynamic_filter.html.twig', ['form' => $form->createView()]); - - return $this->json(['html' => $html]); - } -} diff --git a/src/Form/Type/Filter/GallyDynamicFilterType.php b/src/Form/Type/Filter/GallyDynamicFilterType.php index 93f569e..5eeb923 100644 --- a/src/Form/Type/Filter/GallyDynamicFilterType.php +++ b/src/Form/Type/Filter/GallyDynamicFilterType.php @@ -16,6 +16,7 @@ use Gally\SyliusPlugin\Event\GridFilterUpdateEvent; use Gally\SyliusPlugin\Grid\Filter\Type\SelectFilterType; +use Gally\SyliusPlugin\Search\ActiveFilterResolver; use Gally\SyliusPlugin\Search\Aggregation\Aggregation; use Gally\SyliusPlugin\Search\Aggregation\AggregationOption; use Sylius\Bundle\GridBundle\Form\Type\Filter\BooleanFilterType; @@ -24,10 +25,10 @@ use Sylius\Component\Locale\Context\LocaleContextInterface; use Sylius\Component\Taxonomy\Model\TaxonInterface; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\RangeType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class GallyDynamicFilterType extends AbstractType { @@ -35,7 +36,6 @@ class GallyDynamicFilterType extends AbstractType * @param TaxonRepository $taxonRepository */ public function __construct( - private UrlGeneratorInterface $router, private RequestStack $requestStack, private TaxonRepository $taxonRepository, private LocaleContextInterface $localeContext, @@ -49,6 +49,19 @@ public function __construct( public function buildForm(FormBuilderInterface $builder, array $options): void { + $isTaxonPage = $this->isTaxonPage(); + $categoryFieldAdded = false; + $facetContext = $this->buildFacetContext(); + + if ($isTaxonPage && !$this->hasCategoryAggregation()) { + // Gally doesn't always return a "category" aggregation on taxon listing pages (e.g. a + // leaf taxon with no sub-taxons), so there's no signal telling us where it would + // normally sit in the facet order. Placed first, right below the active filters, in + // that case. + $this->addCategoryTaxonomyAnchor($builder); + $categoryFieldAdded = true; + } + foreach ($this->aggregations as $aggregation) { switch ($aggregation->getType()) { case 'slider': @@ -95,26 +108,50 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /* @var AggregationOption $option */ $choices[$option->getLabel()] = $option->getId(); } - $options = [ - 'block_prefix' => 'sylius_gally_filter_checkbox', - 'label' => $aggregation->getLabel(), - 'choices' => $choices, - 'expanded' => true, - 'multiple' => true, - ]; - if ($aggregation->hasMore()) { - $options['has_more_url'] = $this->buildHasMoreUrl($aggregation->getField()); - } $builder->add( $aggregation->getField(), SelectFilterType::class, - $options + [ + 'block_prefix' => 'sylius_gally_filter_checkbox', + 'label' => $aggregation->getLabel(), + 'choices' => $choices, + 'expanded' => true, + 'multiple' => true, + 'facet_search' => $facetContext['search'], + 'facet_filters' => $facetContext['filters'], + 'facet_taxon_code' => $facetContext['taxonCode'], + 'has_more' => $aggregation->hasMore(), + ] ); break; + case 'category': + // A taxon-scoped request is still a category browsing context even when + // Gally does return this aggregation (e.g. a parent taxon whose products + // span several sub-taxons): direct taxon navigation (native component) is + // used either way, never the Gally query-string filter, which would produce + // links "filtering" the current taxon page instead of going to the sub-taxon + // page directly. + if ($isTaxonPage) { + $this->addCategoryTaxonomyAnchor($builder); + } else { + $this->addCategoryField($builder, $aggregation); + } + $categoryFieldAdded = true; + break; default: break; } } + + // Gally excludes the category aggregation from the response as soon as a category filter is + // active, so once selected the field above never reappears. Without this, submitting any + // other filter would silently drop the category criteria. + if (!$categoryFieldAdded) { + $categoryId = $this->getCurrentCategoryId(); + if (null !== $categoryId) { + $builder->add(ActiveFilterResolver::CATEGORY_FIELD, HiddenType::class, ['data' => $categoryId]); + } + } } public function onFilterUpdate(GridFilterUpdateEvent $event): void @@ -122,7 +159,77 @@ public function onFilterUpdate(GridFilterUpdateEvent $event): void $this->aggregations = $event->getAggregations(); } - private function buildHasMoreUrl(string $field): string + private function isTaxonPage(): bool + { + $slug = $this->requestStack->getCurrentRequest()?->attributes->get('slug'); + + return \is_string($slug) && '' !== $slug; + } + + private function hasCategoryAggregation(): bool + { + foreach ($this->aggregations as $aggregation) { + if ('category' === $aggregation->getType()) { + return true; + } + } + + return false; + } + + /** + * On taxon listing pages Gally never returns a "category" aggregation at all (the taxon + * already scopes the search), so browsing there is handled by the native Sylius taxonomy + * component instead (proper direct links, plus a "go level up" link), inserted as a plain + * form field so it renders through the same form_widget(form) call and keeps its position + * in the facet order (see buildForm()). The block rendering it lives in checkbox.html.twig + * (block "sylius_gally_filter_category_taxonomy_row"). + */ + private function addCategoryTaxonomyAnchor(FormBuilderInterface $builder): void + { + $builder->add(ActiveFilterResolver::CATEGORY_FIELD, HiddenType::class, [ + 'block_prefix' => 'sylius_gally_filter_category_taxonomy', + ]); + } + + /** + * Rendered through the same SelectFilterType/autosubmit mechanism as the other facets (real + * radio inputs, native browser change event), just themed to look like plain links via CSS + * (see the "sylius_gally_filter_category" block and #searchbar .category-links rule in + * filters.css) since a category is single-select, not a combinable criterion. + */ + private function addCategoryField(FormBuilderInterface $builder, Aggregation $aggregation): void + { + $choices = []; + foreach ($aggregation->getOptions() as $option) { + /* @var AggregationOption $option */ + $choices[$option->getLabel()] = $option->getId(); + } + + $builder->add(ActiveFilterResolver::CATEGORY_FIELD, SelectFilterType::class, [ + 'block_prefix' => 'sylius_gally_filter_category', + 'label' => $aggregation->getLabel(), + 'choices' => $choices, + 'expanded' => true, + 'multiple' => false, + 'data' => $this->getCurrentCategoryId(), + ]); + } + + private function getCurrentCategoryId(): ?string + { + $queryParameters = $this->requestStack->getCurrentRequest()?->query->all() ?? []; + $criteria = \is_array($queryParameters['criteria'] ?? null) ? $queryParameters['criteria'] : []; + $gallyCriteria = \is_array($criteria['gally'] ?? null) ? $criteria['gally'] : []; + $categoryId = $gallyCriteria[ActiveFilterResolver::CATEGORY_FIELD] ?? null; + + return \is_string($categoryId) && '' !== $categoryId ? $categoryId : null; + } + + /** + * @return array{search: ?string, filters: array, taxonCode: ?string} + */ + private function buildFacetContext(): array { $request = $this->requestStack->getCurrentRequest(); /** @var array $queryParameters */ @@ -131,20 +238,18 @@ private function buildHasMoreUrl(string $field): string /** @var array> $criteria */ $criteria = $parameters->get('criteria', []); $query = $parameters->get('query', null); - $search = (\is_string($query) && '' !== $query) ? $query : ((isset($criteria['search'], $criteria['search']['value'])) ? $criteria['search']['value'] : ''); - unset($criteria['search']); + $search = (\is_string($query) && '' !== $query) ? $query : ((isset($criteria['search'], $criteria['search']['value']) && \is_string($criteria['search']['value'])) ? $criteria['search']['value'] : null); /** @var string $slug */ $slug = $request?->attributes->get('slug') ?? ''; $taxon = $this->taxonRepository->findOneBySlug($slug, $this->localeContext->getLocaleCode()); - return $this->router->generate( - 'gally_filter_view_more_ajax', - [ - 'filterField' => $field, - 'search' => $search, - 'filters' => $criteria, - 'taxon' => $taxon?->getId(), - ] - ); + /** @var array $filters */ + $filters = \is_array($criteria['gally'] ?? null) ? $criteria['gally'] : []; + + return [ + 'search' => $search, + 'filters' => $filters, + 'taxonCode' => $taxon?->getCode(), + ]; } } diff --git a/src/Grid/Filter/Type/SelectFilterType.php b/src/Grid/Filter/Type/SelectFilterType.php index d2b5890..3b387a9 100644 --- a/src/Grid/Filter/Type/SelectFilterType.php +++ b/src/Grid/Filter/Type/SelectFilterType.php @@ -24,8 +24,11 @@ final class SelectFilterType extends AbstractType { public function configureOptions(OptionsResolver $resolver): void { - $resolver->setDefined(['has_more_url']) - ->addAllowedTypes('has_more_url', 'string'); + $resolver->setDefined(['has_more', 'facet_search', 'facet_filters', 'facet_taxon_code']) + ->addAllowedTypes('has_more', 'bool') + ->addAllowedTypes('facet_search', ['string', 'null']) + ->addAllowedTypes('facet_filters', 'array') + ->addAllowedTypes('facet_taxon_code', ['string', 'null']); } public function getParent(): string @@ -36,6 +39,12 @@ public function getParent(): string public function buildView(FormView $view, FormInterface $form, array $options): void { // @phpstan-ignore offsetAccess.nonOffsetAccessible - $view->vars['has_more_url'] = \array_key_exists('has_more_url', $options) ? $options['has_more_url'] : null; + $view->vars['has_more'] = \array_key_exists('has_more', $options) ? $options['has_more'] : false; + // @phpstan-ignore offsetAccess.nonOffsetAccessible + $view->vars['facet_search'] = \array_key_exists('facet_search', $options) ? $options['facet_search'] : null; + // @phpstan-ignore offsetAccess.nonOffsetAccessible + $view->vars['facet_filters'] = \array_key_exists('facet_filters', $options) ? $options['facet_filters'] : []; + // @phpstan-ignore offsetAccess.nonOffsetAccessible + $view->vars['facet_taxon_code'] = \array_key_exists('facet_taxon_code', $options) ? $options['facet_taxon_code'] : null; } } diff --git a/src/Indexer/CategoryIndexer.php b/src/Indexer/CategoryIndexer.php index 57e7d43..7819121 100644 --- a/src/Indexer/CategoryIndexer.php +++ b/src/Indexer/CategoryIndexer.php @@ -119,7 +119,7 @@ public function getDocumentsToIndex( private function formatTaxon(TaxonInterface $taxon, TaxonTranslationInterface $translation, TaxonInterface $menuTaxon): array { - $parentId = ''; + $parentId = null; if (null !== $taxon->getParent() && $menuTaxon->getId() !== $taxon->getId()) { $parentId = str_replace('/', '_', (string) $taxon->getParent()->getCode()); } diff --git a/src/Indexer/ProductIndexer.php b/src/Indexer/ProductIndexer.php index 46a11b1..ce0fb27 100644 --- a/src/Indexer/ProductIndexer.php +++ b/src/Indexer/ProductIndexer.php @@ -242,12 +242,21 @@ private function formatCategories(ProductInterface $product): array $categories = []; foreach ($product->getTaxons() as $taxon) { - if ($taxon->isEnabled()) { - $categories[$taxon->getCode()] = [ - 'id' => str_replace('/', '_', (string) $taxon->getCode()), - 'category_uid' => str_replace('/', '_', (string) $taxon->getCode()), - 'name' => $taxon->getName(), - 'is_parent' => $taxon->hasChildren(), + // include the taxon itself as well as all its ancestors (e.g. the root menu taxon), + // otherwise a product only assigned to a leaf taxon never appears in its parent categories + $taxonAndAncestors = $taxon->getAncestors()->toArray(); + $taxonAndAncestors[] = $taxon; + + foreach ($taxonAndAncestors as $taxonOrAncestor) { + if (!$taxonOrAncestor->isEnabled()) { + continue; + } + + $categories[$taxonOrAncestor->getCode()] = [ + 'id' => str_replace('/', '_', (string) $taxonOrAncestor->getCode()), + 'category_uid' => str_replace('/', '_', (string) $taxonOrAncestor->getCode()), + 'name' => $taxonOrAncestor->getName(), + 'is_parent' => $taxonOrAncestor->hasChildren(), ]; } } diff --git a/src/Resources/assets/shop/controllers/FiltersAutosubmitController.js b/src/Resources/assets/shop/controllers/FiltersAutosubmitController.js new file mode 100644 index 0000000..426dc87 --- /dev/null +++ b/src/Resources/assets/shop/controllers/FiltersAutosubmitController.js @@ -0,0 +1,26 @@ +import { Controller } from '@hotwired/stimulus'; + +// Submits the filters form as soon as a facet field changes, instead of waiting for a submit click. +export default class extends Controller { + submit(event) { + // the facet-search text input isn't a real filter field: its own controller stops this + // "change" event from bubbling up, but keep this guard in case markup changes. + if (event.target.classList.contains('facet-search')) { + return; + } + + // don't submit facets that haven't actually been set (e.g. an untouched price range, + // or a boolean select left on its "All" placeholder) - disabled fields are left out + // of the submission entirely, and the page is about to unload anyway + this.element.querySelectorAll('input, select').forEach((field) => { + if (field.type === 'checkbox' || field.type === 'radio') { + return; + } + if (field.value === '') { + field.disabled = true; + } + }); + + this.element.closest('form').requestSubmit(); + } +} diff --git a/src/Resources/assets/shop/controllers/RangeSliderController.js b/src/Resources/assets/shop/controllers/RangeSliderController.js new file mode 100644 index 0000000..d20e551 --- /dev/null +++ b/src/Resources/assets/shop/controllers/RangeSliderController.js @@ -0,0 +1,79 @@ +import { Controller } from '@hotwired/stimulus'; +import noUiSlider from '../../../public/nouislider.min.js'; +import '../../../public/nouislider.min.css'; +import '../../../public/slider.css'; + +// debounce delay (ms) before the filter is actually applied once the user stops moving a handle +const FILTER_DEBOUNCE_DELAY = 500; + +export default class extends Controller { + static targets = ['slider', 'input']; + static values = { + min: Number, + max: Number, + value: String, + }; + + connect() { + const valuesForSlider = []; + for (let i = this.minValue; i <= this.maxValue; i++) { + valuesForSlider.push(i); + } + + let start = [valuesForSlider[0], valuesForSlider[valuesForSlider.length - 1]]; + if (this.hasValueValue) { + const parts = this.valueValue.split('|'); + if (parts.length === 2) { + start = parts; + } + } + + this.slider = noUiSlider.create(this.sliderTarget, { + start, + step: 1, + tooltips: true, + connect: true, + range: { + min: 0, + max: valuesForSlider.length - 1, + }, + format: { + to: (value) => valuesForSlider[Math.round(value)], + from: (value) => valuesForSlider.indexOf(Number(value)), + }, + }); + + // Only treat the slider as "touched" after a real pointer/keyboard interaction. noUiSlider's + // own events ('update', 'end', ...) can also fire from creation, resize or other passive + // re-renders, which would otherwise fill/submit the hidden input on its own. + this.userInteracted = false; + this.sliderTarget.addEventListener('pointerdown', () => { + this.userInteracted = true; + }); + this.sliderTarget.addEventListener('keydown', () => { + this.userInteracted = true; + }); + + this.debounceTimer = null; + this.slider.on('end', (values) => { + if (!this.userInteracted) { + return; + } + + this.inputTarget.value = `${values[0]}|${values[1]}`; + + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => { + this.inputTarget.dispatchEvent(new Event('change', { bubbles: true })); + }, FILTER_DEBOUNCE_DELAY); + }); + } + + disconnect() { + clearTimeout(this.debounceTimer); + if (this.slider) { + this.slider.destroy(); + this.slider = null; + } + } +} diff --git a/src/Resources/assets/shop/controllers/SearchAutocompleteController.js b/src/Resources/assets/shop/controllers/SearchAutocompleteController.js new file mode 100644 index 0000000..d22b58d --- /dev/null +++ b/src/Resources/assets/shop/controllers/SearchAutocompleteController.js @@ -0,0 +1,173 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Debounced, cached, abortable autocomplete search preview. One instance is connected per + * ".searchFormContainer" (desktop and mobile header variants each get their own). + */ +export default class extends Controller { + static targets = ['input', 'results', 'loading', 'resultsPanel']; + static values = { previewUrl: String }; + + connect() { + this.abortController = null; + this.debounceTimer = null; + this.queryCache = new Map(); + this.lastNonEmptyContent = null; + } + + disconnect() { + if (this.abortController) { + this.abortController.abort(); + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } + + onInput(event) { + const queryText = event.target.value; + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + if (queryText.length >= 3) { + // Keep previous results visible while waiting for debounce + this.resultsPanelTarget.classList.add('show'); + this.debounceTimer = setTimeout(() => this.performSearch(), 200); + + return; + } + + // Also cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + // New search session: reset last results so stale content won't reappear + this.lastNonEmptyContent = null; + this.resultsTarget.textContent = ''; + this.resultsPanelTarget.classList.remove('show'); + } + + onFocus(event) { + const queryText = event.target.value; + if (queryText.length < 3) { + return; + } + + if (this.resultsTarget.innerHTML.trim() !== '') { + this.resultsPanelTarget.classList.add('show'); + } else { + // Search silently: panel will only appear when results arrive via displayResults + this.performSearch({ showWhileLoading: false }); + } + } + + outsideClick(event) { + if ( + this.resultsPanelTarget.classList.contains('show') + && !this.resultsPanelTarget.contains(event.target) + && !this.inputTarget.contains(event.target) + ) { + this.resultsPanelTarget.classList.remove('show'); + } + } + + displayResults(content) { + this.loadingTarget.classList.add('d-none'); + this.resultsTarget.classList.remove('d-none'); + + // If response is empty but we have a previous non-empty result, keep showing it + const displayContent = content.htmlResults ? content : this.lastNonEmptyContent; + + if (!displayContent || !displayContent.htmlResults) { + this.resultsPanelTarget.classList.remove('show'); + + return; + } + + this.resultsPanelTarget.classList.add('show'); + this.resultsTarget.innerHTML = displayContent.htmlResults; + + if (this.resultsTarget.querySelector('.products')) { + this.resultsPanelTarget.parentElement.classList.add('start-0'); + this.resultsPanelTarget.parentElement.style.width = '100%'; + } else { + this.resultsPanelTarget.parentElement.classList.remove('start-0'); + this.resultsPanelTarget.parentElement.style.width = 'auto'; + } + } + + performSearch({ showWhileLoading = true } = {}) { + const form = this.element.querySelector('form'); + const formData = new FormData(form); + const plainFormData = Object.fromEntries(formData.entries()); + const formDataString = new URLSearchParams(plainFormData).toString(); + + // Serve from cache if available + if (this.queryCache.has(formDataString)) { + const cached = this.queryCache.get(formDataString); + this.resultsPanelTarget.classList.add('show'); + this.displayResults(cached); + if (cached.htmlResults) { + this.lastNonEmptyContent = cached; + } + + return; + } + + // While loading, show panel only if requested (not on focus) + if (showWhileLoading) { + if (this.lastNonEmptyContent) { + // Keep showing previous results (no spinner) + this.loadingTarget.classList.add('d-none'); + this.resultsTarget.classList.remove('d-none'); + } else { + // First search: show spinner + this.loadingTarget.classList.remove('d-none'); + this.resultsTarget.classList.add('d-none'); + } + this.resultsPanelTarget.classList.add('show'); + } + + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + fetch(this.previewUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formDataString, + signal: this.abortController.signal, + }) + .then((response) => response.json()) + .then((content) => { + // Track last non-empty result + if (content.htmlResults) { + this.lastNonEmptyContent = content; + } + + // Cache the result: if empty, store last non-empty result instead + const cachedContent = content.htmlResults ? content : this.lastNonEmptyContent; + + if (cachedContent) { + this.queryCache.set(formDataString, cachedContent); + this.displayResults(cachedContent); + } else { + // No result ever received yet, just hide the panel + this.loadingTarget.classList.add('d-none'); + this.resultsPanelTarget.classList.remove('show'); + } + }) + .catch((error) => { + if (error.name !== 'AbortError') { + console.error(error); + } + }); + } +} diff --git a/src/Resources/assets/shop/entrypoint.js b/src/Resources/assets/shop/entrypoint.js index 186d668..6b59964 100644 --- a/src/Resources/assets/shop/entrypoint.js +++ b/src/Resources/assets/shop/entrypoint.js @@ -1 +1,2 @@ -import '@elastic-suite/gally-sdk'; +import '@elastic-suite/gally-sdk/browser'; +import '../../public/filters.css'; diff --git a/src/Resources/config/app/twig_hooks_shop.yml b/src/Resources/config/app/twig_hooks_shop.yml index 3289a6f..9fabb68 100644 --- a/src/Resources/config/app/twig_hooks_shop.yml +++ b/src/Resources/config/app/twig_hooks_shop.yml @@ -61,6 +61,8 @@ sylius_twig_hooks: priority: 0 'sylius_shop.product.index.content.body.sidebar': + taxonomy: + enabled: false gally_filters: template: '@GallySyliusPlugin/shop/product/index/content/body/sidebar/filters.html.twig' priority: 0 diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 4a1576e..72496ed 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -8,6 +8,7 @@ + @@ -39,20 +40,6 @@ - - - - - - - - - - - - - - diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/src/Resources/config/services/search.xml b/src/Resources/config/services/search.xml index d037882..2ec0f5d 100644 --- a/src/Resources/config/services/search.xml +++ b/src/Resources/config/services/search.xml @@ -5,6 +5,15 @@ + + + + + + + + + @@ -41,7 +50,6 @@ - diff --git a/src/Resources/config/services/twig/component/filter.xml b/src/Resources/config/services/twig/component/filter.xml new file mode 100644 index 0000000..66eecaf --- /dev/null +++ b/src/Resources/config/services/twig/component/filter.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/services/twig/component/product.xml b/src/Resources/config/services/twig/component/product.xml index 456a606..7879663 100644 --- a/src/Resources/config/services/twig/component/product.xml +++ b/src/Resources/config/services/twig/component/product.xml @@ -17,5 +17,10 @@ + + + + + diff --git a/src/Resources/config/shop_routing.yml b/src/Resources/config/shop_routing.yml index 86dae6e..7cf8753 100644 --- a/src/Resources/config/shop_routing.yml +++ b/src/Resources/config/shop_routing.yml @@ -5,13 +5,6 @@ gally_tracking_graphql_proxy: defaults: _controller: Gally\SyliusPlugin\Controller\Shop\TrackingController::graphqlProxy -gally_filter_view_more_ajax: - path: /viewMore/{filterField} - methods: [ GET ] - defaults: - _controller: Gally\SyliusPlugin\Controller\Shop\FilterController::viewMore - _format: json - gally_search_form: path: /search methods: [ GET ] diff --git a/src/Resources/public/filters.css b/src/Resources/public/filters.css index 7891bac..6bfe7f9 100644 --- a/src/Resources/public/filters.css +++ b/src/Resources/public/filters.css @@ -1,6 +1,10 @@ #searchbar .view-more { color: rgba(0, 0, 0, 0.87); cursor: pointer; + appearance: none; + border: none; + background: none; + padding: 0; } #searchbar .col-form-label @@ -13,14 +17,72 @@ font-weight: bold; } +/* !important is required: Bootstrap's own .mb-3 utility class (also present on these + elements) sets margin-bottom with !important, so a plain override would be ignored */ #searchbar .field { - margin: 0 0 10px 0; + margin: 0 0 20px 0 !important; +} + +#searchbar .filters-title +{ + font-size: 1.25em; + font-weight: bold; + margin-bottom: 20px; +} + +#searchbar .categories, +#searchbar .active-filters +{ + margin: 0 0 20px 0 !important; } #searchbar .view-more { - font-size: 0.75em; + font-size: 0.875em; +} + +#searchbar .view-more:hover +{ + color: var(--bs-link-hover-color, #1b947b); +} + +#searchbar .facet-search-wrapper +{ + position: relative; + margin-bottom: 0.5em; +} + +#searchbar .facet-search +{ + padding-right: 1.75em; +} + +#searchbar .facet-search-clear +{ + position: absolute; + top: 50%; + right: 0.4em; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + color: var(--bs-secondary-color, rgba(0, 0, 0, 0.6)); + line-height: 0; +} + +#searchbar .facet-search-clear svg +{ + width: 0.9em; + height: 0.9em; +} + +#searchbar .facet-search-clear:hover +{ + color: var(--bs-body-color); } #searchbar .form-check-input.is-invalid @@ -33,4 +95,103 @@ color: var(--bs-body-color); } +/* Category is single-select and rendered as native radio inputs (so the existing autosubmit + still applies unchanged), but themed to look like a plain list of links: a category is a + navigation, not a combinable criterion like the other facets. */ +#searchbar .category-links .form-check +{ + padding-left: 0; +} + +#searchbar .category-links .form-check-input +{ + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +#searchbar .category-links .form-check-label +{ + display: inline-block; + padding: 0.25em 0; + font-weight: normal; + cursor: pointer; +} + +#searchbar .active-filters .active-filter-chip +{ + text-decoration: none; + font-weight: normal; + padding: 0.5em 0.85em; +} + +#searchbar .active-filters .active-filter-chip:hover +{ + background-color: var(--bs-secondary-bg-subtle, #e9ecef); +} + +#searchbar .active-filters .active-filter-chip-remove +{ + font-weight: bold; +} + +#searchbar .active-filters .active-filters-clear-all +{ + font-size: 0.8em; + color: var(--bs-secondary-color, rgba(0, 0, 0, 0.6)); + text-decoration: underline; +} + +#searchbar .active-filters .active-filters-clear-all:hover +{ + color: var(--bs-body-color); +} + +/* Loader shown on a single facet while its options are being fetched (view more / facet search), + scoped to that facet only so the rest of the sidebar stays interactive. */ +#searchbar .facet-options.is-loading +{ + pointer-events: none; +} + +#searchbar .facet-options.is-loading .ui +{ + opacity: 0.5; +} + +#searchbar .facet-options.is-loading .facet-search-clear +{ + display: none; +} + +#searchbar .facet-options.is-loading .facet-search-wrapper::after +{ + content: ''; + position: absolute; + top: 0; + bottom: 0; + right: 0.4em; + margin: auto 0; + width: 1em; + height: 1em; + border: 0.15em solid var(--bs-secondary-bg-subtle, #e9ecef); + border-top-color: var(--bs-primary, #3d4750); + border-radius: 50%; + animation: gally-facet-spin 0.6s linear infinite; +} + +@keyframes gally-facet-spin +{ + to + { + transform: rotate(360deg); + } +} + diff --git a/src/Resources/public/range-slider.js b/src/Resources/public/range-slider.js deleted file mode 100644 index 7288f17..0000000 --- a/src/Resources/public/range-slider.js +++ /dev/null @@ -1,46 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - var sliderElement = document.querySelector('.range-slider'); - if (sliderElement) { - var inputSelector = sliderElement.getAttribute('data-input-id'); - var hiddenInput = document.querySelector(inputSelector); - if (hiddenInput) { - var min = parseInt(sliderElement.getAttribute('data-min')); - var max = parseInt(sliderElement.getAttribute('data-max')); - var valuesForSlider = []; - for (var i = min; i <= max; i++) { - valuesForSlider.push(i); - } - - var start = [valuesForSlider[0], valuesForSlider[valuesForSlider.length - 1]]; - var value = sliderElement.getAttribute('data-value'); - if (value !== null) { - value = value.split("|"); - if (value.length === 2) { - start = value; - } - } - - var slider = noUiSlider.create(sliderElement, { - start: start, - step: 1, - tooltips: true, - connect: true, - range: { - 'min': 0, - 'max': valuesForSlider.length - 1, - }, - format: { - to: function (value) { - return valuesForSlider[Math.round(value)]; - }, - from: function (value) { - return valuesForSlider.indexOf(Number(value)); - } - } - }); - slider.on('end', function(values) { - hiddenInput.value = values[0] + "|" + values[1]; - }); - } - } -}); diff --git a/src/Resources/public/search.js b/src/Resources/public/search.js deleted file mode 100644 index b26e45e..0000000 --- a/src/Resources/public/search.js +++ /dev/null @@ -1,168 +0,0 @@ -const gallySearchFormHandler = function () { - const gallySearchFormContainers = document.querySelectorAll('.searchFormContainer'); - - gallySearchFormContainers.forEach(container => { - const gallyPreviewUrl = container.dataset.previewUrl; - const gallySearchForm = container.querySelector('form'); - const gallySearchInput = gallySearchForm.querySelector('input'); - const gallySearchResult = container.querySelector('.collapsedSearchResults'); - - let abortController = null; - let debounceTimer = null; - const queryCache = new Map(); - let lastNonEmptyContent = null; - - const displayResults = (content) => { - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.querySelector('.results').classList.remove('d-none'); - - // If response is empty but we have a previous non-empty result, keep showing it - const displayContent = content.htmlResults ? content : lastNonEmptyContent; - - if (!displayContent || !displayContent.htmlResults) { - gallySearchResult.classList.remove('show'); - return; - } - - gallySearchResult.classList.add('show'); - gallySearchResult.querySelector('.results').innerHTML = displayContent.htmlResults; - - if (gallySearchResult.querySelector('.results .products')) { - gallySearchResult.parentElement.classList.add('start-0'); - gallySearchResult.parentElement.style.width = '100%'; - } else { - gallySearchResult.parentElement.classList.remove('start-0'); - gallySearchResult.parentElement.style.width = 'auto'; - } - }; - - const performSearch = ({ showWhileLoading = true } = {}) => { - const formData = new FormData(gallySearchForm); - const plainFormData = Object.fromEntries(formData.entries()); - const formDataString = new URLSearchParams(plainFormData).toString(); - - // Serve from cache if available - if (queryCache.has(formDataString)) { - const cached = queryCache.get(formDataString); - gallySearchResult.classList.add('show'); - displayResults(cached); - if (cached.htmlResults) { - lastNonEmptyContent = cached; - } - return; - } - - // While loading, show panel only if requested (not on focus) - if (showWhileLoading) { - if (lastNonEmptyContent) { - // Keep showing previous results (no spinner) - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.querySelector('.results').classList.remove('d-none'); - } else { - // First search: show spinner - gallySearchResult.querySelector('.loading-results').classList.remove('d-none'); - gallySearchResult.querySelector('.results').classList.add('d-none'); - } - gallySearchResult.classList.add('show'); - } - - if (abortController) { - abortController.abort(); - } - - abortController = new AbortController(); - - (async () => { - try { - const rawResponse = await fetch(gallyPreviewUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: formDataString, - signal: abortController.signal - }); - - const content = await rawResponse.json(); - - // Track last non-empty result - if (content.htmlResults) { - lastNonEmptyContent = content; - } - - // Cache the result: if empty, store last non-empty result instead - const cachedContent = content.htmlResults ? content : lastNonEmptyContent; - - if (cachedContent) { - queryCache.set(formDataString, cachedContent); - displayResults(cachedContent); - } else { - // No result ever received yet, just hide the panel - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.classList.remove('show'); - } - - } catch (error) { - if (error.name !== 'AbortError') { - console.error(error); - } - } - })(); - }; - - gallySearchInput.addEventListener('input', (event) => { - console.log('Input events__6:'); - const queryText = event.target.value; - - // Always cancel the previous debounce timer - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - - if (queryText.length >= 3) { - // Keep previous results visible while waiting for debounce - gallySearchResult.classList.add('show'); - - debounceTimer = setTimeout(() => { - performSearch(); - }, 200); - } else { - // Also cancel any ongoing request - if (abortController) { - abortController.abort(); - abortController = null; - } - // New search session: reset last results so stale content won't reappear - lastNonEmptyContent = null; - gallySearchResult.querySelector('.results').textContent = ''; - gallySearchResult.classList.remove('show'); - } - }); - - gallySearchInput.addEventListener('focus', (event) => { - const queryText = event.target.value; - if (queryText.length >= 3) { - if (gallySearchResult.querySelector('.results').innerHTML.trim() !== '') { - gallySearchResult.classList.add('show'); - } else { - // Search silently: panel will only appear when results arrive via displayResults - performSearch({ showWhileLoading: false }); - } - } - }); - }); - - // Close when clicking outside the search results or the search input - document.addEventListener('mousedown', function (event) { - document.querySelectorAll('.collapsedSearchResults.show').forEach(result => { - const container = result.closest('.searchFormContainer'); - const input = container ? container.querySelector('form input') : null; - if (!result.contains(event.target) && !(input && input.contains(event.target))) { - result.classList.remove('show'); - } - }); - }); -}; - -window.addEventListener("DOMContentLoaded", gallySearchFormHandler); diff --git a/src/Resources/public/view-more.js b/src/Resources/public/view-more.js deleted file mode 100644 index 1e91e65..0000000 --- a/src/Resources/public/view-more.js +++ /dev/null @@ -1,35 +0,0 @@ -document.addEventListener('DOMContentLoaded', function () { - document.addEventListener('click', function (event) { - const viewMoreBtn = event.target.closest('#searchbarTextField .view-more'); - if (!viewMoreBtn) return; - - event.preventDefault(); - - const form = viewMoreBtn.closest('form'); - const dataFor = viewMoreBtn.dataset.for; - const dataHref = viewMoreBtn.dataset.href; - const choicesEl = document.querySelector(`#${dataFor}`); - - form.classList.add('loading'); - - fetch(dataHref) - .then(response => response.json()) - .then(data => { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = data.html; - const newFields = tempDiv.querySelector(`#${dataFor}`); - - if (newFields && choicesEl) { - choicesEl.replaceWith(newFields); - } - - viewMoreBtn.style.display = 'none'; - }) - .catch(error => { - console.error('Fetch error:', error); - }) - .finally(() => { - form.classList.remove('loading'); - }); - }); -}); diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml index cd3ebc6..b52bd39 100644 --- a/src/Resources/translations/messages.en.yaml +++ b/src/Resources/translations/messages.en.yaml @@ -19,6 +19,11 @@ gally_sylius: filters: headline: Filters view_more: View more + search_placeholder: Search + search_clear: Clear search + remove: Remove filter + clear_all: Clear all filters + categories: Categories sort: relevance: Relevance direction: diff --git a/src/Resources/views/shop/events_javascript.html.twig b/src/Resources/views/shop/events_javascript.html.twig index 534f016..54dd564 100644 --- a/src/Resources/views/shop/events_javascript.html.twig +++ b/src/Resources/views/shop/events_javascript.html.twig @@ -1,4 +1 @@ -{#todo : make inclusion of scripts compatible with Webpack Encore and with the new strcuture of sylius 2#} -{% for script in ['nouislider.min.js', 'range-slider.js', 'view-more.js', 'search.js'] %} - -{% endfor %} +{{ encore_entry_script_tags('gally-shop-entry', null, 'app.shop') }} diff --git a/src/Resources/views/shop/events_stylesheets.html.twig b/src/Resources/views/shop/events_stylesheets.html.twig index fcda0a4..330e9e9 100644 --- a/src/Resources/views/shop/events_stylesheets.html.twig +++ b/src/Resources/views/shop/events_stylesheets.html.twig @@ -1,4 +1 @@ -{#todo : make inclusion of scripts compatible with Webpack Encore and with the new strcuture of sylius 2#} -{% for css in ['nouislider.min.css', 'slider.css', 'filters.css'] %} - -{% endfor %} +{{ encore_entry_link_tags('gally-shop-entry', null, 'app.shop') }} diff --git a/src/Resources/views/shop/form/checkbox.html.twig b/src/Resources/views/shop/form/checkbox.html.twig index 146dc19..9e99d37 100644 --- a/src/Resources/views/shop/form/checkbox.html.twig +++ b/src/Resources/views/shop/form/checkbox.html.twig @@ -1,10 +1,34 @@ {% block sylius_gally_filter_checkbox_row -%}
{{- form_label(form) -}} - {% set attr = attr|merge({'class': attr.class|default ~ ' ui'}) %} - {{- form_widget(form, {'attr': attr}) -}} - {% if has_more_url %} - {{ 'gally_sylius.ui.filters.view_more'|trans }} - {% endif %} + {% set choices = [] %} + {% for choice in form.vars.choices %} + {# merge() renumbers integer-like keys (PHP array_merge behavior), so labels that + look like numbers (e.g. "12") must not be used as array keys: kept as a list of + {label, value} pairs instead. #} + {% set choices = choices|merge([{label: choice.label, value: choice.value}]) %} + {% endfor %} + {{ component('gally_shop:filter:facet_options', { + filterField: form.vars.name, + fieldName: form.vars.full_name, + baseId: form.vars.id, + initialChoices: choices, + selectedValues: form.vars.value, + hasMore: has_more, + search: facet_search, + filters: facet_filters, + taxonCode: facet_taxon_code, + }) }}
{%- endblock sylius_gally_filter_checkbox_row %} + +{% block sylius_gally_filter_category_taxonomy_row -%} + {{ component('sylius_shop:product:show:taxonomy', {template: '@GallySyliusPlugin/shop/product/_shared/categories.html.twig'}) }} +{%- endblock sylius_gally_filter_category_taxonomy_row %} + +{% block sylius_gally_filter_category_row -%} +
+ {{- form_label(form) -}} + {{- form_widget(form) -}} +
+{%- endblock sylius_gally_filter_category_row %} diff --git a/src/Resources/views/shop/form/range_widget.html.twig b/src/Resources/views/shop/form/range_widget.html.twig index a1ddf1f..48ce971 100644 --- a/src/Resources/views/shop/form/range_widget.html.twig +++ b/src/Resources/views/shop/form/range_widget.html.twig @@ -1,7 +1,12 @@ {%- block sylius_gally_filter_range_widget -%} -
- - {% set valueParts = value ? value|split('|') : [] %} -
+ {% set valueParts = value ? value|split('|') : [] %} +
+ +
{%- endblock sylius_gally_filter_range_widget -%} diff --git a/src/Resources/views/shop/grid/filter/gally_dynamic_filter.html.twig b/src/Resources/views/shop/grid/filter/gally_dynamic_filter.html.twig index de28819..98c9c23 100644 --- a/src/Resources/views/shop/grid/filter/gally_dynamic_filter.html.twig +++ b/src/Resources/views/shop/grid/filter/gally_dynamic_filter.html.twig @@ -4,5 +4,10 @@ '@GallySyliusPlugin/shop/form/range_widget.html.twig' ] %} -{% set label = 'gally_sylius.ui.filters.headline'|trans %} -{{ form_row(form, {'label': label }) }} +
+
{{ 'gally_sylius.ui.filters.headline'|trans }}
+ {{ component('gally_shop:product:active_filters', {template: '@GallySyliusPlugin/shop/product/_shared/active_filters.html.twig'}) }} + {{ form_widget(form) }} + {{ form_help(form) }} + {{ form_errors(form) }} +
diff --git a/src/Resources/views/shop/product/_shared/active_filters.html.twig b/src/Resources/views/shop/product/_shared/active_filters.html.twig new file mode 100644 index 0000000..7eb4204 --- /dev/null +++ b/src/Resources/views/shop/product/_shared/active_filters.html.twig @@ -0,0 +1,15 @@ +{% if active_filters|length > 0 %} +
+
+ {% for activeFilter in active_filters %} + + {{ activeFilter.label }} + × + + {% endfor %} +
+ {% if clear_all_url %} + {{ 'gally_sylius.ui.filters.clear_all'|trans }} + {% endif %} +
+{% endif %} diff --git a/src/Resources/views/shop/product/_shared/categories.html.twig b/src/Resources/views/shop/product/_shared/categories.html.twig new file mode 100644 index 0000000..706c3e6 --- /dev/null +++ b/src/Resources/views/shop/product/_shared/categories.html.twig @@ -0,0 +1,18 @@ +{% if taxon.enabledChildren|length > 0 or (taxon.parent is not empty and not taxon.parent.isRoot() and taxon.parent.enabled) %} +
+
{{ 'gally_sylius.ui.filters.categories'|trans }}
+
+ {% for child in taxon.enabledChildren %} + + {{ child.name }} + + {% endfor %} + + {% if taxon.parent is not empty and not taxon.parent.isRoot() and taxon.parent.enabled %} + + {{ 'sylius.ui.go_level_up'|trans }} + + {% endif %} +
+
+{% endif %} diff --git a/src/Resources/views/shop/product/_shared/facet_options.html.twig b/src/Resources/views/shop/product/_shared/facet_options.html.twig new file mode 100644 index 0000000..c7ebaab --- /dev/null +++ b/src/Resources/views/shop/product/_shared/facet_options.html.twig @@ -0,0 +1,29 @@ + + {% if hasMore %} +
+ + +
+ {% endif %} +
+ {% for choice in choices %} +
+ + +
+ {% endfor %} +
+ {% if hasMore and optionSearch is empty and not expanded %} + + {% endif %} +
diff --git a/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig b/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig index 25bf88a..560d335 100644 --- a/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig +++ b/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig @@ -1,4 +1,3 @@ -{% import '@SyliusShop/shared/buttons.html.twig' as buttons %} {% if is_gally_enabled() %} {% set products = hookable_metadata.context.products %}
@@ -11,7 +10,10 @@ {% if products.parameters.get('criteria').search is defined %} {% endif %} - diff --git a/src/Resources/views/shop/product/search/content/body/sidebar/filters.html.twig b/src/Resources/views/shop/product/search/content/body/sidebar/filters.html.twig index e899fb6..37f45e3 100644 --- a/src/Resources/views/shop/product/search/content/body/sidebar/filters.html.twig +++ b/src/Resources/views/shop/product/search/content/body/sidebar/filters.html.twig @@ -1,4 +1,3 @@ -{% import '@SyliusShop/shared/buttons.html.twig' as buttons %} {% if is_gally_enabled() %} {% set products = hookable_metadata.context.products %} {% set query = products.parameters.get('query')|default(products.parameters.get('criteria').search.value|default(false)) %} @@ -12,7 +11,10 @@ {% if query %} {% endif %} - diff --git a/src/Resources/views/shop/shared/components/header/search/autocomplete/results.html.twig b/src/Resources/views/shop/shared/components/header/search/autocomplete/results.html.twig index deb65c0..2ce9db1 100644 --- a/src/Resources/views/shop/shared/components/header/search/autocomplete/results.html.twig +++ b/src/Resources/views/shop/shared/components/header/search/autocomplete/results.html.twig @@ -61,8 +61,10 @@ {{ option_label }} {% endfor %} + {% endif %} -
+ {% if products|length %} +
{% endif %}
diff --git a/src/Resources/views/shop/shared/components/header/search/form.html.twig b/src/Resources/views/shop/shared/components/header/search/form.html.twig index 337524d..3ca7fc3 100644 --- a/src/Resources/views/shop/shared/components/header/search/form.html.twig +++ b/src/Resources/views/shop/shared/components/header/search/form.html.twig @@ -1,20 +1,27 @@ -
+
{{ form_start(searchForm) }}
- {{ form_widget(searchForm.query) }} + {{ form_widget(searchForm.query, {'attr': { + 'data-gally--sylius-plugin--search-autocomplete-target': 'input', + 'data-action': 'input->gally--sylius-plugin--search-autocomplete#onInput focus->gally--sylius-plugin--search-autocomplete#onFocus', + }}) }}
{{ form_end(searchForm) }}
-
-
+
+
Loading...
-
+
diff --git a/src/Search/ActiveFilterResolver.php b/src/Search/ActiveFilterResolver.php new file mode 100644 index 0000000..0469945 --- /dev/null +++ b/src/Search/ActiveFilterResolver.php @@ -0,0 +1,306 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Search; + +use Gally\SyliusPlugin\Event\GridFilterUpdateEvent; +use Gally\SyliusPlugin\Search\Aggregation\ActiveFilter; +use Gally\SyliusPlugin\Search\Aggregation\Aggregation; +use Sylius\Bundle\TaxonomyBundle\Doctrine\ORM\TaxonRepository; +use Sylius\Component\Locale\Context\LocaleContextInterface; +use Sylius\Component\Taxonomy\Model\TaxonInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Contracts\Service\ResetInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Resolves the facet filters currently active in the request query, to be displayed as removable chips. + */ +class ActiveFilterResolver implements ResetInterface +{ + // Gally excludes the category aggregation from the response as soon as a category filter is + // active (a product only ever matches one category), so its label can't come from $aggregations + // like the other facets and has to be resolved from the taxon directly. + public const CATEGORY_FIELD = 'category__id'; + + /** + * @var Aggregation[] + */ + private array $aggregations = []; + + /** + * @param TaxonRepository $taxonRepository + */ + public function __construct( + private RequestStack $requestStack, + private TranslatorInterface $translator, + private TaxonRepository $taxonRepository, + private LocaleContextInterface $localeContext, + ) { + } + + public function onFilterUpdate(GridFilterUpdateEvent $event): void + { + $this->aggregations = $event->getAggregations(); + } + + /** + * Guards against stale aggregations leaking across requests in long-running PHP + * processes (Messenger workers, RoadRunner/Swoole), where this service isn't + * re-instantiated per request like it is under classic PHP-FPM. + */ + public function reset(): void + { + $this->aggregations = []; + } + + /** + * @return ActiveFilter[] + */ + public function resolve(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $queryParameters = $request->query->all(); + $gallyCriteria = $this->extractGallyCriteria($queryParameters); + + $activeFilters = []; + foreach ($gallyCriteria as $field => $value) { + $field = (string) $field; + if (null === $value || '' === $value || [] === $value) { + continue; + } + + if (self::CATEGORY_FIELD === $field) { + if (\is_string($value)) { + $activeFilters[] = $this->resolveCategoryFilter($field, $value, $queryParameters); + } + continue; + } + + // Gally returns no aggregation at all once a combination of filters yields zero + // results, so this can legitimately be null; the resolve*Filter methods below fall + // back to a best-effort label in that case instead of dropping the chip entirely. + $aggregation = $this->findAggregation($field); + + if (str_contains($field, '_slider')) { + $activeFilters[] = $this->resolveSliderFilter($aggregation, $field, $value, $queryParameters); + continue; + } + + if (str_contains($field, '_boolean')) { + $activeFilters[] = $this->resolveBooleanFilter($aggregation, $field, $value, $queryParameters); + continue; + } + + if (\is_array($value)) { + foreach ($value as $optionValue) { + if (!\is_scalar($optionValue)) { + continue; + } + $activeFilters[] = $this->resolveCheckboxFilter($aggregation, $field, (string) $optionValue, $queryParameters); + } + continue; + } + + if (\is_scalar($value)) { + $activeFilters[] = $this->resolveCheckboxFilter($aggregation, $field, (string) $value, $queryParameters); + } + } + + return array_values(array_filter($activeFilters)); + } + + public function resolveClearAllUrl(): ?string + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return null; + } + + $queryParameters = $request->query->all(); + if ([] === $this->extractGallyCriteria($queryParameters)) { + return null; + } + + return $this->buildUrlWithGallyCriteria($queryParameters, []); + } + + /** + * @param array $queryParameters + */ + private function resolveSliderFilter(?Aggregation $aggregation, string $field, mixed $value, array $queryParameters): ?ActiveFilter + { + if (!\is_string($value)) { + return null; + } + + $parts = explode('|', $value, 2); + + return new ActiveFilter( + sprintf('%s: %s - %s', $this->resolveLabel($aggregation, $field), $parts[0], $parts[1] ?? ''), + $this->buildRemoveUrl($queryParameters, $field) + ); + } + + /** + * @param array $queryParameters + */ + private function resolveBooleanFilter(?Aggregation $aggregation, string $field, mixed $value, array $queryParameters): ActiveFilter + { + $label = 'true' === $value ? 'sylius.ui.yes_label' : 'sylius.ui.no_label'; + + return new ActiveFilter( + sprintf('%s: %s', $this->resolveLabel($aggregation, $field), $this->translator->trans($label)), + $this->buildRemoveUrl($queryParameters, $field) + ); + } + + /** + * @param array $queryParameters + */ + private function resolveCheckboxFilter(?Aggregation $aggregation, string $field, string $optionValue, array $queryParameters): ActiveFilter + { + $optionLabel = $optionValue; + foreach ($aggregation?->getOptions() ?? [] as $option) { + if ($option->getId() === $optionValue) { + $optionLabel = $option->getLabel(); + break; + } + } + + return new ActiveFilter( + sprintf('%s: %s', $this->resolveLabel($aggregation, $field), $optionLabel), + $this->buildRemoveUrl($queryParameters, $field, $optionValue) + ); + } + + /** + * Best-effort label when Gally didn't return the aggregation for this field (e.g. the + * current combination of filters yields zero results), so the real label isn't available. + */ + private function resolveLabel(?Aggregation $aggregation, string $field): string + { + if (null !== $aggregation) { + return $aggregation->getLabel(); + } + + $rawField = str_replace(['_slider', '_boolean'], '', $field); + + return ucfirst(str_replace('_', ' ', $rawField)); + } + + /** + * @param array $queryParameters + */ + private function resolveCategoryFilter(string $field, string $categoryId, array $queryParameters): ?ActiveFilter + { + /** @var TaxonInterface|null $taxon */ + $taxon = $this->taxonRepository->findOneBy(['code' => $categoryId]); + if (null === $taxon) { + return null; + } + + $translation = $taxon->getTranslation($this->localeContext->getLocaleCode()); + + return new ActiveFilter( + sprintf('%s: %s', $this->translator->trans('gally_sylius.ui.filters.categories'), $translation->getName()), + $this->buildRemoveUrl($queryParameters, $field) + ); + } + + private function findAggregation(string $field): ?Aggregation + { + $rawField = str_replace(['_slider', '_boolean'], '', $field); + foreach ($this->aggregations as $aggregation) { + if ($aggregation->getField() === $rawField) { + return $aggregation; + } + } + + return null; + } + + /** + * @param array $queryParameters + * + * @return array + */ + private function extractGallyCriteria(array $queryParameters): array + { + $criteria = $queryParameters['criteria'] ?? null; + if (!\is_array($criteria)) { + return []; + } + + $gallyCriteria = $criteria['gally'] ?? null; + + return \is_array($gallyCriteria) ? $gallyCriteria : []; + } + + /** + * @param array $queryParameters + */ + private function buildRemoveUrl(array $queryParameters, string $field, ?string $optionValue = null): string + { + $gallyCriteria = $this->extractGallyCriteria($queryParameters); + + if (null !== $optionValue && \is_array($gallyCriteria[$field] ?? null)) { + $remainingValues = []; + foreach ($gallyCriteria[$field] as $currentValue) { + if (\is_scalar($currentValue) && (string) $currentValue !== $optionValue) { + $remainingValues[] = $currentValue; + } + } + if ([] === $remainingValues) { + unset($gallyCriteria[$field]); + } else { + $gallyCriteria[$field] = $remainingValues; + } + } else { + unset($gallyCriteria[$field]); + } + + return $this->buildUrlWithGallyCriteria($queryParameters, $gallyCriteria); + } + + /** + * @param array $queryParameters + * @param array $gallyCriteria + */ + private function buildUrlWithGallyCriteria(array $queryParameters, array $gallyCriteria): string + { + $request = $this->requestStack->getCurrentRequest(); + + $criteria = \is_array($queryParameters['criteria'] ?? null) ? $queryParameters['criteria'] : []; + if ([] === $gallyCriteria) { + unset($criteria['gally']); + } else { + $criteria['gally'] = $gallyCriteria; + } + $queryParameters['criteria'] = $criteria; + // filtering changes the result set, the current page number may no longer be valid + unset($queryParameters['page']); + + $queryString = http_build_query($queryParameters); + // getBaseUrl() is required in addition to getPathInfo() so links stay correct when the + // app is served from a subdirectory instead of the domain root. + $path = null !== $request ? $request->getBaseUrl() . $request->getPathInfo() : ''; + + return $path . ('' !== $queryString ? '?' . $queryString : ''); + } +} diff --git a/src/Search/Aggregation/ActiveFilter.php b/src/Search/Aggregation/ActiveFilter.php new file mode 100644 index 0000000..f2da96b --- /dev/null +++ b/src/Search/Aggregation/ActiveFilter.php @@ -0,0 +1,37 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Search\Aggregation; + +/** + * A currently active facet filter, rendered as a dismissible chip. + */ +final class ActiveFilter +{ + public function __construct( + private string $label, + private string $removeUrl, + ) { + } + + public function getLabel(): string + { + return $this->label; + } + + public function getRemoveUrl(): string + { + return $this->removeUrl; + } +} diff --git a/src/Twig/Component/Filter/FacetOptionsComponent.php b/src/Twig/Component/Filter/FacetOptionsComponent.php new file mode 100644 index 0000000..fd169e5 --- /dev/null +++ b/src/Twig/Component/Filter/FacetOptionsComponent.php @@ -0,0 +1,163 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Twig\Component\Filter; + +use Gally\Sdk\Entity\Metadata; +use Gally\Sdk\GraphQl\Request as GallyRequest; +use Gally\Sdk\Service\SearchManager; +use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; +use Gally\SyliusPlugin\Search\FilterConverter; +use Sylius\Component\Channel\Context\ChannelContextInterface; +use Sylius\Component\Core\Model\ChannelInterface; +use Sylius\Component\Locale\Context\LocaleContextInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + +/** + * Renders one checkbox facet (in-facet search + "view more" + the choice list itself) and keeps + * it in sync with Gally without a page reload, replacing the former fetch-based Stimulus + * controller. Selecting a checkbox is untouched: it's a plain native input, still picked up by + * the FiltersAutosubmit controller bubbling from #searchbar. + */ +#[AsLiveComponent(name: 'gally_shop:filter:facet_options', template: '@GallySyliusPlugin/shop/product/_shared/facet_options.html.twig')] +class FacetOptionsComponent +{ + use DefaultActionTrait; + + #[LiveProp] + public string $filterField = ''; + + #[LiveProp] + public string $fieldName = ''; + + #[LiveProp] + public string $baseId = ''; + + /** @var list */ + #[LiveProp] + public array $initialChoices = []; + + /** @var string[] */ + #[LiveProp] + public array $selectedValues = []; + + #[LiveProp] + public bool $hasMore = false; + + #[LiveProp] + public ?string $taxonCode = null; + + /** @var array */ + #[LiveProp] + public array $filters = []; + + #[LiveProp] + public ?string $search = null; + + #[LiveProp(writable: true)] + public string $optionSearch = ''; + + #[LiveProp(writable: true)] + public bool $expanded = false; + + public function __construct( + private CatalogProvider $catalogProvider, + private SearchManager $searchManager, + private ChannelContextInterface $channelContext, + private LocaleContextInterface $localeContext, + private FilterConverter $filterConverter, + ) { + } + + #[LiveAction] + public function clearSearch(): void + { + $this->optionSearch = ''; + } + + #[LiveAction] + public function viewMore(): void + { + $this->expanded = true; + } + + /** + * @return list + */ + #[ExposeInTemplate('choices')] + public function getChoices(): array + { + if ('' === $this->optionSearch && !$this->expanded) { + return $this->initialChoices; + } + + $choices = []; + $aggregationOptions = $this->searchManager->viewMoreProductFilterOption( + $this->buildGallyRequest(), + $this->filterField, + '' !== $this->optionSearch ? $this->optionSearch : null, + ); + + /** @var array $option */ + foreach ($aggregationOptions as $option) { + if (isset($option['label'])) { + $choices[] = ['label' => $option['label'], 'value' => $option['value'] ?? '']; + } + } + + return $choices; + } + + private function buildGallyRequest(): GallyRequest + { + /** @var ChannelInterface $channel */ + $channel = $this->channelContext->getChannel(); + $currentLocaleCode = $this->localeContext->getLocaleCode(); + $currentLocale = $channel->getDefaultLocale(); + if (null === $currentLocale) { + throw new \LogicException(sprintf('Missing default locale on channel %s', $channel->getName())); + } + + foreach ($channel->getLocales() as $locale) { + if ($currentLocaleCode === $locale->getCode()) { + $currentLocale = $locale; + break; + } + } + + $gallyFilters = []; + foreach ($this->filters as $field => $value) { + $gallyFilter = $this->filterConverter->convert((string) $field, $value); + if (null !== $gallyFilter) { + $gallyFilters[] = $gallyFilter; + } + } + + return new GallyRequest( + $this->catalogProvider->buildLocalizedCatalog($channel, $currentLocale), + new Metadata('product'), + false, + ['sku', 'source'], + 1, + 0, + $this->taxonCode, + $this->search, + $gallyFilters, + ); + } +} diff --git a/src/Twig/Component/Product/ActiveFiltersComponent.php b/src/Twig/Component/Product/ActiveFiltersComponent.php new file mode 100644 index 0000000..73022f6 --- /dev/null +++ b/src/Twig/Component/Product/ActiveFiltersComponent.php @@ -0,0 +1,44 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Twig\Component\Product; + +use Gally\SyliusPlugin\Search\ActiveFilterResolver; +use Gally\SyliusPlugin\Search\Aggregation\ActiveFilter; +use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; + +#[AsTwigComponent] +class ActiveFiltersComponent +{ + public function __construct( + private ActiveFilterResolver $activeFilterResolver, + ) { + } + + /** + * @return ActiveFilter[] + */ + #[ExposeInTemplate('active_filters')] + public function activeFilters(): array + { + return $this->activeFilterResolver->resolve(); + } + + #[ExposeInTemplate('clear_all_url')] + public function clearAllUrl(): ?string + { + return $this->activeFilterResolver->resolveClearAllUrl(); + } +}