Skip to content

Instantly share code, notes, and snippets.

@ker0x
Last active September 4, 2024 18:53
Show Gist options
  • Save ker0x/8a1bad79a22f0ebae9ddf28c264dd13f to your computer and use it in GitHub Desktop.
Save ker0x/8a1bad79a22f0ebae9ddf28c264dd13f to your computer and use it in GitHub Desktop.
Enhanced Symfony Form CollectionType with Stimulus
'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);
}
}
{% 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 %}
<?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