Last active
September 4, 2024 18:53
-
-
Save ker0x/8a1bad79a22f0ebae9ddf28c264dd13f to your computer and use it in GitHub Desktop.
Enhanced Symfony Form CollectionType with Stimulus
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
import {Controller} from 'stimulus' | |
export default class extends Controller { | |
static targets = [ | |
'container', | |
'entry' | |
] | |
static values = { | |
allowAdd: Boolean, | |
allowDelete: Boolean, | |
buttonAdd: String, | |
buttonAddPosition: String, | |
buttonDelete: String, | |
buttonDeletePosition: String, | |
prototype: String, | |
prototypeName: String, | |
startIndex: Number | |
} | |
connect() { | |
this.controllerName = this.context.scope.identifier; | |
this.index = this.hasStartIndexValue ? this.startIndexValue : this.entryTargets.length | |
this._dispatchEvent('advanced-collection:pre-connect', { | |
allowAdd: this.allowAddValue, | |
allowDelete: this.allowDeleteValue, | |
}); | |
// insert add button | |
if (true === this.allowAddValue) { | |
const addButton = this._htmlToElement(this.buttonAddValue); | |
this.containerTarget.insertAdjacentElement(this.buttonAddPositionValue, addButton); | |
} | |
// insert delete button on each existing entry. | |
if (true === this.allowDeleteValue && this.entryTargets.length > 0) { | |
this.entryTargets.forEach(function (element, index) { | |
this._addDeleteButton(element, index) | |
}, this); | |
} | |
this._dispatchEvent('advanced-collection:connect', { | |
allowAdd: this.allowAddValue, | |
allowDelete: this.allowDeleteValue, | |
}); | |
} | |
add(event) { | |
let newEntry = this.prototypeValue; | |
newEntry = newEntry.replace(new RegExp(this.prototypeNameValue, 'g'), this.index); | |
newEntry = this._htmlToElement(newEntry); | |
if (true === this.allowDeleteValue) { | |
newEntry = this._addDeleteButton(newEntry, this.index); | |
} | |
this.containerTarget.appendChild(newEntry); | |
this.index++; | |
this._dispatchEvent('advanced-collection:add', { | |
element: newEntry, | |
}); | |
} | |
delete(event) { | |
let entry = event.target.closest(`[data-${this.controllerName}-target="entry"]`); | |
entry.remove(); | |
this._dispatchEvent('advanced-collection:delete', { | |
element: entry, | |
}); | |
} | |
/** | |
* Insert the delete button to the entry. | |
* | |
* @private | |
* | |
* @param {string} entry | |
* @param {int} index | |
* | |
* @returns {(string|ChildNode)} | |
*/ | |
_addDeleteButton(entry, index) { | |
// link the button and the entry by the data-index-entry attribute | |
entry.dataset.indexEntry = index; | |
let buttonDelete = this._htmlToElement(this.buttonDeleteValue); | |
if (!buttonDelete) { | |
return entry; | |
} | |
buttonDelete.dataset.indexEntry = index; | |
entry.insertAdjacentElement(this.buttonDeletePositionValue, buttonDelete) | |
return entry; | |
} | |
/** | |
* Convert html to Element to insert in the DOM. | |
* | |
* @private | |
* | |
* @param {string} html | |
* | |
* @returns {ChildNode} | |
*/ | |
_htmlToElement(html) { | |
let template = document.createElement('template'); | |
html = html.trim(); // never return a text node of whitespace as the result | |
template.innerHTML = html; | |
return template.content.firstChild; | |
} | |
/** | |
* Dispatch an event | |
* | |
* @private | |
* | |
* @param {string} name | |
* @param {Object} payload | |
* @param {boolean} canBubble | |
* @param {boolean} cancelable | |
* | |
* @return {void} | |
*/ | |
_dispatchEvent(name, payload = null, canBubble = false, cancelable = false) { | |
const userEvent = document.createEvent('CustomEvent'); | |
userEvent.initCustomEvent(name, canBubble, cancelable, payload); | |
this.element.dispatchEvent(userEvent); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{% use 'form_div_layout.html.twig' %} | |
{% block enhanced_collection_widget -%} | |
{%- set controller_values = controller_values|merge({ | |
'button-add': block('button_add')|trim, | |
'button-delete': block('button_delete')|trim | |
}) -%} | |
{# attr for the data target on the entry of the collection #} | |
{%- set attr_data_target = {('data-' ~ controller_name ~ '-target'): 'entry' } -%} | |
{% if prototype is defined and not prototype.rendered %} | |
{%- set prototype_attr = prototype.vars.row_attr|merge(attr_data_target) -%} | |
{%- set controller_values = controller_values|merge({ | |
'prototype': form_row(prototype, {'row_attr': prototype_attr}) | |
}) -%} | |
{% endif %} | |
<div {{ stimulus_controller(controller_name, controller_values) }}{{ block('widget_container_attributes') }}> | |
{%- if form is rootform -%} | |
{{ form_errors(form) }} | |
{%- endif -%} | |
<div {{ stimulus_target(controller_name, 'container') }}{% if entry_container_attr %}{% with { attr: entry_container_attr } %}{{ block('attributes') }}{% endwith %}{% endif %}> | |
{% for child in form|filter(child => not child.rendered) %} | |
{%- set child_row_attr = child.vars.row_attr|merge(attr_data_target) -%} | |
{{- form_row(child, {'row_attr': child_row_attr}) -}} | |
{% endfor %} | |
</div> | |
{{- form_rest(form) -}} | |
</div> | |
{%- endblock %} | |
{% block button_add %} | |
<button {{ stimulus_action(controller_name, 'add', 'click') }}{% if button_add_attr %}{% with { attr: button_add_attr } %}{{ block('attributes') }}{% endwith %}{% endif %} | |
type="button"> | |
{%- if translation_domain is same as(false) -%} | |
{%- if button_add_html is same as(false) -%} | |
{{- button_add -}} | |
{%- else -%} | |
{{- button_add|raw -}} | |
{%- endif -%} | |
{%- else -%} | |
{%- if button_add_html is same as(false) -%} | |
{{- button_add|trans(button_add_translation_parameters, translation_domain) -}} | |
{%- else -%} | |
{{- button_add|trans(button_add_translation_parameters, translation_domain)|raw -}} | |
{%- endif -%} | |
{%- endif -%} | |
</button> | |
{% endblock %} | |
{% block button_delete %} | |
<button {{ stimulus_action(controller_name, 'delete', 'click') }}{% if button_delete_attr %}{% with { attr: button_delete_attr } %}{{ block('attributes') }}{% endwith %}{% endif %} | |
type="button"> | |
{%- if translation_domain is same as(false) -%} | |
{%- if button_delete_html is same as(false) -%} | |
{{- button_delete -}} | |
{%- else -%} | |
{{- button_delete|raw -}} | |
{%- endif -%} | |
{%- else -%} | |
{%- if button_delete_html is same as(false) -%} | |
{{- button_delete|trans(button_delete_translation_parameters, translation_domain) -}} | |
{%- else -%} | |
{{- button_delete|trans(button_delete_translation_parameters, translation_domain)|raw -}} | |
{%- endif -%} | |
{%- endif -%} | |
</button> | |
{% endblock %} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace App\Form; | |
use App\Form\Util\StringUtil; | |
use Symfony\Component\Form\AbstractType; | |
use Symfony\Component\Form\Extension\Core\Type\CollectionType; | |
use Symfony\Component\Form\FormInterface; | |
use Symfony\Component\Form\FormView; | |
use Symfony\Component\OptionsResolver\OptionsResolver; | |
final class EnhancedCollectionType extends AbstractType | |
{ | |
public function buildView(FormView $view, FormInterface $form, array $options): void | |
{ | |
parent::buildView($view, $form, $options); | |
$data = $form->getData(); | |
$controllerName = $options['controller_name'] ?? $this->getBlockPrefix(); | |
$view->vars['button_add'] = $options['button_add']; | |
$view->vars['button_add_attr'] = $options['button_add_attr']; | |
$view->vars['button_add_html'] = $options['button_add_html']; | |
$view->vars['button_add_translation_parameters'] = $options['button_add_translation_parameters']; | |
$view->vars['button_delete'] = $options['button_delete']; | |
$view->vars['button_delete_attr'] = $options['button_delete_attr']; | |
$view->vars['button_delete_html'] = $options['button_delete_html']; | |
$view->vars['button_delete_translation_parameters'] = $options['button_delete_translation_parameters']; | |
$view->vars['entry_container_attr'] = $options['entry_container_attr']; | |
// stimulus controller name and values | |
$view->vars['controller_name'] = $controllerName; | |
$view->vars['controller_values']['allow-add'] = $options['allow_add']; | |
$view->vars['controller_values']['allow-delete'] = $options['allow_delete']; | |
$view->vars['controller_values']['button-add-position'] = $options['button_add_position']; | |
$view->vars['controller_values']['button-delete-position'] = $options['button_delete_position']; | |
$view->vars['controller_values']['prototype_name'] = $options['prototype_name']; | |
$view->vars['controller_values']['start-index'] = is_countable($data) ? \count($data) : 0; | |
} | |
public function configureOptions(OptionsResolver $resolver): void | |
{ | |
$resolver->setDefined([ | |
'button_add', | |
'button_add_attr', | |
'button_add_html', | |
'button_add_translation_parameters', | |
'button_add_position', | |
'button_delete', | |
'button_delete_attr', | |
'button_delete_html', | |
'button_delete_translation_parameters', | |
'button_delete_position', | |
'entry_container_attr', | |
]); | |
$resolver->setAllowedTypes('button_add', ['null', 'string']); | |
$resolver->setAllowedTypes('button_add_attr', ['array']); | |
$resolver->setAllowedTypes('button_add_html', ['bool']); | |
$resolver->setAllowedTypes('button_add_translation_parameters', ['array']); | |
$resolver->setAllowedTypes('button_add_position', ['string']); | |
$resolver->setAllowedTypes('button_delete', ['null', 'string']); | |
$resolver->setAllowedTypes('button_delete_attr', ['array']); | |
$resolver->setAllowedTypes('button_delete_html', ['bool']); | |
$resolver->setAllowedTypes('button_delete_translation_parameters', ['array']); | |
$resolver->setAllowedTypes('button_delete_position', ['string']); | |
$resolver->setAllowedTypes('entry_container_attr', ['array']); | |
$resolver->setAllowedValues('button_add_position', ['beforebegin', 'afterbegin', 'beforeend', 'afterend']); | |
$resolver->setAllowedValues('button_delete_position', ['afterbegin', 'beforeend']); | |
$resolver->setDefaults([ | |
'button_add' => null, | |
'button_add_attr' => [], | |
'button_add_html' => false, | |
'button_add_translation_parameters' => [], | |
'button_add_position' => 'afterend', | |
'button_delete' => null, | |
'button_delete_attr' => [], | |
'button_delete_html' => false, | |
'button_delete_translation_parameters' => [], | |
'button_delete_position' => 'beforeend', | |
'entry_container_attr' => [], | |
]); | |
} | |
public function getParent(): string | |
{ | |
return CollectionType::class; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment