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 19d8e06..d9b478f 100644 --- a/docs/00-docs.md +++ b/docs/00-docs.md @@ -3,3 +3,8 @@ ## Immutable tabs form type - [Immutable Tabs Form Type](./10-immutable-tabs-type.md) + + +## Sortabe admin list + +- [Sortabe admin list](./20-Sortable-admin-list.md) diff --git a/docs/20-Sortable-admin-list.md b/docs/20-Sortable-admin-list.md new file mode 100644 index 0000000..c16fea6 --- /dev/null +++ b/docs/20-Sortable-admin-list.md @@ -0,0 +1,272 @@ +# 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 + +You must declare a new service +``` + + + + + + + + + + + +``` + +## 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 +(you can set an other name for this field if you want) + +``` + + + + + + + + + + ... + + + +``` + + +``` +class YourEntity +{ + ... + + /** + * @var int + */ + protected $position; + + ... + + /** + * {@inheritdoc} + */ + public function setPosition($position) + { + $this->position = $position; + } + + /** + * {@inheritdoc} + */ + public function getPosition() + { + return $this->position; + } +} +``` + +### Admin + +You must add some config to your admin object : +- list is the only mode, you must disable mosaic button +- your list must be only sorted by position asc +- you must configure pagination to display all objects in a single page +- you must add a new route `save_positions` +- you must configure admin to use specific template twig +- a new object will be saved with a position + + +``` + + + + + + YourEntityPath + YourEntityAdminControllerPath + + + + + +``` + + +``` +class YourEntityAdmin 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', + ]; + + ... + + /** + * {@inheritdoc} + */ + protected function configureListFields(ListMapper $listMapper) + { + $listMapper + ->addIdentifier('name', TextType::class, [ + 'label' => 'Nom', + 'sortable' => false, // Sorted only by position asc + ]) + ->add('_action', null, [ + 'actions' => [ + 'edit' => [], + 'delete' => [], + ], + 'row_align' => 'center', + 'header_style' => 'width:20%', + ]) + ; + } + + /** + * {@inheritdoc} + */ + protected function configureRoutes(RouteCollection $collection) + { + parent::configureRoutes($collection); + + $collection->add('save_positions'); // New route to save positions + } + + + /** + * {@inheritdoc} + */ + public function getTemplate($name) + { + switch ($name) { + case 'list': // Templace with css classes to get drag&drop + return 'CanalPlusAwakenAdminBundle:CRUD:list_sortable.html.twig'; + case 'outer_sortable_list_rows_list': // Templace with css classes to get drag&drop + return 'CanalPlusAwakenAdminBundle:CRUD:list_outer_rows_sortable_list.html.twig'; + default: + return parent::getTemplate($name); + } + } + + /** + * {@inheritdoc} + */ + public function prePersist($object) + { + parent::prePersist($object); + + // Set position + $connection = $this->doctrine->getConnection(); + $stmt = $connection->executeQuery( + 'SELECT MAX(position) as position FROM your_entity_table;', + ); + $result = $stmt->fetch(); + + $object->setPosition($result['position'] + 1); + + return []; + } +} +``` + +### 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 +``` +class YourEntityAdminController extends Controller +{ + /** + * @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(DecoderChannelsGroup::class, $idsByPositions); + + if ($result['status'] === 'error') { + return $this->renderJson($result, 500); + } + + return $this->renderJson($result); + } +``` + +### sort_action service + +`/sonata/src/SortAction/SortAction.php` + +This service generate and execute a query to update all positions by id +``` +UPDATE decoder_channels_group 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) + ``` + +You can add a new sortable class to define it database table and it sort field +``` +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/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/SortAction/SortAction.php b/src/SortAction/SortAction.php new file mode 100644 index 0000000..1fa5978 --- /dev/null +++ b/src/SortAction/SortAction.php @@ -0,0 +1,133 @@ + + */ +class SortAction +{ + // Sortable class names and their database table names + const SORTABLE_CLASS = [ + //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) + { + 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) + { + $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) + { + return array_key_exists($className, self::SORTABLE_CLASS); + } +}