diff --git a/src/Controller/Filter.php b/src/Controller/Filter.php index 697553f..a2cf679 100644 --- a/src/Controller/Filter.php +++ b/src/Controller/Filter.php @@ -62,8 +62,8 @@ public function viewMore(Request $request, string $filterField): Response } $choices = []; - /** @var Taxon $currentTaxon */ - $currentTaxon = $this->taxonRepository->find($request->get('taxon')); + /** @var ?Taxon $currentTaxon */ + $currentTaxon = $request->get('taxon') ? $this->taxonRepository->find($request->get('taxon')) : null; /** @var ChannelInterface $currentChannel */ $currentChannel = $this->channelContext->getChannel(); $currentLocaleCode = $this->localeContext->getLocaleCode(); @@ -86,7 +86,7 @@ public function viewMore(Request $request, string $filterField): Response ['sku', 'source'], 1, 0, - $currentTaxon->getCode(), + $currentTaxon?->getCode(), $search, $gallyFilters, ); diff --git a/src/Controller/Shop/SearchController.php b/src/Controller/Shop/SearchController.php new file mode 100644 index 0000000..9d82932 --- /dev/null +++ b/src/Controller/Shop/SearchController.php @@ -0,0 +1,159 @@ +, 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\GraphQl\Response as GallyResponse; +use Gally\SyliusPlugin\Form\Type\SearchFormType; +use Gally\SyliusPlugin\Model\GallyChannelInterface; +use Gally\SyliusPlugin\Search\Finder; +use Sylius\Component\Channel\Context\ChannelContextInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; + +class SearchController extends AbstractController +{ + public function __construct( + private Finder $finder, + private ChannelContextInterface $channelContext, + ) { + } + + public function getForm(Request $renderRequest, RequestStack $requestStack): Response + { + /** @var string $query */ + $query = $requestStack->getMainRequest()?->get('query'); + if (empty($query)) { + /** @var array $query */ + $query = $requestStack->getMainRequest()?->get('criteria', []); + $query = $query['search']['value'] ?? ''; + } + + $searchForm = $this->createForm( + SearchFormType::class, + ['query' => $query], + ['action' => $this->generateUrl('gally_search_result_page'), 'method' => 'POST'] + ); + + return $this->render( + '@GallySyliusPlugin/shop/shared/components/header/search/form.html.twig', + [ + 'searchForm' => $searchForm->createView(), + 'mobileMode' => $renderRequest->get('mobile_mode'), + ] + ); + } + + public function getResults(Request $request): Response + { + $searchForm = $this->createForm(SearchFormType::class); + $searchForm->handleRequest($request); + + if ($searchForm->isSubmitted() && $searchForm->isValid()) { + return new RedirectResponse( + $this->generateUrl('gally_search_result_page', ['query' => $searchForm->get('query')->getData()]) + ); + } + + return new RedirectResponse('/'); + } + + public function getPreview(Request $request): Response + { + $searchForm = $this->createForm(SearchFormType::class); + $searchForm->handleRequest($request); + /** @var GallyChannelInterface $currentChannel */ + $currentChannel = $this->channelContext->getChannel(); + + if ($searchForm->isSubmitted() && $searchForm->isValid()) { + /** @var string $query */ + $query = $searchForm->get('query')->getData(); + + $products = $this->getProductAutocomplete($query, $currentChannel); + + return new JsonResponse([ + 'htmlResults' => $this->renderView( + '@GallySyliusPlugin/shop/shared/components/header/search/autocomplete/results.html.twig', + [ + 'products' => $products->getCollection(), + 'categories' => $this->getCategoryAutocomplete($query, $currentChannel), + 'attributes' => $this->getAttributeAutocomplete($products, $currentChannel), + 'query' => $query, $this->generateUrl('gally_search_result_page', ['query' => $query]), + ] + ), + ]); + } + + return new JsonResponse(['gallyError' => true]); + } + + private function getProductAutocomplete(string $query, GallyChannelInterface $channel): GallyResponse + { + return $this->finder->getAutocompleteResults( + $query, + $channel->getGallyAutocompleteProductMaxSize(), + 'product', + ['sku', 'name', 'slug', 'image'] + ); + } + + private function getCategoryAutocomplete(string $query, GallyChannelInterface $channel): array + { + $categories = $this->finder + ->getAutocompleteResults( + $query, + $channel->getGallyAutocompleteCategoryMaxSize(), + 'category', + ['id', 'name', 'path', 'slug'] + ) + ->getCollection(); + + foreach ($categories as &$category) { + $category['path'] = implode( + ' > ', + array_map( + fn (string $slug) => ucfirst($slug), + explode('/', $category['slug']) + ) + ); + } + + return $categories; + } + + private function getAttributeAutocomplete(GallyResponse $products, GallyChannelInterface $channel): array + { + $attributes = []; + $count = 0; + foreach ($products->getAggregations() as $aggregation) { + foreach ($aggregation['options'] as $option) { + $attributes[] = [ + 'field' => $aggregation['field'], + 'label' => $aggregation['label'], + 'option_label' => $option['label'], + ]; + ++$count; + if ($count >= $channel->getGallyAutocompleteAttributeMaxSize()) { + break 2; + } + } + } + + return $attributes; + } +} diff --git a/src/Form/Extension/ChannelTypeExtension.php b/src/Form/Extension/ChannelTypeExtension.php index 30f6566..7332177 100644 --- a/src/Form/Extension/ChannelTypeExtension.php +++ b/src/Form/Extension/ChannelTypeExtension.php @@ -34,6 +34,15 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('gallyCategoryIndexBatchSize', IntegerType::class, [ 'label' => 'gally_sylius.form.category_index_batch_size', + ]) + ->add('gallyAutocompleteProductMaxSize', IntegerType::class, [ + 'label' => 'gally_sylius.form.gally_autocomplete_product_max_size', + ]) + ->add('gallyAutocompleteCategoryMaxSize', IntegerType::class, [ + 'label' => 'gally_sylius.form.gally_autocomplete_category_max_size', + ]) + ->add('gallyAutocompleteAttributeMaxSize', IntegerType::class, [ + 'label' => 'gally_sylius.form.gally_autocomplete_attribute_max_size', ]); } diff --git a/src/Form/Type/Filter/GallyDynamicFilterType.php b/src/Form/Type/Filter/GallyDynamicFilterType.php index 0e6c203..76170a4 100644 --- a/src/Form/Type/Filter/GallyDynamicFilterType.php +++ b/src/Form/Type/Filter/GallyDynamicFilterType.php @@ -130,7 +130,8 @@ private function buildHasMoreUrl(string $field): string $parameters = new Parameters($queryParameters); /** @var array> $criteria */ $criteria = $parameters->get('criteria', []); - $search = (isset($criteria['search'], $criteria['search']['value'])) ? $criteria['search']['value'] : ''; + $query = $parameters->get('query', null); + $search = $query ?: ((isset($criteria['search'], $criteria['search']['value'])) ? $criteria['search']['value'] : ''); unset($criteria['search']); /** @var string $slug */ $slug = $request?->attributes->get('slug') ?? ''; diff --git a/src/Form/Type/SearchFormType.php b/src/Form/Type/SearchFormType.php new file mode 100644 index 0000000..ad46cfb --- /dev/null +++ b/src/Form/Type/SearchFormType.php @@ -0,0 +1,40 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Form\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; + +class SearchFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add( + 'query', + TextType::class, + [ + 'label' => false, + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'gally_sylius.form.header.query.placeholder', + 'autocomplete' => 'off', + 'aria-label' => 'form.header.query.label', + 'aria-describedby' => 'collapsedSearchResults', // This should be the ID of the button below + ], + ] + ); + } +} diff --git a/src/Grid/DataProvider.php b/src/Grid/DataProvider.php index dc5f4a0..30318e9 100644 --- a/src/Grid/DataProvider.php +++ b/src/Grid/DataProvider.php @@ -35,7 +35,7 @@ public function __construct( public function getData(Grid $grid, Parameters $parameters) { - if ('sylius_shop_product' === $grid->getCode()) { + if ('sylius_shop_product' === $grid->getCode() || 'gally_shop_product_search' === $grid->getCode()) { $channel = $this->channelContext->getChannel(); if (($channel instanceof GallyChannelInterface) && $channel->getGallyActive()) { $dataSource = $this->dataSourceProvider->getDataSource($grid, $parameters); diff --git a/src/Grid/Gally/Search/DataSource.php b/src/Grid/Gally/Search/DataSource.php new file mode 100644 index 0000000..49a12b1 --- /dev/null +++ b/src/Grid/Gally/Search/DataSource.php @@ -0,0 +1,72 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Grid\Gally\Search; + +use Doctrine\ORM\QueryBuilder; +use Gally\Sdk\Service\SearchManager; +use Gally\SyliusPlugin\Grid\Gally\ExpressionBuilder; +use Gally\SyliusPlugin\Grid\Gally\PagerfantaGally; +use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; +use Sylius\Component\Grid\Data\DataSourceInterface; +use Sylius\Component\Grid\Data\ExpressionBuilderInterface; +use Sylius\Component\Grid\Parameters; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +final class DataSource implements DataSourceInterface +{ + private ExpressionBuilderInterface $expressionBuilder; + private array $filters = []; + + public function __construct( + private QueryBuilder $queryBuilder, + private SearchManager $searchManager, + private CatalogProvider $catalogProvider, + private EventDispatcherInterface $eventDispatcher + ) { + $this->expressionBuilder = new ExpressionBuilder(); + } + + public function restrict($expression, string $condition = DataSourceInterface::CONDITION_AND): void + { + $this->filters[] = $expression; + } + + public function getExpressionBuilder(): ExpressionBuilderInterface + { + return $this->expressionBuilder; + } + + public function getData(Parameters $parameters) + { + /** @var int|string $page */ + $page = $parameters->get('page', 1); + $page = (int) $page; + $paginator = new PagerfantaGally( + new SearchAdapter( + $this->queryBuilder, + $this->searchManager, + $this->catalogProvider, + $this->eventDispatcher, + $parameters, + $this->filters + ) + ); + + $paginator->setNormalizeOutOfRangePages(true); + $paginator->setCurrentPage($page > 0 ? $page : 1); + + return $paginator; + } +} diff --git a/src/Grid/Gally/Search/SearchAdapter.php b/src/Grid/Gally/Search/SearchAdapter.php new file mode 100644 index 0000000..21d3a9b --- /dev/null +++ b/src/Grid/Gally/Search/SearchAdapter.php @@ -0,0 +1,131 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Grid\Gally\Search; + +use Doctrine\ORM\QueryBuilder; +use Gally\Sdk\Entity\Metadata; +use Gally\Sdk\GraphQl\Request; +use Gally\Sdk\Service\SearchManager; +use Gally\SyliusPlugin\Event\GridFilterUpdateEvent; +use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; +use Gally\SyliusPlugin\Search\Aggregation\AggregationBuilder; +use Gally\SyliusPlugin\Search\Result; +use Pagerfanta\Adapter\AdapterInterface; +use Sylius\Component\Core\Model\ProductInterface; +use Sylius\Component\Grid\Parameters; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @implements AdapterInterface + */ +class SearchAdapter implements AdapterInterface +{ + private ?Result $gallyResult = null; + + public function __construct( + private QueryBuilder $queryBuilder, + private SearchManager $searchManager, + private CatalogProvider $catalogProvider, + private EventDispatcherInterface $eventDispatcher, + private Parameters $parameters, + private array $filters + ) { + } + + public function getNbResults(): int + { + if (null === $this->gallyResult) { + return 1; + } + + return max($this->gallyResult->getTotalResultCount(), 0); + } + + public function getSlice(int $offset, int $length): iterable + { + /** @var array $criteria */ + $criteria = $this->parameters->get('criteria', []); + /** @var string $search */ + $search = $this->parameters->get('query', $criteria['search']['value'] ?? ''); + /** @var array $sorting */ + $sorting = $this->parameters->get('sorting', []); + /** @var string|int $page */ + $page = $this->parameters->get('page', 1); + $sortField = array_key_first($sorting); + $sortDirection = $sorting[$sortField] ?? null; + $page = (int) $page; + + $request = new Request( + $this->catalogProvider->getLocalizedCatalog(), + new Metadata('product'), + false, // @todo: parameterize + ['sku', 'source'], + $page, + $length, + null, + $search, + $this->filters, + (string) $sortField, + $sortDirection + ); + $response = $this->searchManager->search($request); + + $productNumbers = []; + /** @var array|string> $productRawData */ + foreach ($response->getCollection() as $productRawData) { + /** @var string $sku */ + $sku = $productRawData['sku']; + $productNumbers[$sku] = true; + } + + /** @var array>|string|bool>> $aggregationsData */ + $aggregationsData = $response->getAggregations(); + $this->gallyResult = new Result( + $productNumbers, + $response->getTotalCount(), + $offset, + $response->getItemsPerPage(), + $response->getSortField(), + $response->getSortDirection(), + AggregationBuilder::build($aggregationsData) + ); + + $this->eventDispatcher->dispatch(new GridFilterUpdateEvent($this->gallyResult), 'gally.grid.configure_filter'); + + $this->queryBuilder->andWhere('o.code IN (:code)'); + $this->queryBuilder->setParameter('code', array_keys($this->gallyResult->getProductNumbers())); + + /** @var array $products */ + $products = $this->queryBuilder->getQuery()->execute(); + + return $this->sortProductResults($this->gallyResult->getProductNumbers(), $products); + } + + /** + * @param ProductInterface[] $products + * + * @return array<(int|string), ProductInterface> + */ + private function sortProductResults(array $productNumbers, array $products): array + { + foreach ($products as $product) { + /* @var ProductInterface $product */ + $productNumbers[$product->getCode()] = $product; + } + + /** @var array<(int|string), ProductInterface> $productNumbers */ + return $productNumbers; + } +} diff --git a/src/Grid/Gally/Search/SearchDriver.php b/src/Grid/Gally/Search/SearchDriver.php new file mode 100644 index 0000000..b424ba7 --- /dev/null +++ b/src/Grid/Gally/Search/SearchDriver.php @@ -0,0 +1,62 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Grid\Gally\Search; + +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Gally\Sdk\Service\SearchManager; +use Gally\SyliusPlugin\Grid\Gally\Search\DataSource as SearchDataSource; +use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; +use Sylius\Component\Core\Repository\ProductRepositoryInterface; +use Sylius\Component\Grid\Data\DataSourceInterface; +use Sylius\Component\Grid\Data\DriverInterface; +use Sylius\Component\Grid\Parameters; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +final class SearchDriver implements DriverInterface +{ + public const NAME = 'gally/search'; + + public function __construct( + private SearchManager $searchManager, + private CatalogProvider $catalogProvider, + private EventDispatcherInterface $eventDispatcher, + private ManagerRegistry $managerRegistry + ) { + } + + public function getDataSource(array $configuration, Parameters $parameters): DataSourceInterface + { + if (!\array_key_exists('class', $configuration)) { + throw new \InvalidArgumentException('"class" must be configured.'); + } + + /** @var ObjectManager $manager */ + $manager = $this->managerRegistry->getManagerForClass($configuration['class']); + + /** @var ProductRepositoryInterface $repository */ + $repository = $manager->getRepository($configuration['class']); // @phpstan-ignore-line + + $method = $configuration['repository']['method']; + $arguments = isset($configuration['repository']['arguments']) ? array_values($configuration['repository']['arguments']) : []; + + return new SearchDataSource( + $repository->{$method}(...$arguments), + $this->searchManager, + $this->catalogProvider, + $this->eventDispatcher + ); + } +} diff --git a/src/Indexer/CategoryIndexer.php b/src/Indexer/CategoryIndexer.php index a86c9b0..6fb42dd 100644 --- a/src/Indexer/CategoryIndexer.php +++ b/src/Indexer/CategoryIndexer.php @@ -128,6 +128,7 @@ private function formatTaxon(TaxonInterface $taxon, TaxonTranslationInterface $t 'level' => $taxon->getLevel() + 1 - $menuTaxon->getLevel(), 'path' => $this->pathCache[$taxon->getCode()], 'name' => $translation->getName(), + 'slug' => $translation->getSlug(), ]; } } diff --git a/src/Indexer/ProductIndexer.php b/src/Indexer/ProductIndexer.php index 8564f13..6c0864d 100644 --- a/src/Indexer/ProductIndexer.php +++ b/src/Indexer/ProductIndexer.php @@ -100,6 +100,7 @@ private function formatProduct(ProductInterface $product, ChannelInterface $chan 'sku' => [$product->getCode()], 'name' => [$product->getTranslation($locale->getCode())->getName()], 'description' => [$product->getTranslation($locale->getCode())->getDescription()], + 'slug' => [$product->getTranslation($locale->getCode())->getSlug()], 'image' => ['' !== $this->formatMedia($product) ? $this->formatMedia($product) : null], 'price' => $this->formatPrice($variant, $channel), 'stock' => [ diff --git a/src/Indexer/Provider/CatalogProvider.php b/src/Indexer/Provider/CatalogProvider.php index 976e528..c6ce9f3 100644 --- a/src/Indexer/Provider/CatalogProvider.php +++ b/src/Indexer/Provider/CatalogProvider.php @@ -16,7 +16,9 @@ use Gally\Sdk\Entity\Catalog; use Gally\Sdk\Entity\LocalizedCatalog; +use Sylius\Component\Channel\Context\ChannelContextInterface; use Sylius\Component\Core\Model\ChannelInterface; +use Sylius\Component\Locale\Context\LocaleContextInterface; use Sylius\Component\Locale\Model\LocaleInterface; use Sylius\Component\Resource\Repository\RepositoryInterface; @@ -30,6 +32,8 @@ class CatalogProvider implements ProviderInterface public function __construct( private RepositoryInterface $channelRepository, + private ChannelContextInterface $channelContext, + private LocaleContextInterface $localeContext, ) { } @@ -67,4 +71,16 @@ public function buildLocalizedCatalog(ChannelInterface $channel, LocaleInterface (string) $channel->getBaseCurrency()?->getCode(), ); } + + public function getLocalizedCatalog(): LocalizedCatalog + { + /** @var ChannelInterface $channel */ + $channel = $this->channelContext->getChannel(); + /** @var LocaleInterface $locale */ + $locale = $channel->getLocales()->filter(function (LocaleInterface $locale) { + return $locale->getCode() === $this->localeContext->getLocaleCode(); + })->first(); + + return $this->buildLocalizedCatalog($channel, $locale); + } } diff --git a/src/Indexer/Provider/SourceFieldProvider.php b/src/Indexer/Provider/SourceFieldProvider.php index e3b6719..1ed67e6 100644 --- a/src/Indexer/Provider/SourceFieldProvider.php +++ b/src/Indexer/Provider/SourceFieldProvider.php @@ -33,7 +33,7 @@ class SourceFieldProvider implements ProviderInterface /** @var LocalizedCatalog[] */ private array $localizedCatalogs = []; - /** @var \Gally\Sdk\Entity\Metadata[] */ + /** @var Metadata[] */ private array $metadataCache = []; public function __construct( @@ -59,6 +59,22 @@ public function provide(): iterable foreach ($this->productOptionRepository->findAll() as $productOption) { yield $this->buildSourceField('product', $productOption, 'select'); } + + $staticSourceField = [ + 'product' => ['slug' => 'text'], + 'category' => ['slug' => 'text'], + ]; + + // Static source field + foreach ($staticSourceField as $entity => $fields) { + foreach ($fields as $code => $type) { + if (!\array_key_exists($entity, $this->metadataCache)) { + $this->metadataCache[$entity] = new Metadata($entity); + } + + yield new SourceField($this->metadataCache[$entity], $code, $this->getGallyType($type), $code, []); + } + } } public function buildSourceField( diff --git a/src/Migrations/Version20250725112500.php b/src/Migrations/Version20250725112500.php new file mode 100644 index 0000000..fae3657 --- /dev/null +++ b/src/Migrations/Version20250725112500.php @@ -0,0 +1,36 @@ +, Gally Team + * @copyright 2022-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\SyliusPlugin\Migrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250725112500 extends AbstractMigration +{ + public function getDescription(): string + { + return "Add autocomplete columns in 'sylius_channel' table."; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_channel ADD gally_autocomplete_product_max_size INT NOT NULL DEFAULT 6, ADD gally_autocomplete_category_max_size INT NOT NULL DEFAULT 6, ADD gally_autocomplete_attribute_max_size INT NOT NULL DEFAULT 6'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE sylius_channel DROP gally_autocomplete_product_max_size, DROP gally_autocomplete_category_max_size, DROP gally_autocomplete_attribute_max_size'); + } +} diff --git a/src/Model/GallyChannelInterface.php b/src/Model/GallyChannelInterface.php index ffcdd4f..ace37b2 100644 --- a/src/Model/GallyChannelInterface.php +++ b/src/Model/GallyChannelInterface.php @@ -29,4 +29,16 @@ public function setGallyProductIndexBatchSize(int $gallyProductIndexBatchSize): public function getGallyCategoryIndexBatchSize(): int; public function setGallyCategoryIndexBatchSize(int $gallyCategoryIndexBatchSize): void; + + public function getGallyAutocompleteProductMaxSize(): int; + + public function setGallyAutocompleteProductMaxSize(int $gallyAutocompleteProductMaxSize): void; + + public function getGallyAutocompleteCategoryMaxSize(): int; + + public function setGallyAutocompleteCategoryMaxSize(int $gallyAutocompleteCategoryMaxSize): void; + + public function getGallyAutocompleteAttributeMaxSize(): int; + + public function setGallyAutocompleteAttributeMaxSize(int $gallyAutocompleteAttributeMaxSize): void; } diff --git a/src/Model/GallyChannelTrait.php b/src/Model/GallyChannelTrait.php index e1dd1ee..e163ce6 100644 --- a/src/Model/GallyChannelTrait.php +++ b/src/Model/GallyChannelTrait.php @@ -28,6 +28,15 @@ trait GallyChannelTrait #[ORM\Column(name: 'gally_category_index_batch_size', type: 'integer')] protected $gallyCategoryIndexBatchSize = 50; + #[ORM\Column(name: 'gally_autocomplete_product_max_size', type: 'integer')] + protected $gallyAutocompleteProductMaxSize = 6; + + #[ORM\Column(name: 'gally_autocomplete_category_max_size', type: 'integer')] + protected $gallyAutocompleteCategoryMaxSize = 6; + + #[ORM\Column(name: 'gally_autocomplete_attribute_max_size', type: 'integer')] + protected $gallyAutocompleteAttributeMaxSize = 6; + public function getGallyActive(): bool { return $this->gallyActive; @@ -57,4 +66,34 @@ public function setGallyCategoryIndexBatchSize(int $gallyCategoryIndexBatchSize) { $this->gallyCategoryIndexBatchSize = $gallyCategoryIndexBatchSize; } + + public function getGallyAutocompleteProductMaxSize(): int + { + return $this->gallyAutocompleteProductMaxSize; + } + + public function setGallyAutocompleteProductMaxSize(int $gallyAutocompleteProductMaxSize): void + { + $this->gallyAutocompleteProductMaxSize = $gallyAutocompleteProductMaxSize; + } + + public function getGallyAutocompleteCategoryMaxSize(): int + { + return $this->gallyAutocompleteCategoryMaxSize; + } + + public function setGallyAutocompleteCategoryMaxSize(int $gallyAutocompleteCategoryMaxSize): void + { + $this->gallyAutocompleteCategoryMaxSize = $gallyAutocompleteCategoryMaxSize; + } + + public function getGallyAutocompleteAttributeMaxSize(): int + { + return $this->gallyAutocompleteAttributeMaxSize; + } + + public function setGallyAutocompleteAttributeMaxSize(int $gallyAutocompleteAttributeMaxSize): void + { + $this->gallyAutocompleteAttributeMaxSize = $gallyAutocompleteAttributeMaxSize; + } } diff --git a/src/Resources/config/app/filters.yml b/src/Resources/config/app/filters.yml new file mode 100644 index 0000000..8024566 --- /dev/null +++ b/src/Resources/config/app/filters.yml @@ -0,0 +1,7 @@ +liip_imagine: + filter_sets: + gally_shop_product_autocomplete_thumbnail: + format: webp + quality: 80 + filters: + thumbnail: { size: [80, 100], mode: outbound } diff --git a/src/Resources/config/app/grid.yml b/src/Resources/config/app/grid.yml index e17ed63..d77af33 100644 --- a/src/Resources/config/app/grid.yml +++ b/src/Resources/config/app/grid.yml @@ -24,6 +24,18 @@ sylius_grid: filters: gally: type: gally_dynamic_filter + gally_shop_product_search: + driver: + name: gally/search + options: + class: "%sylius.model.product.class%" + repository: + method: createListQueryBuilder + arguments: + locale: "expr:service('sylius.context.locale').getLocaleCode()" + filters: + gally: + type: gally_dynamic_filter templates: filter: gally_dynamic_filter: '@GallySyliusPlugin/shop/grid/filter/gally_dynamic_filter.html.twig' diff --git a/src/Resources/config/app/twig_hooks.yml b/src/Resources/config/app/twig_hooks.yml index 5023be0..13dbe0e 100644 --- a/src/Resources/config/app/twig_hooks.yml +++ b/src/Resources/config/app/twig_hooks.yml @@ -1,5 +1,48 @@ sylius_twig_hooks: hooks: + 'sylius_shop.base.header.content': + search_bar: + template: '@GallySyliusPlugin/shop/shared/components/header/search/search_bar.html.twig' + priority: 250 + + 'sylius_shop.base.header': + search_bar: + template: '@GallySyliusPlugin/shop/shared/components/header/search/search_bar_mobile.html.twig' + priority: 150 + + 'sylius_shop.product.search.content': + breadcrumbs: + template: '@GallySyliusPlugin/shop/product/search/content/breadcrumbs.html.twig' + component: ~ + props: ~ + priority: 120 + + 'sylius_shop.product.search.content.body.main': + header: + template: '@GallySyliusPlugin/shop/product/search/content/body/main/header.html.twig' + component: ~ + props: ~ + priority: 350 + + 'sylius_shop.product.index.content.body.main.filters': + filter_button: + template: '@GallySyliusPlugin/shop/product/search/content/body/main/filters/filter_button.html.twig' + priority: 100 + + 'sylius_shop.product.search.content.body.main.filters': + search: + template: '@GallySyliusPlugin/shop/product/search/content/body/main/filters/search.html.twig' + filter_button: + template: '@GallySyliusPlugin/shop/product/search/content/body/main/filters/filter_button.html.twig' + priority: 100 + + 'sylius_shop.product.search.content.body.sidebar': + taxonomy: + enabled: false + gally_filters: + template: '@GallySyliusPlugin/shop/product/search/content/body/sidebar/filters.html.twig' + + 'gally_admin.gally.index': sidebar: template: '@SyliusAdmin/shared/crud/common/sidebar.html.twig' diff --git a/src/Resources/config/config.yml b/src/Resources/config/config.yml index 3298834..e8e48ec 100644 --- a/src/Resources/config/config.yml +++ b/src/Resources/config/config.yml @@ -1,3 +1,4 @@ imports: - { resource: "app/twig_hooks.yml" } - { resource: "app/grid.yml" } + - { resource: "app/filters.yml" } diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 281ce1e..3e71489 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -51,6 +51,16 @@ + + + + + + + + + + diff --git a/src/Resources/config/services/indexers.xml b/src/Resources/config/services/indexers.xml index 0f2be8e..08eb6f6 100644 --- a/src/Resources/config/services/indexers.xml +++ b/src/Resources/config/services/indexers.xml @@ -5,6 +5,8 @@ + + diff --git a/src/Resources/config/services/search.xml b/src/Resources/config/services/search.xml index 6748aa5..6d5f250 100644 --- a/src/Resources/config/services/search.xml +++ b/src/Resources/config/services/search.xml @@ -5,6 +5,19 @@ + + + + + + + + + + + + + diff --git a/src/Resources/config/shop_routing.yml b/src/Resources/config/shop_routing.yml index b5a53e5..de85d7c 100644 --- a/src/Resources/config/shop_routing.yml +++ b/src/Resources/config/shop_routing.yml @@ -5,3 +5,34 @@ gally_filter_view_more_ajax: defaults: _controller: Gally\SyliusPlugin\Controller\Filter::viewMore _format: json + +gally_search_form: + path: /search + methods: [ GET ] + defaults: + _controller: Gally\SyliusPlugin\Controller\Shop\SearchController::getForm + +gally_search_post_result: + path: /search-results + methods: [ POST ] + defaults: + _controller: Gally\SyliusPlugin\Controller\Shop\SearchController::getResults + +gally_search_result_page: + path: /search-results + methods: [ GET ] + defaults: + _controller: sylius.controller.product::indexAction + _sylius: + template: "@GallySyliusPlugin/shop/product/search.html.twig" + grid: gally_shop_product_search + +gally_search_result_preview_ajax: + path: /search-preview + methods: [ POST ] + defaults: + _controller: Gally\SyliusPlugin\Controller\Shop\SearchController::getPreview + _format: json + _sylius: + template: "@SyliusShop/product/index.html.twig" + grid: gally_shop_product_search diff --git a/src/Resources/public/search.js b/src/Resources/public/search.js new file mode 100644 index 0000000..53a6f6b --- /dev/null +++ b/src/Resources/public/search.js @@ -0,0 +1,93 @@ +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; + + gallySearchInput.addEventListener('input', (event) => { + const queryText = event.target.value; + + if (queryText.length >= 3) { + const formData = new FormData(gallySearchForm); + const plainFormData = Object.fromEntries(formData.entries()); + const formDataString = new URLSearchParams(plainFormData).toString(); + + gallySearchResult.querySelector('.loading-results').classList.remove('d-none'); + gallySearchResult.querySelector('.results').classList.add('d-none'); + gallySearchResult.querySelector('.results').textContent = ''; + 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(); + + gallySearchResult.querySelector('.loading-results').classList.add('d-none'); + gallySearchResult.querySelector('.results').classList.remove('d-none'); + gallySearchResult.querySelector('.results').innerHTML = content.htmlResults; + + if (!content.htmlResults) { + gallySearchResult.classList.remove('show'); + } + + if (gallySearchResult.querySelector('.results .products')) { + console.log('has product'); + gallySearchResult.parentElement.classList.add('start-0'); + gallySearchResult.parentElement.style.width = '100%'; + } else { + console.log('no has product'); + gallySearchResult.parentElement.classList.remove('start-0'); + gallySearchResult.parentElement.style.width = 'auto'; + } + + } catch (error) { + if (error.name !== 'AbortError') { + console.error(error); + } + } + })(); + } else { + 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 { + gallySearchInput.dispatchEvent(new Event('input')); + } + } + }); + }); + + // Close when clicking outside + document.addEventListener('click', function (event) { + if (!event.target.closest('.collapsedSearchResults') && !event.target.closest('.searchFormContainer')) { + document.querySelectorAll('.collapsedSearchResults').forEach(element => element.classList.remove('show')); + } + }); +}; + +window.addEventListener("DOMContentLoaded", gallySearchFormHandler); diff --git a/src/Resources/translations/messages.en.yaml b/src/Resources/translations/messages.en.yaml index 321b479..b31ca22 100644 --- a/src/Resources/translations/messages.en.yaml +++ b/src/Resources/translations/messages.en.yaml @@ -19,8 +19,29 @@ gally_sylius: direction: asc: ascending desc: descending + + breadcrumb: + search_results: Search results + search_results: + title: Search results + query: Your query + form: active: Enable Gally product_index_batch_size: Product Indexing Batch Size category_index_batch_size: Category Indexing Batch Size checkSSL: Validate the SSL certificate of gally domain + gally_autocomplete_product_max_size: Autocomplete Product Max Size + gally_autocomplete_category_max_size: Autocomplete Category Max Size + gally_autocomplete_attribute_max_size: Autocomplete Attribute Max Size + + header: + query: + placeholder: Search for products + label: Search + + autocomplete: + products_title: Products + view_all_search_results: View all search results + categories_title: Categories + attributes_title: Attributes diff --git a/src/Resources/views/admin/channel/form/sections/gally.html.twig b/src/Resources/views/admin/channel/form/sections/gally.html.twig index 4f74b07..ca9340f 100644 --- a/src/Resources/views/admin/channel/form/sections/gally.html.twig +++ b/src/Resources/views/admin/channel/form/sections/gally.html.twig @@ -16,6 +16,15 @@
{{ form_row(hookable_metadata.context.form.gallyCategoryIndexBatchSize, sylius_test_form_attribute('gally-category-index-batch-size')) }}
+
+ {{ form_row(hookable_metadata.context.form.gallyAutocompleteProductMaxSize, sylius_test_form_attribute('gally-autocomplete-product-max-size')) }} +
+
+ {{ form_row(hookable_metadata.context.form.gallyAutocompleteCategoryMaxSize, sylius_test_form_attribute('gally-autocomplete-category-max-size')) }} +
+
+ {{ form_row(hookable_metadata.context.form.gallyAutocompleteAttributeMaxSize, sylius_test_form_attribute('gally-autocomplete-attribute-max-size')) }} +
diff --git a/src/Resources/views/shop/events_javascript.html.twig b/src/Resources/views/shop/events_javascript.html.twig index 988bbd9..534f016 100644 --- a/src/Resources/views/shop/events_javascript.html.twig +++ b/src/Resources/views/shop/events_javascript.html.twig @@ -1,4 +1,4 @@ {#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'] %} +{% for script in ['nouislider.min.js', 'range-slider.js', 'view-more.js', 'search.js'] %} {% endfor %} 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 e8753b1..25bf88a 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,8 +1,11 @@ {% import '@SyliusShop/shared/buttons.html.twig' as buttons %} {% if is_gally_enabled() %} {% set products = hookable_metadata.context.products %} -
-
+
+
+ +
+
{# Keep the current search in the query on filtering #} {% if products.parameters.get('criteria').search is defined %} diff --git a/src/Resources/views/shop/product/search.html.twig b/src/Resources/views/shop/product/search.html.twig new file mode 100644 index 0000000..9338e1b --- /dev/null +++ b/src/Resources/views/shop/product/search.html.twig @@ -0,0 +1,5 @@ +{% extends '@SyliusShop/shared/layout/base.html.twig' %} + +{% block content %} + {% hook ['sylius_shop.product.search', 'sylius_shop.product.index'] with { products, resources } %} +{% endblock %} diff --git a/src/Resources/views/shop/product/search/content/body/main/filters/filter_button.html.twig b/src/Resources/views/shop/product/search/content/body/main/filters/filter_button.html.twig new file mode 100644 index 0000000..1d2357f --- /dev/null +++ b/src/Resources/views/shop/product/search/content/body/main/filters/filter_button.html.twig @@ -0,0 +1,5 @@ +
+ +
diff --git a/src/Resources/views/shop/product/search/content/body/main/filters/search.html.twig b/src/Resources/views/shop/product/search/content/body/main/filters/search.html.twig new file mode 100644 index 0000000..fa826c6 --- /dev/null +++ b/src/Resources/views/shop/product/search/content/body/main/filters/search.html.twig @@ -0,0 +1,3 @@ +
+{# No search filters for now #} +
diff --git a/src/Resources/views/shop/product/search/content/body/main/header.html.twig b/src/Resources/views/shop/product/search/content/body/main/header.html.twig new file mode 100644 index 0000000..d2d0c4b --- /dev/null +++ b/src/Resources/views/shop/product/search/content/body/main/header.html.twig @@ -0,0 +1,4 @@ +
+

{{ 'gally_sylius.ui.search_results.title'|trans() }}

+

{{ 'gally_sylius.ui.search_results.query'|trans() }} : {{ app.request.get('query', app.request.get('criteria').search.value|default('')) }}

+
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 new file mode 100644 index 0000000..e899fb6 --- /dev/null +++ b/src/Resources/views/shop/product/search/content/body/sidebar/filters.html.twig @@ -0,0 +1,33 @@ +{% 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)) %} +
+
+ +
+
+ + {# Keep the current search in the query on filtering #} + {% if query %} + + {% endif %} + + +
+
+{% endif %} diff --git a/src/Resources/views/shop/product/search/content/breadcrumbs.html.twig b/src/Resources/views/shop/product/search/content/breadcrumbs.html.twig new file mode 100644 index 0000000..9ecfe44 --- /dev/null +++ b/src/Resources/views/shop/product/search/content/breadcrumbs.html.twig @@ -0,0 +1,8 @@ +{% from '@SyliusShop/shared/breadcrumbs.html.twig' import breadcrumbs as breadcrumbs %} + +{% set items = [ + { path: path('sylius_shop_homepage'), label: 'sylius.ui.home'|trans }, + { label: 'gally_sylius.ui.breadcrumb.search_results'|trans, active: true }, +] %} + +{{ breadcrumbs(items) }} \ No newline at end of file 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 new file mode 100644 index 0000000..51047f5 --- /dev/null +++ b/src/Resources/views/shop/shared/components/header/search/autocomplete/results.html.twig @@ -0,0 +1,80 @@ +{% import "@SyliusShop/shared/macro/money.html.twig" as money %} +{% if categories|length or attributes|length or products|length %} +
+ {% if categories|length or attributes|length %} + {% if products|length %} +
+ {% else %} +
+ {% endif %} + + {% if categories|length %} +
{{ 'gally_sylius.autocomplete.categories_title'|trans }}
+ {% for categorie in categories %} + {% set categorie_name = categorie['name'] %} + {% set categorie_path = categorie['path'] %} + {% set categorie_slug = categorie['slug'] %} + + + {{ categorie_name }}
+ {{ categorie_path }} +
+ {% endfor %} + {% endif %} + + {% if attributes|length %} + {% if categories|length %} +
+ {% endif %} +
{{ 'gally_sylius.autocomplete.attributes_title'|trans }}
+ {% for attribute in attributes %} + {% set attribute_label = attribute['label'] %} + {% set attribute_code = attribute['field'] %} + {% set option_label = attribute['option_label'] %} + {% set base_url = path('gally_search_result_page', {'_locale': app.request.locale}) %} + + {% set query_string = + 'criteria[search][value]=' ~ query|url_encode ~ + '&criteria[gally][' ~ attribute_code ~ '][]=' ~ option_label|url_encode + %} + + + {{ attribute_label }}
+ {{ option_label }} +
+ {% endfor %} + +
+ {% endif %} +
+ +
+ {% else %} +
+ {% endif %} + {% if products|length %} +
+
{{ 'gally_sylius.autocomplete.products_title'|trans() }}
+ {% for product in products %} + {% set product_name = product['name'] %} + {% set product_sku = product['sku'] %} + {% set product_slug = product['slug'] %} + {% set product_price = product['price'][0]['price'] %} + {% set image_path = product['image']|imagine_filter('gally_shop_product_autocomplete_thumbnail') %} + + + {{ 'sylius.ui.product'|trans }} {{ product_name }} {{ 'sylius.ui.image'|trans }} +

{{ product_name }}

+

{{ money.convertAndFormat(100 * product_price) }}

+
+ {% endfor %} + + {{ 'gally_sylius.autocomplete.view_all_search_results'|trans() }} + +
+ {% endif %} +
+
+{% 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 new file mode 100644 index 0000000..337524d --- /dev/null +++ b/src/Resources/views/shop/shared/components/header/search/form.html.twig @@ -0,0 +1,21 @@ +
+ {{ form_start(searchForm) }} +
+ {{ form_widget(searchForm.query) }} + +
+ {{ form_end(searchForm) }} +
+
+
+
+ Loading... +
+
+
+
+
+
+
diff --git a/src/Resources/views/shop/shared/components/header/search/search_bar.html.twig b/src/Resources/views/shop/shared/components/header/search/search_bar.html.twig new file mode 100644 index 0000000..9cdba24 --- /dev/null +++ b/src/Resources/views/shop/shared/components/header/search/search_bar.html.twig @@ -0,0 +1 @@ +{{ render(path('gally_search_form')) }} diff --git a/src/Resources/views/shop/shared/components/header/search/search_bar_mobile.html.twig b/src/Resources/views/shop/shared/components/header/search/search_bar_mobile.html.twig new file mode 100644 index 0000000..c2b27a9 --- /dev/null +++ b/src/Resources/views/shop/shared/components/header/search/search_bar_mobile.html.twig @@ -0,0 +1 @@ +{{ render(path('gally_search_form', {mobile_mode: true})) }} diff --git a/src/Search/Finder.php b/src/Search/Finder.php new file mode 100644 index 0000000..5730a15 --- /dev/null +++ b/src/Search/Finder.php @@ -0,0 +1,54 @@ +, 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\Sdk\Entity\Metadata; +use Gally\Sdk\GraphQl\Request; +use Gally\Sdk\GraphQl\Response; +use Gally\Sdk\Service\SearchManager; +use Gally\SyliusPlugin\Indexer\Provider\CatalogProvider; + +/** + * Perform search operations on Gally index and return array of Sylius products. + */ +class Finder +{ + public function __construct( + private SearchManager $searchManager, + private CatalogProvider $catalogProvider, + ) { + } + + public function getAutocompleteResults( + string $query, + int $resultLimit, + string $metadata, + array $fields, + ): Response { + $request = new Request( + $this->catalogProvider->getLocalizedCatalog(), + new Metadata($metadata), + true, + $fields, + 1, + $resultLimit, + null, + $query, + [] + ); + + return $this->searchManager->search($request); + } +} diff --git a/src/Twig/Component/Product/SortOptionComponent.php b/src/Twig/Component/Product/SortOptionComponent.php index 38b0152..75f6bb8 100644 --- a/src/Twig/Component/Product/SortOptionComponent.php +++ b/src/Twig/Component/Product/SortOptionComponent.php @@ -58,12 +58,16 @@ protected function getSortData(): array $criteria = $this->requestStack->getMainRequest()?->get('criteria', []); /** @var array> $criteria */ $criteria = $criteria ?? []; - $search = (isset($criteria['search'], $criteria['search']['value'])) ? $criteria['search']['value'] : ''; - + $search = $this->requestStack->getMainRequest()?->get('query') + ?? ((isset($criteria['search'], $criteria['search']['value'])) ? $criteria['search']['value'] : ''); $sortData = []; $sortData['current_sorting_label'] = ''; $sortData['sort_options'] = [ - ['field' => 'category__position', 'sorting' => null, 'label' => $this->translator->trans('sylius.ui.by_position')], + 'category__position' => [ + 'field' => 'category__position', + 'sorting' => null, + 'label' => $this->translator->trans('sylius.ui.by_position'), + ], ]; $channel = $this->channelContext->getChannel(); @@ -76,7 +80,7 @@ protected function getSortData(): array $label = \array_key_exists("{$option->getCode()}.$direction", $this->translationKeys) ? $this->translator->trans($this->translationKeys["{$option->getCode()}.$direction"]) : $option->getDefaultLabel() . ' ' . $this->translator->trans('gally_sylius.ui.sort.direction.' . $direction); - $sortData['sort_options'][] = [ + $sortData['sort_options'][$option->getCode() . $direction] = [ 'field' => $option->getCode(), 'sorting' => [$option->getCode() => $direction], 'label' => $label, @@ -89,12 +93,17 @@ protected function getSortData(): array } if ($search) { - $sortData['sort_options'][] = [ + $sortData['sort_options']['category__position'] = [ 'field' => 'category__position', 'sorting' => null, 'label' => $this->translator->trans('gally_sylius.ui.sort.relevance'), ]; } + + if (empty($sortData['current_sorting_label'])) { + // set first element by default + $sortData['current_sorting_label'] = reset($sortData['sort_options'])['label']; + } } else { // default Sylius sorting logic (copied from @SyliusShopBundle/Product/Index/_sorting.html.twig template) $sortData['sort_options'] = [ @@ -118,7 +127,10 @@ protected function getSortData(): array } } - $this->sortData = $sortData; + $this->sortData = [ + 'current_sorting_label' => $sortData['current_sorting_label'], + 'sort_options' => array_values($sortData['sort_options']), + ]; return $this->sortData; }