├── .prettierrc.json ├── LICENSE ├── README.md ├── assets ├── dist │ ├── controller.d.ts │ ├── controller.js │ ├── controllers │ │ ├── range-slider_controller.d.ts │ │ ├── range-slider_controller.js │ │ ├── refinement-list_controller.d.ts │ │ └── refinement-list_controller.js │ └── default.min.css └── package.json ├── compose.yaml ├── composer.json ├── config └── services.php ├── docs ├── components │ ├── ClearRefinements.md │ ├── CurrentRefinements.md │ ├── Facet │ │ ├── RangeInput.md │ │ ├── RangeSlider.md │ │ └── RefinementList.md │ ├── Hits.md │ ├── HitsPerPage.md │ ├── Layout.md │ ├── Pagination.md │ ├── SearchInput.md │ ├── SortBy.md │ └── TotalHits.md ├── create-own-adapter.md ├── image │ └── preview.png └── usage │ ├── algolia.md │ ├── customize-your-search.md │ ├── doctrine.md │ └── meilisearch.md ├── eslint.config.mjs ├── package-lock.json ├── phpunit.dist.xml ├── src ├── Adapter │ ├── AbstractAdapter.php │ ├── AdapterFactoryInterface.php │ ├── AdapterInterface.php │ ├── AdapterProvider.php │ ├── Algolia │ │ ├── AlgoliaAdapter.php │ │ ├── AlgoliaFactory.php │ │ └── QueryBuilder.php │ ├── Doctrine │ │ ├── DoctrineAdapter.php │ │ ├── DoctrineFactory.php │ │ └── QueryBuilderHelper.php │ └── Meilisearch │ │ ├── MeilisearchAdapter.php │ │ ├── MeilisearchFactory.php │ │ └── QueryBuilder.php ├── Attribute │ └── AsSearch.php ├── Context │ ├── Context.php │ └── ContextProvider.php ├── DependencyInjection │ ├── AdapterFactoryPass.php │ ├── RegisterSearchPass.php │ └── UrlFormaterPass.php ├── Event │ ├── PostSearchEvent.php │ └── PreSearchEvent.php ├── EventSubscriber │ └── ContextSubscriber.php ├── Exception │ ├── AdapterException.php │ ├── ContextException.php │ ├── DoctrineAdapterException.php │ ├── ResultSetException.php │ ├── SearchException.php │ └── UrlFormaterException.php ├── Maker │ └── MakeSearch.php ├── MezcalitoUxSearchBundle.php ├── Search │ ├── AbstractSearch.php │ ├── Facet.php │ ├── Filter │ │ ├── AbstractFilter.php │ │ ├── FilterInterface.php │ │ ├── RangeFilter.php │ │ └── TermFilter.php │ ├── Query.php │ ├── ResultSet │ │ ├── FacetStat.php │ │ ├── FacetTermDistribution.php │ │ ├── Hit.php │ │ └── ResultSet.php │ ├── SearchInterface.php │ ├── SearchProvider.php │ ├── Searcher.php │ ├── Sort.php │ └── Url │ │ ├── CurrentRequest.php │ │ ├── DefaultUrlFormater.php │ │ ├── UrlFormaterInterface.php │ │ └── UrlFormaterProvider.php └── Twig │ ├── Components │ ├── ClearRefinements.php │ ├── CurrentRefinements.php │ ├── Facet.php │ ├── Facet │ │ ├── AbstractFacet.php │ │ ├── RangeInput.php │ │ ├── RangeSlider.php │ │ └── RefinementList.php │ ├── Hits.php │ ├── HitsPerPage.php │ ├── Layout.php │ ├── Pagination.php │ ├── SearchInput.php │ ├── SortBy.php │ └── TotalHits.php │ └── UxSearchExtension.php ├── templates ├── ClearRefinements.html.twig ├── CurrentRefinements.html.twig ├── Facet.html.twig ├── Facet │ ├── RangeInput.html.twig │ ├── RangeSlider.html.twig │ └── RefinementList.html.twig ├── Hits.html.twig ├── HitsPerPage.html.twig ├── Layout.html.twig ├── Pagination.html.twig ├── SearchInput.html.twig ├── SortBy.html.twig ├── TotalHits.html.twig └── skeleton │ └── search.tpl.php └── translations ├── mezcalito_ux_search.en.php └── mezcalito_ux_search.fr.php /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "avoid", 6 | "printWidth": 120 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Mezcalito 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /assets/dist/controller.d.ts: -------------------------------------------------------------------------------- 1 | import { type ActionEvent, Controller } from '@hotwired/stimulus'; 2 | import { type Component } from '@symfony/ux-live-component'; 3 | import './styles/default.scss'; 4 | export default class extends Controller { 5 | component: Component; 6 | initialize(): Promise; 7 | connect(): void; 8 | private handleHistoryUpdate; 9 | updateFacetRange(event: SubmitEvent & ActionEvent): Promise; 10 | private getRangeValues; 11 | updateUrl(url: string): void; 12 | disconnect(): void; 13 | } 14 | -------------------------------------------------------------------------------- /assets/dist/controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { getComponent } from '@symfony/ux-live-component'; 3 | 4 | class controller extends Controller { 5 | async initialize() { 6 | this.component = await getComponent(this.element); 7 | } 8 | connect() { 9 | window.addEventListener('history:update', this.handleHistoryUpdate); 10 | } 11 | handleHistoryUpdate = (event) => { 12 | const customEvent = event; 13 | this.updateUrl(customEvent.detail.url); 14 | }; 15 | async updateFacetRange(event) { 16 | const { property, rangeMin, rangeMax } = event.params; 17 | const form = event.currentTarget; 18 | const { min, max } = this.getRangeValues(form, property, parseFloat(rangeMin), parseFloat(rangeMax)); 19 | await this.component.action('updateFacetRange', { property, min, max }); 20 | } 21 | getRangeValues(form, property, rangeMin, rangeMax) { 22 | const data = new FormData(form); 23 | const getValue = (suffix) => { 24 | const value = data.get(`${property}-${suffix}`); 25 | return value?.length ? Number(value) : null; 26 | }; 27 | const min = getValue('min'); 28 | const max = getValue('max'); 29 | return { 30 | min: typeof min === 'number' ? (min > rangeMin ? min : null) : null, 31 | max: typeof max === 'number' ? (max < rangeMax ? max : null) : null, 32 | }; 33 | } 34 | updateUrl(url) { 35 | history.replaceState(history.state, '', url); 36 | } 37 | disconnect() { 38 | window.removeEventListener('history:update', this.handleHistoryUpdate); 39 | } 40 | } 41 | 42 | export { controller as default }; 43 | -------------------------------------------------------------------------------- /assets/dist/controllers/range-slider_controller.d.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | export default class extends Controller { 3 | static values: { 4 | precision: { 5 | type: NumberConstructor; 6 | default: number; 7 | }; 8 | leading: { 9 | type: StringConstructor; 10 | default: string; 11 | }; 12 | trailing: { 13 | type: StringConstructor; 14 | default: string; 15 | }; 16 | isReady: { 17 | type: BooleanConstructor; 18 | default: boolean; 19 | }; 20 | }; 21 | precisionValue: number; 22 | leadingValue: string; 23 | trailingValue: string; 24 | isReadyValue: boolean; 25 | static targets: string[]; 26 | formTarget: HTMLFormElement; 27 | minInputTarget: HTMLInputElement; 28 | maxInputTarget: HTMLInputElement; 29 | hasMinValueTarget: boolean; 30 | minValueTarget: HTMLElement; 31 | hasMaxValueTarget: boolean; 32 | maxValueTarget: HTMLElement; 33 | formTargetConnected(): void; 34 | updateFloor: () => void; 35 | updateCeil: () => void; 36 | update(method?: 'floor' | 'ceil'): void; 37 | submit(): void; 38 | } 39 | -------------------------------------------------------------------------------- /assets/dist/controllers/range-slider_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | 3 | class default_1 extends Controller { 4 | static values = { 5 | precision: { 6 | type: Number, 7 | default: 3, 8 | }, 9 | leading: { 10 | type: String, 11 | default: '', 12 | }, 13 | trailing: { 14 | type: String, 15 | default: '', 16 | }, 17 | isReady: { 18 | type: Boolean, 19 | default: false, 20 | }, 21 | }; 22 | static targets = ['form', 'minInput', 'maxInput', 'minValue', 'maxValue']; 23 | formTargetConnected() { 24 | this.update(); 25 | this.isReadyValue = true; 26 | } 27 | updateFloor = () => this.update('floor'); 28 | updateCeil = () => this.update('ceil'); 29 | update(method = 'ceil') { 30 | const min = parseFloat(this.minInputTarget.min); 31 | const max = parseFloat(this.maxInputTarget.max); 32 | const step = parseFloat(this.minInputTarget.step); 33 | const minValue = parseFloat(this.minInputTarget.value); 34 | const maxValue = parseFloat(this.maxInputTarget.value); 35 | const midValue = (maxValue - minValue) / 2; 36 | const mid = minValue + Math[method](midValue / step) * step; 37 | const range = max - min; 38 | const thumbWidthVariable = getComputedStyle(this.minInputTarget).getPropertyValue('--ux-search-range-slider-thumb-width'); 39 | const thumbWidth = parseFloat(thumbWidthVariable); 40 | const thumbWidthUnit = thumbWidthVariable.replace(/^[\d.]+/, ''); 41 | const leftWidth = ((mid - min) / range) * 100; 42 | const rightWidth = ((max - mid) / range) * 100; 43 | this.minInputTarget.style.flexBasis = `calc(${leftWidth}% + ${thumbWidthVariable})`; 44 | this.maxInputTarget.style.flexBasis = `calc(${rightWidth}% + ${thumbWidthVariable})`; 45 | this.minInputTarget.max = mid.toFixed(this.precisionValue); 46 | this.maxInputTarget.min = mid.toFixed(this.precisionValue); 47 | const minFill = (minValue - min) / (mid - min) || 0; 48 | const maxFill = (maxValue - mid) / (max - mid) || 0; 49 | const minFillThumb = ((0.5 - minFill) * thumbWidth).toFixed(this.precisionValue); 50 | const maxFillThumb = ((0.5 - maxFill) * thumbWidth).toFixed(this.precisionValue); 51 | this.element.style.setProperty('--ux-search-range-slider-min-gradient-position', `calc(${(minFill * 100).toFixed(this.precisionValue)}% + ${minFillThumb}${thumbWidthUnit})`); 52 | this.element.style.setProperty('--ux-search-range-slider-max-gradient-position', `calc(${(maxFill * 100).toFixed(this.precisionValue)}% + ${maxFillThumb}${thumbWidthUnit})`); 53 | if (this.hasMinValueTarget) { 54 | this.minValueTarget.innerHTML = `${this.leadingValue}${this.minInputTarget.value}${this.trailingValue}`; 55 | } 56 | if (this.hasMaxValueTarget) { 57 | this.maxValueTarget.innerHTML = `${this.leadingValue}${this.maxInputTarget.value}${this.trailingValue}`; 58 | } 59 | } 60 | submit() { 61 | this.formTarget.requestSubmit(); 62 | } 63 | } 64 | 65 | export { default_1 as default }; 66 | -------------------------------------------------------------------------------- /assets/dist/controllers/refinement-list_controller.d.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | export default class extends Controller { 3 | static values: { 4 | isShowingMore: { 5 | type: BooleanConstructor; 6 | default: boolean; 7 | }; 8 | showMoreLabel: StringConstructor; 9 | showLessLabel: StringConstructor; 10 | }; 11 | isShowingMoreValue: boolean; 12 | showMoreLabelValue: string; 13 | showLessLabelValue: string; 14 | static targets: string[]; 15 | hasToggleTarget: boolean; 16 | toggleTarget: HTMLFormElement; 17 | mutationObserver: MutationObserver; 18 | initialize(): void; 19 | connect(): void; 20 | private handleMutation; 21 | isShowingMoreValueChanged(): void; 22 | toggleShowMore(): void; 23 | private updateToggleLabel; 24 | disconnect(): void; 25 | } 26 | -------------------------------------------------------------------------------- /assets/dist/controllers/refinement-list_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | 3 | class default_1 extends Controller { 4 | static values = { 5 | isShowingMore: { 6 | type: Boolean, 7 | default: false, 8 | }, 9 | showMoreLabel: String, 10 | showLessLabel: String, 11 | }; 12 | static targets = ['toggle']; 13 | mutationObserver; 14 | initialize() { 15 | this.mutationObserver = new MutationObserver(this.handleMutation); 16 | } 17 | connect() { 18 | this.mutationObserver.observe(this.element, { 19 | childList: true, 20 | }); 21 | } 22 | handleMutation = () => { 23 | this.updateToggleLabel(); 24 | }; 25 | isShowingMoreValueChanged() { 26 | this.updateToggleLabel(); 27 | } 28 | toggleShowMore() { 29 | this.isShowingMoreValue = !this.isShowingMoreValue; 30 | } 31 | updateToggleLabel() { 32 | if (!this.hasToggleTarget) 33 | return; 34 | this.toggleTarget.innerHTML = this.isShowingMoreValue ? this.showLessLabelValue : this.showMoreLabelValue; 35 | } 36 | disconnect() { 37 | this.mutationObserver.disconnect(); 38 | } 39 | } 40 | 41 | export { default_1 as default }; 42 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mezcalito/ux-search", 3 | "description": "Search interface for Symfony application", 4 | "main": "dist/controller.js", 5 | "types": "dist/controller.d.ts", 6 | "version": "1.0.0", 7 | "license": "MIT", 8 | "symfony": { 9 | "controllers": { 10 | "ux-search": { 11 | "main": "dist/controller.js", 12 | "name": "ux-search", 13 | "webpackMode": "eager", 14 | "fetch": "eager", 15 | "enabled": true, 16 | "autoimport": { 17 | "@mezcalito/ux-search/dist/default.min.css": true 18 | } 19 | }, 20 | "ux-search--refinement-list": { 21 | "main": "dist/controllers/refinement-list_controller.js", 22 | "name": "ux-search--refinement-list", 23 | "webpackMode": "eager", 24 | "fetch": "eager", 25 | "enabled": true 26 | }, 27 | "ux-search-range-slider": { 28 | "main": "dist/controllers/range-slider_controller.js", 29 | "name": "ux-search-range-slider", 30 | "webpackMode": "eager", 31 | "fetch": "eager", 32 | "enabled": true 33 | } 34 | }, 35 | "importmap": { 36 | "@hotwired/stimulus": "^3.2.0", 37 | "@mezcalito/ux-search": "path:%PACKAGE%/dist/controller.js" 38 | } 39 | }, 40 | "peerDependencies": { 41 | "@hotwired/stimulus": "^3.2.0" 42 | }, 43 | "devDependencies": { 44 | "@hotwired/stimulus": "^3.2.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | build: $PWD/.infrastructure/frankenphp 4 | restart: on-failure 5 | environment: 6 | SERVER_NAME: ${SERVER_NAME:-localhost, caddy:80} 7 | volumes: 8 | - ./:/srv/app 9 | ports: 10 | # HTTP 11 | - target: 80 12 | published: ${HTTP_PORT:-80} 13 | protocol: tcp 14 | # HTTPS 15 | - target: 443 16 | published: ${HTTPS_PORT:-443} 17 | protocol: tcp 18 | # HTTP/3 19 | - target: 443 20 | published: ${HTTP3_PORT:-443} 21 | protocol: udp 22 | 23 | node: 24 | build: $PWD/.infrastructure/node 25 | restart: on-failure 26 | volumes: 27 | - ./:/srv/app:rw,cached 28 | 29 | meilisearch: 30 | image: getmeili/meilisearch:latest 31 | container_name: uxsearch_meilisearch 32 | restart: on-failure 33 | volumes: 34 | - search_data:/meili_data 35 | environment: 36 | - MEILI_MASTER_KEY=secret 37 | - MEILI_NO_ANALYTICS=true 38 | ports: 39 | - "7700:7700" 40 | 41 | volumes: 42 | search_data: ~ 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mezcalito/ux-search", 3 | "type": "symfony-bundle", 4 | "description": "Effortless search and faceted search with Symfony UX and Mezcalito UX Search", 5 | "homepage": "https://github.com/Mezcalito/ux-search", 6 | "keywords": [ 7 | "symfony-ux", "listing", "search", "meilisearch", "faceted search" 8 | ], 9 | "license": "MIT", 10 | "require": { 11 | "php": ">=8.3", 12 | "nyholm/psr7": "^1.0", 13 | "phpdocumentor/reflection-docblock": "^5.4", 14 | "symfony/asset": "^6.4|^7.0", 15 | "symfony/config": "^6.4|^7.0", 16 | "symfony/dependency-injection": "^6.4|^7.0", 17 | "symfony/http-client": "^6.4|^7.0", 18 | "symfony/options-resolver": "^6.4|^7.0", 19 | "symfony/serializer": "^6.4|^7.0", 20 | "symfony/ux-live-component": "^2.20", 21 | "symfony/ux-twig-component": "^2.20", 22 | "twig/twig": "^3.8" 23 | }, 24 | "require-dev": { 25 | "algolia/search-bundle": "^6.0|^7.0", 26 | "doctrine/doctrine-bundle": "^2.13", 27 | "doctrine/orm": "^3.3", 28 | "friendsofphp/php-cs-fixer": "^3.64", 29 | "meilisearch/meilisearch-php": "^1.10", 30 | "phpstan/phpstan": "^1.12", 31 | "phpstan/phpstan-symfony": "^1.4", 32 | "phpunit/phpunit": "^11.4", 33 | "rector/rector": "^1.2", 34 | "symfony/asset-mapper": "^6.4|^7.0", 35 | "symfony/framework-bundle": "^6.4|^7.0", 36 | "symfony/maker-bundle": "^1.62", 37 | "symfony/runtime": "^6.4|^7.0", 38 | "symfony/translation": "^7.2", 39 | "symfony/web-profiler-bundle": "^6.4|^7.0" 40 | }, 41 | "suggest": { 42 | "algolia/search-bundle": "Needed to use Algolia adapter", 43 | "doctrine/doctrine-bundle": "Needed to use Orm adapter", 44 | "meilisearch/meilisearch-php": "Needed to use Meilisearch adapter" 45 | }, 46 | "config": { 47 | "sort-packages": true, 48 | "allow-plugins": { 49 | "symfony/runtime": true, 50 | "php-http/discovery": true 51 | } 52 | }, 53 | "autoload": { 54 | "psr-4": { 55 | "Mezcalito\\UxSearchBundle\\": "src/" 56 | } 57 | }, 58 | "autoload-dev": { 59 | "psr-4": { 60 | "Mezcalito\\UxSearchBundle\\Tests\\": "tests", 61 | "Mezcalito\\UxSearchBundle\\Tests\\TestApplication\\": "tests/TestApplication/src" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | services() 46 | ->set(DoctrineFactory::class) 47 | ->arg('$managerRegistry', service(ManagerRegistry::class)->nullOnInvalid()) 48 | ->tag('mezcalito_ux_search.adapter_factory') 49 | ->set(MeilisearchFactory::class) 50 | ->arg('$httpClient', service('psr18.http_client')) 51 | ->tag('mezcalito_ux_search.adapter_factory') 52 | ->set(AlgoliaFactory::class)->tag('mezcalito_ux_search.adapter_factory') 53 | ->set(Searcher::class) 54 | ->arg('$adapterProvider', service(AdapterProvider::class)) 55 | ->arg('$contextProvider', service(ContextProvider::class)) 56 | ->set(QueryBuilder::class) 57 | ->set(ContextProvider::class) 58 | ->set(AdapterProvider::class) 59 | ->arg('$defaultAdapterName', param('mezcalito_ux_search.default_adapter')) 60 | ->arg('$adapterConfiguration', param('mezcalito_ux_search.adapters')) 61 | ->set(SearchProvider::class) 62 | ->set(UrlFormaterProvider::class) 63 | ->set(Layout::class) 64 | ->arg('$searchConfigurationProvider', service(SearchProvider::class)) 65 | ->arg('$searcher', service(Searcher::class)) 66 | ->arg('$requestStack', service(RequestStack::class)) 67 | ->arg('$urlFormaterProvider', service(UrlFormaterProvider::class)) 68 | ->call('setLiveResponder', [service(LiveResponder::class)]) 69 | ->tag('twig.component', [ 70 | 'key' => 'Mezcalito:UxSearch:Layout', 71 | 'expose_public_props' => true, 72 | 'attributes_var' => 'attributes', 73 | 'live' => true, 74 | 'csrf' => true, 75 | 'route' => 'ux_live_component', 76 | 'method' => 'post', 77 | 'url_reference_type' => true, 78 | ]) 79 | ->tag('controller.service_arguments') 80 | ->public() 81 | ->set(Hits::class) 82 | ->arg('$contextProvider', service(ContextProvider::class)) 83 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:Hits']) 84 | ->set(TotalHits::class) 85 | ->arg('$contextProvider', service(ContextProvider::class)) 86 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:TotalHits']) 87 | ->set(SortBy::class) 88 | ->arg('$contextProvider', service(ContextProvider::class)) 89 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:SortBy']) 90 | ->set(HitsPerPage::class) 91 | ->arg('$contextProvider', service(ContextProvider::class)) 92 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:HitsPerPage']) 93 | ->set(Pagination::class) 94 | ->arg('$contextProvider', service(ContextProvider::class)) 95 | ->tag('twig.component', [ 96 | 'key' => 'Mezcalito:UxSearch:Pagination', 97 | 'expose_public_props' => true, 98 | ]) 99 | ->set(Facet::class) 100 | ->arg('$contextProvider', service(ContextProvider::class)) 101 | ->tag('twig.component', [ 102 | 'key' => 'Mezcalito:UxSearch:Facet', 103 | 'expose_public_props' => true, 104 | ]) 105 | ->set(Facet\RefinementList::class) 106 | ->arg('$contextProvider', service(ContextProvider::class)) 107 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:Facet:RefinementList']) 108 | ->set(Facet\RangeInput::class) 109 | ->arg('$contextProvider', service(ContextProvider::class)) 110 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:Facet:RangeInput']) 111 | ->set(Facet\RangeSlider::class) 112 | ->arg('$contextProvider', service(ContextProvider::class)) 113 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:Facet:RangeSlider']) 114 | ->set(CurrentRefinements::class) 115 | ->arg('$contextProvider', service(ContextProvider::class)) 116 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:CurrentRefinements']) 117 | ->set(ClearRefinements::class) 118 | ->arg('$contextProvider', service(ContextProvider::class)) 119 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:ClearRefinements']) 120 | ->set(SearchInput::class) 121 | ->tag('twig.component', ['key' => 'Mezcalito:UxSearch:SearchInput']) 122 | ->set(ContextSubscriber::class) 123 | ->arg('$contextProvider', service(ContextProvider::class)) 124 | ->set(UxSearchExtension::class)->tag('twig.extension') 125 | ->set(DefaultUrlFormater::class) 126 | ->arg('$urlGenerator', service(UrlGeneratorInterface::class)) 127 | ->tag('mezcalito_ux_search.url_formater') 128 | ->set('maker.maker.make_search', MakeSearch::class) 129 | ->tag('maker.command') 130 | ; 131 | }; 132 | -------------------------------------------------------------------------------- /docs/components/ClearRefinements.md: -------------------------------------------------------------------------------- 1 | # ClearRefinements 2 | 3 | The `ClearRefinements` component displays a button that lets users clean every refinement applied to the search. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |---------|-------------| 9 | | content | - | 10 | 11 | 12 | ## Default layout 13 | 14 | ```twig 15 | {% block content %} 16 | {%- if activeFilters is defined and activeFilters|length > 0 %} 17 | 26 | {% endif %} 27 | {% endblock -%} 28 | ``` 29 | 30 | ## Default HTML output 31 | ```html 32 | 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/components/Facet/RangeInput.md: -------------------------------------------------------------------------------- 1 | # RangeInput 2 | 3 | The `RangeInput` component displays allows a user to select a numeric range using a minimum and maximum input. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |--------|-------------| 9 | | label | - | 10 | | form | - | 11 | | submit | - | 12 | 13 | ## Default layout 14 | 15 | ```twig 16 |
20 | {% block label %}{{ label }}{% endblock %} 21 | {% block form %} 22 |
29 |
30 | 31 | 42 |
43 |
44 | 45 | 56 |
57 | {% block submit %} 58 | 61 | {% endblock %} 62 |
63 | {% endblock %} 64 |
65 | ``` 66 | 67 | ## Default HTML output 68 | ```html 69 |
70 | Price 71 |
72 |
73 | 74 | 75 |
76 |
77 | 78 | 79 |
80 | 83 |
84 |
85 | ``` 86 | -------------------------------------------------------------------------------- /docs/components/Facet/RangeSlider.md: -------------------------------------------------------------------------------- 1 | # RangeSlider 2 | 3 | The `RangeSlider` component provides a user-friendly way to filter the results, based on a single numeric range. 4 | 5 | ## Default layout 6 | 7 | ```twig 8 |
15 | {{ label }} 16 |
24 | 45 | 66 |
67 |
68 | 73 | {{ this.leading ~ facetStat.userMin|default(facetStat.min) ~ this.trailing }} 74 | 75 | 80 | {{ this.leading ~ facetStat.userMax|default(facetStat.max) ~ this.trailing }} 81 | 82 |
83 |
84 | ``` 85 | 86 | ## Default HTML output 87 | ```html 88 |
89 | Price 90 |
91 | 98 | 105 |
106 |
107 | 1.99 108 | 4998.99 109 |
110 |
111 | ``` 112 | -------------------------------------------------------------------------------- /docs/components/Facet/RefinementList.md: -------------------------------------------------------------------------------- 1 | # RefinementList 2 | 3 | The `RefinementList` component allow users can filter the dataset based on facets. 4 | 5 | The widget only displays the most relevant facet values for the current search context. 6 | The sort option only affects the facets that are returned by the engine, not which facets are returned. 7 | 8 | | name | Description | 9 | |-----------|-------------| 10 | | label | - | 11 | | list | - | 12 | | show_more | - | 13 | 14 | ## Default layout 15 | 16 | ```twig 17 |
25 | {% block label %}{{ label }}{% endblock %} 26 | 27 | {% block list %} 28 |
    29 | {%- for key,value in distribution.values %} 30 |
  • 33 | 44 | 48 |
  • 49 | {% endfor -%} 50 |
