Skip to content

Commit ec0396e

Browse files
committed
FEAT: [Admin] 100 add order functionality for content elements
1 parent 768a4d3 commit ec0396e

11 files changed

Lines changed: 392 additions & 10 deletions

File tree

UPGRADE-1.2.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,26 @@ The default remains `trix`, so no action is required to keep the previous behavi
5050

5151
See the documentation for setup and configuration details:
5252
https://docs.sylius.com/cms-plugin/development/wysiwyg-editors
53+
54+
### New Stimulus Controller: `ContentElementsOrderController`
55+
56+
This controller allows ordering content elements.
57+
58+
In your end application, run the following command to add a new dependency to `package.json` file:
59+
60+
```bash
61+
yarn add @sylius-cms-plugin/admin@file:vendor/sylius/cms-plugin/assets/admin
62+
```
63+
64+
And add the following to your `controllers.json` file:
65+
66+
```json
67+
{
68+
"@sylius-cms-plugin/admin": {
69+
"content-elements-order": {
70+
"enabled": true,
71+
"fetch": "lazy"
72+
}
73+
}
74+
}
75+
```

assets/admin/controllers.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
"webpackMode": "lazy",
77
"fetch": "lazy",
88
"enabled": true
9+
},
10+
"content-elements-order": {
11+
"main": "controllers/ContentElementsOrderController.js",
12+
"webpackMode": "lazy",
13+
"fetch": "lazy",
14+
"enabled": true
915
}
1016
},
1117
"@ehyiah/ux-quill": {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* This file is part of the Sylius CMS Plugin package.
3+
*
4+
* (c) Sylius Sp. z o.o.
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
import { Controller } from '@hotwired/stimulus';
11+
12+
/* stimulusFetch: 'lazy' */
13+
export default class extends Controller {
14+
moveUp(event) {
15+
this.move(event, 'up');
16+
}
17+
18+
moveDown(event) {
19+
this.move(event, 'down');
20+
}
21+
22+
move(event, direction) {
23+
const button = event.currentTarget;
24+
25+
this.#hideTooltip(button);
26+
27+
const entry = button.closest('.sortable-item');
28+
if (!entry) {
29+
return;
30+
}
31+
32+
const sibling = direction === 'up'
33+
? this.findPreviousSibling(entry)
34+
: this.findNextSibling(entry);
35+
36+
if (!sibling) {
37+
return;
38+
}
39+
40+
const entryIndex = this.extractIndex(entry);
41+
const siblingIndex = this.extractIndex(sibling);
42+
43+
if (entryIndex < 0 || siblingIndex < 0) {
44+
return;
45+
}
46+
47+
if (direction === 'up') {
48+
entry.parentElement.insertBefore(entry, sibling);
49+
} else {
50+
entry.parentElement.insertBefore(sibling, entry);
51+
}
52+
53+
this.swapEntryIndexes(entry, sibling, entryIndex, siblingIndex);
54+
this.updateButtonStates();
55+
}
56+
57+
#hideTooltip(button) {
58+
window.bootstrap?.Tooltip?.getInstance(button)?.hide();
59+
}
60+
61+
findPreviousSibling(entry) {
62+
let element = entry.previousElementSibling;
63+
64+
while (element) {
65+
if (element.classList.contains('sortable-item')) {
66+
return element;
67+
}
68+
69+
element = element.previousElementSibling;
70+
}
71+
72+
return null;
73+
}
74+
75+
findNextSibling(entry) {
76+
let element = entry.nextElementSibling;
77+
78+
while (element) {
79+
if (element.classList.contains('sortable-item')) {
80+
return element;
81+
}
82+
83+
element = element.nextElementSibling;
84+
}
85+
86+
return null;
87+
}
88+
89+
extractIndex(entry) {
90+
const input = entry.querySelector('[name*="[contentElements]"]');
91+
92+
if (!input) {
93+
return -1;
94+
}
95+
96+
const match = input.name.match(/\[contentElements]\[(\d+)]/);
97+
98+
return match ? parseInt(match[1], 10) : -1;
99+
}
100+
101+
swapEntryIndexes(entryA, entryB, indexA, indexB) {
102+
const tempIndex = '__TEMP_INDEX__';
103+
104+
this.renameEntry(entryA, indexA, tempIndex);
105+
this.renameEntry(entryB, indexB, indexA);
106+
this.renameEntry(entryA, tempIndex, indexB);
107+
}
108+
109+
renameEntry(entry, oldIndex, newIndex) {
110+
entry.id = this.replaceInId(entry.id, oldIndex, newIndex);
111+
112+
entry.querySelectorAll('[name]').forEach(element => {
113+
element.name = element.name.replaceAll(
114+
`[contentElements][${oldIndex}]`,
115+
`[contentElements][${newIndex}]`,
116+
);
117+
});
118+
119+
entry.querySelectorAll('[id]').forEach(element => {
120+
element.id = this.replaceInId(element.id, oldIndex, newIndex);
121+
});
122+
123+
entry.querySelectorAll('[for]').forEach(element => {
124+
element.htmlFor = this.replaceInId(element.htmlFor, oldIndex, newIndex);
125+
});
126+
}
127+
128+
replaceInId(value, oldIndex, newIndex) {
129+
return value.replace(
130+
new RegExp(`_contentElements_${oldIndex}(?=_|$)`, 'g'),
131+
`_contentElements_${newIndex}`,
132+
);
133+
}
134+
135+
updateButtonStates() {
136+
const entries = [...this.element.querySelectorAll('.sortable-item')];
137+
138+
entries.forEach((entry, index) => {
139+
const upButton = entry.querySelector('[data-sort-direction="up"]');
140+
const downButton = entry.querySelector('[data-sort-direction="down"]');
141+
142+
if (upButton) {
143+
upButton.disabled = index === 0;
144+
}
145+
146+
if (downButton) {
147+
downButton.disabled = index === entries.length - 1;
148+
}
149+
});
150+
}
151+
}

