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
35 changes: 35 additions & 0 deletions features/admin/page/inserting_content_elements_on_page.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@managing_pages
Feature: Inserting content elements between existing elements on a page
In order to manage the structure of content on a page
As an Administrator
I want to be able to insert content elements between existing ones

Background:
Given I am logged in as an administrator
And the store operates on a single channel in "United States"

@ui @javascript
Scenario: Inserting a content element between two existing elements
When I go to the create page page
And I fill the code with "insert-test-page"
And I fill the name with "Insert Test Page"
And I fill the slug with "insert-test-page"
And I add a heading content element with type "h1" and "My Title" content
And I add a textarea content element with "My body text" content
When I insert a textarea content element after the 1st content element
Then the 1st content element should be a "Heading" element
And the 2nd content element should be a "Textarea" element
And the 3rd content element should be a "Textarea" element

@ui @javascript
Scenario: Inserting a content element before the first element
When I go to the create page page
And I fill the code with "insert-test-page"
And I fill the name with "Insert Test Page"
And I fill the slug with "insert-test-page"
And I add a heading content element with type "h1" and "My Title" content
And I add a textarea content element with "My body text" content
When I insert a textarea content element before the 1st content element
Then the 1st content element should be a "Textarea" element
And the 2nd content element should be a "Heading" element
And the 3rd content element should be a "Textarea" element
69 changes: 69 additions & 0 deletions features/admin/page/sorting_content_elements_on_page.feature
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,72 @@ Feature: Sorting content elements on a page
And I add a heading content element with type "h1" and "My Title" content
And I add a textarea content element with "My body text" content
Then the move down button of the 2nd content element should be disabled

@ui @javascript
Scenario: Moving a content element down keeps its content when editing an existing page
Given there is a page in the store with a textarea content element with "First content" content and a textarea content element with "Second content" content
When I want to edit this page
And I move the 1st content element down
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "First content"

@ui @javascript
Scenario: Moving a content element up keeps its content when editing an existing page
Given there is a page in the store with a textarea content element with "First content" content and a textarea content element with "Second content" content
When I want to edit this page
And I move the 2nd content element up
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "First content"

@ui @javascript @quill
Scenario: Moving a content element down keeps its content with the Quill editor
Given there is a page in the store with a textarea content element with "First content" content and a textarea content element with "Second content" content
When I want to edit this page
And I move the 1st content element down
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "First content"

@ui @javascript @quill
Scenario: Moving a content element up keeps its content with the Quill editor
Given there is a page in the store with a textarea content element with "First content" content and a textarea content element with "Second content" content
When I want to edit this page
And I move the 2nd content element up
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "First content"

@ui @javascript
Scenario: Moving a content element down moves it one position only
Given there is a page in the store with textarea content elements with "First content", "Second content" and "Third content" content
When I want to edit this page
And I move the 1st content element down
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "First content"
And the 3rd content element should contain "Third content"

@ui @javascript
Scenario: Moving a content element up moves it one position only
Given there is a page in the store with textarea content elements with "First content", "Second content" and "Third content" content
When I want to edit this page
And I move the 3rd content element up
Then the 1st content element should contain "First content"
And the 2nd content element should contain "Third content"
And the 3rd content element should contain "Second content"

@ui @javascript
Scenario: Moving the same content element down repeatedly
Given there is a page in the store with textarea content elements with "First content", "Second content" and "Third content" content
When I want to edit this page
And I move the 1st content element down
And I move the 2nd content element down
Then the 1st content element should contain "Second content"
And the 2nd content element should contain "Third content"
And the 3rd content element should contain "First content"

@ui @javascript
Scenario: Moving a content element after inserting a new one in between
Given there is a page in the store with a textarea content element with "First content" content and a textarea content element with "Second content" content
When I want to edit this page
And I insert a textarea content element after the 1st content element
And I move the 3rd content element up
Then the 1st content element should contain "First content"
And the 2nd content element should contain "Second content"
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ public function moveCollectionItem(
return;
}

// Swap the two values while both rows keep their original keys. Handing a moved row
// a fresh key makes it look like a brand new element to Symfony's CollectionType,
// which only ever appends unknown keys at the end of its children (see the note in
// insertCollectionItem) - the row would jump to the bottom of the collection instead
// of moving one position, and every following move would work on an order that no
// longer matches what is rendered. Keeping the keys is safe for the stateful WYSIWYG
// widgets because ContentElementConfigurationType puts a signature of the element's
// content into the configuration container's DOM id: when the content at a position
// changes, so does that id, and the Live Component replaces the whole subtree instead
// of morphing the widget in place.
$swapKey = $keys[$swapPos];
[$data[$index], $data[$swapKey]] = [$data[$swapKey], $data[$index]];

