Skip to content

Commit e035ca3

Browse files
Migrate shop JS from raw scripts to Stimulus controllers
1 parent 0215a07 commit e035ca3

16 files changed

Lines changed: 348 additions & 276 deletions

File tree

README.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,7 @@
5050
use GallyChannelTrait;
5151
}
5252
```
53-
- Copy the bundle assets (Javascript & CSS files):
54-
- Run `php bin/console assets:install`
55-
- Run `php bin/console sylius:install:assets`
56-
- Run `php bin/console sylius:theme:assets:install`
57-
58-
- **Alternative: install assets via Webpack Encore (recommended for Sylius 2.x)**
53+
- Install assets via Webpack Encore:
5954
- Add the plugin and its JS SDK as npm dependencies in your app's `package.json`:
6055
```json
6156
{
@@ -97,6 +92,37 @@
9792
```
9893
> The `copyFiles()` call exposes `gally-sdk.global.js` as a standalone IIFE script
9994
> (available as `window.GallySDK`) for use in Twig templates via `{{ asset('build/app/shop/gally/gally-sdk.global.js') }}`.
95+
- Register the plugin's Stimulus controllers in your app's root `assets/controllers.json`:
96+
```json
97+
{
98+
"controllers": {
99+
"@gally/sylius-plugin": {
100+
"range-slider": {
101+
"main": "src/Resources/assets/shop/controllers/RangeSliderController.js",
102+
"enabled": true,
103+
"fetch": "eager"
104+
},
105+
"search-autocomplete": {
106+
"main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js",
107+
"enabled": true,
108+
"fetch": "eager"
109+
},
110+
"view-more": {
111+
"main": "src/Resources/assets/shop/controllers/ViewMoreController.js",
112+
"enabled": true,
113+
"fetch": "eager"
114+
}
115+
}
116+
}
117+
}
118+
```
119+
> This step is required because `@gally/sylius-plugin` is installed as a local `path` repository
120+
> rather than a real registered npm/Symfony UX package: Symfony Flex normally adds a package's
121+
> Stimulus controllers to your app's `assets/controllers.json` automatically via that package's
122+
> recipe when you run `composer require`, but local `path` packages have no such recipe. This
123+
> entry must therefore mirror the `symfony.controllers` section of the plugin's own `package.json`
124+
> and be kept in sync manually if a future version of the plugin adds, renames, or removes a
125+
> controller.
100126
- Install JS dependencies and build assets:
101127
- **Without Docker** (from the Sylius root directory):
102128
```shell

package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22
"name": "@gally/sylius-plugin",
33
"version": "2.3.0",
44
"license": "OSL-3.0",
5+
"symfony": {
6+
"controllers": {
7+
"range-slider": {
8+
"main": "src/Resources/assets/shop/controllers/RangeSliderController.js",
9+
"enabled": true,
10+
"fetch": "eager"
11+
},
12+
"search-autocomplete": {
13+
"main": "src/Resources/assets/shop/controllers/SearchAutocompleteController.js",
14+
"enabled": true,
15+
"fetch": "eager"
16+
},
17+
"view-more": {
18+
"main": "src/Resources/assets/shop/controllers/ViewMoreController.js",
19+
"enabled": true,
20+
"fetch": "eager"
21+
}
22+
}
23+
},
24+
"peerDependencies": {
25+
"@hotwired/stimulus": "^3.0.0"
26+
},
527
"dependencies": {
628
"@elastic-suite/gally-sdk": "2.2.2-alpha.0",
729
"file-loader": "^6.2.0"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import noUiSlider from '../../../public/nouislider.min.js';
3+
import '../../../public/nouislider.min.css';
4+
import '../../../public/slider.css';
5+
6+
export default class extends Controller {
7+
static targets = ['slider', 'input'];
8+
static values = {
9+
min: Number,
10+
max: Number,
11+
value: String,
12+
};
13+
14+
connect() {
15+
const valuesForSlider = [];
16+
for (let i = this.minValue; i <= this.maxValue; i++) {
17+
valuesForSlider.push(i);
18+
}
19+
20+
let start = [valuesForSlider[0], valuesForSlider[valuesForSlider.length - 1]];
21+
if (this.hasValueValue) {
22+
const parts = this.valueValue.split('|');
23+
if (parts.length === 2) {
24+
start = parts;
25+
}
26+
}
27+
28+
this.slider = noUiSlider.create(this.sliderTarget, {
29+
start,
30+
step: 1,
31+
tooltips: true,
32+
connect: true,
33+
range: {
34+
min: 0,
35+
max: valuesForSlider.length - 1,
36+
},
37+
format: {
38+
to: (value) => valuesForSlider[Math.round(value)],
39+
from: (value) => valuesForSlider.indexOf(Number(value)),
40+
},
41+
});
42+
43+
this.slider.on('end', (values) => {
44+
this.inputTarget.value = `${values[0]}|${values[1]}`;
45+
});
46+
}
47+
48+
disconnect() {
49+
if (this.slider) {
50+
this.slider.destroy();
51+
this.slider = null;
52+
}
53+
}
54+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/**
4+
* Debounced, cached, abortable autocomplete search preview. One instance is connected per
5+
* ".searchFormContainer" (desktop and mobile header variants each get their own).
6+
*/
7+
export default class extends Controller {
8+
static targets = ['input', 'results', 'loading', 'resultsPanel'];
9+
static values = { previewUrl: String };
10+
11+
connect() {
12+
this.abortController = null;
13+
this.debounceTimer = null;
14+
this.queryCache = new Map();
15+
this.lastNonEmptyContent = null;
16+
}
17+
18+
disconnect() {
19+
if (this.abortController) {
20+
this.abortController.abort();
21+
}
22+
if (this.debounceTimer) {
23+
clearTimeout(this.debounceTimer);
24+
}
25+
}
26+
27+
onInput(event) {
28+
const queryText = event.target.value;
29+
30+
if (this.debounceTimer) {
31+
clearTimeout(this.debounceTimer);
32+
this.debounceTimer = null;
33+
}
34+
35+
if (queryText.length >= 3) {
36+
// Keep previous results visible while waiting for debounce
37+
this.resultsPanelTarget.classList.add('show');
38+
this.debounceTimer = setTimeout(() => this.performSearch(), 200);
39+
40+
return;
41+
}
42+
43+
// Also cancel any ongoing request
44+
if (this.abortController) {
45+
this.abortController.abort();
46+
this.abortController = null;
47+
}
48+
// New search session: reset last results so stale content won't reappear
49+
this.lastNonEmptyContent = null;
50+
this.resultsTarget.textContent = '';
51+
this.resultsPanelTarget.classList.remove('show');
52+
}
53+
54+
onFocus(event) {
55+
const queryText = event.target.value;
56+
if (queryText.length < 3) {
57+
return;
58+
}
59+
60+
if (this.resultsTarget.innerHTML.trim() !== '') {
61+
this.resultsPanelTarget.classList.add('show');
62+
} else {
63+
// Search silently: panel will only appear when results arrive via displayResults
64+
this.performSearch({ showWhileLoading: false });
65+
}
66+
}
67+
68+
outsideClick(event) {
69+
if (
70+
this.resultsPanelTarget.classList.contains('show')
71+
&& !this.resultsPanelTarget.contains(event.target)
72+
&& !this.inputTarget.contains(event.target)
73+
) {
74+
this.resultsPanelTarget.classList.remove('show');
75+
}
76+
}
77+
78+
displayResults(content) {
79+
this.loadingTarget.classList.add('d-none');
80+
this.resultsTarget.classList.remove('d-none');
81+
82+
// If response is empty but we have a previous non-empty result, keep showing it
83+
const displayContent = content.htmlResults ? content : this.lastNonEmptyContent;
84+
85+
if (!displayContent || !displayContent.htmlResults) {
86+
this.resultsPanelTarget.classList.remove('show');
87+
88+
return;
89+
}
90+
91+
this.resultsPanelTarget.classList.add('show');
92+
this.resultsTarget.innerHTML = displayContent.htmlResults;
93+
94+
if (this.resultsTarget.querySelector('.products')) {
95+
this.resultsPanelTarget.parentElement.classList.add('start-0');
96+
this.resultsPanelTarget.parentElement.style.width = '100%';
97+
} else {
98+
this.resultsPanelTarget.parentElement.classList.remove('start-0');
99+
this.resultsPanelTarget.parentElement.style.width = 'auto';
100+
}
101+
}
102+
103+
performSearch({ showWhileLoading = true } = {}) {
104+
const form = this.element.querySelector('form');
105+
const formData = new FormData(form);
106+
const plainFormData = Object.fromEntries(formData.entries());
107+
const formDataString = new URLSearchParams(plainFormData).toString();
108+
109+
// Serve from cache if available
110+
if (this.queryCache.has(formDataString)) {
111+
const cached = this.queryCache.get(formDataString);
112+
this.resultsPanelTarget.classList.add('show');
113+
this.displayResults(cached);
114+
if (cached.htmlResults) {
115+
this.lastNonEmptyContent = cached;
116+
}
117+
118+
return;
119+
}
120+
121+
// While loading, show panel only if requested (not on focus)
122+
if (showWhileLoading) {
123+
if (this.lastNonEmptyContent) {
124+
// Keep showing previous results (no spinner)
125+
this.loadingTarget.classList.add('d-none');
126+
this.resultsTarget.classList.remove('d-none');
127+
} else {
128+
// First search: show spinner
129+
this.loadingTarget.classList.remove('d-none');
130+
this.resultsTarget.classList.add('d-none');
131+
}
132+
this.resultsPanelTarget.classList.add('show');
133+
}
134+
135+
if (this.abortController) {
136+
this.abortController.abort();
137+
}
138+
this.abortController = new AbortController();
139+
140+
fetch(this.previewUrlValue, {
141+
method: 'POST',
142+
headers: {
143+
'Content-Type': 'application/x-www-form-urlencoded',
144+
},
145+
body: formDataString,
146+
signal: this.abortController.signal,
147+
})
148+
.then((response) => response.json())
149+
.then((content) => {
150+
// Track last non-empty result
151+
if (content.htmlResults) {
152+
this.lastNonEmptyContent = content;
153+
}
154+
155+
// Cache the result: if empty, store last non-empty result instead
156+
const cachedContent = content.htmlResults ? content : this.lastNonEmptyContent;
157+
158+
if (cachedContent) {
159+
this.queryCache.set(formDataString, cachedContent);
160+
this.displayResults(cachedContent);
161+
} else {
162+
// No result ever received yet, just hide the panel
163+
this.loadingTarget.classList.add('d-none');
164+
this.resultsPanelTarget.classList.remove('show');
165+
}
166+
})
167+
.catch((error) => {
168+
if (error.name !== 'AbortError') {
169+
console.error(error);
170+
}
171+
});
172+
}
173+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
/**
4+
* Connected on the facet list container: Stimulus keeps wiring "click" on any ".view-more"
5+
* link matching data-action, including ones inserted later by the AJAX swap below.
6+
*/
7+
export default class extends Controller {
8+
load(event) {
9+
event.preventDefault();
10+
11+
const viewMoreBtn = event.currentTarget;
12+
const form = viewMoreBtn.closest('form');
13+
const dataFor = viewMoreBtn.dataset.for;
14+
const dataHref = viewMoreBtn.dataset.href;
15+
const choicesEl = document.querySelector(`#${dataFor}`);
16+
17+
form.classList.add('loading');
18+
19+
fetch(dataHref)
20+
.then((response) => response.json())
21+
.then((data) => {
22+
const tempDiv = document.createElement('div');
23+
tempDiv.innerHTML = data.html;
24+
const newFields = tempDiv.querySelector(`#${dataFor}`);
25+
26+
if (newFields && choicesEl) {
27+
choicesEl.replaceWith(newFields);
28+
}
29+
30+
viewMoreBtn.style.display = 'none';
31+
})
32+
.catch((error) => {
33+
console.error('Fetch error:', error);
34+
})
35+
.finally(() => {
36+
form.classList.remove('loading');
37+
});
38+
}
39+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
import '@elastic-suite/gally-sdk';
1+
import '@elastic-suite/gally-sdk/browser';
2+
import '../../public/filters.css';

0 commit comments

Comments
 (0)