diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc75c2b..04745c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1 +1,2 @@
- Add new form type : ImmutableTabsType
+- Add new feature : sortable admin list
diff --git a/docs/00-docs.md b/docs/00-docs.md
index 2d84201..7130e3b 100644
--- a/docs/00-docs.md
+++ b/docs/00-docs.md
@@ -7,3 +7,8 @@
## Test helpers
- [Test Helpers](./20-test-helper-traits.md)
+
+
+## Sortabe admin list
+
+- [Sortabe admin list](./30-sortable-admin-list.md)
diff --git a/docs/30-sortable-admin-list.md b/docs/30-sortable-admin-list.md
new file mode 100644
index 0000000..2d0db61
--- /dev/null
+++ b/docs/30-sortable-admin-list.md
@@ -0,0 +1,175 @@
+# Admin sortable list
+
+To sort an admin list by drag&drop
+
+![sortable-admin-list.png](img/sortable-admin-list.png)
+
+## How it works
+
+Specific templates are used by your admin to add a class to HTML table to init javascript drag&drop:
+- `sonata/src/Ressources/views/CRUD/list_outer_rows_sortable_list.html.twig`
+- `sonata/src/Ressources/views/CRUD/list_sortable.html.twig`
+
+When you drop the row of your list, an ajax call send all positions of your items list to a new route of your admin to save all positions
+
+## Installation
+
+You must install some javascript libraries to your project :
+ - `"@shopify/draggable": "^1.0.0-beta.4"`
+ - `"superagent": "3.8.2"`
+
+You must include css `sonata/src/Ressources/public/css/sortableAdminList.css` to your project
+
+You must call the `sonata/src/Ressources/public/js/backoffice.js` to init drag&drop if a sortable list is display
+
+## How to implement drag&drop to your admin
+
+To use drag&drop to your entity admin, you must update your code like below
+
+### Position field
+
+You must add a field position to your entities
+
+```xml
+
+
+
+
+
+
+
+
+
+ ...
+
+
+
+```
+
+And use the trait `Sonata\SonataHelpersBundle\SortableListAdmin\PositionEntityTrait` to your entity to add the field position and getter/setter
+
+```php
+use SortableListAdmin/PositionEntityTrait.php
+
+class YourEntity
+{
+ use PositionEntityTrait;
+
+ ...
+}
+```
+
+### Admin
+
+To use drag&drop to an admin list, there must be some restrictions :
+- list is the only mode, you must disable mosaic button
+- your list must be only sorted by position asc
+- your list must display all objects in a single page
+- your admin must have a new route `save_positions`
+- you admin must use specific twig templates
+- a new object will be saved with a position
+
+You must set a Registry to your admin :
+```xml
+
+
+
+
+
+ YourEntityPath
+ YourEntityAdminControllerPath
+
+
+
+
+
+```
+
+You can extend your admin with `Sonata\SonataHelpersBundle\SortableListAdmin\AbstractSortableListAdmin`.
+This abstract class provides some configurations to get only one page and methods to set a registry, add a route `save_positions`, set specific twig templates and add a position before persist an entity
+
+But your list fields must not be sortable, so you must update your admin :
+
+```php
+class YourEntityAdmin extends AbstractAdmin
+{
+ ...
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configureListFields(ListMapper $listMapper)
+ {
+ $listMapper
+ ->addIdentifier('name', TextType::class, [
+ 'label' => 'Nom',
+ 'sortable' => false, // List no sortable
+ ])
+ ;
+ }
+
+ ...
+}
+```
+
+And you must update `AbstractAdmin` with the name of your bundle
+```php
+abstract class AbstractSortableListAdmin extends AbstractAdmin
+{
+ ...
+ public function getTemplate($name)
+ {
+ switch ($name) {
+ // @Todo : update twig path with your bundle
+ //case 'list':
+ // return 'YourBundle:CRUD:list_sortable.html.twig';
+ //case 'outer_sortable_list_rows_list':
+ // return 'YourBundle:CRUD:list_outer_rows_sortable_list.html.twig';
+ default:
+ return parent::getTemplate($name);
+ }
+ }
+ ...
+```
+
+### Admin controller
+
+A new action `savePositionsAction` for the route `save_positions` that will save new positions of all objects
+This action use the service `admin.sort_action` that will update field `position` in database
+
+You must use trait `Sonata\SonataHelpersBundle\SortAction\SortActionAdminControllerTrait` to your admin controller to add the action method
+
+
+```php
+use SortAction/SortActionAdminControllerTrait.php
+
+class YourEntityAdminController extends Controller
+{
+ use SortActionAdminControllerTrait;
+
+ ...
+}
+```
+
+### `admin.sort_action` service
+
+`/sonata/src/SortAction/SortAction.php`
+
+This service is used by `Sonata\SonataHelpersBundle\SortAction\SortActionAdminControllerTrait` to save positions of items.
+
+You must add a your sortable class to define it database table and it sort field
+```php
+class SortAction
+{
+ // Sortable class names and their database table name and sort field
+ const SORTABLE_CLASS = [
+ YourEntity::class => [
+ 'table' => 'your_entity_table',
+ 'field' => 'position',
+ ],
+ ];
+ ...
+```
\ No newline at end of file
diff --git a/docs/img/sortable-admin-list.png b/docs/img/sortable-admin-list.png
new file mode 100644
index 0000000..4ba7b41
Binary files /dev/null and b/docs/img/sortable-admin-list.png differ
diff --git a/src/DependencyInjection/SonataHelperExtension.php b/src/DependencyInjection/SonataHelperExtension.php
new file mode 100644
index 0000000..ce86b7a
--- /dev/null
+++ b/src/DependencyInjection/SonataHelperExtension.php
@@ -0,0 +1,34 @@
+
+ */
+class SonataHelperExtension extends Extension
+{
+ /**
+ * {@inheritdoc}
+ */
+ public function load(array $configs, ContainerBuilder $container)
+ {
+ $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
+ $loader->load('services.xml');
+ }
+}
diff --git a/src/Ressources/config/services.xml b/src/Ressources/config/services.xml
new file mode 100644
index 0000000..81ccbb4
--- /dev/null
+++ b/src/Ressources/config/services.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ressources/public/css/sortableAdminList.css b/src/Ressources/public/css/sortableAdminList.css
new file mode 100644
index 0000000..89b091d
--- /dev/null
+++ b/src/Ressources/public/css/sortableAdminList.css
@@ -0,0 +1,12 @@
+/* sortable table element*/
+.sortable-table tr {
+ cursor: move;
+}
+
+/* draggable elements */
+.draggable--over {
+ opacity: 0.33;
+}
+.draggable-mirror {
+ border: solid 1px #3c8dbc;
+}
diff --git a/src/Ressources/public/js/Sortable/Sortable.js b/src/Ressources/public/js/Sortable/Sortable.js
new file mode 100644
index 0000000..9f8551b
--- /dev/null
+++ b/src/Ressources/public/js/Sortable/Sortable.js
@@ -0,0 +1,28 @@
+/**
+ * This file is part of the Sonata for Ekino project.
+ *
+ * (c) 2018 - Ekino
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+import SortableTable from './SortableTable';
+
+/**
+ * Initialize SortableList behavior on all suitable children of the given node
+ *
+ * @param {Element} node
+ */
+export default function initializeSortableList (node) {
+ if (!node.querySelectorAll) {
+ return;
+ }
+
+ const listNodes = node.querySelectorAll('.sonata-ba-sortable-list');
+ listNodes.forEach((listNode) => {
+ const sortableTable = new SortableTable(listNode);
+ sortableTable.initialize();
+ });
+};
diff --git a/src/Ressources/public/js/Sortable/SortableTable.js b/src/Ressources/public/js/Sortable/SortableTable.js
new file mode 100644
index 0000000..6767c95
--- /dev/null
+++ b/src/Ressources/public/js/Sortable/SortableTable.js
@@ -0,0 +1,102 @@
+/**
+ * This file is part of the Sonata for Ekino project.
+ *
+ * (c) 2018 - Ekino
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+import { Sortable } from '@shopify/draggable';
+import Request from 'superagent';
+import Logger from './../Utils/Logger';
+
+const initializedClass = 'sortable-table-initialized';
+const sortableClass = 'sortable-table';
+
+class SortableTable {
+ /**
+ * @param {HTMLElement} element
+ */
+ constructor (element) {
+ this.element = element;
+ this.idsByPositions = [];
+ this.urlSavePositions = element.dataset.urlSavePositions;
+ }
+
+ initialize () {
+ // Ensure this element has not yet been initialized
+ if (this.element.classList.contains(initializedClass)) {
+ return;
+ }
+
+ this.element.classList.add(initializedClass, sortableClass);
+
+ this.element.querySelectorAll('.sonata-ba-sortable-row').forEach((row) => {
+ this.idsByPositions.push(row.dataset.id);
+ });
+
+ const sortable = new Sortable(this.element, {
+ draggable: 'tr',
+ mirror: {
+ constrainDimensions: true,
+ },
+ });
+
+ Logger.groupCollapsed('Drag&drop table element');
+ Logger.debug(this.element);
+ Logger.debug(this.idsByPositions);
+ Logger.groupEnd();
+
+ sortable.on('sortable:stop', this.sortableStopHandler.bind(this));
+ }
+
+ /**
+ *
+ * @param sortableStopEvent
+ */
+ sortableStopHandler (sortableStopEvent) {
+ const sourceId = sortableStopEvent.data.dragEvent.data.source.dataset.id;
+ const oldIndex = sortableStopEvent.data.oldIndex;
+ const newIndex = sortableStopEvent.data.newIndex;
+
+ if (oldIndex !== newIndex) {
+ // Update position of each row between oldIndex and newIndex
+ const diffIndex = newIndex - oldIndex;
+ if (diffIndex > 0) {
+ for (let i = oldIndex; i < newIndex; i++) {
+ this.idsByPositions[i] = this.idsByPositions[i + 1];
+ }
+ } else {
+ for (let i = oldIndex; i > newIndex; i--) {
+ this.idsByPositions[i] = this.idsByPositions[i - 1];
+ }
+ }
+ this.idsByPositions[newIndex] = sourceId;
+
+ // Ajax call to save positions
+ Request.post(this.urlSavePositions)
+ // .set('Content-Type', 'application/json')
+ .type('form')
+ .send({ idsByPositions: JSON.stringify(this.idsByPositions) })
+ .then((reponse) => {
+ Logger.debug('Drag&drop ajax call success');
+ })
+ .catch((error) => {
+ Logger.error(`Drag&drop ajax call error : ${error.message}`);
+ Logger.error(error.response);
+ })
+ ;
+
+ Logger.groupCollapsed('Drag&drop draggable fin');
+ Logger.debug(`source id : ${sourceId}`);
+ Logger.debug(`old index : ${oldIndex}`);
+ Logger.debug(`new index : ${newIndex}`);
+ Logger.debug(this.idsByPositions);
+ Logger.groupEnd();
+ }
+ }
+}
+
+export default SortableTable;
diff --git a/src/Ressources/public/js/Utils/Logger.js b/src/Ressources/public/js/Utils/Logger.js
new file mode 100644
index 0000000..b2ae719
--- /dev/null
+++ b/src/Ressources/public/js/Utils/Logger.js
@@ -0,0 +1,63 @@
+/**
+ * This file is part of the Sonata for Ekino project.
+ *
+ * (c) 2018 - Ekino
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+const useLogger = window.location.pathname.match(/app_dev.php/g);
+
+export default {
+ log (message, level = 'info') {
+ if (!useLogger) {
+ return;
+ }
+
+ if (level === 'error') {
+ console.error(message);
+ } else if (level === 'warn') {
+ console.warn(message);
+ } else if (level === 'info') {
+ console.info(message);
+ } else if (level === 'group') {
+ console.group(message);
+ } else if (level === 'groupCollapsed') {
+ console.groupCollapsed(message);
+ } else if (level === 'groupEnd') {
+ console.groupEnd();
+ } else {
+ console.log(message);
+ }
+ },
+
+ debug (message) {
+ this.log(message, 'debug');
+ },
+
+ info (message) {
+ this.log(message, 'info');
+ },
+
+ warn (message) {
+ this.log(message, 'warn');
+ },
+
+ error (message) {
+ this.log(message, 'error');
+ },
+
+ group (name) {
+ this.log(name, 'group');
+ },
+
+ groupCollapsed (name) {
+ this.log(name, 'groupCollapsed');
+ },
+
+ groupEnd () {
+ this.log('', 'groupEnd');
+ },
+}
diff --git a/src/Ressources/public/js/backoffice.js b/src/Ressources/public/js/backoffice.js
new file mode 100644
index 0000000..08bc568
--- /dev/null
+++ b/src/Ressources/public/js/backoffice.js
@@ -0,0 +1,50 @@
+/**
+ * This file is part of the Sonata for Ekino project.
+ *
+ * (c) 2018 - Ekino
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ */
+
+import initializeSortable from './Sortable/Sortable';
+
+/**
+ * Initialize all behaviors on target and its children
+ *
+ * @param {Element} target
+ */
+const setup = (target) => {
+ initializeSortable(target);
+};
+
+// Create an observer instance
+const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ if (!mutation.addedNodes || mutation.addedNodes.length === 0) {
+ return;
+ }
+
+ // When there is new nodes, we're executing setup on them
+ mutation.addedNodes.forEach((node) => {
+ setup(node);
+ });
+ });
+});
+
+// Configuration of the observer
+const config = { attributes: false, childList: true, characterData: true, subtree: true };
+
+/**
+ * This module can be used to execute javascript in an ES6 way on the backoffice
+ */
+jQuery(document).ready(() => {
+ // Remove sonata default logs polluting the console
+ if (Admin) {
+ Admin.log = () => {};
+ }
+
+ setup(document);
+ observer.observe(document, config);
+});
diff --git a/src/Ressources/views/CRUD/list_outer_rows_sortable_list.html.twig b/src/Ressources/views/CRUD/list_outer_rows_sortable_list.html.twig
new file mode 100644
index 0000000..94173de
--- /dev/null
+++ b/src/Ressources/views/CRUD/list_outer_rows_sortable_list.html.twig
@@ -0,0 +1,14 @@
+{#
+This file is part of the Sonata for Ekino project.
+
+(c) 2018 - Ekino
+
+For the full copyright and license information, please view the LICENSE
+file that was distributed with this source code.
+#}
+
+{% for object in admin.datagrid.results %}
+
+ {% include admin.getTemplate('inner_list_row') %}
+
+{% endfor %}
diff --git a/src/Ressources/views/CRUD/list_sortable.html.twig b/src/Ressources/views/CRUD/list_sortable.html.twig
new file mode 100644
index 0000000..273cac1
--- /dev/null
+++ b/src/Ressources/views/CRUD/list_sortable.html.twig
@@ -0,0 +1,16 @@
+{#
+This file is part of the Sonata for Ekino project.
+
+(c) 2018 - Ekino
+
+For the full copyright and license information, please view the LICENSE
+file that was distributed with this source code.
+#}
+
+{% extends 'SonataAdminBundle:CRUD:list.html.twig' %}
+
+{% block table_body %}
+
+ {% include admin.getTemplate('outer_sortable_list_rows_list') %}
+
+{% endblock %}
diff --git a/src/SortableListAdmin/AbstractSortableListAdmin.php b/src/SortableListAdmin/AbstractSortableListAdmin.php
new file mode 100644
index 0000000..37a4e93
--- /dev/null
+++ b/src/SortableListAdmin/AbstractSortableListAdmin.php
@@ -0,0 +1,100 @@
+
+ */
+abstract class AbstractSortableListAdmin extends AbstractAdmin
+{
+ // Sortable list can be only with one page
+ protected $maxPerPage = 500;
+ protected $perPageOptions = [500];
+
+ protected $datagridValues = [
+ '_page' => 1,
+ '_sort_order' => 'ASC',
+ '_sort_by' => 'position',
+ ];
+
+ /**
+ * @var RegistryInterface
+ */
+ protected $doctrine;
+
+ /**
+ * Set Registry.
+ *
+ * @param RegistryInterface $doctrine
+ */
+ public function setRegistry(RegistryInterface $doctrine)
+ {
+ $this->doctrine = $doctrine;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTemplate($name)
+ {
+ switch ($name) {
+ // @Todo : update twig path with your bundle
+ //case 'list':
+ // return 'YourBundle:CRUD:list_sortable.html.twig';
+ //case 'outer_sortable_list_rows_list':
+ // return 'YourBundle:CRUD:list_outer_rows_sortable_list.html.twig';
+ default:
+ return parent::getTemplate($name);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function prePersist($object)
+ {
+ parent::prePersist($object);
+
+ $em = $this->doctrine->getManager();
+ $tableName = $em->getClassMetadata($this->getClass())->getTableName();
+
+ // Set position
+ $connection = $this->doctrine->getConnection();
+ $stmt = $connection->executeQuery(
+ sprintf('SELECT MAX(position) as position FROM %s;', $tableName),
+ []
+ );
+ $result = $stmt->fetch();
+
+ $object->setPosition($result['position'] + 1);
+
+ return [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configureRoutes(RouteCollection $collection)
+ {
+ parent::configureRoutes($collection);
+
+ $collection->add('save_positions');
+ }
+}
diff --git a/src/SortableListAdmin/PositionEntityTrait.php b/src/SortableListAdmin/PositionEntityTrait.php
new file mode 100644
index 0000000..15c8036
--- /dev/null
+++ b/src/SortableListAdmin/PositionEntityTrait.php
@@ -0,0 +1,45 @@
+
+ */
+trait PositionEntityTrait
+{
+ /**
+ * @var int
+ */
+ protected $position;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setPosition($position)
+ {
+ $this->position = $position;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPosition()
+ {
+ return $this->position;
+ }
+}
diff --git a/src/SortableListAdmin/SortAction.php b/src/SortableListAdmin/SortAction.php
new file mode 100644
index 0000000..5668b87
--- /dev/null
+++ b/src/SortableListAdmin/SortAction.php
@@ -0,0 +1,136 @@
+
+ */
+class SortAction
+{
+ // Sortable class names and their database table names
+ const SORTABLE_CLASS = [
+ // @Todo : update with your entity
+ //YourEntity::class => [
+ // 'table' => 'your_entity_table',
+ // 'field' => 'position',
+ //],
+ ];
+
+ /**
+ * @var RegistryInterface
+ */
+ private $doctrine;
+
+ /**
+ * ObjectPositions constructor.
+ *
+ * @param RegistryInterface $doctrine
+ */
+ public function __construct(RegistryInterface $doctrine)
+ {
+ $this->doctrine = $doctrine;
+ }
+
+ /**
+ * Sort entities by update their `position` in database.
+ *
+ * Generate and execute a query to update all positions by id
+ * "UPDATE your_entity_table SET position = (
+ * case when id = 1 then 0
+ * when id = 2 then 1
+ * when id = 3 then 2
+ * end
+ * ) WHERE id in ( 1, 2, 3)"
+ *
+ * @param string $className
+ * @param array $idsByPositions
+ *
+ * @return array
+ */
+ public function sortEntities($className, $idsByPositions): array
+ {
+ if (!$this->isSortableClass($className)) {
+ return ['status' => 'error', 'message' => sprintf('%s is not a sortable class', $className)];
+ }
+
+ $queryAndParams = $this->generateUpdateQuery($className, $idsByPositions);
+
+ try {
+ $connection = $this->doctrine->getConnection();
+ $connection->executeQuery($queryAndParams['query'], $queryAndParams['params'])->execute();
+ } catch (\Exception $e) {
+ return ['status' => 'error', 'message' => $e->getMessage()];
+ }
+
+ return ['status' => 'success'];
+ }
+
+ /**
+ * Generate update query and params.
+ *
+ * @param string $className
+ * @param array $idsByPositions
+ *
+ * @return array
+ */
+ public function generateUpdateQuery($className, $idsByPositions): array
+ {
+ $queryStart = sprintf(
+ 'UPDATE %s SET %s = (case ',
+ self::SORTABLE_CLASS[$className]['table'],
+ self::SORTABLE_CLASS[$className]['field']
+ );
+ $queryEnd = '';
+
+ $casesParams = [];
+ $whereParams = [];
+ foreach ($idsByPositions as $position => $id) {
+ $queryStart .= ' when id = ? then ? ';
+ if (!$queryEnd) {
+ $queryEnd = ' end) WHERE id in ( ?';
+ } else {
+ $queryEnd .= ', ?';
+ }
+ $casesParams[] = (int) $id;
+ $casesParams[] = $position;
+ $whereParams[] = (int) $id;
+ }
+ $query = sprintf('%s %s)', $queryStart, $queryEnd);
+
+ $params = array_merge($casesParams, $whereParams);
+
+ return [
+ 'query' => $query,
+ 'params' => $params,
+ ];
+ }
+
+ /**
+ * Check if a class name is sortable.
+ *
+ * @param string $className
+ *
+ * @return bool
+ */
+ private function isSortableClass($className): bool
+ {
+ return array_key_exists($className, self::SORTABLE_CLASS);
+ }
+}
diff --git a/src/SortableListAdmin/SortActionAdminControllerTrait.php b/src/SortableListAdmin/SortActionAdminControllerTrait.php
new file mode 100644
index 0000000..2c5396e
--- /dev/null
+++ b/src/SortableListAdmin/SortActionAdminControllerTrait.php
@@ -0,0 +1,54 @@
+
+ */
+trait SortActionAdminControllerTrait
+{
+ /**
+ * @param Request|null $request
+ *
+ * @return Response
+ */
+ public function savePositionsAction(Request $request = null)
+ {
+ $this->admin->checkAccess('list');
+
+ $idsByPositions = $request->request->get('idsByPositions');
+
+ if (!$idsByPositions) {
+ return $this->renderJson(['status' => 'error', 'message' => 'idsByPositions is null'], 500);
+ }
+
+ // Update positions
+ $idsByPositions = json_decode($idsByPositions, true);
+ $sortAction = $this->get('admin.sort_action');
+ $result = $sortAction->sortEntities($this->admin->getClass(), $idsByPositions);
+
+ if ('error' === $result['status']) {
+ return $this->renderJson($result, 500);
+ }
+
+ return $this->renderJson($result);
+ }
+}