51 | {% endblock %} 52 | 53 | {% if distribution.values|length > this.limit %} 54 | {% block show_more %} 55 | 63 | {% endblock %} 64 | {% endif %} 65 |
66 | 67 | ``` 68 | 69 | ## Default HTML output 70 | ```html 71 |
72 | Type 73 |
    74 |
  • 75 | 76 | 80 |
  • 81 |
  • 82 | 83 | 87 |
  • 88 | // ... 89 |
90 | 91 |
92 | ``` 93 | -------------------------------------------------------------------------------- /docs/components/Hits.md: -------------------------------------------------------------------------------- 1 | # Hits 2 | 3 | The `Hits` component displays a list of refinements applied to the search. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |----------|-------------| 9 | | content | - | 10 | | hit | - | 11 | | noResult | - | 12 | 13 | ## Default layout 14 | 15 | ```twig 16 | {% block content %} 17 |
20 | {% if results.hits|length > 0 %} 21 |
22 | {% for hit in results.hits %} 23 | {% block hit %} 24 |
25 |
{{ hit.data|json_encode(constant('JSON_PRETTY_PRINT')) }}
26 |
27 | {% endblock %} 28 | {% endfor %} 29 |
30 | {% else %} 31 | {% block noResult %} 32 |
{{ 'no_result'|trans(domain='mezcalito_ux_search') }}
33 | {% endblock %} 34 | {% endif %} 35 |
36 | {% endblock %} 37 | ``` 38 | 39 | ## Default HTML output 40 | ```html 41 |
42 |
43 |
44 |
45 |                 // ...
46 |             
47 |
48 |
49 |
50 |                 // ...
51 |             
52 |
53 | // ... 54 |
55 |
56 | ``` 57 | -------------------------------------------------------------------------------- /docs/components/HitsPerPage.md: -------------------------------------------------------------------------------- 1 | # HitsPerPage 2 | 3 | The `HitsPerPage` component displays a dropdown menu to let users change the number of displayed hits. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |----------|-------------| 9 | | content | - | 10 | 11 | ## Default layout 12 | 13 | ```twig 14 | {% block content %} 15 |
18 | 23 | 24 |
25 | {% endblock %} 26 | ``` 27 | 28 | ## Default HTML output 29 | ```html 30 |
31 | 36 | 37 |
38 | ``` 39 | -------------------------------------------------------------------------------- /docs/components/Layout.md: -------------------------------------------------------------------------------- 1 | # Layout 2 | 3 | The `Layout` is the root wrapper component for all components 4 | 5 | ## Block available 6 | 7 | | name | 8 | |------------| 9 | | content | 10 | | form | 11 | | toolbar | 12 | | facets | 13 | | listing | 14 | | stats | 15 | | hits | 16 | | pagination | 17 | 18 | ## Default layout 19 | 20 | ```twig 21 |
26 | {% block content %} 27 |
28 |
29 | {% block form %} 30 | 31 | {% endblock %} 32 |
33 | 34 |
35 | {% block toolbar %} 36 | 37 | 38 | 39 | 40 | {% endblock %} 41 |
42 | 43 |
44 | {% block facets %} 45 | {% for facet in search.facets %} 46 | 47 | {% endfor %} 48 | {% endblock %} 49 |
50 | 51 |
52 | {% block listing %} 53 |
54 | {% block stats %} 55 | 56 | {% endblock %} 57 |
58 | 59 | {% block hits %} 60 | 61 | {% endblock %} 62 | 63 | {% block pagination %} 64 | 65 | {% endblock %} 66 | {% endblock %} 67 |
68 |
69 | {% endblock %} 70 |
71 | ``` 72 | -------------------------------------------------------------------------------- /docs/components/SearchInput.md: -------------------------------------------------------------------------------- 1 | # SearchInput 2 | 3 | The `SearchInput` component is used to let users perform a text-based query. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |---------|-------------| 9 | | content | - | 10 | 11 | 12 | ## Default layout 13 | 14 | ```twig 15 | {% block content %} 16 | 22 | {% endblock %} 23 | ``` 24 | 25 | ## Default HTML output 26 | ```html 27 | 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/components/SortBy.md: -------------------------------------------------------------------------------- 1 | # SortBy 2 | 3 | The `SortBy` component displays a list of sorting possibility, allowing a user to change the way hits are sorted. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |---------|-------------| 9 | | content | | 10 | 11 | 12 | ## Default layout 13 | 14 | ```twig 15 | {% block content %} 16 |
19 | 24 | 25 |
26 | {% endblock %} 27 | ``` 28 | 29 | ## Default HTML output 30 | ```html 31 |
32 | 38 | 39 |
40 | ``` 41 | -------------------------------------------------------------------------------- /docs/components/TotalHits.md: -------------------------------------------------------------------------------- 1 | # TotalHits 2 | 3 | The `TotalHits` component displays the total number of matching hits. 4 | 5 | ## Block available 6 | 7 | | name | Description | 8 | |---------|-------------| 9 | | content | - | 10 | 11 | 12 | ## Default layout 13 | 14 | ```twig 15 | {% block content %} 16 | 19 | {{ 'results'|trans({'%count%': totalHits}, domain='mezcalito_ux_search') }} 20 | 21 | {% endblock %} 22 | ``` 23 | 24 | ## Default HTML output 25 | ```html 26 |
27 | 28 | 10000 results 29 | 30 |
31 | ``` 32 | -------------------------------------------------------------------------------- /docs/create-own-adapter.md: -------------------------------------------------------------------------------- 1 | # Create own Adapter 2 | 3 | In this part of the documentation describes how to create an own adapter. Before you start with it let us know via an [issue](https://github.com/mezcalito/ux-search/issues) if it maybe an Adapter which make sense to add to the project, and we can work together to get it in it. 4 | 5 | ## Create Basic Classes 6 | 7 | ### Create Adapter 8 | 9 | ```php 10 | [!IMPORTANT] 4 | > If you haven't used the maker, you first need to create a class and add the `AsSearch` attribute to it. 5 | 6 | **Create a search** 7 | ```php 8 | addFacet('type', 'Type') 58 | ->addFacet('price', 'Price', RangeInput::class) 59 | ->addFacet('price', 'Price', null, ['limit' => 20]) 60 | ; 61 | } 62 | } 63 | ``` 64 | 65 | ## Add sort 66 | 67 | You can also add sorting options to your Search. To do this, you need to use the `addAvailableSort` method. 68 | This method takes 2 mandatory parameters: 69 | 70 | | Parameter | Description | Type | 71 | |-----------|-----------------------------------------|---------| 72 | | key | Attribute key and order separate by ':' | ?string | 73 | | label | Label displayed | string | 74 | 75 | If you do not specify a sort or if the key is empty, the default sorting of your adapter will be applied. 76 | 77 | ```php 78 | use Mezcalito\UxSearchBundle\Search\Sort; 79 | 80 | // .. 81 | 82 | public function build(array $options = []): void 83 | { 84 | $this 85 | // .. 86 | ->addAvailableSort(null, 'Relevancy') 87 | ->addAvailableSort('price:asc', 'Price ↑') 88 | ->addAvailableSort('price:desc', 'Price ↓') 89 | ; 90 | } 91 | ``` 92 | 93 | ## Add EventListener or EventSubscriber 94 | For example, you can modify the `ResultSet` on the `PostSearchEvent` to enrich a `Hit` with data from database. 95 | 96 | ```php 97 | use Mezcalito\UxSearchBundle\Event\PreSearchEvent; 98 | use Mezcalito\UxSearchBundle\Event\PostSearchEvent; 99 | // .. 100 | 101 | public function build(array $options = []): void 102 | { 103 | $this 104 | // ... 105 | ->addEventListener(PreSearchEvent::class, function (PreSearchEvent $event) { 106 | // $event->getSearch(); 107 | // $event->getQuery(); 108 | }) 109 | ->addEventListener(PostSearchEvent::class, function (PostSearchEvent $event) { 110 | // $event->getSearch(); 111 | // $event->getQuery(); 112 | // $event->getResultSet(); 113 | }) 114 | ->addEventSubscriber(YourEventSubscriber::cass) 115 | ; 116 | } 117 | ``` 118 | 119 | ## Enable urlRewriting and set up an urlFormater 120 | It is possible to enable a URL rewriting system to allow sharing of configured search URLs. To do this, simply add the `->enableUrlRewriting` method. By default, a `DefaultUrlFormater` is provided in the bundle. This allows you to add query parameters with the values of the selected facets. 121 | 122 | ```php 123 | 124 | public function build(array $options = []): void 125 | { 126 | $this 127 | // ... 128 | ->enableUrlRewriting() 129 | ; 130 | } 131 | ``` 132 | 133 | You can also create your own UrlFormater. To do so, you need to implement the `UrlFormaterInterface` and define your own logic in the `generateUrl` and `applyFilters` methods. All that is left is to use it in a search via the `->setUrlFormater()` method. 134 | 135 | ```php 136 | use App\Url\YourCustomUrlFormater; 137 | // ... 138 | 139 | public function build(array $options = []): void 140 | { 141 | $this 142 | // ... 143 | ->enableUrlRewriting() 144 | ->setUrlFormater(YourCustomUrlFormater::class) 145 | ; 146 | } 147 | ``` 148 | -------------------------------------------------------------------------------- /docs/usage/doctrine.md: -------------------------------------------------------------------------------- 1 | # Doctrine 2 | 3 | ## Available configuration for adapter 4 | 5 | | Constant name | type | default value | 6 | |------------------------|----------|--------------------------------------------| 7 | | MAX_FACET_VALUES_PARAM | int | 100 | 8 | | QUERY_BUILDER_ALIAS | string | o | 9 | | QUERY_BUILDER | closure | `function (QueryBuilder $queryBuilder) {}` | 10 | | SEARCH_FIELDS | string[] | [] | 11 | -------------------------------------------------------------------------------- /docs/usage/meilisearch.md: -------------------------------------------------------------------------------- 1 | # Meilisearch 2 | 3 | ## Available configuration for adapter 4 | 5 | | Constant name | Meilisearch name | Type | Default value | 6 | |-------------------------------|-----------------------|----------|---------------| 7 | | ATTRIBUTES_TO_RETRIEVE_PARAM | attributesToRetrieve | string[] | ['*'] | 8 | | ATTRIBUTES_TO_CROP_PARAM | attributesToCrop | string[] | [] | 9 | | CROP_LENGTH_PARAM | cropLength | int | 10 | 10 | | CROP_MARKER_PARAM | cropMarker | string | ... | 11 | | ATTRIBUTES_TO_HIGHLIGHT_PARAM | attributesToHighlight | string[] | [] | 12 | | HIGHLIGHT_PRE_TAG_PARAM | highlightPreTag | string | <em> | 13 | | HIGHLIGHT_POST_TAG_PARAM | highlightPostTag | string | </em> | 14 | 15 | If you need more inforamtion about this configuration check [Meilisearch documentation](https://www.meilisearch.com/docs/reference/api/search#body) 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import pluginTypescript from 'typescript-eslint'; 4 | import pluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 5 | 6 | export default [ 7 | { files: ['**/*.{js,mjs,cjs,ts}'] }, 8 | { 9 | ignores: ['**/build', '**/dist', '**/public', '**/var', '**/vendor', '**/coverage', '**/tests', 'rollup.config.js'], 10 | }, 11 | { languageOptions: { globals: globals.browser } }, 12 | pluginJs.configs.recommended, 13 | ...pluginTypescript.configs.recommended, 14 | pluginPrettierRecommended, 15 | 16 | // Override rules 17 | { 18 | rules: { 19 | 'no-duplicate-imports': 'warn', 20 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 21 | 22 | // TypeScript 23 | '@typescript-eslint/ban-ts-comment': 'off', 24 | '@typescript-eslint/consistent-type-imports': [ 25 | 'warn', 26 | { 27 | prefer: 'type-imports', 28 | disallowTypeAnnotations: false, 29 | fixStyle: 'inline-type-imports', 30 | }, 31 | ], 32 | }, 33 | }, 34 | ]; 35 | -------------------------------------------------------------------------------- /phpunit.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | tests 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Adapter/AbstractAdapter.php: -------------------------------------------------------------------------------- 1 | getFacetDistributionKey(); 34 | $facetStatsKey = $this->getFacetStatsKey(); 35 | 36 | $mergedFacetDistribution = array_reduce($results['results'], function ($carry, $result) use ($facetDistributionKey) { 37 | if (isset($result[$facetDistributionKey])) { 38 | foreach ($result[$facetDistributionKey] as $facetKey => $facetValues) { 39 | $carry[$facetKey] = $facetValues; 40 | } 41 | } 42 | 43 | return $carry; 44 | }, []); 45 | 46 | $mergedFacetStats = array_reduce($results['results'], function ($carry, $result) use ($facetStatsKey) { 47 | if (isset($result[$facetStatsKey])) { 48 | foreach ($result[$facetStatsKey] as $facetKey => $facetStat) { 49 | $carry[$facetKey] = $facetStat; 50 | } 51 | } 52 | 53 | return $carry; 54 | }, []); 55 | 56 | $facetsDistributions = []; 57 | 58 | foreach ($search->getFacets() as $facet) { 59 | $filter = $query->getActiveFilter($facet->getProperty()); 60 | $facetsDistributions[$facet->getProperty()] = $this->hydrateTermDistribution($mergedFacetDistribution, $facet, $filter); 61 | 62 | if (!isset($mergedFacetStats[$facet->getProperty()])) { 63 | $mergedFacetStats[$facet->getProperty()] = ['min' => 0, 'max' => 0]; 64 | } 65 | } 66 | 67 | foreach ($facetsDistributions as $property => $distribution) { 68 | if ($distribution instanceof FacetTermDistribution) { 69 | $values = $distribution->getValues(); 70 | $checkedValues = $distribution->getCheckedValues(); 71 | 72 | $checkedFacets = []; 73 | $uncheckedFacets = []; 74 | 75 | foreach ($values as $key => $value) { 76 | if (\in_array($key, $checkedValues)) { 77 | $checkedFacets[$key] = $value; 78 | } else { 79 | $uncheckedFacets[$key] = $value; 80 | } 81 | } 82 | 83 | $sortedFacets = $checkedFacets + $uncheckedFacets; 84 | 85 | $distribution->setValues($sortedFacets); 86 | } 87 | } 88 | 89 | $facetStats = []; 90 | foreach ($mergedFacetStats as $property => $values) { 91 | $filter = $query->getActiveFilter($property); 92 | if ($filter instanceof RangeFilter) { 93 | $userMin = $filter->getMin(); 94 | $userMax = $filter->getMax(); 95 | } 96 | 97 | $facetStats[] = new FacetStat($property, $values['min'], $values['max'], $userMin ?? null, $userMax ?? null); 98 | } 99 | 100 | return [$facetsDistributions, $facetStats]; 101 | } 102 | 103 | protected function hydrateTermDistribution(array $mergedFacetDistribution, Facet $facet, ?FilterInterface $filter): FacetTermDistribution 104 | { 105 | $values = $mergedFacetDistribution[$facet->getProperty()] ?? []; 106 | 107 | $termDistribution = (new FacetTermDistribution()) 108 | ->setProperty($facet->getProperty()) 109 | ->setValues($values) 110 | ; 111 | 112 | if ($filter instanceof TermFilter) { 113 | $termDistribution->setCheckedValues($filter->getValues()); 114 | } 115 | 116 | return $termDistribution; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Adapter/AdapterFactoryInterface.php: -------------------------------------------------------------------------------- 1 | defaultAdapterName; 31 | } 32 | 33 | if (!\array_key_exists($name, $this->adapterConfiguration)) { 34 | throw AdapterException::configurationNotFound($name); 35 | } 36 | 37 | $dsn = $this->adapterConfiguration[$name]['dsn']; 38 | 39 | foreach ($this->factories as $factory) { 40 | if ($factory->support($dsn)) { 41 | return $factory->createAdapter($dsn); 42 | } 43 | } 44 | 45 | throw AdapterException::factoryNotFound($dsn); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Adapter/Algolia/AlgoliaAdapter.php: -------------------------------------------------------------------------------- 1 | queryBuilder->build($query, $search); 83 | 84 | $results = $this->client->search($queries); 85 | 86 | $resultsToProcess = $results['results'][0]; 87 | 88 | $hits = []; 89 | foreach ($resultsToProcess['hits'] as $hit) { 90 | $hits[] = new Hit($hit, $hit['_rankingInfo']['userScore']); 91 | } 92 | 93 | [$facetsDistributions, $facetStats] = $this->getFacets($results, $search, $query); 94 | 95 | return (new ResultSet()) 96 | ->setIndexUid($resultsToProcess['index']) 97 | ->setHits($hits) 98 | ->setTotalResults($resultsToProcess['nbHits']) 99 | ->setFacetDistributions($facetsDistributions) 100 | ->setFacetStats($facetStats) 101 | ; 102 | } 103 | 104 | public function configureParameters(OptionsResolver $resolver): void 105 | { 106 | $resolver->setDefaults([ 107 | self::ADVANCED_SYNTAX_PARAM => false, 108 | self::ADVANCED_SYNTAX_FEATURES_PARAM => ['exactPhrase', 'excludeWords'], 109 | self::ALLOW_TYPOS_ON_NUMERIC_TOKENS_PARAM => true, 110 | self::ALTERNATIVES_AS_EXACT_PARAM => ['ignorePlurals', 'singleWordSynonym'], 111 | self::ANALYTICS_PARAM => true, 112 | self::ANALYTICS_TAGS_PARAM => [], 113 | self::ATTRIBUTES_TO_HIGHLIGHT_PARAM => ['*'], 114 | self::ATTRIBUTES_TO_RETRIEVE_PARAM => ['*'], 115 | self::DECOMPOUND_QUERY_PARAM => true, 116 | self::DISABLE_EXACT_ON_ATTRIBUTES_PARAM => [], 117 | self::DISABLE_TYPO_TOLERANCE_ON_ATTRIBUTES_PARAM => [], 118 | self::ENABLE_AB_TEST_PARAM => true, 119 | self::ENABLE_PERSONALIZATION_PARAM => false, 120 | self::HIGHLIGHT_POST_TAG_PARAM => '', 121 | self::HIGHLIGHT_PRE_TAG_PARAM => '', 122 | self::MAX_VALUES_PER_FACET_PARAM => 100, 123 | self::QUERY_TYPE_PARAM => 'prefixLast', 124 | self::SORT_FACET_VALUES_BY_PARAM => 'count', 125 | self::SYNONYMS_PARAM => true, 126 | ]); 127 | 128 | $resolver->setAllowedTypes(self::ADVANCED_SYNTAX_PARAM, 'bool'); 129 | $resolver->setAllowedTypes(self::ADVANCED_SYNTAX_FEATURES_PARAM, 'string[]'); 130 | $resolver->setAllowedTypes(self::ALLOW_TYPOS_ON_NUMERIC_TOKENS_PARAM, 'bool'); 131 | $resolver->setAllowedTypes(self::ALTERNATIVES_AS_EXACT_PARAM, 'string[]'); 132 | $resolver->setAllowedTypes(self::ANALYTICS_PARAM, 'bool'); 133 | $resolver->setAllowedTypes(self::ANALYTICS_TAGS_PARAM, 'string[]'); 134 | $resolver->setAllowedTypes(self::ATTRIBUTES_TO_HIGHLIGHT_PARAM, 'string[]'); 135 | $resolver->setAllowedTypes(self::ATTRIBUTES_TO_RETRIEVE_PARAM, 'string[]'); 136 | $resolver->setAllowedTypes(self::DECOMPOUND_QUERY_PARAM, 'bool'); 137 | $resolver->setAllowedTypes(self::DISABLE_EXACT_ON_ATTRIBUTES_PARAM, 'string[]'); 138 | $resolver->setAllowedTypes(self::DISABLE_TYPO_TOLERANCE_ON_ATTRIBUTES_PARAM, 'string[]'); 139 | $resolver->setAllowedTypes(self::ENABLE_AB_TEST_PARAM, 'bool'); 140 | $resolver->setAllowedTypes(self::ENABLE_PERSONALIZATION_PARAM, 'bool'); 141 | $resolver->setAllowedTypes(self::HIGHLIGHT_POST_TAG_PARAM, 'string'); 142 | $resolver->setAllowedTypes(self::HIGHLIGHT_PRE_TAG_PARAM, 'string'); 143 | $resolver->setAllowedTypes(self::MAX_VALUES_PER_FACET_PARAM, 'int'); 144 | $resolver->setAllowedTypes(self::QUERY_TYPE_PARAM, 'string'); 145 | $resolver->setAllowedTypes(self::SORT_FACET_VALUES_BY_PARAM, 'string'); 146 | $resolver->setAllowedTypes(self::SYNONYMS_PARAM, 'bool'); 147 | 148 | $resolver->setAllowedValues(self::ADVANCED_SYNTAX_FEATURES_PARAM, function (array $values) { 149 | foreach ($values as $value) { 150 | if (!\in_array($value, ['exactPhrase', 'excludeWords'])) { 151 | return false; 152 | } 153 | } 154 | 155 | return true; 156 | }); 157 | $resolver->setAllowedValues(self::ALTERNATIVES_AS_EXACT_PARAM, function (array $values) { 158 | foreach ($values as $value) { 159 | if (!\in_array($value, ['ignoreConjugations', 'ignorePlurals', 'multiWordsSynonym', 'singleWordSynonym'])) { 160 | return false; 161 | } 162 | } 163 | 164 | return true; 165 | }); 166 | $resolver->setAllowedValues(self::MAX_VALUES_PER_FACET_PARAM, fn (int $value): bool => $value <= 1000); 167 | $resolver->setAllowedValues(self::QUERY_TYPE_PARAM, ['prefixAll', 'prefixLast', 'prefixNone']); 168 | $resolver->setAllowedValues(self::SORT_FACET_VALUES_BY_PARAM, ['count', 'alpha']); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Adapter/Algolia/AlgoliaFactory.php: -------------------------------------------------------------------------------- 1 | createClient($dsn), new QueryBuilder()); 30 | } 31 | 32 | public function createClient(string $dsn): SearchClient 33 | { 34 | if (!class_exists(SearchClient::class)) { 35 | throw new \LogicException(\sprintf('You cannot use the "%s" as Algolia, Client is not installed. Try running "composer require algolia/search-bundle".', self::class)); 36 | } 37 | 38 | $parsedDsn = parse_url($dsn); 39 | 40 | return SearchClient::create($parsedDsn['host'], $parsedDsn['user']); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Adapter/Algolia/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | getIndexName(); 27 | $options = $search->getResolvedAdapterParameters(); 28 | 29 | $hitsPerPage = $query->getActiveHitsPerPage(); 30 | $queries = []; 31 | 32 | if ($query->getActiveSort()) { 33 | $indexName = $query->getActiveSort(); 34 | } 35 | 36 | $displayedFacets = []; 37 | 38 | foreach ($search->getFacets() as $facet) { 39 | $displayedFacets[] = $facet->getProperty(); 40 | } 41 | 42 | $algoliaQuery = array_merge($options, [ 43 | 'indexName' => $indexName, 44 | 'getRankingInfo' => true, 45 | 'query' => $query->getQueryString(), 46 | 'filters' => $this->formatFilters($query->getActiveFilters()), 47 | 'hitsPerPage' => $hitsPerPage, 48 | 'page' => ($query->getCurrentPage() - 1), // Algolia page start to 0 49 | ]); 50 | 51 | if ([] !== $displayedFacets) { 52 | $algoliaQuery['facets'] = $displayedFacets; 53 | } 54 | 55 | $queries[] = $algoliaQuery; 56 | 57 | $activeFilters = $query->getActiveFilters(); 58 | 59 | foreach ($activeFilters as $activeFilter) { 60 | $otherFilters = []; 61 | foreach ($activeFilters as $filter) { 62 | if ($filter->getProperty() !== $activeFilter->getProperty()) { 63 | $otherFilters[] = $filter; 64 | } 65 | } 66 | 67 | $queries[] = [ 68 | 'indexName' => $indexName, 69 | 'query' => $query->getQueryString(), 70 | 'facets' => [$activeFilter->getProperty()], 71 | 'filters' => $this->formatFilters($otherFilters), 72 | ]; 73 | } 74 | 75 | return $queries; 76 | } 77 | 78 | /** 79 | * @param FilterInterface[] $filters 80 | */ 81 | private function formatFilters(array $filters): string 82 | { 83 | $formated = []; 84 | foreach ($filters as $filter) { 85 | switch ($filter::class) { 86 | case TermFilter::class: 87 | $or = []; 88 | foreach ($filter->getValues() as $value) { 89 | $or[] = \sprintf('%s:"%s"', $filter->getProperty(), addslashes((string) $value)); 90 | } 91 | 92 | $formated[] = implode(' OR ', $or); 93 | break; 94 | case RangeFilter::class: 95 | if ($filter->getMin()) { 96 | $formated[] = \sprintf('%s >= %d', $filter->getProperty(), $filter->getMin()); 97 | } 98 | 99 | if ($filter->getMax()) { 100 | $formated[] = \sprintf('%s <= %d', $filter->getProperty(), $filter->getMax()); 101 | } 102 | 103 | break; 104 | default: 105 | throw new \Exception(\sprintf('Facet filter "%s" not supported', $filter::class)); 106 | } 107 | } 108 | 109 | return implode(' AND ', $formated); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DoctrineAdapter.php: -------------------------------------------------------------------------------- 1 | manager, $query, $search); 47 | 48 | $paginator = new Paginator($helper->getResultsQuery()->getQuery(), fetchJoinCollection: true); 49 | $hits = []; 50 | foreach ($paginator as $item) { 51 | $hits[] = new Hit($item, 1); 52 | } 53 | 54 | $total = $helper->getTotalResultsQuery()->getQuery()->getSingleScalarResult(); 55 | 56 | return (new ResultSet()) 57 | ->setIndexUid($search->getIndexName()) 58 | ->setHits($hits) 59 | ->setFacetDistributions($this->getFacetDistributions($query, $search)) 60 | ->setFacetStats($this->getFacetStats($query, $search)) 61 | ->setTotalResults($total); 62 | } 63 | 64 | public function configureParameters(OptionsResolver $resolver): void 65 | { 66 | $resolver->setDefaults([ 67 | self::MAX_FACET_VALUES_PARAM => 100, 68 | self::QUERY_BUILDER_ALIAS => 'o', 69 | self::QUERY_BUILDER => function (QueryBuilder $queryBuilder) {}, 70 | self::SEARCH_FIELDS => [], 71 | ]); 72 | 73 | $resolver->setAllowedTypes(self::MAX_FACET_VALUES_PARAM, 'int'); 74 | $resolver->setAllowedTypes(self::QUERY_BUILDER_ALIAS, 'string'); 75 | $resolver->setAllowedTypes(self::QUERY_BUILDER, 'Closure'); 76 | $resolver->setAllowedTypes(self::SEARCH_FIELDS, 'string[]'); 77 | } 78 | 79 | private function getFacetDistributions(Query $query, SearchInterface $search): array 80 | { 81 | $distributions = []; 82 | 83 | $helper = new QueryBuilderHelper($this->manager, $query, $search); 84 | 85 | foreach ($search->getFacets() as $facet) { 86 | $filter = $query->getActiveFilter($facet->getProperty()); 87 | $checkedValues = []; 88 | if ($filter instanceof TermFilter) { 89 | $checkedValues = $filter->getValues(); 90 | } 91 | 92 | $checkedFacets = []; 93 | $uncheckedFacets = []; 94 | $qb = $helper->getFacetTermQuery($facet); 95 | foreach ($qb->getQuery()->getArrayResult() as $row) { 96 | $rowValue = $row['value'] instanceof \BackedEnum ? $row['value']->value : $row['value']; 97 | 98 | if (\in_array($rowValue, $checkedValues)) { 99 | $checkedFacets[$rowValue] = $row['total']; 100 | } else { 101 | $uncheckedFacets[$rowValue] = $row['total']; 102 | } 103 | } 104 | 105 | $values = $checkedFacets + $uncheckedFacets; 106 | 107 | $distributions[] = (new FacetTermDistribution()) 108 | ->setProperty($facet->getProperty()) 109 | ->setValues($values) 110 | ->setCheckedValues($checkedValues); 111 | } 112 | 113 | return $distributions; 114 | } 115 | 116 | private function getFacetStats(Query $query, SearchInterface $search): array 117 | { 118 | $stats = []; 119 | 120 | $helper = new QueryBuilderHelper($this->manager, $query, $search); 121 | 122 | foreach ($search->getFacets() as $facet) { 123 | $filter = $query->getActiveFilter($facet->getProperty()); 124 | 125 | $userMin = null; 126 | $userMax = null; 127 | if ($filter instanceof RangeFilter) { 128 | $userMin = $filter->getMin(); 129 | $userMax = $filter->getMax(); 130 | } 131 | 132 | $qb = $helper->getFacetStatsQuery($facet); 133 | 134 | $rs = $qb->getQuery()->getArrayResult()[0]; 135 | 136 | if (\is_string($rs['min']) || \is_string($rs['max'])) { 137 | continue; 138 | } 139 | 140 | $stats[] = (new FacetStat( 141 | property: $facet->getProperty(), 142 | min: $rs['min'] ?? 0, 143 | max: $rs['max'] ?? 0, 144 | userMin: $userMin, 145 | userMax: $userMax 146 | )); 147 | } 148 | 149 | return $stats; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/DoctrineFactory.php: -------------------------------------------------------------------------------- 1 | managerRegistry instanceof ManagerRegistry) { 36 | throw new \LogicException(\sprintf('You cannot use the "%s" as Doctrine ORM is not installed. Try running "composer require symfony/orm-pack".', self::class)); 37 | } 38 | 39 | $parsedDsn = parse_url($dsn); 40 | $managerName = $parsedDsn['host'] ?? 'default'; 41 | 42 | $manager = $this->managerRegistry->getManager($managerName); 43 | 44 | if (!$manager instanceof EntityManager) { 45 | throw DoctrineAdapterException::isNotOrmManager($managerName); 46 | } 47 | 48 | return new DoctrineAdapter($manager); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Adapter/Doctrine/QueryBuilderHelper.php: -------------------------------------------------------------------------------- 1 | createBaseQueryBuilder() 39 | ->select(\sprintf('count(DISTINCT (%s)) AS total', $this->getIdentifierField())); 40 | 41 | $this->applyQueryString($qb); 42 | 43 | foreach ($this->query->getActiveFilters() as $filter) { 44 | $this->applyFilter($qb, $filter); 45 | } 46 | 47 | return $qb; 48 | } 49 | 50 | public function getResultsQuery(): QueryBuilder 51 | { 52 | $qb = $this->createBaseQueryBuilder(); 53 | $this->applyPagination($qb); 54 | $this->applySort($qb); 55 | $this->applyQueryString($qb); 56 | 57 | foreach ($this->query->getActiveFilters() as $filter) { 58 | $this->applyFilter($qb, $filter); 59 | } 60 | 61 | return $qb; 62 | } 63 | 64 | public function getFacetTermQuery(Facet $facet): QueryBuilder 65 | { 66 | $qb = $this->createBaseQueryBuilder(); 67 | 68 | [$alias, $property] = $this->extractAliasAndProperty($facet->getProperty()); 69 | $this->updateQueryBuilderAssociations($qb, $alias); 70 | 71 | $qb 72 | ->select(\sprintf('%s.%s as value, count(%s.%s) AS total', $alias, $property, $alias, $property)) 73 | ->orderBy('total', 'desc') 74 | ->groupBy(\sprintf('%s.%s', $alias, $property)) 75 | ->setMaxResults($this->search->getResolvedAdapterParameter(DoctrineAdapter::MAX_FACET_VALUES_PARAM)); 76 | 77 | $this->applyQueryString($qb); 78 | 79 | foreach ($this->query->getActiveFilters() as $filter) { 80 | if ($filter->getProperty() === $facet->getProperty()) { 81 | continue; 82 | } 83 | 84 | $this->applyFilter($qb, $filter); 85 | } 86 | 87 | return $qb; 88 | } 89 | 90 | public function getFacetStatsQuery(mixed $facet): QueryBuilder 91 | { 92 | $qb = $this->createBaseQueryBuilder(); 93 | 94 | [$alias, $property] = $this->extractAliasAndProperty($facet->getProperty()); 95 | $this->updateQueryBuilderAssociations($qb, $alias); 96 | 97 | $qb 98 | ->select(\sprintf('min(%s.%s) as min, max(%s.%s) AS max', $alias, $property, $alias, $property)); 99 | 100 | $this->applyQueryString($qb); 101 | 102 | foreach ($this->query->getActiveFilters() as $filter) { 103 | if ($filter->getProperty() === $facet->getProperty()) { 104 | continue; 105 | } 106 | 107 | $this->applyFilter($qb, $filter); 108 | } 109 | 110 | return $qb; 111 | } 112 | 113 | private function createBaseQueryBuilder(): QueryBuilder 114 | { 115 | $qb = $this->manager 116 | ->getRepository($this->search->getIndexName()) 117 | ->createQueryBuilder($this->search->getResolvedAdapterParameter(DoctrineAdapter::QUERY_BUILDER_ALIAS)); 118 | 119 | $this->search->getResolvedAdapterParameter(DoctrineAdapter::QUERY_BUILDER)($qb); 120 | 121 | return $qb; 122 | } 123 | 124 | private function extractAliasAndProperty(string $property): array 125 | { 126 | if (str_contains($property, '.')) { 127 | return explode('.', $property); 128 | } 129 | 130 | return ['o', $property]; 131 | } 132 | 133 | private function updateQueryBuilderAssociations(QueryBuilder $qb, string $alias): void 134 | { 135 | if ('o' === $alias) { 136 | return; 137 | } 138 | 139 | $metadata = $this->manager->getClassMetadata($this->search->getIndexName()); 140 | if (\array_key_exists($alias, $metadata->associationMappings) && !\in_array($alias, $qb->getAllAliases(), true)) { 141 | $qb->leftJoin('o.'.$alias, $alias); 142 | } 143 | } 144 | 145 | private function getIdentifierField(): string 146 | { 147 | $metadata = $this->manager->getClassMetadata($this->search->getIndexName()); 148 | 149 | return \sprintf('%s.%s', 150 | $this->search->getResolvedAdapterParameter(DoctrineAdapter::QUERY_BUILDER_ALIAS), 151 | $metadata->getIdentifier()[0] 152 | ); 153 | } 154 | 155 | private function applyFilter(QueryBuilder $qb, FilterInterface $filter) 156 | { 157 | [$alias, $property] = $this->extractAliasAndProperty($filter->getProperty()); 158 | $this->updateQueryBuilderAssociations($qb, $alias); 159 | 160 | if ($filter instanceof TermFilter && $filter->hasValues()) { 161 | $parameterName = u(\sprintf('%s_%s_terms', $alias, $property))->snake()->toString(); 162 | 163 | $qb->andWhere(\sprintf('%s.%s in (:%s)', $alias, $property, $parameterName)); 164 | $qb->setParameter($parameterName, array_values($filter->getValues())); 165 | } 166 | 167 | if ($filter instanceof RangeFilter && $filter->getMax()) { 168 | $parameterName = u(\sprintf('%s_%s_max', $alias, $property))->snake()->toString(); 169 | $qb->andWhere(\sprintf('%s.%s <= :%s ', $alias, $property, $parameterName)); 170 | $qb->setParameter($parameterName, $filter->getMax()); 171 | } 172 | 173 | if ($filter instanceof RangeFilter && $filter->getMin()) { 174 | $parameterName = u(\sprintf('%s_%s_min', $alias, $property))->snake()->toString(); 175 | $qb->andWhere(\sprintf('%s.%s >= :%s', $alias, $property, $parameterName)); 176 | $qb->setParameter($parameterName, $filter->getMin()); 177 | } 178 | } 179 | 180 | private function applyPagination(QueryBuilder $qb): void 181 | { 182 | $qb 183 | ->setFirstResult($this->query->getActiveHitsPerPage() * ($this->query->getCurrentPage() - 1)) 184 | ->setMaxResults($this->query->getActiveHitsPerPage()); 185 | } 186 | 187 | private function applySort(QueryBuilder $qb): void 188 | { 189 | if ($this->query->getActiveSort()) { 190 | [$sort, $order] = explode(':', $this->query->getActiveSort()); 191 | $qb->orderBy($sort, $order); 192 | } 193 | } 194 | 195 | private function applyQueryString(QueryBuilder $qb) 196 | { 197 | $fields = $this->search->getResolvedAdapterParameter(DoctrineAdapter::SEARCH_FIELDS); 198 | if ('' === $this->query->getQueryString() || 0 === \count($fields)) { 199 | return; 200 | } 201 | 202 | $orX = $qb->expr()->orX(); 203 | foreach ($fields as $fieldName) { 204 | $orX->add(\sprintf('%s like :queryString', $fieldName)); 205 | } 206 | 207 | $qb->add('where', $orX); 208 | 209 | $qb->setParameter('queryString', \sprintf('%%%s%%', $this->query->getQueryString())); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Adapter/Meilisearch/MeilisearchAdapter.php: -------------------------------------------------------------------------------- 1 | queryBuilder->build($query, $search); 59 | 60 | $results = $this->client->multiSearch($queries); 61 | 62 | $resultsToProcess = $results['results'][0]; 63 | 64 | $hits = []; 65 | foreach ($resultsToProcess['hits'] as $hit) { 66 | $hits[] = new Hit($hit, $hit['_rankingScore']); 67 | } 68 | 69 | [$facetsDistributions, $facetStats] = $this->getFacets($results, $search, $query); 70 | 71 | return (new ResultSet()) 72 | ->setIndexUid($resultsToProcess['indexUid']) 73 | ->setHits($hits) 74 | ->setTotalResults($resultsToProcess['totalHits']) 75 | ->setFacetDistributions($facetsDistributions) 76 | ->setFacetStats($facetStats) 77 | ; 78 | } 79 | 80 | public function configureParameters(OptionsResolver $resolver): void 81 | { 82 | $resolver->setDefaults([ 83 | self::ATTRIBUTES_TO_RETRIEVE_PARAM => ['*'], 84 | self::ATTRIBUTES_TO_CROP_PARAM => [], 85 | self::CROP_LENGTH_PARAM => 10, 86 | self::CROP_MARKER_PARAM => '...', 87 | self::ATTRIBUTES_TO_HIGHLIGHT_PARAM => [], 88 | self::HIGHLIGHT_PRE_TAG_PARAM => '', 89 | self::HIGHLIGHT_POST_TAG_PARAM => '', 90 | ]); 91 | 92 | $resolver->setAllowedTypes(self::ATTRIBUTES_TO_RETRIEVE_PARAM, 'string[]'); 93 | $resolver->setAllowedTypes(self::ATTRIBUTES_TO_CROP_PARAM, 'string[]'); 94 | $resolver->setAllowedTypes(self::CROP_LENGTH_PARAM, 'int'); 95 | $resolver->setAllowedTypes(self::CROP_MARKER_PARAM, 'string'); 96 | $resolver->setAllowedTypes(self::ATTRIBUTES_TO_HIGHLIGHT_PARAM, 'string[]'); 97 | $resolver->setAllowedTypes(self::HIGHLIGHT_PRE_TAG_PARAM, 'string'); 98 | $resolver->setAllowedTypes(self::HIGHLIGHT_POST_TAG_PARAM, 'string'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Adapter/Meilisearch/MeilisearchFactory.php: -------------------------------------------------------------------------------- 1 | createClient($dsn), new QueryBuilder()); 35 | } 36 | 37 | public function createClient(string $dsn): Client 38 | { 39 | if (!class_exists(Client::class)) { 40 | throw new \LogicException(\sprintf('You cannot use the "%s" as Meilisearch Client is not installed. Try running "composer require meilisearch/meilisearch-php".', self::class)); 41 | } 42 | 43 | $parsedDsn = parse_url($dsn); 44 | parse_str($parsedDsn['query'] ?? '', $params); 45 | 46 | $tls = !isset($params['tls']) || 'true' === $params['tls']; 47 | 48 | $url = \sprintf('%s://%s:%s', 49 | $tls ? 'https' : 'http', 50 | $parsedDsn['host'] ?? 'localhost', 51 | $parsedDsn['port'] ?? '7700', 52 | ); 53 | 54 | return new Client($url, $parsedDsn['user'] ?? null, $this->httpClient); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Adapter/Meilisearch/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | getResolvedAdapterParameters(); 28 | 29 | $hitsPerPage = $query->getActiveHitsPerPage(); 30 | $queries = []; 31 | 32 | $formatedSorting = $query->getActiveSort() ? [$query->getActiveSort()] : []; 33 | $displayedFacets = []; 34 | 35 | foreach ($search->getFacets() as $facet) { 36 | $displayedFacets[] = $facet->getProperty(); 37 | } 38 | 39 | $indexName = $search->getIndexName(); 40 | 41 | $meilisearchQuery = (new SearchQuery()) 42 | ->setIndexUid($indexName) 43 | ->setQuery($query->getQueryString()) 44 | ->setFilter($this->formatFilters($query->getActiveFilters())) 45 | ->setSort($formatedSorting) 46 | ->setShowRankingScore(true) 47 | ->setHitsPerPage($hitsPerPage) 48 | ->setPage($query->getCurrentPage()) 49 | ->setAttributesToRetrieve($options['attributesToRetrieve']) 50 | ->setAttributesToCrop($options['attributesToCrop']) 51 | ->setCropLength($options['cropLength']) 52 | ->setCropMarker($options['cropMarker']) 53 | ->setAttributesToHighlight($options['attributesToHighlight']) 54 | ->setHighlightPreTag($options['highlightPreTag']) 55 | ->setHighlightPostTag($options['highlightPostTag']) 56 | ; 57 | 58 | if ([] !== $displayedFacets) { 59 | $meilisearchQuery->setFacets($displayedFacets); 60 | } 61 | 62 | $queries[] = $meilisearchQuery; 63 | 64 | $activeFilters = $query->getActiveFilters(); 65 | 66 | foreach ($activeFilters as $activeFilter) { 67 | $otherFilters = []; 68 | foreach ($activeFilters as $filter) { 69 | if ($filter->getProperty() !== $activeFilter->getProperty()) { 70 | $otherFilters[] = $filter; 71 | } 72 | } 73 | 74 | $queries[] = (new SearchQuery()) 75 | ->setIndexUid($indexName) 76 | ->setQuery($query->getQueryString()) 77 | ->setFacets([$activeFilter->getProperty()]) 78 | ->setFilter($this->formatFilters($otherFilters)) 79 | ->setLimit(0); 80 | } 81 | 82 | return $queries; 83 | } 84 | 85 | /** 86 | * @param FilterInterface[] $filters 87 | */ 88 | private function formatFilters(array $filters): array 89 | { 90 | $formated = []; 91 | foreach ($filters as $filter) { 92 | switch ($filter::class) { 93 | case TermFilter::class: 94 | $or = []; 95 | foreach ($filter->getValues() as $value) { 96 | $or[] = \sprintf('%s = "%s"', $filter->getProperty(), addslashes((string) $value)); 97 | } 98 | 99 | $formated[] = $or; 100 | break; 101 | case RangeFilter::class: 102 | if ($filter->getMin()) { 103 | $formated[] = \sprintf('%s >= %d', $filter->getProperty(), $filter->getMin()); 104 | } 105 | 106 | if ($filter->getMax()) { 107 | $formated[] = \sprintf('%s <= %d', $filter->getProperty(), $filter->getMax()); 108 | } 109 | 110 | break; 111 | default: 112 | throw new \Exception(\sprintf('Facet filter "%s" not supported', $filter::class)); 113 | } 114 | } 115 | 116 | return $formated; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Attribute/AsSearch.php: -------------------------------------------------------------------------------- 1 | query; 31 | } 32 | 33 | public function setQuery(Query $query): static 34 | { 35 | $this->query = $query; 36 | 37 | return $this; 38 | } 39 | 40 | public function getSearch(): SearchInterface 41 | { 42 | return $this->search; 43 | } 44 | 45 | public function setSearch(SearchInterface $search): static 46 | { 47 | $this->search = $search; 48 | 49 | return $this; 50 | } 51 | 52 | public function getResults(): ?ResultSet 53 | { 54 | return $this->results; 55 | } 56 | 57 | public function setResults(?ResultSet $results): static 58 | { 59 | $this->results = $results; 60 | 61 | return $this; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Context/ContextProvider.php: -------------------------------------------------------------------------------- 1 | context = (new Context()) 27 | ->setQuery($query) 28 | ->setSearch($search) 29 | ; 30 | } 31 | 32 | public function hasCurrentContext(): bool 33 | { 34 | return $this->context instanceof Context; 35 | } 36 | 37 | public function getCurrentContext(): Context 38 | { 39 | if (!$this->hasCurrentContext()) { 40 | throw ContextException::contextNotInitialized(); 41 | } 42 | 43 | return $this->context; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/DependencyInjection/AdapterFactoryPass.php: -------------------------------------------------------------------------------- 1 | findTaggedServiceIds('mezcalito_ux_search.adapter_factory'); 30 | 31 | $factories = array_map(fn ($fqcn) => new Reference($fqcn), array_keys($taggedServices)); 32 | 33 | $container 34 | ->getDefinition(AdapterProvider::class) 35 | ->setArgument('$factories', new IteratorArgument($factories)) 36 | ; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/DependencyInjection/RegisterSearchPass.php: -------------------------------------------------------------------------------- 1 | findTaggedServiceIds('mezcalito_ux_search.search'); 30 | $listSearchTypes = array_combine( 31 | array_map(fn ($attr) => $attr[0]['name'], $taggedServices), 32 | array_map(fn ($fqcn) => new Reference($fqcn), array_keys($taggedServices)) 33 | ); 34 | 35 | $container 36 | ->getDefinition(SearchProvider::class) 37 | ->setArgument('$searchs', new IteratorArgument($listSearchTypes)) 38 | ; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DependencyInjection/UrlFormaterPass.php: -------------------------------------------------------------------------------- 1 | findTaggedServiceIds('mezcalito_ux_search.url_formater'); 30 | 31 | $formaters = array_combine( 32 | array_keys($taggedServices), 33 | array_map(fn ($fqcn) => new Reference($fqcn), array_keys($taggedServices)) 34 | ); 35 | 36 | $container 37 | ->getDefinition(UrlFormaterProvider::class) 38 | ->setArgument('$formaters', new IteratorArgument($formaters)) 39 | ; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Event/PostSearchEvent.php: -------------------------------------------------------------------------------- 1 | query; 33 | } 34 | 35 | public function getSearch(): SearchInterface 36 | { 37 | return $this->search; 38 | } 39 | 40 | public function getResultSet(): ResultSet 41 | { 42 | return $this->resultSet; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Event/PreSearchEvent.php: -------------------------------------------------------------------------------- 1 | query; 31 | } 32 | 33 | public function getSearch(): SearchInterface 34 | { 35 | return $this->search; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/EventSubscriber/ContextSubscriber.php: -------------------------------------------------------------------------------- 1 | 'onPreSearchEvent', 32 | PostSearchEvent::class => 'onPostSearchEvent', 33 | ]; 34 | } 35 | 36 | public function onPreSearchEvent(PreSearchEvent $event): void 37 | { 38 | $this->contextProvider->init($event->getQuery(), $event->getSearch()); 39 | } 40 | 41 | public function onPostSearchEvent(PostSearchEvent $event): void 42 | { 43 | $this->contextProvider->getCurrentContext()->setResults($event->getResultSet()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Exception/AdapterException.php: -------------------------------------------------------------------------------- 1 | addArgument('name', InputArgument::OPTIONAL, \sprintf('Choose a search name (e.g. %sSearch)', Str::asClassName(Str::getRandomTerm()))) 46 | ->addArgument('indexName', InputArgument::OPTIONAL, 'Define your index name or Doctrine entity FQCN (Post::class)') 47 | ; 48 | } 49 | 50 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): int 51 | { 52 | $searchName = trim((string) $input->getArgument('name')); 53 | $searchNameHasSearchSuffix = str_ends_with($searchName, 'Search'); 54 | 55 | $indexName = trim((string) $input->getArgument('indexName')); 56 | $indexNameIsClassName = str_ends_with($indexName, '::class'); 57 | 58 | $searchClassNameDetails = $generator->createClassNameDetails( 59 | $searchNameHasSearchSuffix ? substr($searchName, 0, -6) : $searchName, 60 | 'Search\\', 61 | 'Search' 62 | ); 63 | 64 | $this->classesToBeImported = [ 65 | AsSearch::class, 66 | AbstractSearch::class, 67 | ]; 68 | 69 | if ($indexNameIsClassName) { 70 | $boundClassDetails = $generator->createClassNameDetails( 71 | substr($indexName, 0, -7), 72 | 'Entity\\' 73 | ); 74 | 75 | $this->addUseStatement($boundClassDetails->getFullName()); 76 | } 77 | 78 | $generator->generateClass( 79 | $searchClassNameDetails->getFullName(), 80 | __DIR__.'/../../templates/skeleton/search.tpl.php', 81 | [ 82 | 'use_statements' => $this->generateUse(), 83 | 'search_name' => $searchClassNameDetails->getShortName(), 84 | 'index_name' => $indexNameIsClassName ? \sprintf('%s::class', $boundClassDetails->getShortName()) : \sprintf("'%s'", $indexName), 85 | ] 86 | ); 87 | 88 | $generator->writeChanges(); 89 | 90 | $this->writeSuccessMessage($io); 91 | $io->text([ 92 | 'Next: open your new search class and customize it!', 93 | 'Find the documentation at https://github.com/Mezcalito/ux-search/blob/0.x/docs/usage/customize-your-search.md', 94 | ]); 95 | 96 | return Command::SUCCESS; 97 | } 98 | 99 | public function configureDependencies(DependencyBuilder $dependencies): void 100 | { 101 | } 102 | 103 | // Override based on UseStatementGenerator::class because is @internal 104 | public function generateUse(): string 105 | { 106 | $transformed = []; 107 | foreach ($this->classesToBeImported as $key => $class) { 108 | $transformedClass = str_replace('\\', ' ', $class); 109 | if (!\in_array($transformedClass, $transformed, true)) { 110 | $transformed[$key] = $transformedClass; 111 | } 112 | } 113 | 114 | asort($transformed); 115 | 116 | $statements = ''; 117 | 118 | foreach (array_keys($transformed) as $key) { 119 | $importedClass = $this->classesToBeImported[$key]; 120 | 121 | $statements .= \sprintf("use %s;\n", $importedClass); 122 | } 123 | 124 | return $statements; 125 | } 126 | 127 | public function addUseStatement(string $className): void 128 | { 129 | if (\in_array($className, $this->classesToBeImported, true)) { 130 | return; 131 | } 132 | 133 | $this->classesToBeImported[] = $className; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/MezcalitoUxSearchBundle.php: -------------------------------------------------------------------------------- 1 | rootNode(); 36 | $rootNode 37 | ->children() 38 | ->scalarNode('default_adapter')->defaultValue('default')->end() 39 | ->end() 40 | ->children() 41 | ->arrayNode('adapters') 42 | ->isRequired() 43 | ->normalizeKeys(false) 44 | ->useAttributeAsKey('name') 45 | ->arrayPrototype() 46 | ->beforeNormalization() 47 | ->ifString() 48 | ->then(fn (string $v): array => ['dsn' => $v]) 49 | ->end() 50 | ->children() 51 | ->scalarNode('dsn')->isRequired()->end() 52 | ->end() 53 | ->end() 54 | ->end() 55 | ->end() 56 | ; 57 | } 58 | 59 | public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void 60 | { 61 | $container->import('../config/services.php'); 62 | 63 | $builder->setParameter('mezcalito_ux_search.default_adapter', $config['default_adapter']); 64 | $builder->setParameter('mezcalito_ux_search.adapters', $config['adapters']); 65 | 66 | $builder->registerAttributeForAutoconfiguration(AsSearch::class, static function (ChildDefinition $definition, AsSearch $attribute, \Reflector $reflector) { 67 | $tagAttributes = get_object_vars($attribute); 68 | if (null === $tagAttributes['name'] && $reflector instanceof \ReflectionClass) { 69 | $tagAttributes['name'] = strtolower(str_replace('Search', '', $reflector->getShortName())); 70 | } 71 | 72 | $definition->addTag('mezcalito_ux_search.search', $tagAttributes); 73 | }); 74 | 75 | $builder->registerForAutoconfiguration(AdapterFactoryInterface::class)->addTag('mezcalito_ux_search.adapter_factory'); 76 | $builder->registerForAutoconfiguration(UrlFormaterInterface::class)->addTag('mezcalito_ux_search.url_formater'); 77 | } 78 | 79 | public function build(ContainerBuilder $container): void 80 | { 81 | parent::build($container); 82 | 83 | $container->addCompilerPass(new RegisterSearchPass()); 84 | $container->addCompilerPass(new AdapterFactoryPass()); 85 | $container->addCompilerPass(new UrlFormaterPass()); 86 | } 87 | 88 | public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void 89 | { 90 | $builder->prependExtensionConfig('twig_component', [ 91 | 'defaults' => [ 92 | 'Mezcalito\UxSearchBundle\Twig\Components\\' => [ 93 | 'template_directory' => '@MezcalitoUxSearch/', 94 | 'name_prefix' => 'Mezcalito:UxSearch', 95 | ], 96 | ], 97 | ]); 98 | 99 | if ($this->isAssetMapperAvailable($builder)) { 100 | $builder->prependExtensionConfig('framework', [ 101 | 'asset_mapper' => [ 102 | 'paths' => [ 103 | __DIR__.'/../assets/dist' => '@mezcalito/ux-search', 104 | ], 105 | ], 106 | ]); 107 | } 108 | } 109 | 110 | private function isAssetMapperAvailable(ContainerBuilder $builder): bool 111 | { 112 | if (!interface_exists(AssetMapperInterface::class)) { 113 | return false; 114 | } 115 | 116 | // check that FrameworkBundle 6.3 or higher is installed 117 | $bundlesMetadata = $builder->getParameter('kernel.bundles_metadata'); 118 | if (!isset($bundlesMetadata['FrameworkBundle'])) { 119 | return false; 120 | } 121 | 122 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Search/AbstractSearch.php: -------------------------------------------------------------------------------- 1 | eventDispatcher = new EventDispatcher(); 46 | $this->build($options); 47 | 48 | return $this; 49 | } 50 | 51 | public function build(array $options = []): void 52 | { 53 | } 54 | 55 | public function getIndexName(): ?string 56 | { 57 | if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsSearch::class)) { 58 | return $attribute[0]->newInstance()->index; 59 | } 60 | 61 | return null; 62 | } 63 | 64 | public function getAdapterName(): ?string 65 | { 66 | if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsSearch::class)) { 67 | return $attribute[0]->newInstance()->adapter; 68 | } 69 | 70 | return null; 71 | } 72 | 73 | public function getAvailableHitsPerPage(): array 74 | { 75 | return $this->availableHitsPerPage; 76 | } 77 | 78 | public function setAvailableHitsPerPage(array $availableHitsPerPage): static 79 | { 80 | $this->availableHitsPerPage = $availableHitsPerPage; 81 | 82 | return $this; 83 | } 84 | 85 | public function addAvailableSort(?string $key, string $label): static 86 | { 87 | $this->availableSorts[] = new Sort($key, $label); 88 | 89 | return $this; 90 | } 91 | 92 | public function getAvailableSorts(): array 93 | { 94 | return $this->availableSorts; 95 | } 96 | 97 | public function addFacet(string $property, string $label, ?string $displayComponent = null, array $props = []): static 98 | { 99 | $this->facets[] = (new Facet($property, $label, $displayComponent, $props)); 100 | 101 | return $this; 102 | } 103 | 104 | public function getFacets(): array 105 | { 106 | return $this->facets; 107 | } 108 | 109 | public function getFacet(string $property): ?Facet 110 | { 111 | foreach ($this->getFacets() as $facet) { 112 | if ($facet->getProperty() === $property) { 113 | return $facet; 114 | } 115 | } 116 | 117 | throw SearchException::facetNotConfigured($property); 118 | } 119 | 120 | public function getEventDispatcher(): EventDispatcher 121 | { 122 | return $this->eventDispatcher; 123 | } 124 | 125 | public function addEventSubscriber(EventSubscriberInterface $eventSubscriber): static 126 | { 127 | $this->eventDispatcher->addSubscriber($eventSubscriber); 128 | 129 | return $this; 130 | } 131 | 132 | public function addEventListener(string $eventName, callable $listener, int $priority = 0): static 133 | { 134 | $this->eventDispatcher->addListener($eventName, $listener, $priority); 135 | 136 | return $this; 137 | } 138 | 139 | public function getAdapterParameters(): array 140 | { 141 | return $this->adapterParameters; 142 | } 143 | 144 | public function setAdapterParameters(array $adapterParameters): static 145 | { 146 | $this->adapterParameters = $adapterParameters; 147 | 148 | return $this; 149 | } 150 | 151 | public function getResolvedAdapterParameters(): array 152 | { 153 | return $this->resolvedAdapterParameters; 154 | } 155 | 156 | public function getResolvedAdapterParameter(string $name): mixed 157 | { 158 | return $this->resolvedAdapterParameters[$name] ?? null; 159 | } 160 | 161 | public function setResolvedAdapterParameters(array $resolvedAdapterParameters): static 162 | { 163 | $this->resolvedAdapterParameters = $resolvedAdapterParameters; 164 | 165 | return $this; 166 | } 167 | 168 | public function createQuery(): Query 169 | { 170 | $query = new Query(); 171 | 172 | if ($this->availableHitsPerPage) { 173 | $query->setActiveHitsPerPage(current($this->availableHitsPerPage)); 174 | } 175 | 176 | if ($this->availableSorts) { 177 | /** @var Sort $defaultSort */ 178 | $defaultSort = current($this->availableSorts); 179 | $query->setActiveSort($defaultSort->getKey()); 180 | } 181 | 182 | return $query; 183 | } 184 | 185 | public function enableUrlRewriting(): static 186 | { 187 | $this->urlRewritting = true; 188 | 189 | return $this; 190 | } 191 | 192 | public function hasUrlRewriting(): bool 193 | { 194 | return $this->urlRewritting; 195 | } 196 | 197 | public function getUrlFormater(): string 198 | { 199 | return $this->urlFormater ?? DefaultUrlFormater::class; 200 | } 201 | 202 | public function setUrlFormater(string $urlFormater): static 203 | { 204 | $this->urlFormater = $urlFormater; 205 | 206 | return $this; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Search/Facet.php: -------------------------------------------------------------------------------- 1 | property; 29 | } 30 | 31 | public function getLabel(): string 32 | { 33 | return $this->label; 34 | } 35 | 36 | public function getDisplayComponent(): ?string 37 | { 38 | return $this->displayComponent; 39 | } 40 | 41 | public function getProps(): array 42 | { 43 | return $this->props; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Search/Filter/AbstractFilter.php: -------------------------------------------------------------------------------- 1 | property; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Search/Filter/FilterInterface.php: -------------------------------------------------------------------------------- 1 | RangeFilter::class, 20 | 'term' => TermFilter::class, 21 | ])] 22 | interface FilterInterface 23 | { 24 | public function getProperty(): ?string; 25 | 26 | public function hasValues(): bool; 27 | } 28 | -------------------------------------------------------------------------------- /src/Search/Filter/RangeFilter.php: -------------------------------------------------------------------------------- 1 | min; 29 | } 30 | 31 | public function setMin(int|float|null $min): static 32 | { 33 | $this->min = $min; 34 | 35 | return $this; 36 | } 37 | 38 | public function getMax(): int|float|null 39 | { 40 | return $this->max; 41 | } 42 | 43 | public function setMax(int|float|null $max): static 44 | { 45 | $this->max = $max; 46 | 47 | return $this; 48 | } 49 | 50 | public function hasValues(): bool 51 | { 52 | return $this->min || $this->max; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Search/Filter/TermFilter.php: -------------------------------------------------------------------------------- 1 | values; 26 | } 27 | 28 | public function setValues(array $values): static 29 | { 30 | $this->values = $values; 31 | 32 | return $this; 33 | } 34 | 35 | public function hasValue(string $value): bool 36 | { 37 | return \in_array($value, $this->values); 38 | } 39 | 40 | public function hasValues(): bool 41 | { 42 | return [] !== $this->values; 43 | } 44 | 45 | public function addValue(string $value): void 46 | { 47 | $this->values[] = $value; 48 | } 49 | 50 | public function removeValue(string $value): void 51 | { 52 | if (($key = array_search($value, $this->values, true)) !== false) { 53 | unset($this->values[$key]); 54 | } 55 | } 56 | 57 | public function toggleValue(string $value): void 58 | { 59 | if ($this->hasValue($value)) { 60 | $this->removeValue($value); 61 | } else { 62 | $this->addValue($value); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Search/Query.php: -------------------------------------------------------------------------------- 1 | queryString; 34 | } 35 | 36 | public function setQueryString(string $queryString): static 37 | { 38 | $this->queryString = $queryString; 39 | 40 | return $this; 41 | } 42 | 43 | public function getActiveFilters(): array 44 | { 45 | return $this->activeFilters; 46 | } 47 | 48 | public function setActiveFilters(array $activeFilters): static 49 | { 50 | $this->activeFilters = $activeFilters; 51 | 52 | return $this; 53 | } 54 | 55 | public function getActiveFilter(string $property): ?FilterInterface 56 | { 57 | foreach ($this->activeFilters as $activeFilter) { 58 | if ($activeFilter->getProperty() === $property) { 59 | return $activeFilter; 60 | } 61 | } 62 | 63 | return null; 64 | } 65 | 66 | public function addActiveFilter(FilterInterface $filter): static 67 | { 68 | $this->activeFilters[] = $filter; 69 | 70 | return $this; 71 | } 72 | 73 | public function hasActiveFilter(string $property): bool 74 | { 75 | return $this->getActiveFilter($property) instanceof FilterInterface; 76 | } 77 | 78 | public function getCurrentPage(): int 79 | { 80 | return $this->currentPage; 81 | } 82 | 83 | public function setCurrentPage(int $currentPage): static 84 | { 85 | $this->currentPage = $currentPage; 86 | 87 | return $this; 88 | } 89 | 90 | public function setActiveSort(?string $sort): static 91 | { 92 | $this->activeSort = $sort; 93 | 94 | return $this; 95 | } 96 | 97 | public function getActiveSort(): ?string 98 | { 99 | return $this->activeSort; 100 | } 101 | 102 | public function getActiveHitsPerPage(): int 103 | { 104 | return $this->activeHitsPerPage; 105 | } 106 | 107 | public function setActiveHitsPerPage(int $activeHitsPerPage): static 108 | { 109 | $this->activeHitsPerPage = $activeHitsPerPage; 110 | 111 | return $this; 112 | } 113 | 114 | public function removeActiveFilter(FilterInterface $filter): void 115 | { 116 | $this->activeFilters = array_filter($this->activeFilters, fn (FilterInterface $activeFilter) => $activeFilter !== $filter); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Search/ResultSet/FacetStat.php: -------------------------------------------------------------------------------- 1 | property; 30 | } 31 | 32 | public function getMin(): float|int 33 | { 34 | return $this->min; 35 | } 36 | 37 | public function getMax(): float|int 38 | { 39 | return $this->max; 40 | } 41 | 42 | public function getUserMin(): float|int|null 43 | { 44 | return $this->userMin; 45 | } 46 | 47 | public function getUserMax(): float|int|null 48 | { 49 | return $this->userMax; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Search/ResultSet/FacetTermDistribution.php: -------------------------------------------------------------------------------- 1 | */ 21 | private array $values = []; 22 | 23 | private array $checkedValues = []; 24 | 25 | public function getProperty(): string 26 | { 27 | return $this->property; 28 | } 29 | 30 | public function setProperty(string $property): static 31 | { 32 | $this->property = $property; 33 | 34 | return $this; 35 | } 36 | 37 | public function getValues(): array 38 | { 39 | return $this->values; 40 | } 41 | 42 | public function setValues(array $values): static 43 | { 44 | $this->values = $values; 45 | 46 | return $this; 47 | } 48 | 49 | public function getCheckedValues(): array 50 | { 51 | return $this->checkedValues; 52 | } 53 | 54 | public function setCheckedValues(array $checkedValues): static 55 | { 56 | $this->checkedValues = $checkedValues; 57 | 58 | return $this; 59 | } 60 | 61 | public function isChecked(mixed $value): bool 62 | { 63 | return \in_array($value, $this->checkedValues); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Search/ResultSet/Hit.php: -------------------------------------------------------------------------------- 1 | data; 27 | } 28 | 29 | public function setData(object|array $data): self 30 | { 31 | $this->data = $data; 32 | 33 | return $this; 34 | } 35 | 36 | public function getScore(): float 37 | { 38 | return $this->score; 39 | } 40 | 41 | public function setScore(float $score): self 42 | { 43 | $this->score = $score; 44 | 45 | return $this; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Search/ResultSet/ResultSet.php: -------------------------------------------------------------------------------- 1 | indexUid; 36 | } 37 | 38 | public function setIndexUid(?string $indexUid): static 39 | { 40 | $this->indexUid = $indexUid; 41 | 42 | return $this; 43 | } 44 | 45 | public function getHits(): array 46 | { 47 | return $this->hits; 48 | } 49 | 50 | public function setHits(array $hits): static 51 | { 52 | $this->hits = $hits; 53 | 54 | return $this; 55 | } 56 | 57 | public function getTotalResults(): int 58 | { 59 | return $this->totalResults; 60 | } 61 | 62 | public function setTotalResults(int $totalResults): static 63 | { 64 | $this->totalResults = $totalResults; 65 | 66 | return $this; 67 | } 68 | 69 | public function getFacetDistributions(): array 70 | { 71 | return $this->facetDistributions; 72 | } 73 | 74 | public function setFacetDistributions(array $facetDistributions): static 75 | { 76 | $this->facetDistributions = $facetDistributions; 77 | 78 | return $this; 79 | } 80 | 81 | public function getFacetDistribution(string $property): FacetTermDistribution 82 | { 83 | foreach ($this->facetDistributions as $facetDistribution) { 84 | if ($facetDistribution->getProperty() === $property) { 85 | return $facetDistribution; 86 | } 87 | } 88 | 89 | throw ResultSetException::facetDistributionNotFound($property); 90 | } 91 | 92 | public function getFacetStats(): array 93 | { 94 | return $this->facetStats; 95 | } 96 | 97 | public function setFacetStats(array $facetStats): static 98 | { 99 | $this->facetStats = $facetStats; 100 | 101 | return $this; 102 | } 103 | 104 | public function getFacetStat(string $property): FacetStat 105 | { 106 | foreach ($this->facetStats as $facetStat) { 107 | if ($facetStat->getProperty() === $property) { 108 | return $facetStat; 109 | } 110 | } 111 | 112 | throw ResultSetException::facetStatNotFound($property); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Search/SearchInterface.php: -------------------------------------------------------------------------------- 1 | searchs as $searchName => $search) { 29 | if ($name === $searchName) { 30 | return $search; 31 | } 32 | } 33 | 34 | throw SearchException::nameNotFound($name); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Search/Searcher.php: -------------------------------------------------------------------------------- 1 | getEventDispatcher(); 34 | $search->addEventSubscriber(new ContextSubscriber($this->contextProvider)); 35 | 36 | $eventDispatcher->dispatch(new PreSearchEvent($query, $search)); 37 | 38 | $adapter = $this->adapterProvider->getAdapter($search->getAdapterName()); 39 | 40 | $optionResolver = new OptionsResolver(); 41 | $adapter->configureParameters($optionResolver); 42 | $search->setResolvedAdapterParameters($optionResolver->resolve($search->getAdapterParameters())); 43 | 44 | $results = $adapter->search($query, $search); 45 | 46 | $eventDispatcher->dispatch(new PostSearchEvent($query, $search, $results)); 47 | 48 | return $results; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Search/Sort.php: -------------------------------------------------------------------------------- 1 | key; 27 | } 28 | 29 | public function getLabel(): string 30 | { 31 | return $this->label; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Search/Url/CurrentRequest.php: -------------------------------------------------------------------------------- 1 | attributes->all(), $request->query->all()), fn ($key) => !str_starts_with((string) $key, '_'), \ARRAY_FILTER_USE_KEY); 29 | 30 | return new self($request->attributes->get('_route'), $parameters); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Search/Url/DefaultUrlFormater.php: -------------------------------------------------------------------------------- 1 | clearParameters($currentRequest->parameters, $search); 37 | 38 | if ('' !== $query->getQueryString()) { 39 | $params[self::QUERY] = $query->getQueryString(); 40 | } 41 | 42 | if ($query->getActiveSort()) { 43 | $params[self::SORT_BY] = $query->getActiveSort(); 44 | } 45 | 46 | if ($query->getCurrentPage() > 1) { 47 | $params[self::PAGE] = $query->getCurrentPage(); 48 | } 49 | 50 | foreach ($query->getActiveFilters() as $filter) { 51 | $propertyForUrl = str_replace('.', '_', $filter->getProperty()); 52 | if ($filter instanceof TermFilter) { 53 | $params[$propertyForUrl] = implode('~~', $filter->getValues()); 54 | } 55 | 56 | if ($filter instanceof RangeFilter) { 57 | $params[$propertyForUrl.'Min'] = $filter->getMin(); 58 | $params[$propertyForUrl.'Max'] = $filter->getMax(); 59 | } 60 | } 61 | 62 | return $this->urlGenerator->generate($currentRequest->route, $params, UrlGeneratorInterface::ABSOLUTE_URL); 63 | } 64 | 65 | public function applyFilters(CurrentRequest $currentRequest, SearchInterface $search, Query $query): void 66 | { 67 | if ($q = $currentRequest->parameters[self::QUERY] ?? null) { 68 | $query->setQueryString($q); 69 | } 70 | 71 | if ($s = $currentRequest->parameters[self::SORT_BY] ?? null) { 72 | $query->setActiveSort($s); 73 | } 74 | 75 | if ($p = $currentRequest->parameters[self::PAGE] ?? null) { 76 | $query->setCurrentPage((int) $p); 77 | } 78 | 79 | foreach ($search->getFacets() as $facet) { 80 | $property = $facet->getProperty(); 81 | $propertyInUrl = str_replace('.', '_', $property); 82 | 83 | if ($value = $currentRequest->parameters[$propertyInUrl] ?? null) { 84 | $query->addActiveFilter(new TermFilter($property, explode('~~', (string) $value))); 85 | } 86 | 87 | $minValue = $currentRequest->parameters[$propertyInUrl.'Min'] ?? null; 88 | $maxValue = $currentRequest->parameters[$propertyInUrl.'Max'] ?? null; 89 | if ($minValue || $maxValue) { 90 | $query->addActiveFilter(new RangeFilter( 91 | $property, 92 | null !== $minValue ? (float) $minValue : null, 93 | null !== $maxValue ? (float) $maxValue : null 94 | )); 95 | } 96 | } 97 | } 98 | 99 | private function clearParameters(array $params, SearchInterface $search): array 100 | { 101 | $searchableParameterKeys = $this->getSearchableParameterKeys($search); 102 | 103 | return array_filter($params, fn ($key) => !\in_array($key, $searchableParameterKeys), \ARRAY_FILTER_USE_KEY); 104 | } 105 | 106 | private function getSearchableParameterKeys(SearchInterface $search): array 107 | { 108 | $keys = [self::PAGE, self::SORT_BY]; 109 | foreach ($search->getFacets() as $facet) { 110 | $propertyInUrl = str_replace('.', '_', $facet->getProperty()); 111 | $keys[] = $propertyInUrl; 112 | $keys[] = $propertyInUrl.'Min'; 113 | $keys[] = $propertyInUrl.'Max'; 114 | } 115 | 116 | return $keys; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Search/Url/UrlFormaterInterface.php: -------------------------------------------------------------------------------- 1 | formaters as $urlFormaterName => $formater) { 29 | if ($fqcn === $urlFormaterName) { 30 | return $formater; 31 | } 32 | } 33 | 34 | throw UrlFormaterException::urlFormaterNotFound($fqcn); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Twig/Components/ClearRefinements.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getQuery()->getActiveFilters(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Twig/Components/CurrentRefinements.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getQuery()->getActiveFilters(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Twig/Components/Facet.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getSearch()->getFacet($this->property); 33 | 34 | return $facet->getDisplayComponent() ?? RefinementList::class; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Twig/Components/Facet/AbstractFacet.php: -------------------------------------------------------------------------------- 1 | getFacet()->getLabel(); 33 | } 34 | 35 | #[ExposeInTemplate] 36 | protected function getFacet(): Facet 37 | { 38 | return $this->contextProvider->getCurrentContext()->getSearch()->getFacet($this->property); 39 | } 40 | 41 | #[PreMount(priority: -100)] 42 | public function mergeFacetData(array $data): array 43 | { 44 | $facet = $this->contextProvider->getCurrentContext()->getSearch()->getFacet($data['property']); 45 | 46 | return array_merge($facet->getProps(), $data); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Twig/Components/Facet/RangeInput.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getResults()->getFacetStat($this->property); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Twig/Components/Facet/RangeSlider.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getResults()->getFacetStat($this->property); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Twig/Components/Facet/RefinementList.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getResults()->getFacetDistribution($this->property); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Twig/Components/Hits.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getResults(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Twig/Components/HitsPerPage.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getSearch()->getAvailableHitsPerPage(); 30 | } 31 | 32 | #[ExposeInTemplate] 33 | public function getActiveHitPerPage(): int 34 | { 35 | return $this->contextProvider->getCurrentContext()->getQuery()->getActiveHitsPerPage(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Twig/Components/Layout.php: -------------------------------------------------------------------------------- 1 | search = $this->getSearch($data['name'])->create($data['options'] ?? []); 65 | $this->query = $this->getSearch($data['name'])->createQuery(); 66 | $this->currentRequest = CurrentRequest::fromRequest($this->requestStack->getMainRequest()); 67 | 68 | if ($this->search->hasUrlRewriting()) { 69 | $this->getUrlFormater()->applyFilters($this->currentRequest, $this->search, $this->query); 70 | } 71 | 72 | $this->searcher->search($this->query, $this->search); 73 | } 74 | 75 | #[PreReRender] 76 | public function onReRender(): void 77 | { 78 | $this->search = $this->getSearch($this->name)->create($this->options); 79 | $this->searcher->search($this->query, $this->search); 80 | 81 | if ($this->search->hasUrlRewriting()) { 82 | $this->dispatchBrowserEvent('history:update', ['url' => $this->getUrlFormater()->generateUrl($this->currentRequest, $this->search, $this->query)]); 83 | } 84 | } 85 | 86 | #[LiveAction] 87 | public function changeCurrentPage(#[LiveArg] int $page): void 88 | { 89 | $this->query->setCurrentPage($page); 90 | } 91 | 92 | #[LiveAction] 93 | public function toggleFacetTerm(#[LiveArg] string $property, #[LiveArg] string $value): void 94 | { 95 | $filter = $this->query->getActiveFilter($property); 96 | 97 | if (!$filter instanceof TermFilter) { 98 | $filter = new TermFilter($property); 99 | $this->query->addActiveFilter($filter); 100 | } 101 | 102 | $filter->toggleValue($value); 103 | 104 | if (!$filter->hasValues()) { 105 | $this->query->removeActiveFilter($filter); 106 | } 107 | 108 | $this->query->setCurrentPage(1); 109 | } 110 | 111 | #[LiveAction] 112 | public function updateFacetRange(#[LiveArg] string $property, #[LiveArg] float|int|null $min, #[LiveArg] float|int|null $max): void 113 | { 114 | $filter = $this->query->getActiveFilter($property); 115 | 116 | if (!$filter instanceof RangeFilter) { 117 | $filter = new RangeFilter($property); 118 | $this->query->addActiveFilter($filter); 119 | } 120 | 121 | $filter->setMin($min); 122 | $filter->setMax($max); 123 | 124 | if (!$filter->hasValues()) { 125 | $this->query->removeActiveFilter($filter); 126 | } 127 | 128 | $this->query->setCurrentPage(1); 129 | } 130 | 131 | #[LiveAction] 132 | public function clearRefinements(): void 133 | { 134 | $this->query->setActiveFilters([]); 135 | } 136 | 137 | private function getSearch(string $name): SearchInterface 138 | { 139 | return $this->searchConfigurationProvider->getSearch($name); 140 | } 141 | 142 | private function getUrlFormater(): UrlFormaterInterface 143 | { 144 | return $this->urlFormaterProvider->getUrlFormater($this->search->getUrlFormater()); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Twig/Components/Pagination.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getQuery()->getCurrentPage() - $this->range, 1); 32 | } 33 | 34 | #[ExposeInTemplate] 35 | public function getEndRange(): int 36 | { 37 | $endRange = min($this->contextProvider->getCurrentContext()->getQuery()->getCurrentPage() + $this->range, $this->getTotalPage() - $this->range); 38 | 39 | return 0 == $endRange ? $this->getTotalPage() : $endRange; 40 | } 41 | 42 | #[ExposeInTemplate] 43 | public function getTotalPage(): int 44 | { 45 | return (int) ceil($this->contextProvider->getCurrentContext()->getResults()->getTotalResults() / $this->contextProvider->getCurrentContext()->getQuery()->getActiveHitsPerPage()); 46 | } 47 | 48 | #[ExposeInTemplate] 49 | public function getPage(): int 50 | { 51 | return $this->contextProvider->getCurrentContext()->getQuery()->getCurrentPage(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Twig/Components/SearchInput.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getSearch()->getAvailableSorts(); 30 | } 31 | 32 | #[ExposeInTemplate] 33 | public function getActiveSort(): ?string 34 | { 35 | return $this->contextProvider->getCurrentContext()->getQuery()->getActiveSort(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Twig/Components/TotalHits.php: -------------------------------------------------------------------------------- 1 | contextProvider->getCurrentContext()->getResults()->getTotalResults(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Twig/UxSearchExtension.php: -------------------------------------------------------------------------------- 1 | isRangeFilter(...)), 28 | new TwigFunction('ux_search_is_term_filter', $this->isTermFilter(...)), 29 | ]; 30 | } 31 | 32 | public function isRangeFilter(FilterInterface $filter): bool 33 | { 34 | return $filter instanceof RangeFilter; 35 | } 36 | 37 | public function isTermFilter(FilterInterface $filter): bool 38 | { 39 | return $filter instanceof TermFilter; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /templates/ClearRefinements.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | {%- if activeFilters is defined and activeFilters|length > 0 %} 3 | 12 | {% endif %} 13 | {% endblock -%} 14 | -------------------------------------------------------------------------------- /templates/CurrentRefinements.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
5 |
    6 | {%- for active_filter in activeFilters %} 7 | {% if ux_search_is_term_filter(active_filter) %} 8 | {% for value in active_filter.values %} 9 |
  • 10 | {{ value }} 11 | 22 |
  • 23 | {% endfor %} 24 | {% elseif ux_search_is_range_filter(active_filter) %} 25 | {% if active_filter.min is not null %} 26 |
  • 27 | {{ active_filter.property }} >= {{ active_filter.min }} 28 | 29 | 40 |
  • 41 | {% endif %} 42 | 43 | {% if active_filter.max is not null %} 44 |
  • 45 | {{ active_filter.property }} <= {{ active_filter.max }} 46 | 47 | 58 |
  • 59 | {% endif %} 60 | {% endif %} 61 | {% endfor -%} 62 |
