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); + } +}