assets/admin/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
"webpackMode": "lazy",
1111
"fetch": "lazy",
1212
"enabled": true
13+
},
14+
"content-elements-order": {
15+
"main": "controllers/ContentElementsOrderController.js",
16+
"webpackMode": "lazy",
17+
"fetch": "lazy",
18+
"enabled": true
1319
}
1420
}
1521
},
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@managing_pages
2+
Feature: Sorting content elements on a page
3+
In order to manage the order of content on a page
4+
As an Administrator
5+
I want to be able to reorder content elements
6+
7+
Background:
8+
Given I am logged in as an administrator
9+
And the store operates on a single channel in "United States"
10+
11+
@ui @javascript
12+
Scenario: Moving a content element down
13+
When I go to the create page page
14+
And I fill the code with "sort-test-page"
15+
And I fill the name with "Sort Test Page"
16+
And I fill the slug with "sort-test-page"
17+
And I add a heading content element with type "h1" and "My Title" content
18+
And I add a textarea content element with "My body text" content
19+
When I move the 1st content element down
20+
Then the 1st content element should be a "Textarea" element
21+
And the 2nd content element should be a "Heading" element
22+
23+
@ui @javascript
24+
Scenario: Moving a content element up
25+
When I go to the create page page
26+
And I fill the code with "sort-test-page"
27+
And I fill the name with "Sort Test Page"
28+
And I fill the slug with "sort-test-page"
29+
And I add a heading content element with type "h1" and "My Title" content
30+
And I add a textarea content element with "My body text" content
31+
When I move the 2nd content element up
32+
Then the 1st content element should be a "Textarea" element
33+
And the 2nd content element should be a "Heading" element
34+
35+
@ui @javascript
36+
Scenario: The first content element cannot be moved up
37+
When I go to the create page page
38+
And I fill the code with "sort-test-page"
39+
And I fill the name with "Sort Test Page"
40+
And I fill the slug with "sort-test-page"
41+
And I add a heading content element with type "h1" and "My Title" content
42+
And I add a textarea content element with "My body text" content
43+
Then the move up button of the 1st content element should be disabled
44+
45+
@ui @javascript
46+
Scenario: The last content element cannot be moved down
47+
When I go to the create page page
48+
And I fill the code with "sort-test-page"
49+
And I fill the name with "Sort Test Page"
50+
And I fill the slug with "sort-test-page"
51+
And I add a heading content element with type "h1" and "My Title" content
52+
And I add a textarea content element with "My body text" content
53+
Then the move down button of the 2nd content element should be disabled

templates/admin/shared/component_elements/form_theme.html.twig

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,55 @@
11
{% extends '@SyliusAdmin/shared/form_theme.html.twig' %}
22

33
{%- block live_collection_widget -%}
4-
{{ block('form_widget') }}
4+
<div {{ stimulus_controller('@sylius-cms-plugin/admin/content-elements-order') }}>
5+
{{ block('form_widget') }}
6+
</div>
57
{%- endblock live_collection_widget -%}
68

79
{%- block live_collection_entry_row -%}
8-
<div id="{{ id }}" {{ block('attributes') }} {{ sylius_test_html_attribute('entry-row') }}>
10+
{% set entryKeys = form.parent.children|keys %}
11+
{% set isFirst = form.vars.name == entryKeys|first %}
12+
{% set isLast = form.vars.name == entryKeys|last %}
13+
14+
<div class="sortable-item" id="{{ id }}" {{ block('attributes') }} {{ sylius_test_html_attribute('entry-row') }}>
915
{{- form_errors(form) -}}
1016