63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /templates/Facet.html.twig: -------------------------------------------------------------------------------- 1 | {{ component(component, {property: property}) }} 2 | -------------------------------------------------------------------------------- /templates/Facet/RangeInput.html.twig: -------------------------------------------------------------------------------- 1 |
5 | {% block label %}{{ label }}{% endblock %} 6 | {% block form %} 7 |
14 |
15 | 16 | 27 |
28 |
29 | 30 | 41 |
42 | {% block submit %} 43 | 46 | {% endblock %} 47 |
48 | {% endblock %} 49 |
50 | -------------------------------------------------------------------------------- /templates/Facet/RangeSlider.html.twig: -------------------------------------------------------------------------------- 1 |
8 | {{ label }} 9 |
17 | 38 | 59 |
60 |
61 | 66 | {{ this.leading ~ facetStat.userMin|default(facetStat.min) ~ this.trailing }} 67 | 68 | 73 | {{ this.leading ~ facetStat.userMax|default(facetStat.max) ~ this.trailing }} 74 | 75 |
76 |
77 | -------------------------------------------------------------------------------- /templates/Facet/RefinementList.html.twig: -------------------------------------------------------------------------------- 1 |
9 | {% block label %}{{ label }}{% endblock %} 10 | {% block list %} 11 |
    12 | {%- for key,value in distribution.values %} 13 |
  • 16 | 27 | 31 |
  • 32 | {% endfor -%} 33 |
