Add subtasks drag and drop

This commit is contained in:
Frederic Guillot 2016-02-19 22:59:47 -05:00
parent 270e0835b2
commit de4519fa2c
16 changed files with 217 additions and 205 deletions

View File

@ -3,6 +3,7 @@ Version 1.0.26 (unreleased)
New features:
* Add subtasks drag and drop
* Add file drag and drop and asynchronous upload
* Enable/Disable users
* Add setting option to disable private projects

View File

@ -23,7 +23,6 @@ class Subtask extends Base
'project' => $this->getProject(),
'subtasks' => $this->subtask->getAll($task['id']),
'editable' => true,
'redirect' => 'subtask',
)));
}
@ -169,15 +168,15 @@ class Subtask extends Base
*/
public function movePosition()
{
$this->checkCSRFParam();
$project_id = $this->request->getIntegerParam('project_id');
$task_id = $this->request->getIntegerParam('task_id');
$subtask_id = $this->request->getIntegerParam('subtask_id');
$direction = $this->request->getStringParam('direction');
$method = $direction === 'up' ? 'moveUp' : 'moveDown';
$redirect = $this->request->getStringParam('redirect', 'task');
$values = $this->request->getJson();
$this->subtask->$method($task_id, $subtask_id);
$this->response->redirect($this->helper->url->to($redirect, 'show', array('project_id' => $project_id, 'task_id' => $task_id), 'subtasks'));
if (! empty($values) && $this->helper->user->hasProjectAccess('Subtask', 'movePosition', $project_id)) {
$result = $this->subtask->changePosition($task_id, $values['subtask_id'], $values['position']);
$this->response->json(array('result' => $result));
}
$this->forbidden();
}
}

View File

