Skip to content

Commit

Permalink
Add sortable admin list feature
Browse files Browse the repository at this point in the history
  • Loading branch information
bdejacobet committed Mar 28, 2019
1 parent f8155aa commit a3c733f
Show file tree
Hide file tree
Showing 17 changed files with 847 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Add new form type : ImmutableTabsType
- Add new feature : sortable admin list
5 changes: 5 additions & 0 deletions docs/00-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@
## Test helpers

- [Test Helpers](./20-test-helper-traits.md)


## Sortabe admin list

- [Sortabe admin list](./30-sortable-admin-list.md)
175 changes: 175 additions & 0 deletions docs/30-sortable-admin-list.md
Original file line number Diff line number Diff line change
@@ -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
<?xml version="1.0" encoding="utf-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">

<entity name="AppBundle\Entity\YourEntity" table="your_entity_table">
<id name="id" type="integer" column="id">
<generator strategy="AUTO"/>
</id>

<field name="position" type="integer" column="position" nullable="true"/>
...

</entity>
</doctrine-mapping>
```

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
<!-- admin.xml -->
<service id="admin.your_entity" class="YourEntityAdminPath">
<tag name="sonata.admin" manager_type="orm" label="Your entity admin" show_mosaic_button="false"/> <!-- disable mosaic button -->

<argument />
<argument>YourEntityPath</argument>
<argument>YourEntityAdminControllerPath</argument> <!-- specific controller -->

<call method="setRegistry"> <!-- to get the last position to create new entity with position -->
<argument type="service" id="doctrine"/>
</call>
</service>
```

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',
],
];
...
```
Binary file added docs/img/sortable-admin-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions src/DependencyInjection/SonataHelperExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
*
* 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.
*
*/

namespace Sonata\SonataHelpersBundle\DependencyInjection;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

/**
* @author Benoit de Jacobet <[email protected]>
*/
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');
}
}
12 changes: 12 additions & 0 deletions src/Ressources/config/services.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<!--SortAction-->
<service id="admin.sort_action" class="Sonata\SonataHelpersBundle\SortableListAdmin\SortAction">
<argument type="service" id="doctrine" />
</service>

</services>
</container>
12 changes: 12 additions & 0 deletions src/Ressources/public/css/sortableAdminList.css
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions src/Ressources/public/js/Sortable/Sortable.js
Original file line number Diff line number Diff line change
@@ -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();
});
};
102 changes: 102 additions & 0 deletions src/Ressources/public/js/Sortable/SortableTable.js
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit a3c733f

Please sign in to comment.