From e035ca3b30fc8178ce699d63507298b1b4bd76cd Mon Sep 17 00:00:00 2001 From: Pierre Gauthier Date: Wed, 15 Jul 2026 11:22:10 +0200 Subject: [PATCH 1/7] Migrate shop JS from raw scripts to Stimulus controllers --- README.md | 38 +++- package.json | 22 +++ .../shop/controllers/RangeSliderController.js | 54 ++++++ .../SearchAutocompleteController.js | 173 ++++++++++++++++++ .../shop/controllers/ViewMoreController.js | 39 ++++ src/Resources/assets/shop/entrypoint.js | 3 +- src/Resources/public/range-slider.js | 46 ----- src/Resources/public/search.js | 168 ----------------- src/Resources/public/view-more.js | 35 ---- .../views/shop/events_javascript.html.twig | 5 +- .../views/shop/events_stylesheets.html.twig | 5 +- .../views/shop/form/checkbox.html.twig | 2 +- .../views/shop/form/range_widget.html.twig | 13 +- .../content/body/sidebar/filters.html.twig | 2 +- .../content/body/sidebar/filters.html.twig | 2 +- .../components/header/search/form.html.twig | 17 +- 16 files changed, 348 insertions(+), 276 deletions(-) create mode 100644 src/Resources/assets/shop/controllers/RangeSliderController.js create mode 100644 src/Resources/assets/shop/controllers/SearchAutocompleteController.js create mode 100644 src/Resources/assets/shop/controllers/ViewMoreController.js delete mode 100644 src/Resources/public/range-slider.js delete mode 100644 src/Resources/public/search.js delete mode 100644 src/Resources/public/view-more.js diff --git a/README.md b/README.md index 61a9606..be17c87 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,7 @@ use GallyChannelTrait; } ``` - - Copy the bundle assets (Javascript & CSS files): - - Run `php bin/console assets:install` - - Run `php bin/console sylius:install:assets` - - Run `php bin/console sylius:theme:assets:install` - - - **Alternative: install assets via Webpack Encore (recommended for Sylius 2.x)** + - Install assets via Webpack Encore: - Add the plugin and its JS SDK as npm dependencies in your app's `package.json`: ```json { @@ -97,6 +92,37 @@ ``` > The `copyFiles()` call exposes `gally-sdk.global.js` as a standalone IIFE script > (available as `window.GallySDK`) for use in Twig templates via `{{ asset('build/app/shop/gally/gally-sdk.global.js') }}`. + - Register the plugin's Stimulus controllers in your app's root `assets/controllers.json`: + ```json + { + "controllers": { + "@gally/sylius-plugin": { + "range-slider": { + "main": "src/Resources/assets/shop/controllers/RangeSliderController.js", + "enabled": true, + "fetch": "eager" + }, + "search-autocomplete": { + "main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js", + "enabled": true, + "fetch": "eager" + }, + "view-more": { + "main": "src/Resources/assets/shop/controllers/ViewMoreController.js", + "enabled": true, + "fetch": "eager" + } + } + } + } + ``` + > This step is required because `@gally/sylius-plugin` is installed as a local `path` repository + > rather than a real registered npm/Symfony UX package: Symfony Flex normally adds a package's + > Stimulus controllers to your app's `assets/controllers.json` automatically via that package's + > recipe when you run `composer require`, but local `path` packages have no such recipe. This + > entry must therefore mirror the `symfony.controllers` section of the plugin's own `package.json` + > and be kept in sync manually if a future version of the plugin adds, renames, or removes a + > controller. - Install JS dependencies and build assets: - **Without Docker** (from the Sylius root directory): ```shell diff --git a/package.json b/package.json index aaa7d6b..1668633 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,28 @@ "name": "@gally/sylius-plugin", "version": "2.3.0", "license": "OSL-3.0", + "symfony": { + "controllers": { + "range-slider": { + "main": "src/Resources/assets/shop/controllers/RangeSliderController.js", + "enabled": true, + "fetch": "eager" + }, + "search-autocomplete": { + "main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js", + "enabled": true, + "fetch": "eager" + }, + "view-more": { + "main": "src/Resources/assets/shop/controllers/ViewMoreController.js", + "enabled": true, + "fetch": "eager" + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, "dependencies": { "@elastic-suite/gally-sdk": "2.2.2-alpha.0", "file-loader": "^6.2.0" diff --git a/src/Resources/assets/shop/controllers/RangeSliderController.js b/src/Resources/assets/shop/controllers/RangeSliderController.js new file mode 100644 index 0000000..8720edb --- /dev/null +++ b/src/Resources/assets/shop/controllers/RangeSliderController.js @@ -0,0 +1,54 @@ +import { Controller } from '@hotwired/stimulus'; +import noUiSlider from '../../../public/nouislider.min.js'; +import '../../../public/nouislider.min.css'; +import '../../../public/slider.css'; + +export default class extends Controller { + static targets = ['slider', 'input']; + static values = { + min: Number, + max: Number, + value: String, + }; + + connect() { + const valuesForSlider = []; + for (let i = this.minValue; i <= this.maxValue; i++) { + valuesForSlider.push(i); + } + + let start = [valuesForSlider[0], valuesForSlider[valuesForSlider.length - 1]]; + if (this.hasValueValue) { + const parts = this.valueValue.split('|'); + if (parts.length === 2) { + start = parts; + } + } + + this.slider = noUiSlider.create(this.sliderTarget, { + start, + step: 1, + tooltips: true, + connect: true, + range: { + min: 0, + max: valuesForSlider.length - 1, + }, + format: { + to: (value) => valuesForSlider[Math.round(value)], + from: (value) => valuesForSlider.indexOf(Number(value)), + }, + }); + + this.slider.on('end', (values) => { + this.inputTarget.value = `${values[0]}|${values[1]}`; + }); + } + + disconnect() { + if (this.slider) { + this.slider.destroy(); + this.slider = null; + } + } +} diff --git a/src/Resources/assets/shop/controllers/SearchAutocompleteController.js b/src/Resources/assets/shop/controllers/SearchAutocompleteController.js new file mode 100644 index 0000000..d22b58d --- /dev/null +++ b/src/Resources/assets/shop/controllers/SearchAutocompleteController.js @@ -0,0 +1,173 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Debounced, cached, abortable autocomplete search preview. One instance is connected per + * ".searchFormContainer" (desktop and mobile header variants each get their own). + */ +export default class extends Controller { + static targets = ['input', 'results', 'loading', 'resultsPanel']; + static values = { previewUrl: String }; + + connect() { + this.abortController = null; + this.debounceTimer = null; + this.queryCache = new Map(); + this.lastNonEmptyContent = null; + } + + disconnect() { + if (this.abortController) { + this.abortController.abort(); + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } + + onInput(event) { + const queryText = event.target.value; + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + if (queryText.length >= 3) { + // Keep previous results visible while waiting for debounce + this.resultsPanelTarget.classList.add('show'); + this.debounceTimer = setTimeout(() => this.performSearch(), 200); + + return; + } + + // Also cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + // New search session: reset last results so stale content won't reappear + this.lastNonEmptyContent = null; + this.resultsTarget.textContent = ''; + this.resultsPanelTarget.classList.remove('show'); + } + + onFocus(event) { + const queryText = event.target.value; + if (queryText.length < 3) { + return; + } + + if (this.resultsTarget.innerHTML.trim() !== '') { + this.resultsPanelTarget.classList.add('show'); + } else { + // Search silently: panel will only appear when results arrive via displayResults + this.performSearch({ showWhileLoading: false }); + } + } + + outsideClick(event) { + if ( + this.resultsPanelTarget.classList.contains('show') + && !this.resultsPanelTarget.contains(event.target) + && !this.inputTarget.contains(event.target) + ) { + this.resultsPanelTarget.classList.remove('show'); + } + } + + displayResults(content) { + this.loadingTarget.classList.add('d-none'); + this.resultsTarget.classList.remove('d-none'); + + // If response is empty but we have a previous non-empty result, keep showing it + const displayContent = content.htmlResults ? content : this.lastNonEmptyContent; + + if (!displayContent || !displayContent.htmlResults) { + this.resultsPanelTarget.classList.remove('show'); + + return; + } + + this.resultsPanelTarget.classList.add('show'); + this.resultsTarget.innerHTML = displayContent.htmlResults; + + if (this.resultsTarget.querySelector('.products')) { + this.resultsPanelTarget.parentElement.classList.add('start-0'); + this.resultsPanelTarget.parentElement.style.width = '100%'; + } else { + this.resultsPanelTarget.parentElement.classList.remove('start-0'); + this.resultsPanelTarget.parentElement.style.width = 'auto'; + } + } + + performSearch({ showWhileLoading = true } = {}) { + const form = this.element.querySelector('form'); + const formData = new FormData(form); + const plainFormData = Object.fromEntries(formData.entries()); + const formDataString = new URLSearchParams(plainFormData).toString(); + + // Serve from cache if available + if (this.queryCache.has(formDataString)) { + const cached = this.queryCache.get(formDataString); + this.resultsPanelTarget.classList.add('show'); + this.displayResults(cached); + if (cached.htmlResults) { + this.lastNonEmptyContent = cached; + } + + return; + } + + // While loading, show panel only if requested (not on focus) + if (showWhileLoading) { + if (this.lastNonEmptyContent) { + // Keep showing previous results (no spinner) + this.loadingTarget.classList.add('d-none'); + this.resultsTarget.classList.remove('d-none'); + } else { + // First search: show spinner + this.loadingTarget.classList.remove('d-none'); + this.resultsTarget.classList.add('d-none'); + } + this.resultsPanelTarget.classList.add('show'); + } + + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + fetch(this.previewUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formDataString, + signal: this.abortController.signal, + }) + .then((response) => response.json()) + .then((content) => { + // Track last non-empty result + if (content.htmlResults) { + this.lastNonEmptyContent = content; + } + + // Cache the result: if empty, store last non-empty result instead + const cachedContent = content.htmlResults ? content : this.lastNonEmptyContent; + + if (cachedContent) { + this.queryCache.set(formDataString, cachedContent); + this.displayResults(cachedContent); + } else { + // No result ever received yet, just hide the panel + this.loadingTarget.classList.add('d-none'); + this.resultsPanelTarget.classList.remove('show'); + } + }) + .catch((error) => { + if (error.name !== 'AbortError') { + console.error(error); + } + }); + } +} diff --git a/src/Resources/assets/shop/controllers/ViewMoreController.js b/src/Resources/assets/shop/controllers/ViewMoreController.js new file mode 100644 index 0000000..08d1497 --- /dev/null +++ b/src/Resources/assets/shop/controllers/ViewMoreController.js @@ -0,0 +1,39 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Connected on the facet list container: Stimulus keeps wiring "click" on any ".view-more" + * link matching data-action, including ones inserted later by the AJAX swap below. + */ +export default class extends Controller { + load(event) { + event.preventDefault(); + + const viewMoreBtn = event.currentTarget; + const form = viewMoreBtn.closest('form'); + const dataFor = viewMoreBtn.dataset.for; + const dataHref = viewMoreBtn.dataset.href; + const choicesEl = document.querySelector(`#${dataFor}`); + + form.classList.add('loading'); + + fetch(dataHref) + .then((response) => response.json()) + .then((data) => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = data.html; + const newFields = tempDiv.querySelector(`#${dataFor}`); + + if (newFields && choicesEl) { + choicesEl.replaceWith(newFields); + } + + viewMoreBtn.style.display = 'none'; + }) + .catch((error) => { + console.error('Fetch error:', error); + }) + .finally(() => { + form.classList.remove('loading'); + }); + } +} diff --git a/src/Resources/assets/shop/entrypoint.js b/src/Resources/assets/shop/entrypoint.js index 186d668..6b59964 100644 --- a/src/Resources/assets/shop/entrypoint.js +++ b/src/Resources/assets/shop/entrypoint.js @@ -1 +1,2 @@ -import '@elastic-suite/gally-sdk'; +import '@elastic-suite/gally-sdk/browser'; +import '../../public/filters.css'; diff --git a/src/Resources/public/range-slider.js b/src/Resources/public/range-slider.js deleted file mode 100644 index 7288f17..0000000 --- a/src/Resources/public/range-slider.js +++ /dev/null @@ -1,46 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - var sliderElement = document.querySelector('.range-slider'); - if (sliderElement) { - var inputSelector = sliderElement.getAttribute('data-input-id'); - var hiddenInput = document.querySelector(inputSelector); - if (hiddenInput) { - var min = parseInt(sliderElement.getAttribute('data-min')); - var max = parseInt(sliderElement.getAttribute('data-max')); - var valuesForSlider = []; - for (var i = min; i <= max; i++) { - valuesForSlider.push(i); - } - - var start = [valuesForSlider[0], valuesForSlider[valuesForSlider.length - 1]]; - var value = sliderElement.getAttribute('data-value'); - if (value !== null) { - value = value.split("|"); - if (value.length === 2) { - start = value; - } - } - - var slider = noUiSlider.create(sliderElement, { - start: start, - step: 1, - tooltips: true, - connect: true, - range: { - 'min': 0, - 'max': valuesForSlider.length - 1, - }, - format: { - to: function (value) { - return valuesForSlider[Math.round(value)]; - }, - from: function (value) { - return valuesForSlider.indexOf(Number(value)); - } - } - }); - slider.on('end', function(values) { - hiddenInput.value = values[0] + "|" + values[1]; - }); - } - } -}); diff --git a/src/Resources/public/search.js b/src/Resources/public/search.js deleted file mode 100644 index b26e45e..0000000 --- a/src/Resources/public/search.js +++ /dev/null @@ -1,168 +0,0 @@ -const gallySearchFormHandler = function () { - const gallySearchFormContainers = document.querySelectorAll('.searchFormContainer'); - - gallySearchFormContainers.forEach(container => { - const gallyPreviewUrl = container.dataset.previewUrl; - const gallySearchForm = container.querySelector('form'); - const gallySearchInput = gallySearchForm.querySelector('input'); - const gallySearchResult = container.querySelector('.collapsedSearchResults'); - - let abortController = null; - let debounceTimer = null; - const queryCache = new Map(); - let lastNonEmptyContent = null; - - const displayResults = (content) => { - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.querySelector('.results').classList.remove('d-none'); - - // If response is empty but we have a previous non-empty result, keep showing it - const displayContent = content.htmlResults ? content : lastNonEmptyContent; - - if (!displayContent || !displayContent.htmlResults) { - gallySearchResult.classList.remove('show'); - return; - } - - gallySearchResult.classList.add('show'); - gallySearchResult.querySelector('.results').innerHTML = displayContent.htmlResults; - - if (gallySearchResult.querySelector('.results .products')) { - gallySearchResult.parentElement.classList.add('start-0'); - gallySearchResult.parentElement.style.width = '100%'; - } else { - gallySearchResult.parentElement.classList.remove('start-0'); - gallySearchResult.parentElement.style.width = 'auto'; - } - }; - - const performSearch = ({ showWhileLoading = true } = {}) => { - const formData = new FormData(gallySearchForm); - const plainFormData = Object.fromEntries(formData.entries()); - const formDataString = new URLSearchParams(plainFormData).toString(); - - // Serve from cache if available - if (queryCache.has(formDataString)) { - const cached = queryCache.get(formDataString); - gallySearchResult.classList.add('show'); - displayResults(cached); - if (cached.htmlResults) { - lastNonEmptyContent = cached; - } - return; - } - - // While loading, show panel only if requested (not on focus) - if (showWhileLoading) { - if (lastNonEmptyContent) { - // Keep showing previous results (no spinner) - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.querySelector('.results').classList.remove('d-none'); - } else { - // First search: show spinner - gallySearchResult.querySelector('.loading-results').classList.remove('d-none'); - gallySearchResult.querySelector('.results').classList.add('d-none'); - } - gallySearchResult.classList.add('show'); - } - - if (abortController) { - abortController.abort(); - } - - abortController = new AbortController(); - - (async () => { - try { - const rawResponse = await fetch(gallyPreviewUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: formDataString, - signal: abortController.signal - }); - - const content = await rawResponse.json(); - - // Track last non-empty result - if (content.htmlResults) { - lastNonEmptyContent = content; - } - - // Cache the result: if empty, store last non-empty result instead - const cachedContent = content.htmlResults ? content : lastNonEmptyContent; - - if (cachedContent) { - queryCache.set(formDataString, cachedContent); - displayResults(cachedContent); - } else { - // No result ever received yet, just hide the panel - gallySearchResult.querySelector('.loading-results').classList.add('d-none'); - gallySearchResult.classList.remove('show'); - } - - } catch (error) { - if (error.name !== 'AbortError') { - console.error(error); - } - } - })(); - }; - - gallySearchInput.addEventListener('input', (event) => { - console.log('Input events__6:'); - const queryText = event.target.value; - - // Always cancel the previous debounce timer - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - - if (queryText.length >= 3) { - // Keep previous results visible while waiting for debounce - gallySearchResult.classList.add('show'); - - debounceTimer = setTimeout(() => { - performSearch(); - }, 200); - } else { - // Also cancel any ongoing request - if (abortController) { - abortController.abort(); - abortController = null; - } - // New search session: reset last results so stale content won't reappear - lastNonEmptyContent = null; - gallySearchResult.querySelector('.results').textContent = ''; - gallySearchResult.classList.remove('show'); - } - }); - - gallySearchInput.addEventListener('focus', (event) => { - const queryText = event.target.value; - if (queryText.length >= 3) { - if (gallySearchResult.querySelector('.results').innerHTML.trim() !== '') { - gallySearchResult.classList.add('show'); - } else { - // Search silently: panel will only appear when results arrive via displayResults - performSearch({ showWhileLoading: false }); - } - } - }); - }); - - // Close when clicking outside the search results or the search input - document.addEventListener('mousedown', function (event) { - document.querySelectorAll('.collapsedSearchResults.show').forEach(result => { - const container = result.closest('.searchFormContainer'); - const input = container ? container.querySelector('form input') : null; - if (!result.contains(event.target) && !(input && input.contains(event.target))) { - result.classList.remove('show'); - } - }); - }); -}; - -window.addEventListener("DOMContentLoaded", gallySearchFormHandler); diff --git a/src/Resources/public/view-more.js b/src/Resources/public/view-more.js deleted file mode 100644 index 1e91e65..0000000 --- a/src/Resources/public/view-more.js +++ /dev/null @@ -1,35 +0,0 @@ -document.addEventListener('DOMContentLoaded', function () { - document.addEventListener('click', function (event) { - const viewMoreBtn = event.target.closest('#searchbarTextField .view-more'); - if (!viewMoreBtn) return; - - event.preventDefault(); - - const form = viewMoreBtn.closest('form'); - const dataFor = viewMoreBtn.dataset.for; - const dataHref = viewMoreBtn.dataset.href; - const choicesEl = document.querySelector(`#${dataFor}`); - - form.classList.add('loading'); - - fetch(dataHref) - .then(response => response.json()) - .then(data => { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = data.html; - const newFields = tempDiv.querySelector(`#${dataFor}`); - - if (newFields && choicesEl) { - choicesEl.replaceWith(newFields); - } - - viewMoreBtn.style.display = 'none'; - }) - .catch(error => { - console.error('Fetch error:', error); - }) - .finally(() => { - form.classList.remove('loading'); - }); - }); -}); diff --git a/src/Resources/views/shop/events_javascript.html.twig b/src/Resources/views/shop/events_javascript.html.twig index 534f016..54dd564 100644 --- a/src/Resources/views/shop/events_javascript.html.twig +++ b/src/Resources/views/shop/events_javascript.html.twig @@ -1,4 +1 @@ -{#todo : make inclusion of scripts compatible with Webpack Encore and with the new strcuture of sylius 2#} -{% for script in ['nouislider.min.js', 'range-slider.js', 'view-more.js', 'search.js'] %} - -{% endfor %} +{{ encore_entry_script_tags('gally-shop-entry', null, 'app.shop') }} diff --git a/src/Resources/views/shop/events_stylesheets.html.twig b/src/Resources/views/shop/events_stylesheets.html.twig index fcda0a4..330e9e9 100644 --- a/src/Resources/views/shop/events_stylesheets.html.twig +++ b/src/Resources/views/shop/events_stylesheets.html.twig @@ -1,4 +1 @@ -{#todo : make inclusion of scripts compatible with Webpack Encore and with the new strcuture of sylius 2#} -{% for css in ['nouislider.min.css', 'slider.css', 'filters.css'] %} - -{% endfor %} +{{ encore_entry_link_tags('gally-shop-entry', null, 'app.shop') }} diff --git a/src/Resources/views/shop/form/checkbox.html.twig b/src/Resources/views/shop/form/checkbox.html.twig index 146dc19..94b74a1 100644 --- a/src/Resources/views/shop/form/checkbox.html.twig +++ b/src/Resources/views/shop/form/checkbox.html.twig @@ -4,7 +4,7 @@ {% set attr = attr|merge({'class': attr.class|default ~ ' ui'}) %} {{- form_widget(form, {'attr': attr}) -}} {% if has_more_url %} - {{ 'gally_sylius.ui.filters.view_more'|trans }} + {{ 'gally_sylius.ui.filters.view_more'|trans }} {% endif %} {%- endblock sylius_gally_filter_checkbox_row %} diff --git a/src/Resources/views/shop/form/range_widget.html.twig b/src/Resources/views/shop/form/range_widget.html.twig index a1ddf1f..48ce971 100644 --- a/src/Resources/views/shop/form/range_widget.html.twig +++ b/src/Resources/views/shop/form/range_widget.html.twig @@ -1,7 +1,12 @@ {%- block sylius_gally_filter_range_widget -%} -
- - {% set valueParts = value ? value|split('|') : [] %} -
+ {% set valueParts = value ? value|split('|') : [] %} +
+ +
{%- endblock sylius_gally_filter_range_widget -%} diff --git a/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig b/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig index 25bf88a..47ca618 100644 --- a/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig +++ b/src/Resources/views/shop/product/index/content/body/sidebar/filters.html.twig @@ -12,7 +12,7 @@ {% endif %}