Expand All @@ -88,6 +98,62 @@ public function applyContentTemplate(#[LiveArg] string $localeCode): void
$this->populateElements($localeCode, $template);
}

#[LiveAction]
public function insertCollectionItem(
PropertyAccessorInterface $propertyAccessor,
#[LiveArg]
string $name,
#[LiveArg]
?string $type = null,
#[LiveArg]
?int $insertAfterIndex = null,
): void {
if (null === $this->formName) {
return;
}

$propertyPath = $this->fieldNameToPropertyPath($name, $this->formName);
$data = $propertyAccessor->getValue($this->formValues, $propertyPath);

if (!\is_array($data)) {
$data = [];
}

$newItem = null === $type ? [] : ['type' => $type];

$keys = array_keys($data);
$items = array_values($data);

if (null === $insertAfterIndex) {
$insertPosition = \count($items);
} elseif ($insertAfterIndex < 0) {
$insertPosition = 0;
} else {
$pos = array_search($insertAfterIndex, $keys, true);
$insertPosition = false !== $pos ? $pos + 1 : \count($items);
}

array_splice($items, $insertPosition, 0, [$newItem]);

// Symfony's CollectionType (via ResizeFormListener) never reorders existing form
// children - it only appends keys it doesn't have yet, always at the end of its
// internal list, no matter where that key sits in the submitted array. Giving only
// the new row a fresh key therefore isn't enough to place it mid-collection: the
// form would still render it last. Every row from the insertion point onward must
// look "new" too, so Symfony drops and re-appends that whole tail in one pass, in
// the order we submit it - landing it right after the untouched prefix. Rows
// strictly before the insertion point keep their original key/DOM node untouched.
$freshKeysNeeded = \count($items) - $insertPosition;
$nextKey = $this->provideNewCollectionItemIndex($data);

$keys = array_slice($keys, 0, $insertPosition);
for ($i = 0; $i < $freshKeysNeeded; ++$i) {
$keys[] = $nextKey + $i;
}

$propertyAccessor->setValue($this->formValues, $propertyPath, array_combine($keys, $items));
}

/** @param TemplateRepositoryInterface<TemplateInterface> $templateRepository */
protected function initializeTemplateRepository(TemplateRepositoryInterface $templateRepository): void
{
Expand Down
21 changes: 21 additions & 0 deletions templates/admin/macros/insert_element_divider.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% macro insert_element_divider(collection_types, collection_name, insert_after_index) %}
<div class="d-flex align-items-center gap-1 my-2 px-2" {{ sylius_test_html_attribute('insert-element-divider') }}>
<hr class="flex-grow-1 m-0 border-secondary-subtle opacity-100">
<div class="dropdown">
<button class="btn btn-sm btn-outline-primary p-2" type="button" data-bs-toggle="dropdown">
{{ 'sylius_cms.ui.add_element'|trans }}
{{ ux_icon('tabler:chevron-down', {style: 'display: block; margin:auto;'}) }}
</button>
<ul class="dropdown-menu">
{%- for type, typeLabel in collection_types -%}
<li>
<button class="dropdown-item" type="button" data-action="live#action" data-live-action-param="insertCollectionItem" data-live-name-param="{{ collection_name }}" data-live-type-param="{{ type }}" data-live-insert-after-index-param="{{ insert_after_index }}" {{ sylius_test_html_attribute('insert-' ~ type) }}>
{{ typeLabel|trans }}
</button>
</li>
{%- endfor -%}
</ul>
</div>
<hr class="flex-grow-1 m-0 border-secondary-subtle opacity-100">
</div>
{% endmacro %}
30 changes: 26 additions & 4 deletions templates/admin/shared/component_elements/form_theme.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
{% extends '@SyliusAdmin/shared/form_theme.html.twig' %}

{%- block live_collection_widget -%}
{{ block('form_widget') }}
{%- import '@SyliusCmsPlugin/admin/macros/insert_element_divider.html.twig' as InsertElementButton -%}

{%- set collection_types = button_add is defined ? button_add.vars.types : {} -%}
{%- set collection_name = button_add is defined ? button_add.vars.attr['data-live-name-param'] : '' -%}

<div>
{%- for child in form -%}
{%- if loop.first and collection_types is not empty -%}
{{ InsertElementButton.insert_element_divider(collection_types, collection_name, -1) }}
{%- endif -%}

{{ form_row(child) }}

