From fd925a216374869f951ecf2c08e2f748077dcbc5 Mon Sep 17 00:00:00 2001 From: Simon Krull Date: Sun, 17 May 2026 10:35:47 +0200 Subject: [PATCH 1/4] FEAT: [Admin] #100 add functionality to add new content elements between existing ones refactor: improve element moving logic chore: apply suggestion review from TheMilek Co-authored-by: Kamil Grygierzec <53942444+TheMilek@users.noreply.github.com> FIX: style attribute in ux_icon FIX: insert new content element at correct position instead of at the end translation: add missing german translation for add_element [Behat] Fix test files after rebase --- ...inserting_content_elements_on_page.feature | 35 ++++++++ .../sorting_content_elements_on_page.feature | 32 ++++++++ ...ntElementsCollectionFormComponentTrait.php | 80 ++++++++++++++++++- .../macros/insert_element_divider.html.twig | 21 +++++ .../component_elements/form_theme.html.twig | 30 ++++++- tests/Behat/Context/Setup/PageContext.php | 21 +++++ .../Ui/Admin/ContentCollectionContext.php | 54 +++++++++---- .../ContentElementsCollectionElement.php | 33 +++++++- ...tentElementsCollectionElementInterface.php | 4 + translations/messages.de.yml | 1 + 10 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 features/admin/page/inserting_content_elements_on_page.feature create mode 100644 templates/admin/macros/insert_element_divider.html.twig diff --git a/features/admin/page/inserting_content_elements_on_page.feature b/features/admin/page/inserting_content_elements_on_page.feature new file mode 100644 index 000000000..3154617d7 --- /dev/null +++ b/features/admin/page/inserting_content_elements_on_page.feature @@ -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 diff --git a/features/admin/page/sorting_content_elements_on_page.feature b/features/admin/page/sorting_content_elements_on_page.feature index 8976e563d..f59d37ce9 100644 --- a/features/admin/page/sorting_content_elements_on_page.feature +++ b/features/admin/page/sorting_content_elements_on_page.feature @@ -78,3 +78,35 @@ 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" diff --git a/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php b/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php index 5b189e560..d94dcca98 100644 --- a/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php +++ b/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php @@ -70,10 +70,28 @@ public function moveCollectionItem( return; } - $swapKey = $keys[$swapPos]; - [$data[$index], $data[$swapKey]] = [$data[$swapKey], $data[$index]]; + $items = array_values($data); + [$items[$currentPos], $items[$swapPos]] = [$items[$swapPos], $items[$currentPos]]; + + // Give fresh keys to the two moved rows only, while keeping the visual (insertion) + // order. New keys mean new DOM ids, so the Live Component re-creates exactly those + // two rows instead of patching them in place. This keeps stateful WYSIWYG widgets + // correct regardless of their morphing strategy: Trix opts out of morphing via + // "data-live-ignore", while Quill builds its own DOM the server never renders - in + // both cases an in-place patch would leave stale or corrupted content. Re-creating + // the row triggers the editor's disconnect()/connect() cycle, which is the path it + // is built to support. Untouched rows keep their keys (and initialized editors). + $keys = array_keys($data); + $freshIndex = $this->provideNewCollectionItemIndex($data); + $keys[$currentPos] = $freshIndex; + $keys[$swapPos] = $freshIndex + 1; + + $reordered = []; + foreach ($items as $position => $item) { + $reordered[$keys[$position]] = $item; + } - $propertyAccessor->setValue($this->formValues, $propertyPath, $data); + $propertyAccessor->setValue($this->formValues, $propertyPath, $reordered); } #[LiveAction] @@ -88,6 +106,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 $templateRepository */ protected function initializeTemplateRepository(TemplateRepositoryInterface $templateRepository): void { diff --git a/templates/admin/macros/insert_element_divider.html.twig b/templates/admin/macros/insert_element_divider.html.twig new file mode 100644 index 000000000..8b553f0e8 --- /dev/null +++ b/templates/admin/macros/insert_element_divider.html.twig @@ -0,0 +1,21 @@ +{% macro insert_element_divider(collection_types, collection_name, insert_after_index) %} +
+
+ +
+
+{% endmacro %} diff --git a/templates/admin/shared/component_elements/form_theme.html.twig b/templates/admin/shared/component_elements/form_theme.html.twig index acc6b0eee..e046fecd9 100644 --- a/templates/admin/shared/component_elements/form_theme.html.twig +++ b/templates/admin/shared/component_elements/form_theme.html.twig @@ -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'] : '' -%} + +
+ {%- 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 -%} +
{%- endblock live_collection_widget -%} {%- block live_collection_entry_row -%} @@ -57,12 +74,17 @@ {% block add_button_row %} {% if types is not empty %} -