34 | {% endblock %} 35 | {% if distribution.values|length > this.limit %} 36 | {% block show_more %} 37 | 45 | {% endblock %} 46 | {% endif %} 47 |
48 | -------------------------------------------------------------------------------- /templates/Hits.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
5 | {% if results.hits|length > 0 %} 6 |
7 | {% for hit in results.hits %} 8 | {% block hit %} 9 |
10 |
{{ hit.data|json_encode(constant('JSON_PRETTY_PRINT')) }}
11 |
12 | {% endblock %} 13 | {% endfor %} 14 |
15 | {% else %} 16 | {% block noResult %} 17 |
{{ 'no_result'|trans(domain='mezcalito_ux_search') }}
18 | {% endblock %} 19 | {% endif %} 20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /templates/HitsPerPage.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
5 | 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/Layout.html.twig: -------------------------------------------------------------------------------- 1 |
6 | {% block content %} 7 |
8 |
9 | {% block form %} 10 | 11 | {% endblock %} 12 |
13 | 14 |
15 | {% block toolbar %} 16 | 17 | 18 | 19 | 20 | {% endblock %} 21 |
22 | 23 |
24 | {% block facets %} 25 | {% for facet in search.facets %} 26 | 27 | {% endfor %} 28 | {% endblock %} 29 |
30 | 31 |
32 | {% block listing %} 33 |
34 | {% block stats %} 35 | 36 | {% endblock %} 37 |
38 | 39 | {% block hits %} 40 | 41 | {% endblock %} 42 | 43 | {% block pagination %} 44 | 45 | {% endblock %} 46 | {% endblock %} 47 |
48 |
49 | {% endblock %} 50 |
51 | -------------------------------------------------------------------------------- /templates/Pagination.html.twig: -------------------------------------------------------------------------------- 1 | {%- block content %} 2 | {%- if totalPage > 1 %} 3 | 84 | {% endif -%} 85 | 86 | {%- macro link(iterator, page) %} 87 | {%- if page == iterator %} 88 | {{ iterator }} 89 | {% else %} 90 | 97 | {{ iterator }} 98 | 99 | {% endif -%} 100 | {% endmacro -%} 101 | 102 | {%- macro elipsis() %} 103 | ... 104 | {% endmacro -%} 105 | {% endblock -%} 106 | -------------------------------------------------------------------------------- /templates/SearchInput.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /templates/SortBy.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
5 | 10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /templates/TotalHits.html.twig: -------------------------------------------------------------------------------- 1 | {% block content %} 2 | 5 | {{ 'results'|trans({'%count%': totalHits}, domain='mezcalito_ux_search') }} 6 | 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /templates/skeleton/search.tpl.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace ; 4 | 5 | 6 | 7 | #[AsSearch()] 8 | class extends AbstractSearch 9 | { 10 | 11 | public function build(array $options = []): void 12 | { 13 | // ->addFacet('type', 'Type', null, ['limit' => 2]) 14 | // ->addFacet('brand', 'Brand') 15 | // ->addFacet('rating', 'Rating') 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /translations/mezcalito_ux_search.en.php: -------------------------------------------------------------------------------- 1 | 'Go', 16 | 'show_more' => 'Show more', 17 | 'show_less' => 'Show less', 18 | 'remove' => 'Remove', 19 | 'reset_filters' => 'Reset filters', 20 | 'results' => '{0} 0 result|{1} 1 result|]1,Inf] %count% results', 21 | 'no_result' => 'No result', 22 | 'range' => [ 23 | 'min' => 'Min', 24 | 'max' => 'Max', 25 | ], 26 | 'pagination' => [ 27 | 'previous_page' => 'Previous page', 28 | 'next_page' => 'Next page', 29 | ], 30 | 'search' => [ 31 | 'placeholder' => 'Search here...', 32 | ], 33 | ]; 34 | -------------------------------------------------------------------------------- /translations/mezcalito_ux_search.fr.php: -------------------------------------------------------------------------------- 1 | 'Go', 16 | 'show_more' => 'Voir plus', 17 | 'show_less' => 'Voir moins', 18 | 'remove' => 'Retirer', 19 | 'reset_filters' => 'Réinitialiser', 20 | 'results' => '{0} 0 résultat|{1} 1 résultat|]1,Inf] %count% résultats', 21 | 'no_result' => 'Aucun résultat', 22 | 'range' => [ 23 | 'min' => 'Min', 24 | 'max' => 'Max', 25 | ], 26 | 'pagination' => [ 27 | 'previous_page' => 'Page précédente', 28 | 'next_page' => 'Page suivante', 29 | ], 30 | 'search' => [ 31 | 'placeholder' => 'Rechercher...', 32 | ], 33 | ]; 34 | --------------------------------------------------------------------------------