{%- if not loop.last and collection_types is not empty -%}
{{ InsertElementButton.insert_element_divider(collection_types, collection_name, child.vars.name) }}
{%- endif -%}
{%- endfor -%}
</div>
{%- endblock live_collection_widget -%}

{%- block live_collection_entry_row -%}
Expand Down Expand Up @@ -57,12 +74,17 @@

{% block add_button_row %}
{% if types is not empty %}
<div class="dropdown" data-bs-toggle="dropdown" {{ sylius_test_html_attribute('add-element-button') }}>
<button class="btn dropdown-toggle" type="button">{{ label|trans }}</button>
<div class="dropdown" {{ sylius_test_html_attribute('add-element-button') }}>
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown">{{ label|trans }}</button>
<ul class="dropdown-menu">
{% for type, label in types %}
<li>
<button class="dropdown-item" type="button" {{ block('button_attributes') }} data-live-type-param="{{ type }}" {{ sylius_test_html_attribute('add-' ~ type) }}>
<button class="dropdown-item" type="button"
data-action="live#action"
data-live-action-param="insertCollectionItem"
data-live-name-param="{{ attr['data-live-name-param'] }}"
data-live-type-param="{{ type }}"
{{ sylius_test_html_attribute('add-' ~ type) }}>
{{ (label)|trans }}
</button>
</li>
Expand Down
22 changes: 22 additions & 0 deletions tests/Behat/Context/Setup/PageContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ public function thereIsAPageInTheStoreWithContentElements(string $contentElement
$this->savePage($page);
}

/**
* @Given there is a page in the store with a textarea content element with :firstContent content and a textarea content element with :secondContent content
* @Given there is a page in the store with textarea content elements with :firstContent, :secondContent and :thirdContent content
*/
public function thereIsAPageInTheStoreWithTextareaContentElements(string ...$contents): void
{
$page = $this->createPage();

foreach ($contents as $content) {
/** @var ContentConfigurationInterface $contentConfiguration */
$contentConfiguration = new ContentConfiguration();
$contentConfiguration->setType('textarea');
$contentConfiguration->setLocale('en_US');
$contentConfiguration->setConfiguration(['textarea' => $content]);
$contentConfiguration->setPage($page);

$page->addContentElement($contentConfiguration);
}

$this->savePage($page);
}

/**
* @Given there is an existing page with :name name
*/
Expand Down
54 changes: 38 additions & 16 deletions tests/Behat/Context/Ui/Admin/ContentCollectionContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,22 +207,6 @@ public function iShouldNotSeeContentElementInTheContentElementsSection(string $c
Assert::false($this->contentElementsCollectionElement->hasContentElement($contentElement));
}

/**
* @When I move the :ordinal content element up
*/
public function iMoveTheContentElementUp(string $ordinal): void
{
$this->contentElementsCollectionElement->moveContentElementUp($this->parseOrdinal($ordinal));
}

/**
* @When I move the :ordinal content element down
*/
public function iMoveTheContentElementDown(string $ordinal): void
{
$this->contentElementsCollectionElement->moveContentElementDown($this->parseOrdinal($ordinal));
}

/**
* @Then the :ordinal content element should be a :type element
*/
Expand Down Expand Up @@ -264,6 +248,44 @@ public function theMoveDownButtonOfTheContentElementShouldBeDisabled(string $ord
);
}

/**
* @When I insert a textarea content element after the :ordinal content element
*/
public function iInsertATextareaContentElementAfterTheContentElement(string $ordinal): void
{
$this->contentElementsCollectionElement->insertContentElementAfterPosition(
TextareaContentElementType::TYPE,
$this->parseOrdinal($ordinal),
);
}

/**
* @When I insert a textarea content element before the :ordinal content element
*/
public function iInsertATextareaContentElementBeforeTheContentElement(string $ordinal): void
{
$this->contentElementsCollectionElement->insertContentElementBeforePosition(
TextareaContentElementType::TYPE,
$this->parseOrdinal($ordinal),
);
}

/**
* @When I move the :ordinal content element up
*/
public function iMoveTheContentElementUp(string $ordinal): void
{
$this->contentElementsCollectionElement->moveContentElementUp($this->parseOrdinal($ordinal));
}

/**
* @When I move the :ordinal content element down
*/
public function iMoveTheContentElementDown(string $ordinal): void
{
$this->contentElementsCollectionElement->moveContentElementDown($this->parseOrdinal($ordinal));
}

private function parseOrdinal(string $ordinal): int
{
return (int) preg_replace('/\D/', '', $ordinal);
Expand Down
Loading
Loading