Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -97,6 +92,42 @@
```
> 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"
},
"search-bar-mover": {
"main": "src/Resources/assets/shop/controllers/SearchBarMoverController.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
Expand Down
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@
"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"
},
"search-bar-mover": {
"main": "src/Resources/assets/shop/controllers/SearchBarMoverController.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"
Expand Down
26 changes: 0 additions & 26 deletions src/Controller/Shop/SearchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;

class SearchController extends AbstractController
Expand All @@ -34,31 +33,6 @@ public function __construct(
) {
}

public function getForm(Request $renderRequest, RequestStack $requestStack): Response
{
/** @var string|null $query */
$query = $requestStack->getMainRequest()?->get('query');
if (null === $query || '' === $query) {
/** @var array<string, array<string, string>> $query */
$query = $requestStack->getMainRequest()?->get('criteria', []);
$query = $query['search']['value'] ?? '';
}

$searchForm = $this->createForm(
SearchFormType::class,
['query' => $query],
['action' => $this->generateUrl('gally_search_result_page'), 'method' => 'POST']
);

return $this->render(
'@GallySyliusPlugin/shop/shared/components/header/search/form.html.twig',
[
'searchForm' => $searchForm->createView(),
'mobileMode' => $renderRequest->get('mobile_mode'),
]
);
}

public function getResults(Request $request): Response
{
$searchForm = $this->createForm(SearchFormType::class);
Expand Down
54 changes: 54 additions & 0 deletions src/Resources/assets/shop/controllers/RangeSliderController.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
173 changes: 173 additions & 0 deletions src/Resources/assets/shop/controllers/SearchAutocompleteController.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
Loading
Loading