Skip to content

Instantly share code, notes, and snippets.

@chapterjason
Forked from tito10047/Order.php
Created November 6, 2025 20:44
Show Gist options
  • Select an option

  • Save chapterjason/ba906cfccbe94180f8cae17a032bf2d5 to your computer and use it in GitHub Desktop.

Select an option

Save chapterjason/ba906cfccbe94180f8cae17a032bf2d5 to your computer and use it in GitHub Desktop.
EasyAdmin sortable CollectionField
//assets/styles/app.css
.accordion-header .move-button {
border: none;
background-color: transparent;
padding: 8px 0px 8px 7px;
}
.accordion-header .move-button i {
color: var(--form-collection-item-collapse-marker-color);
margin: 0 8px 0 4px;
font-size: var(--font-size-base);
}
.form-group.field-collection .accordion-button,
.form-group.field-collection .accordion-button:hover {
flex: none;
}
.form-group.field-collection .accordion-button:not(:first-child),
.form-group.field-collection .accordion-button:hover:not(:first-child) {
padding-left: 0;
}
.form-group.field-collection .accordion-button:first-child,
.form-group.field-collection .accordion-button:hover:first-child {
padding-right: 0;
}
.accordion-button {
width: inherit;
}
//templates/extension/form_theme.html.twig
{% extends '@EasyAdmin/crud/form_theme.html.twig' %}
{%- block form_widget_compound -%}
{% set is_sortable = form.vars.ea_vars.field.customOptions.get('sortable') ?? false %}
{% if is_sortable %}
{% set attr = attr|merge(stimulus_controller('sortable-collection').toArray()) %}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_compound -%}
{% block collection_entry_row %}
{% set is_array_field = 'EasyCorp\\Bundle\\EasyAdminBundle\\Field\\ArrayField' == form_parent(form).vars.ea_vars.field.fieldFqcn ?? false %}
{% set is_complex = form_parent(form).vars.ea_vars.field.customOptions.get('entryIsComplex') ?? false %}
{% set is_sortable = form_parent(form).vars.ea_vars.field.customOptions.get('sortable') ?? false %}
{% set allows_deleting_items = form_parent(form).vars.allow_delete|default(false) %}
{% set render_expanded = not form.vars.valid or form_parent(form).vars.ea_vars.field.customOptions.get('renderExpanded') ?? false %}
{% set delete_item_button %}
<button type="button" class="btn btn-link btn-link-danger field-collection-delete-button ms-auto"
title="{{ 'action.remove_item'|trans({}, 'EasyAdminBundle') }}">
<i class="far fa-trash-alt"></i>
</button>
{% endset %}
<div class="field-collection-item {{ is_complex ? 'field-collection-item-complex' }} {{ not form.vars.valid ? 'is-invalid' }}" {{ stimulus_target('sortable-collection','row') }}>
{% if is_array_field|default(false) %}
{{ form_label(form) }}
{{ form_widget(form) }}
{% if allows_deleting_items and not disabled %}
{{ delete_item_button }}
{% endif %}
{% else %}
<div class="accordion-item">
<h2 class="accordion-header d-flex">
{% if is_sortable %}
<button class="accordion-button move-button " type="button">
<i class="fa-solid fas fa-up-down-left-right"></i>
</button>
{% endif %}
<button class="accordion-button {{ render_expanded ? '' : 'collapsed' }}" type="button"
data-bs-toggle="collapse" data-bs-target="#{{ id }}-contents">
<i class="fas fw fa-chevron-right form-collection-item-collapse-marker"></i>
{{ value|ea_as_string }}
</button>
{% if allows_deleting_items and not disabled %}
{{ delete_item_button }}
{% endif %}
</h2>
<div id="{{ id }}-contents" class="accordion-collapse collapse {{ render_expanded ? 'show' }}">
<div class="accordion-body">
<div class="row">
{{ form_widget(form) }}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock collection_entry_row %}
<?php
//App/Entity/Order.php
#[ORM\Entity()]
#[ORM\Table(name: '`order`')]
class Order {
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ["persist", "remove"], orphanRemoval: true)]
#[ORM\OrderBy(["position" => "ASC"])]
#[Assert\Valid]
private Collection $items;
//...
}
<?php
//App/Entity/OrderItem.php
#[ORM\Entity()]
class OrderItem {
#[ORM\Column]
private int $position = 0;
//...
}
<?php
//App/Form/OrderItemType.php
class OrderItemType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options): void {
$builder
->add("position",HiddenType::class,[
"attr"=>[
"data-sortable-collection-target"=>"position"
]
]);
//...
}
public function configureOptions(OptionsResolver $resolver): void {
$resolver->setDefaults([
'data_class' => OrderItem::class,
]);
}
}
//assets/controllers/sortable-collection_controller.js
import {Controller} from '@hotwired/stimulus';
import Sortable from 'sortablejs';
export default class extends Controller {
static targets = ['row', 'position'];
connect() {
this.sortable = Sortable.create(this.element, {
handle: '.move-button',
onEnd: this.end.bind(this)
});
}
end(evt) {
this.rowTargets.forEach((el, index) => {
let divNodes = Array.prototype.filter.call(this.element.childNodes, node => node.nodeName === 'DIV');
let position = Array.prototype.indexOf.call(divNodes, el);
this.positionTargets[index].value = position
});
}
}
<?php
class OrderCrudController extends AbstractCrudController {
public function configureCrud(Crud $crud): Crud {
return parent::configureCrud($crud)
->setFormThemes([
'extension/form_theme.html.twig',
]);
}
public function configureFields(string $pageName): iterable {
return [
CollectionField::new('items')
->setCustomOption("sortable", true)
->setEntryType(OrderItemType::class)
];
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment