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 00000000..3154617d --- /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 8976e563..ab69e14b 100644 --- a/features/admin/page/sorting_content_elements_on_page.feature +++ b/features/admin/page/sorting_content_elements_on_page.feature @@ -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" diff --git a/src/Form/Strategy/Wysiwyg/QuillStrategy.php b/src/Form/Strategy/Wysiwyg/QuillStrategy.php index 966b0554..d6cf5242 100644 --- a/src/Form/Strategy/Wysiwyg/QuillStrategy.php +++ b/src/Form/Strategy/Wysiwyg/QuillStrategy.php @@ -16,6 +16,8 @@ use Ehyiah\QuillJsBundle\DTO\Modules\FullScreenModule; use Ehyiah\QuillJsBundle\DTO\QuillGroup; use Ehyiah\QuillJsBundle\Form\QuillType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; final class QuillStrategy extends AbstractWysiwygStrategy @@ -30,15 +32,22 @@ public function configureOptions(OptionsResolver $resolver): void parent::configureOptions($resolver); $resolver->setDefaults([ 'quill_options' => QuillGroup::buildWithAllFields(), - 'attr' => [ - 'data-model' => 'norender|*', - ], 'modules' => [ new FullScreenModule(), ], ]); } + public function buildView(FormView $view, FormInterface $form, array $options): void + { + parent::buildView($view, $form, $options); + // data-model="norender|*" doesn't work on individual elements — Live Component + // treats "*" as a literal model name instead of substituting the field's name + // attribute. Use the actual full_name so the Quill content is included in the + // component's formValues when a LiveAction (e.g. moveCollectionItem) fires. + $view->vars['attr']['data-model'] = 'norender|' . $view->vars['full_name']; + } + public function getBlockPrefix(): string { return 'sylius_cms_plugin_quill_strategy'; diff --git a/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php b/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php index 5b189e56..2ea3e165 100644 --- a/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php +++ b/src/Twig/Component/Trait/ContentElementsCollectionFormComponentTrait.php @@ -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]]; @@ -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 $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 00000000..8b553f0e --- /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 acc6b0ee..e046fecd 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 %} -