@ -284,68 +284,36 @@ class Subtask extends Base
}
/**
* Save the new positions for a set of subtasks
* Save subtask position
*
* @access public
* @param array $subtasks Hashmap of column_id/column_position
* @param integer $task_id
* @param integer $subtask_id
* @param integer $position
* @return boolean
*/
public function savePositions(array $subtasks)
public function changePosition($task_id, $subtask_id, $position)
{
return $this->db->transaction(function (Database $db) use ($subtasks) {
if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('task_id', $task_id)->count()) {
return false;
}
foreach ($subtasks as $subtask_id => $position) {
if (! $db->table(Subtask::TABLE)->eq('id', $subtask_id)->update(array('position' => $position))) {
return false;
}
$subtask_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id');
$offset = 1;
$results = array();
foreach ($subtask_ids as $current_subtask_id) {
if ($offset == $position) {
$offset++;
}
});
}
/**
* Move a subtask down, increment the position value
*
* @access public
* @param integer $task_id
* @param integer $subtask_id
* @return boolean
*/
public function moveDown($task_id, $subtask_id)
{
$subtasks = $this->getNormalizedPositions($task_id);
$positions = array_flip($subtasks);
if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] < count($subtasks)) {
$position = ++$subtasks[$subtask_id];
$subtasks[$positions[$position]]--;
return $this->savePositions($subtasks);
$results[] = $this->db->table(self::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset));
$offset++;
}
return false;
}
$results[] = $this->db->table(self::TABLE)->eq('id', $subtask_id)->update(array('position' => $position));
/**
* Move a subtask up, decrement the position value
*
* @access public
* @param integer $task_id
* @param integer $subtask_id
* @return boolean
*/
public function moveUp($task_id, $subtask_id)
{
$subtasks = $this->getNormalizedPositions($task_id);
$positions = array_flip($subtasks);
if (isset($subtasks[$subtask_id]) && $subtasks[$subtask_id] > 1) {
$position = --$subtasks[$subtask_id];
$subtasks[$positions[$position]]++;
return $this->savePositions($subtasks);
}
return false;
return !in_array(false, $results, true);
}
/**

View File

@ -1,16 +1,6 @@
<div class="dropdown">
<a href="#" class="dropdown-menu dropdown-menu-link-icon"><i class="fa fa-cog fa-fw"></i><i class="fa fa-caret-down"></i></a>
<ul>
<?php if ($subtask['position'] != $first_position): ?>
<li>
<?= $this->url->link(t('Move Up'), 'subtask', 'movePosition', array('project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'direction' => 'up', 'redirect' => $redirect), true) ?>
</li>
<?php endif ?>
<?php if ($subtask['position'] != $last_position): ?>
<li>
<?= $this->url->link(t('Move Down'), 'subtask', 'movePosition', array('project_id' => $task['project_id'], 'task_id' => $subtask['task_id'], 'subtask_id' => $subtask['id'], 'direction' => 'down', 'redirect' => $redirect), true) ?>
</li>
<?php endif ?>
<li>
<?= $this->url->link(t('Edit'), 'subtask', 'edit', array('task_id' => $task['id'], 'project_id' => $task['project_id'], 'subtask_id' => $subtask['id']), false, 'popover') ?>
</li>

View File

@ -4,7 +4,7 @@
<div id="subtasks">
<?= $this->render('subtask/table', array('subtasks' => $subtasks, 'task' => $task, 'editable' => $editable, 'redirect' => $redirect)) ?>
<?= $this->render('subtask/table', array('subtasks' => $subtasks, 'task' => $task, 'editable' => $editable)) ?>
<?php if ($editable && $this->user->hasProjectAccess('subtask', 'save', $task['project_id'])): ?>
<form method="post" action="<?= $this->url->href('subtask', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">

View File

@ -3,7 +3,11 @@
<?php $first_position = $subtasks[0]['position']; ?>
<?php $last_position = $subtasks[count($subtasks) - 1]['position']; ?>
<table class="subtasks-table">
<table
class="subtasks-table table-stripped"
data-save-position-url="<?= $this->url->href('Subtask', 'movePosition', array('project_id' => $task['project_id'], 'task_id' => $task['id'])) ?>"
>
<thead>
<tr>
<th class="column-40"><?= t('Title') ?></th>
<th><?= t('Assignee') ?></th>
@ -12,10 +16,13 @@
<th class="column-5"></th>
<?php endif ?>
</tr>
</thead>
<tbody>
<?php foreach ($subtasks as $subtask): ?>
<tr>
<tr data-subtask-id="<?= $subtask['id'] ?>">
<td>
<?php if ($editable): ?>
<i class="fa fa-arrows-alt draggable-row-handle" title="<?= t('Move subtask position') ?>"></i>
<?= $this->subtask->toggleStatus($subtask, $task['project_id'], true) ?>
<?php else: ?>
<?= $this->subtask->getTitle($subtask) ?>
@ -58,12 +65,12 @@
'subtask' => $subtask,
'first_position' => $first_position,
'last_position' => $last_position,
'redirect' => $redirect,
)) ?>
</td>
<?php endif ?>
</tr>
<?php endforeach ?>
</tbody>
</table>
<?php else: ?>
<p class="alert"><?= t('There is no subtask at the moment.') ?></p>

View File

@ -12,7 +12,6 @@
'project' => $project,
'users_list' => isset($users_list) ? $users_list : array(),
'editable' => true,
'redirect' => 'task',
)) ?>
<?= $this->render('tasklink/show', array(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -62,7 +62,7 @@ th a:hover {
text-overflow: ellipsis;
}
.table-stripped tr:nth-child(odd) td {
.table-stripped tr:nth-child(odd) {
background: #fefefe;
}
@ -124,4 +124,38 @@ th a:hover {
.column-70 {
width: 70%;
}
}
.draggable-row-handle {
cursor: move;
color: #dedede;
}
.draggable-row-handle:hover {
color: #333;
}
tr.draggable-item-selected {
background: #fff;
border: 2px solid #666;
box-shadow: 4px 2px 10px -4px rgba(0,0,0,0.55);
}
tr.draggable-item-selected td {
border-top: none;
border-bottom: none;
}
tr.draggable-item-selected td:first-child {
border-left: none;
}
tr.draggable-item-selected td:last-child {
border-right: none;
}
.table-stripped tr.draggable-item-hover,
tr.draggable-item-hover {
background: #FEFFF2;
}

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ function App() {
this.popover = new Popover(this);
this.task = new Task();
this.project = new Project();
this.subtask = new Subtask();
this.subtask = new Subtask(this);
this.file = new FileUpload(this);
this.keyboardShortcuts();
this.chosen();

View File

@ -1,7 +1,12 @@
function Subtask() {
function Subtask(app) {
this.app = app;
}
Subtask.prototype.listen = function() {
var self = this;
this.dragAndDrop();
$(document).on("click", ".subtask-toggle-status", function(e) {
e.preventDefault();
var el = $(this);
@ -15,6 +20,8 @@ Subtask.prototype.listen = function() {
} else {
el.replaceWith(data);
}
self.dragAndDrop();
}
});
});
@ -28,7 +35,60 @@ Subtask.prototype.listen = function() {
url: el.attr("href"),
success: function(data) {
$(".subtasks-table").replaceWith(data);
self.dragAndDrop();
}
});
});
};
Subtask.prototype.dragAndDrop = function() {
var self = this;
$(".draggable-row-handle").mouseenter(function() {
$(this).parent().parent().addClass("draggable-item-hover");
}).mouseleave(function() {
$(this).parent().parent().removeClass("draggable-item-hover");
});
$(".subtasks-table tbody").sortable({
forcePlaceholderSize: true,
handle: "td:first i",
helper: function(e, ui) {
ui.children().each(function() {
$(this).width($(this).width());
});
return ui;
},
stop: function(event, ui) {
var subtask = ui.item;
subtask.removeClass("draggable-item-selected");
self.savePosition(subtask.data("subtask-id"), subtask.index() + 1);
},
start: function(event, ui) {
ui.item.addClass("draggable-item-selected");
}
}).disableSelection();
};
Subtask.prototype.savePosition = function(subtaskId, position) {
var url = $(".subtasks-table").data("save-position-url");
var self = this;
this.app.showLoadingIcon();
$.ajax({
cache: false,
url: url,
contentType: "application/json",
type: "POST",
processData: false,
data: JSON.stringify({
"subtask_id": subtaskId,
"position": position
}),
complete: function() {
self.app.hideLoadingIcon();
}
});
};

View File

@ -27,7 +27,7 @@
"eluceo/ical": "0.8.0",
"erusev/parsedown" : "1.6.0",
"fguillot/json-rpc" : "1.0.3",
"fguillot/picodb" : "1.0.4",
"fguillot/picodb" : "1.0.5",
"fguillot/simpleLogger" : "1.0.0",
"fguillot/simple-validator" : "1.0.0",
"paragonie/random_compat": "@stable",

24
composer.lock generated
View File

@ -4,8 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "99f199c3dac5c68390036cf0330ceb3a",
"content-hash": "ce69cdbd50f2d27eca033e98ae7ff1e4",
"hash": "0e754e4bc3eec85b3d14c748f1ed857a",
"content-hash": "c7f7baadd60fdcf8fb9e2e3a7214357f",
"packages": [
{
"name": "christian-riesen/base32",
@ -239,16 +239,16 @@
},
{
"name": "fguillot/picodb",
"version": "v1.0.4",
"version": "v1.0.5",
"source": {
"type": "git",
"url": "https://github.com/fguillot/picoDb.git",
"reference": "9ed4ee0c412dc9259d45bbc52e55c74150f7fb99"
"reference": "3b388ef12f8c57f3bca85d278a53cf6fa2d832b8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/fguillot/picoDb/zipball/9ed4ee0c412dc9259d45bbc52e55c74150f7fb99",
"reference": "9ed4ee0c412dc9259d45bbc52e55c74150f7fb99",
"url": "https://api.github.com/repos/fguillot/picoDb/zipball/3b388ef12f8c57f3bca85d278a53cf6fa2d832b8",
"reference": "3b388ef12f8c57f3bca85d278a53cf6fa2d832b8",
"shasum": ""
},
"require": {
@ -272,7 +272,7 @@
],
"description": "Minimalist database query builder",
"homepage": "https://github.com/fguillot/picoDb",
"time": "2015-12-24 11:39:04"
"time": "2016-02-20 02:56:11"
},
{
"name": "fguillot/simple-validator",
@ -397,16 +397,16 @@
},
{
"name": "paragonie/random_compat",
"version": "1.1.6",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "e6f80ab77885151908d0ec743689ca700886e8b0"
"reference": "b0e69d10852716b2ccbdff69c75c477637220790"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/e6f80ab77885151908d0ec743689ca700886e8b0",
"reference": "e6f80ab77885151908d0ec743689ca700886e8b0",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/b0e69d10852716b2ccbdff69c75c477637220790",
"reference": "b0e69d10852716b2ccbdff69c75c477637220790",
"shasum": ""
},
"require": {
@ -441,7 +441,7 @@
"pseudorandom",
"random"
],
"time": "2016-01-29 16:19:52"
"time": "2016-02-06 03:52:05"
},
{
"name": "pimple/pimple",

View File

@ -252,117 +252,6 @@ class SubtaskTest extends Base
}
}
public function testMoveUp()
{
$tc = new TaskCreation($this->container);
$s = new Subtask($this->container);
$p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1)));
$this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1)));
$this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1)));
$this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 1)));
// Check positions
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
$this->assertEquals(1, $subtask['position']);
$subtask = $s->getById(2);
$this->assertNotEmpty($subtask);
$this->assertEquals(2, $subtask['position']);
$subtask = $s->getById(3);
$this->assertNotEmpty($subtask);
$this->assertEquals(3, $subtask['position']);
// Move up
$this->assertTrue($s->moveUp(1, 2));
// Check positions
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
$this->assertEquals(2, $subtask['position']);
$subtask = $s->getById(2);
$this->assertNotEmpty($subtask);
$this->assertEquals(1, $subtask['position']);
$subtask = $s->getById(3);
$this->assertNotEmpty($subtask);
$this->assertEquals(3, $subtask['position']);
// We can't move up #2
$this->assertFalse($s->moveUp(1, 2));
// Test remove
$this->assertTrue($s->remove(1));
$this->assertTrue($s->moveUp(1, 3));
// Check positions
$subtask = $s->getById(1);
$this->assertEmpty($subtask);
$subtask = $s->getById(2);
$this->assertNotEmpty($subtask);
$this->assertEquals(2, $subtask['position']);
$subtask = $s->getById(3);
$this->assertNotEmpty($subtask);
$this->assertEquals(1, $subtask['position']);
}
public function testMoveDown()
{
$tc = new TaskCreation($this->container);
$s = new Subtask($this->container);
$p = new Project($this->container);
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1)));
$this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1)));
$this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1)));
$this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 1)));
// Move down #1
$this->assertTrue($s->moveDown(1, 1));
// Check positions
$subtask = $s->getById(1);
$this->assertNotEmpty($subtask);
$this->assertEquals(2, $subtask['position']);
$subtask = $s->getById(2);
$this->assertNotEmpty($subtask);
$this->assertEquals(1, $subtask['position']);
$subtask = $s->getById(3);
$this->assertNotEmpty($subtask);
$this->assertEquals(3, $subtask['position']);
// We can't move down #3
$this->assertFalse($s->moveDown(1, 3));
// Test remove
$this->assertTrue($s->remove(1));
$this->assertTrue($s->moveDown(1, 2));
// Check positions
$subtask = $s->getById(1);
$this->assertEmpty($subtask);
$subtask = $s->getById(2);
$this->assertNotEmpty($subtask);
$this->assertEquals(2, $subtask['position']);
$subtask = $s->getById(3);
$this->assertNotEmpty($subtask);
$this->assertEquals(1, $subtask['position']);
}
public function testDuplicate()
{
$tc = new TaskCreation($this->container);
@ -409,4 +298,69 @@ class SubtaskTest extends Base
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(2, $subtasks[1]['position']);
}
public function testChangePosition()
{
$taskCreationModel = new TaskCreation($this->container);
$subtaskModel = new Subtask($this->container);
$projectModel = new Project($this->container);
$this->assertEquals(1, $projectModel->create(array('name' => 'test1')));
$this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1)));
$this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1)));
$this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1)));
$this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1)));
$subtasks = $subtaskModel->getAll(1);
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(1, $subtasks[0]['id']);
$this->assertEquals(2, $subtasks[1]['position']);
$this->assertEquals(2, $subtasks[1]['id']);
$this->assertEquals(3, $subtasks[2]['position']);
$this->assertEquals(3, $subtasks[2]['id']);
$this->assertTrue($subtaskModel->changePosition(1, 3, 2));
$subtasks = $subtaskModel->getAll(1);
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(1, $subtasks[0]['id']);
$this->assertEquals(2, $subtasks[1]['position']);
$this->assertEquals(3, $subtasks[1]['id']);
$this->assertEquals(3, $subtasks[2]['position']);
$this->assertEquals(2, $subtasks[2]['id']);
$this->assertTrue($subtaskModel->changePosition(1, 2, 1));
$subtasks = $subtaskModel->getAll(1);
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(2, $subtasks[0]['id']);
$this->assertEquals(2, $subtasks[1]['position']);
$this->assertEquals(1, $subtasks[1]['id']);
$this->assertEquals(3, $subtasks[2]['position']);
$this->assertEquals(3, $subtasks[2]['id']);
$this->assertTrue($subtaskModel->changePosition(1, 2, 2));
$subtasks = $subtaskModel->getAll(1);
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(1, $subtasks[0]['id']);
$this->assertEquals(2, $subtasks[1]['position']);
$this->assertEquals(2, $subtasks[1]['id']);
$this->assertEquals(3, $subtasks[2]['position']);
$this->assertEquals(3, $subtasks[2]['id']);
$this->assertTrue($subtaskModel->changePosition(1, 1, 3));
$subtasks = $subtaskModel->getAll(1);
$this->assertEquals(1, $subtasks[0]['position']);
$this->assertEquals(2, $subtasks[0]['id']);
$this->assertEquals(2, $subtasks[1]['position']);
$this->assertEquals(3, $subtasks[1]['id']);
$this->assertEquals(3, $subtasks[2]['position']);
$this->assertEquals(1, $subtasks[2]['id']);
$this->assertFalse($subtaskModel->changePosition(1, 2, 0));
$this->assertFalse($subtaskModel->changePosition(1, 2, 4));
}
}