11-
<div class="alert text-body">
12-
<div class="text-end">
13-
{{- form_row(button_delete, sylius_test_form_attribute('delete-action')|sylius_merge_recursive({'attr': {'class': 'btn-close'}})) -}}
17+
<div class="alert text-body d-flex align-items-center gap-4">
18+
<div class="flex-grow-1">
19+
{{- form_row(form.type) -}}
20+
<div>
21+
{{- form_row(form.configuration, {'label': false}) -}}
22+
</div>
1423
</div>
15-
16-
{{- form_row(form.type) -}}
17-
18-
<div>
19-
{{- form_row(form.configuration, {'label': false}) -}}
24+
<div class="d-flex flex-column align-items-center gap-2">
25+
<button
26+
type="button"
27+
class="btn p-1 btn-outline-primary d-flex align-items-center justify-content-center"
28+
data-bs-toggle="tooltip"
29+
data-bs-title="{{ 'sylius_cms.ui.content_elements.move_up_btn'|trans }}"
30+
{{ stimulus_action('@sylius-cms-plugin/admin/content-elements-order', 'moveUp') }}
31+
data-sort-direction="up"
32+
{{ isFirst ? 'disabled' }}
33+
>
34+
{{ ux_icon('tabler:chevron-up', {style: 'display: block; margin: auto'}) }}
35+
</button>
36+
{{- form_row(button_delete, sylius_test_form_attribute('delete-action')|sylius_merge_recursive({
37+
'label': ux_icon('tabler:trash', {style: 'display: block; margin: auto'}),
38+
'label_html': true,
39+
'attr': {'class': 'btn p-1 btn-outline-danger'},
40+
'row_attr': {'class': 'mb-0'}
41+
})) -}}
42+
<button
43+
type="button"
44+
class="btn p-1 btn-outline-primary d-flex align-items-center justify-content-center lh-1"
45+
data-bs-toggle="tooltip"
46+
data-bs-title="{{ 'sylius_cms.ui.content_elements.move_down_btn'|trans }}"
47+
{{ stimulus_action('@sylius-cms-plugin/admin/content-elements-order', 'moveDown') }}
48+
data-sort-direction="down"
49+
{{ isLast ? 'disabled' }}
50+
>
51+
{{ ux_icon('tabler:chevron-down', {style: 'display: block; margin: auto'}) }}
52+
</button>
2053
</div>
2154
</div>
2255
</div>

tests/Behat/Context/Ui/Admin/ContentCollectionContext.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,56 @@ public function iShouldNotSeeContentElementInTheContentElementsSection(string $c
205205
{
206206
Assert::false($this->contentElementsCollectionElement->hasContentElement($contentElement));
207207
}
208+
209+
/**
210+
* @When I move the :ordinal content element up
211+
*/
212+
public function iMoveTheContentElementUp(string $ordinal): void
213+
{
214+
$this->contentElementsCollectionElement->moveContentElementUp($this->parseOrdinal($ordinal));
215+
}
216+
217+
/**
218+
* @When I move the :ordinal content element down
219+
*/
220+
public function iMoveTheContentElementDown(string $ordinal): void
221+
{
222+
$this->contentElementsCollectionElement->moveContentElementDown($this->parseOrdinal($ordinal));
223+
}
224+
225+
/**
226+
* @Then the :ordinal content element should be a :type element
227+
*/
228+
public function theContentElementAtPositionShouldBeOfType(string $ordinal, string $type): void
229+
{
230+
Assert::same(
231+
$this->contentElementsCollectionElement->getContentElementTypeAtPosition($this->parseOrdinal($ordinal)),
232+
$type,
233+
);
234+
}
235+
236+
/**
237+
* @Then the move up button of the :ordinal content element should be disabled
238+
*/
239+
public function theMoveUpButtonOfTheContentElementShouldBeDisabled(string $ordinal): void
240+
{
241+
Assert::true(
242+
$this->contentElementsCollectionElement->isContentElementMoveUpButtonDisabled($this->parseOrdinal($ordinal)),
243+
);
244+
}
245+
246+
/**
247+
* @Then the move down button of the :ordinal content element should be disabled
248+
*/
249+
public function theMoveDownButtonOfTheContentElementShouldBeDisabled(string $ordinal): void
250+
{
251+
Assert::true(
252+
$this->contentElementsCollectionElement->isContentElementMoveDownButtonDisabled($this->parseOrdinal($ordinal)),
253+
);
254+
}
255+
256+
private function parseOrdinal(string $ordinal): int
257+
{
258+
return (int) preg_replace('/\D/', '', $ordinal);
259+
}
208260
}

0 commit comments

Comments
 (0)