summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/Controller/Base.php2
-rw-r--r--app/Controller/Board.php14
-rw-r--r--app/Controller/Config.php7
-rw-r--r--app/Controller/Link.php189
-rw-r--r--app/Controller/Task.php5
-rw-r--r--app/Controller/Tasklink.php160
-rw-r--r--app/Core/Helper.php4
-rw-r--r--app/Locale/fr_FR/translations.php48
-rw-r--r--app/Model/Base.php2
-rw-r--r--app/Model/Link.php437
-rw-r--r--app/Model/TaskDuplication.php1
-rw-r--r--app/Model/TaskFinder.php25
-rw-r--r--app/Model/TaskLink.php361
-rw-r--r--app/Schema/Mysql.php54
-rw-r--r--app/Schema/Postgres.php53
-rw-r--r--app/Schema/Sqlite.php53
-rw-r--r--app/ServiceProvider/ClassProvider.php2
-rw-r--r--app/Template/board/task.php9
-rw-r--r--app/Template/board/tasklinks.php28
-rw-r--r--app/Template/config/sidebar.php3
-rw-r--r--app/Template/link/edit.php49
-rw-r--r--app/Template/link/index.php30
-rw-r--r--app/Template/link/remove.php17
-rw-r--r--app/Template/task/public.php7
-rw-r--r--app/Template/task/show.php3
-rw-r--r--app/Template/task/sidebar.php3
-rw-r--r--app/Template/tasklink/edit.php53
-rw-r--r--app/Template/tasklink/remove.php17
-rw-r--r--app/Template/tasklink/show.php68
29 files changed, 1694 insertions, 10 deletions
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index 7f65e882..2c8b5cde 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -43,6 +43,7 @@ use Symfony\Component\EventDispatcher\Event;
* @property \Model\Subtask $subtask
* @property \Model\Swimlane $swimlane
* @property \Model\Task $task
+ * @property \Model\Link $link
* @property \Model\TaskCreation $taskCreation
* @property \Model\TaskModification $taskModification
* @property \Model\TaskDuplication $taskDuplication
@@ -54,6 +55,7 @@ use Symfony\Component\EventDispatcher\Event;
* @property \Model\TaskPermission $taskPermission
* @property \Model\TaskStatus $taskStatus
* @property \Model\TaskValidator $taskValidator
+ * @property \Model\TaskLink $taskLink
* @property \Model\CommentHistory $commentHistory
* @property \Model\SubtaskHistory $subtaskHistory
* @property \Model\SubtaskTimeTracking $subtaskTimeTracking
diff --git a/app/Controller/Board.php b/app/Controller/Board.php
index e859348e..ae249982 100644
--- a/app/Controller/Board.php
+++ b/app/Controller/Board.php
@@ -402,6 +402,20 @@ class Board extends Base
}
/**
+ * Get links on mouseover
+ *
+ * @access public
+ */
+ public function tasklinks()
+ {
+ $task = $this->getTask();
+ $this->response->html($this->template->render('board/tasklinks', array(
+ 'links' => $this->taskLink->getAll($task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
* Get subtasks on mouseover
*
* @access public
diff --git a/app/Controller/Config.php b/app/Controller/Config.php
index 01c7ad53..6ec10279 100644
--- a/app/Controller/Config.php
+++ b/app/Controller/Config.php
@@ -87,12 +87,17 @@ class Config extends Base
*
* @access public
*/
- public function board()
+ public function board(array $values = array(), array $errors = array())
{
$this->common('board');
$this->response->html($this->layout('config/board', array(
'default_columns' => implode(', ', $this->board->getDefaultColumns()),
+ 'links' => $this->link->getMergedList(),
+ 'values' => $values + array(
+ 'project_id' => -1
+ ),
+ 'errors' => $errors,
'title' => t('Settings').' > '.t('Board settings'),
)));
}
diff --git a/app/Controller/Link.php b/app/Controller/Link.php
new file mode 100644
index 00000000..fca9017a
--- /dev/null
+++ b/app/Controller/Link.php
@@ -0,0 +1,189 @@
+<?php
+namespace Controller;
+
+/**
+ * Link controller
+ *
+ * @package controller
+ * @author Olivier Maridat
+ */
+class Link extends Base
+{
+ /**
+ * Common layout for config views
+ *
+ * @access private
+ * @param string $template Template name
+ * @param array $params Template parameters
+ * @return string
+ */
+ private function layout($template, array $params)
+ {
+ $params['board_selector'] = $this->projectPermission->getAllowedProjects($this->userSession->getId());
+ $params['config_content_for_layout'] = $this->template->render($template, $params);
+
+ if (isset($params['values']['project_id']) && -1 != $params['values']['project_id']) {
+ return $this->projectLayout($template, $params);
+ }
+ return $this->template->layout('config/layout', $params);
+ }
+
+ /**
+ * Get the current link
+ *
+ * @access private
+ * @return array
+ */
+ private function getLink()
+ {
+ $link = $this->link->getById($this->request->getIntegerParam('link_id'), $this->request->getIntegerParam('project_id', -1));
+ if (! $link) {
+ $this->notfound();
+ }
+ $link['link_id'] = $link[0]['link_id'];
+ $link['project_id'] = $link[0]['project_id'];
+ return $link;
+ }
+
+ /**
+ * Method to get a project
+ *
+ * @access protected
+ * @param integer $project_id Default project id
+ * @return array
+ */
+ protected function getProject($project_id = -1)
+ {
+ $project = array('id' => $project_id);
+ $project_id = $this->request->getIntegerParam('project_id', $project_id);
+ if (-1 != $project_id) {
+ $project = parent::getProject($project_id);
+ }
+ return $project;
+ }
+
+ /**
+ * List of links for a given project
+ *
+ * @access public
+ */
+ public function index(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+ $values['project_id'] = $project['id'];
+ $values[] = array();
+
+ $this->response->html($this->layout('link/index', array(
+ 'links' => $this->link->getMergedList($project['id']),
+ 'values' => $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'title' => t('Settings').' &gt; '.t('Board\'s links settings'),
+ )));
+ }
+
+ /**
+ * Validate and save a new link
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->link->validateCreation($values);
+
+ if ($valid) {
+ if ($this->link->create($values)) {
+ $this->session->flash(t('Link added successfully.'));
+ $this->response->redirect('?controller=link&action=index&project_id='.$values['project_id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to create your link.'));
+ }
+ }
+ if (!empty($values)) {
+ $this->link->prepare($values);
+ }
+ $this->index($values, $errors);
+ }
+
+ /**
+ * Edit form
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->layout('link/edit', array(
+ 'values' => empty($values) ? $this->getLink() : $values,
+ 'errors' => $errors,
+ 'project' => $project,
+ 'edit' => true,
+ 'title' => t('Links')
+ )));
+ }
+
+ /**
+ * Edit a link (validate the form and update the database)
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->link->validateModification($values);
+
+ if ($valid) {
+ if ($this->link->update($values)) {
+ $this->session->flash(t('Link updated successfully.'));
+ $this->response->redirect('?controller=link&action=index&project_id='.$values['project_id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to update your link.'));
+ }
+ }
+ if (!empty($values)) {
+ $this->link->prepare($values);
+ }
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Confirmation dialog before removing a link
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $project = $this->getProject();
+ $link = $this->getLink();
+
+ $this->response->html($this->layout('link/remove', array(
+ 'project' => $project,
+ 'link' => $link,
+ 'title' => t('Remove a link')
+ )));
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $link = $this->getLink();
+
+ if ($this->link->remove($link['link_id'])) {
+ $this->session->flash(t('Link removed successfully.'));
+ $this->response->redirect('?controller=link&action=index&project_id='.$link['project_id']);
+ }
+ else {
+ $this->session->flashError(t('Unable to remove this link.'));
+ }
+ $this->confirm();
+ }
+}
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index fdd20b5e..e4c2d773 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -3,6 +3,7 @@
namespace Controller;
use Model\Project as ProjectModel;
+use Model\Task as TaskModel;
/**
* Task controller
@@ -36,6 +37,7 @@ class Task extends Base
'project' => $project,
'comments' => $this->comment->getAll($task['id']),
'subtasks' => $this->subtask->getAll($task['id']),
+ 'links' => $this->taskLink->getAll($task['id']),
'task' => $task,
'columns_list' => $this->board->getColumnsList($task['project_id']),
'colors_list' => $this->color->getList(),
@@ -70,10 +72,13 @@ class Task extends Base
'files' => $this->file->getAll($task['id']),
'comments' => $this->comment->getAll($task['id']),
'subtasks' => $subtasks,
+ 'links' => $this->taskLink->getAll($task['id']),
'task' => $task,
'values' => $values,
'columns_list' => $this->board->getColumnsList($task['project_id']),
'colors_list' => $this->color->getList(),
+ 'link_list' => $this->link->getLinkLabelList($task['project_id'], false),
+ 'task_list' => $this->taskFinder->getList($task['project_id'], TaskModel::STATUS_OPEN, $task['id']),
'date_format' => $this->config->get('application_date_format'),
'date_formats' => $this->dateParser->getAvailableFormats(),
'title' => $task['project_name'].' &gt; '.$task['title'],
diff --git a/app/Controller/Tasklink.php b/app/Controller/Tasklink.php
new file mode 100644
index 00000000..d76de8fe
--- /dev/null
+++ b/app/Controller/Tasklink.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Controller;
+
+use Model\Task AS TaskModel;
+/**
+ * TaskLink controller
+ *
+ * @package controller
+ * @author Olivier Maridat
+ */
+class Tasklink extends Base
+{
+ /**
+ * Get the current link
+ *
+ * @access private
+ * @return array
+ */
+ private function getTaskLink()
+ {
+ $link = $this->taskLink->getById($this->request->getIntegerParam('link_id'));
+ if (! $link) {
+ $this->notfound();
+ }
+ return $link;
+ }
+
+ /**
+ * Creation form
+ *
+ * @access public
+ */
+ public function create(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+
+ if (empty($values)) {
+ $values = array(
+ 'task_id' => $task['id'],
+ 'another_link' => $this->request->getIntegerParam('another_link', 0)
+ );
+ }
+
+ $this->response->html($this->taskLayout('tasklink/edit', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ 'link_list' => $this->link->getLinkLabelList($task['project_id']),
+ 'task_list' => $this->taskFinder->getList($task['project_id'], TaskModel::STATUS_OPEN, $task['id']),
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Validation and creation
+ *
+ * @access public
+ */
+ public function save()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->taskLink->validateCreation($values);
+
+ if ($valid) {
+ if ($this->taskLink->create($values)) {
+ $this->session->flash(t('Link added successfully.'));
+ if (isset($values['another_link']) && $values['another_link'] == 1) {
+ $this->response->redirect('?controller=tasklink&action=create&task_id='.$task['id'].'&project_id='.$task['project_id'].'&another_link=1');
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'&project_id='.$task['project_id'].'#links');
+ }
+ else {
+ $this->session->flashError(t('Unable to add the link.'));
+ }
+ }
+
+ $this->create($values, $errors);
+ }
+
+ /**
+ * Edit form
+ *
+ * @access public
+ */
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $task = $this->getTask();
+ $taskLink = $this->getTaskLink();
+
+ $this->response->html($this->taskLayout('tasklink/edit', array(
+ 'values' => empty($values) ? $taskLink : $values,
+ 'errors' => $errors,
+ 'link_list' => $this->link->getLinkLabelList($task['project_id'], false),
+ 'task_list' => $this->taskFinder->getList($task['project_id'], TaskModel::STATUS_OPEN, $task['id']),
+ 'link' => $taskLink,
+ 'task' => $task,
+ 'edit' => true,
+ )));
+ }
+
+ /**
+ * Update and validate a link
+ *
+ * @access public
+ */
+ public function update()
+ {
+ $task = $this->getTask();
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->taskLink->validateModification($values);
+
+ if ($valid) {
+ if ($this->taskLink->update($values)) {
+ $this->session->flash(t('Link updated successfully.'));
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'&project_id='.$task['project_id'].'#links');
+ }
+ else {
+ $this->session->flashError(t('Unable to update the link.'));
+ }
+ }
+ $this->edit($values, $errors);
+ }
+
+ /**
+ * Confirmation dialog before removing a link
+ *
+ * @access public
+ */
+ public function confirm()
+ {
+ $task = $this->getTask();
+ $link = $this->getTaskLink();
+ $this->response->html($this->taskLayout('tasklink/remove', array(
+ 'link' => $link,
+ 'task' => $task,
+ )));
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ */
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $task = $this->getTask();
+
+ if ($this->taskLink->remove($this->request->getIntegerParam('link_id'))) {
+ $this->session->flash(t('Link removed successfully.'));
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'&project_id='.$task['project_id'].'#links');
+ }
+ else {
+ $this->session->flashError(t('Unable to remove this link.'));
+ }
+ $this->confirm();
+ }
+}
diff --git a/app/Core/Helper.php b/app/Core/Helper.php
index d60e29d4..a9d455f9 100644
--- a/app/Core/Helper.php
+++ b/app/Core/Helper.php
@@ -155,6 +155,9 @@ class Helper
*/
public function formValue($values, $name)
{
+ if (false !== ($pos = strpos($name, '['))) {
+ $name = substr($name, 0, $pos);
+ }
if (isset($values->$name)) {
return 'value="'.$this->e($values->$name).'"';
}
@@ -581,6 +584,7 @@ class Helper
{
return strpos($haystack, $needle) !== false;
}
+
/**
* Return a value from a dictionary
diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php
index 7fc5e842..ec09348d 100644
--- a/app/Locale/fr_FR/translations.php
+++ b/app/Locale/fr_FR/translations.php
@@ -688,4 +688,52 @@ return array(
'Task age in days' => 'Age de la tâche en jours',
'Days in this column' => 'Jours dans cette colonne',
'%dd' => '%dj',
+ 'relates to' => 'liée à',
+ 'blocks' => 'bloque',
+ 'is blocked by' => 'est bloquée par',
+ 'duplicates' => 'duplique',
+ 'is duplicated by' => 'est dupliquée par',
+ 'is a child of' => 'est un enfant de',
+ 'is a parent of' => 'est un parent de',
+ 'targets milestone' => 'vise la milestone',
+ 'is a milestone o' => 'est une milestone de',
+ 'fixes' => 'corrige',
+ 'is fixed by' => 'est corrigée par',
+ 'Links' => 'Liens',
+ 'Add a link' => 'Ajouter un lien',
+ 'Edit a link' => 'Mettre à jour un lien',
+ 'Remove a link' => 'Supprimer un lien',
+ 'Create another link' => 'Ajouter un autre lien',
+ 'Linked task id' => 'Id de la tâche à lier',
+ 'The exact same link already exists' => 'Un tel lien existe déjà',
+ 'This linked task id doesn\'t exist' => 'Cette tâche n\'existe pas',
+ 'A task can not be linked to itself' => 'Une tâche ne peut être liée à elle-même',
+ 'Link added successfully.' => 'Lien créé avec succès.',
+ 'Link updated successfully.' => 'Lien mis à jour avec succès.',
+ 'Link removed successfully.' => 'Lien supprimé avec succès.',
+ 'Unable to add the link.' => 'Impossible d\'ajouter ce lien.',
+ 'Unable to update the link.' => 'Impossible de mettre à jour ce lien.',
+ 'Unable to remove this link.' => 'Impossible de supprimer ce lien.',
+ 'Do you really want to remove this link with task #%s?' => 'Voulez-vous vraiment supprimer ce lien avec la tâche #%s ?',
+ 'The link type is required' => 'Le type du lien est obligatoire',
+ 'The linked task id is required' => 'L\'id de la tâche à lier est obligatoire',
+ 'The link id must be an integer' => 'L\'id du lien doit être un entier',
+ 'The related task id must be an integer' => 'L\'id de la tâche à lier doit être un entier',
+ 'Link management' => 'Gestion des liens',
+ 'Add a new link' => 'Ajouter un nouveau lien',
+ 'Add a new link label' => 'Ajouter un nouveau libellé de lien',
+ 'Link modification for the project "%s"' => 'Mettre à jour un lien du projet « %s »',
+ 'Links settings' => 'Paramètres des liens',
+ 'Board\'s links settings' => 'Paramètres des liens du tableau',
+ 'Link labels' => 'Libellés des liens',
+ 'Link Label' => 'Libellé du lien',
+ 'Link Inverse Label' => 'Libellé du lien inverse',
+ 'Example:' => 'Exemple :',
+ 'precedes' => 'précède',
+ 'follows' => 'suit',
+ '#9 precedes #10' => '#9 précède #10',
+ '#10 follows #9' => '#10 suit #9',
+ 'and therefore' => 'et donc',
+ 'Do you really want to remove this link: "%s"?' => 'Voulez-vous vraiment supprimer ce lien: « %s » ?',
+ 'You need to add link labels to this project before to link this task to another one.' => 'Il faut ajouter des libellés de liens à ce project avant de pouvoir lier une tâche à une autre.',
);
diff --git a/app/Model/Base.php b/app/Model/Base.php
index 319e53fc..f836231c 100644
--- a/app/Model/Base.php
+++ b/app/Model/Base.php
@@ -25,6 +25,7 @@ use Pimple\Container;
* @property \Model\File $file
* @property \Model\Helper $helper
* @property \Model\LastLogin $lastLogin
+ * @property \Model\Link $link
* @property \Model\Notification $notification
* @property \Model\Project $project
* @property \Model\ProjectDuplication $projectDuplication
@@ -38,6 +39,7 @@ use Pimple\Container;
* @property \Model\TaskExport $taskExport
* @property \Model\TaskFinder $taskFinder
* @property \Model\TaskHistory $taskHistory
+ * @property \Model\TaskLink $taskLink
* @property \Model\TaskPosition $taskPosition
* @property \Model\TaskValidator $taskValidator
* @property \Model\TimeTracking $timeTracking
diff --git a/app/Model/Link.php b/app/Model/Link.php
new file mode 100644
index 00000000..eddf1c6c
--- /dev/null
+++ b/app/Model/Link.php
@@ -0,0 +1,437 @@
+<?php
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use PDO;
+
+/**
+ * Link model
+ * A link is made of one bidirectional (<->), or two sided (<- and ->) link labels.
+ *
+ * @package model
+ * @author Olivier Maridat
+ */
+class Link extends Base
+{
+
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'link';
+
+ const TABLE_LABEL = 'link_label';
+
+ /**
+ * Direction: left to right ->
+ *
+ * @var integer
+ */
+ const BEHAVIOUR_LEFT2RIGTH = 0;
+
+ /**
+ * Direction: right to left <-
+ *
+ * @var integer
+ */
+ const BEHAVIOUR_RIGHT2LEFT = 1;
+
+ /**
+ * Bidirectional <->
+ *
+ * @var integer
+ */
+ const BEHAVIOUR_BOTH = 2;
+
+ /**
+ * Get a link by the id
+ *
+ * @access public
+ * @param integer $link_id
+ * Link id
+ * @param integer $project_id
+ * Specify a project id. Default: -1 to target all projects
+ * @return array
+ */
+ public function getById($link_id, $project_id = -1)
+ {
+ return $this->db->table(self::TABLE)
+ ->eq(self::TABLE . '.link_id', $link_id)
+ ->in('project_id', array(
+ - 1,
+ $project_id
+ ))
+ ->join(self::TABLE_LABEL, 'link_id', 'link_id')
+ ->findAll();
+ }
+
+ /**
+ * Get the id of the inverse link label by a link label id
+ *
+ * @access public
+ * @param integer $link_id
+ * Link id
+ * @param integer $link_label_id
+ * Link label id
+ * @return integer
+ */
+ public function getInverseLinkLabelId($link_label_id)
+ {
+ $sql = 'SELECT
+ la2.id
+ FROM ' . self::TABLE_LABEL . ' la1
+ JOIN '.self::TABLE_LABEL.' la2 ON la2.link_id = la1.link_id AND (la2.behaviour=2 OR la2.id != la1.id)
+ WHERE la1.id = ?
+ ';
+ $rq = $this->db->execute($sql, array(
+ $link_label_id
+ ));
+ return $rq->fetchColumn(0);
+ }
+
+ /**
+ * Return all link labels for a given project
+ *
+ * @access public
+ * @param integer $project_id
+ * Specify a project id. Default: -1 to target all projects
+ * @return array
+ */
+ public function getLinkLabels($project_id = -1)
+ {
+ return $this->db->table(self::TABLE_LABEL)
+ ->in(self::TABLE . '.project_id', array(
+ - 1,
+ $project_id
+ ))
+ ->join(self::TABLE, 'link_id', 'link_id')
+ ->asc(self::TABLE_LABEL.'.link_id', 'behaviour')
+ ->columns('id', self::TABLE . '.project_id', self::TABLE_LABEL.'.link_id', 'label', 'behaviour')
+ ->findAll();
+ }
+
+ /**
+ * Return the list of all link labels
+ * Used to select a link label
+ *
+ * @access public
+ * @param integer $project_id
+ * Specify a project id. Default: -1 to target all projects
+ * @return array
+ */
+ public function getLinkLabelList($project_id = -1)
+ {
+ $listing = $this->getLinkLabels($project_id);
+ $mergedListing = array();
+ foreach ($listing as $link) {
+ $suffix = '';
+ $prefix = '';
+ if (self::BEHAVIOUR_BOTH == $link['behaviour'] || self::BEHAVIOUR_LEFT2RIGTH == $link['behaviour']) {
+ $suffix = ' &raquo;';
+ }
+ if (self::BEHAVIOUR_BOTH == $link['behaviour'] || self::BEHAVIOUR_RIGHT2LEFT == $link['behaviour']) {
+ $prefix = '&laquo; ';
+ }
+ $mergedListing[$link['id']] = $prefix . t($link['label']) . $suffix;
+ }
+ $listing = $mergedListing;
+ return $listing;
+ }
+
+ /**
+ * Return the list of all links (label + inverse label)
+ *
+ * @access public
+ * @param integer $project_id
+ * Specify a project id. Default: -1 to target all projects
+ * @return array
+ */
+ public function getMergedList($project_id = -1)
+ {
+ $listing = $this->getLinkLabels($project_id);
+ $mergedListing = array();
+ $current = null;
+ foreach ($listing as $link) {
+ if (self::BEHAVIOUR_BOTH == $link['behaviour'] || self::BEHAVIOUR_LEFT2RIGTH == $link['behaviour']) {
+ $current = $link;
+ }
+ else {
+ $current['label_inverse'] = $link['label'];
+ }
+ if (self::BEHAVIOUR_BOTH == $link['behaviour'] || self::BEHAVIOUR_RIGHT2LEFT == $link['behaviour']) {
+ $mergedListing[] = $current;
+ $current = null;
+ }
+ }
+ $listing = $mergedListing;
+ return $listing;
+ }
+
+ /**
+ * Prepare data before insert/update
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ */
+ public function prepare(array &$values)
+ {
+ // Prepare label 1
+ $link = array(
+ 'project_id' => $values['project_id']
+ );
+ $label1 = array(
+ 'label' => @$values['label'][0],
+ 'behaviour' => (isset($values['behaviour'][0]) || !isset($values['label'][1]) || null == $values['label'][1]) ? self::BEHAVIOUR_BOTH : self::BEHAVIOUR_LEFT2RIGTH
+ );
+ $label2 = array(
+ 'label' => @$values['label'][1],
+ 'behaviour' => self::BEHAVIOUR_RIGHT2LEFT
+ );
+ if (isset($values['link_id'])) {
+ $link['link_id'] = $values['link_id'];
+ $label1['id'] = $values['id'][0];
+ $label2['id'] = @$values['id'][1];
+ $label1['link_id'] = $values['link_id'];
+ $label2['link_id'] = $values['link_id'];
+ }
+
+ $values = $link;
+ $values[] = $label1;
+ $values[] = $label2;
+ return array(
+ $link,
+ $label1,
+ $label2
+ );
+ }
+
+ /**
+ * Create a link
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return bool integer
+ */
+ public function create(array $values)
+ {
+ list ($link, $label1, $label2) = $this->prepare($values);
+ // Create link
+ $this->db->startTransaction();
+ $res = $this->db->table(self::TABLE)->save($link);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ // Create label 1
+ $label1['link_id'] = $this->db->getConnection()->lastInsertId(self::TABLE);
+ $res = $this->db->table(self::TABLE_LABEL)->save($label1);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ // Create label 2 if any
+ if (null != $label2 && self::BEHAVIOUR_BOTH != $label1['behaviour']) {
+ $label2['link_id'] = $label1['link_id'];
+ $res = $this->db->table(self::TABLE_LABEL)->save($label2);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ }
+ $this->db->closeTransaction();
+ return $res;
+ }
+
+ /**
+ * Update a link
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return bool
+ */
+ public function update(array $values)
+ {
+ list($link, $label1, $label2) = $this->prepare($values);
+ // Update link
+ $this->db->startTransaction();
+ $res = $this->db->table(self::TABLE)
+ ->eq('link_id', $link['link_id'])
+ ->save($link);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ // Update label 1
+ $this->db->startTransaction();
+ $res = $this->db->table(self::TABLE_LABEL)
+ ->eq('id', $label1['id'])
+ ->save($label1);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+
+ // Update label 2 (if label 1 not bidirectional)
+ if (null != $label2 && self::BEHAVIOUR_BOTH != $label1['behaviour']) {
+ // Create
+ if (! isset($label2['id']) || null == $label2['id']) {
+ unset($label2['id']);
+ $res = $this->db->table(self::TABLE_LABEL)->save($label2);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $label2['id'] = $this->db->getConnection()->lastInsertId(self::TABLE_LABEL);
+ $this->taskLink->changeLinkLabel($link['link_id'], $label2['id'], true);
+ }
+ // Update
+ else {
+ $res = $this->db->table(self::TABLE_LABEL)
+ ->eq('id', $label2['id'])
+ ->save($label2);
+ if (! $res) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ }
+ }
+ // Remove label 2 (if label 1 bidirectional)
+ else {
+ $this->taskLink->changeLinkLabel($link['link_id'], $label1['id']);
+ $this->db->table(self::TABLE_LABEL)
+ ->eq('link_id', $link['link_id'])
+ ->neq('id', $label1['id'])
+ ->remove();
+ }
+ $this->db->closeTransaction();
+ return $res;
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ * @param integer $link_id
+ * Link id
+ * @return bool
+ */
+ public function remove($link_id)
+ {
+ $this->db->startTransaction();
+ if (! $this->db->table(self::TABLE)
+ ->eq('link_id', $link_id)
+ ->remove()) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $this->db->closeTransaction();
+ return true;
+ }
+
+ /**
+ * Duplicate links from a project to another one, must be executed inside a transaction
+ *
+ * @param integer $src_project_id
+ * Source project id
+ * @return integer $dst_project_id Destination project id
+ * @return boolean
+ */
+ public function duplicate($src_project_id, $dst_project_id)
+ {
+ $labels = $this->db->table(self::TABLE_LABEL)
+ ->columns(self::TABLE_LABEL.'.link_id', 'label', 'behaviour')
+ ->eq('project_id', $src_project_id)
+ ->join(self::TABLE, 'link_id', 'link_id')
+ ->asc(self::TABLE_LABEL.'.link_id', 'behaviour')
+ ->findAll();
+
+ $this->db->startTransaction();
+ $link = array('project_id' => $dst_project_id);
+ if (! $this->db->table(self::TABLE)->save($link)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $link['id'] = $this->db->getConnection()->lastInsertId(self::TABLE);
+
+ foreach ($labels as $label) {
+ $label['link_id'] = $link['id'];
+ if (! $this->db->table(self::TABLE_LABEL)->save($label)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ }
+ $this->db->closeTransaction();
+ return true;
+ }
+
+ /**
+ * Validate link creation
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Validate link modification
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('link_id', t('The id is required')),
+// new Validators\Required('id[0]', t('The label id is required'))
+ );
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+
+ return array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ // TODO Update simple-validator to support array in forms
+ return array(
+ new Validators\Required('project_id', t('The project id required')),
+ // new Validators\Required('label[0]', t('The link label is required')),
+ new Validators\Integer('project_id', t('The project id must be an integer')),
+ new Validators\Integer('link_id', t('The link id must be an integer')),
+// new Validators\Integer('id[0]', t('The link label id must be an integer')),
+// new Validators\Integer('id[1]', t('The link label id must be an integer')),
+// new Validators\Integer('behaviour[0]', t('The link label id must be an integer')),
+// new Validators\Integer('behaviour[1]', t('The link label id must be an integer')),
+// new Validators\MaxLength('label[0]', t('The maximum length is %d characters', 200), 200),
+// new Validators\MaxLength('label[1]', t('The maximum length is %d characters', 200), 200)
+ );
+ }
+}
diff --git a/app/Model/TaskDuplication.php b/app/Model/TaskDuplication.php
index bd593dc1..faa5467f 100644
--- a/app/Model/TaskDuplication.php
+++ b/app/Model/TaskDuplication.php
@@ -159,6 +159,7 @@ class TaskDuplication extends Base
if ($new_task_id) {
$this->subtask->duplicate($task_id, $new_task_id);
+ $this->taskLink->duplicate($task_id, $new_task_id);
}
return $new_task_id;
diff --git a/app/Model/TaskFinder.php b/app/Model/TaskFinder.php
index 27fa8150..cf756cd8 100644
--- a/app/Model/TaskFinder.php
+++ b/app/Model/TaskFinder.php
@@ -3,6 +3,7 @@
namespace Model;
use PDO;
+use Model\TaskLink;
/**
* Task Finder model
@@ -84,6 +85,7 @@ class TaskFinder extends Base
'(SELECT count(*) FROM task_has_files WHERE task_id=tasks.id) AS nb_files',
'(SELECT count(*) FROM task_has_subtasks WHERE task_id=tasks.id) AS nb_subtasks',
'(SELECT count(*) FROM task_has_subtasks WHERE task_id=tasks.id AND status=2) AS nb_completed_subtasks',
+ '(SELECT count(*) FROM ' . TaskLink::TABLE . ' WHERE ' . TaskLink::TABLE . '.task_id = tasks.id) AS nb_links',
'tasks.id',
'tasks.reference',
'tasks.title',
@@ -128,6 +130,29 @@ class TaskFinder extends Base
->asc('tasks.position')
->findAll();
}
+
+ /**
+ * Get ids and names of all (limited by $limit) tasks for a given project and status
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $status_id Status id
+ * @param integer $exclude_id Exclude this task id in the result
+ * @param integer $limit Number of tasks to list
+ * @return array
+ */
+ public function getList($project_id, $status_id = Task::STATUS_OPEN, $exclude_id=null, $limit=50)
+ {
+ $sql = $this->db
+ ->hashtable(Task::TABLE)
+ ->eq('project_id', $project_id)
+ ->eq('is_active', $status_id)
+ ->limit($limit);
+ if (null != $exclude_id) {
+ $sql->neq('id', $exclude_id);
+ }
+ return $sql->getAll('id', 'title');
+ }
/**
* Get all tasks for a given project and status
diff --git a/app/Model/TaskLink.php b/app/Model/TaskLink.php
new file mode 100644
index 00000000..09f37d2e
--- /dev/null
+++ b/app/Model/TaskLink.php
@@ -0,0 +1,361 @@
+<?php
+namespace Model;
+
+use Core\Helper;
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+use PDO;
+
+/**
+ * TaskLink model
+ *
+ * @package model
+ * @author Olivier Maridat
+ */
+class TaskLink extends Base
+{
+
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_links';
+
+ /**
+ * Get a link by the task link id
+ *
+ * @access public
+ * @param integer $task_link_id
+ * Task link id
+ * @return array
+ */
+ public function getById($task_link_id)
+ {
+ $sql = 'SELECT
+ tl1.id AS id,
+ tl1.link_label_id AS link_label_id,
+ tl1.task_id AS task_id,
+ tl1.task_inverse_id AS task_inverse_id,
+ tl2.id AS task_link_inverse_id
+ FROM ' . self::TABLE . ' tl1
+ LEFT JOIN ' . Link::TABLE_LABEL . ' l1 ON l1.id = tl1.link_label_id
+ LEFT JOIN ' . Link::TABLE_LABEL . ' l2 ON l2.link_id = l1.link_id
+ LEFT JOIN ' . self::TABLE . ' tl2 ON tl2.task_id = tl1.task_inverse_id
+ AND ( (l1.behaviour = 2 AND tl2.link_label_id = l1.id) OR (tl2.link_label_id = l2.id) )
+ WHERE tl1.id = ?
+ ';
+ $rq = $this->db->execute($sql, array(
+ $task_link_id
+ ));
+ return $rq->fetch();
+ }
+
+ /**
+ * Get the id of the inverse task link by a task link id
+ *
+ * @access public
+ * @param integer $link_id
+ * Task link id
+ * @return integer
+ */
+ public function getInverseTaskLinkId($task_link_id)
+ {
+ $sql = 'SELECT
+ tl2.id
+ FROM ' . self::TABLE . ' tl1
+ LEFT JOIN ' . Link::TABLE_LABEL . ' l1 ON l1.id = tl1.link_label_id
+ LEFT JOIN ' . Link::TABLE_LABEL . ' l2 ON l2.link_id = l1.link_id
+ LEFT JOIN ' . self::TABLE . ' tl2 ON tl2.task_id = tl1.task_inverse_id
+ AND ( (l1.behaviour = 2 AND tl2.link_label_id = l1.id) OR (tl2.link_label_id = l2.id) )
+ WHERE tl1.id = ?
+ ';
+ $rq = $this->db->execute($sql, array(
+ $task_link_id
+ ));
+ return $rq->fetchColumn(0);
+ }
+
+ /**
+ * Return all links for a given task
+ *
+ * @access public
+ * @param integer $task_id
+ * Task id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ $sql = 'SELECT
+ tl1.id,
+ l.label AS label,
+ t2.id AS task_inverse_id,
+ t2.project_id AS task_inverse_project_id,
+ t2.title AS task_inverse_title,
+ t2.is_active AS task_inverse_is_active,
+ t2cat.name AS task_inverse_category
+ FROM ' . self::TABLE . ' tl1
+ LEFT JOIN ' . Link::TABLE_LABEL . ' l ON l.id = tl1.link_label_id
+ LEFT JOIN ' . Task::TABLE . ' t2 ON t2.id = tl1.task_inverse_id
+ LEFT JOIN ' . Category::TABLE . ' t2cat ON t2cat.id = t2.category_id
+ WHERE tl1.task_id = ?
+ ORDER BY l.label, t2cat.name, t2.id
+ ';
+ $rq = $this->db->execute($sql, array(
+ $task_id
+ ));
+ $res = $rq->fetchAll(PDO::FETCH_ASSOC);
+ return $res;
+ }
+
+ /**
+ * Prepare data before insert/update
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ */
+ public function prepare(array &$values)
+ {
+ $this->removeFields($values, array(
+ 'another_link'
+ ));
+ $taskLink1 = array(
+ 'link_label_id' => $values['link_label_id'],
+ 'task_id' => $values['task_id'],
+ 'task_inverse_id' => $values['task_inverse_id']
+ );
+ $taskLink2 = array(
+ 'link_label_id' => $this->link->getInverseLinkLabelId($taskLink1['link_label_id']),
+ 'task_id' => $values['task_inverse_id'],
+ 'task_inverse_id' => $values['task_id']
+ );
+ if (isset($values['id']) && isset($values['task_link_inverse_id'])) {
+ $taskLink1['id'] = $values['id'];
+ $taskLink2['id'] = $values['task_link_inverse_id'];
+ }
+ return array(
+ $taskLink1,
+ $taskLink2
+ );
+ }
+
+ /**
+ * Create a link
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return bool integer
+ */
+ public function create(array $values)
+ {
+ list($taskLink1, $taskLink2) = $this->prepare($values);
+ $this->db->startTransaction();
+ if (! $this->db->table(self::TABLE)->save($taskLink1)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ if (! $this->db->table(self::TABLE)->save($taskLink2)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $this->db->closeTransaction();
+ return true;
+ }
+
+ /**
+ * Update a link
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return bool
+ */
+ public function update(array $values)
+ {
+ list($taskLink1, $taskLink2) = $this->prepare($values);
+ $this->db->startTransaction();
+ if (! $this->db->table(self::TABLE)
+ ->eq('id', $taskLink1['id'])
+ ->save($taskLink1)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ if (! $this->db->table(self::TABLE)
+ ->eq('id', $taskLink2['id'])
+ ->save($taskLink2)) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $this->db->closeTransaction();
+ return true;
+ }
+
+ /**
+ * Remove a link
+ *
+ * @access public
+ * @param integer $task_link_id
+ * Task Link id
+ * @return bool
+ */
+ public function remove($task_link_id)
+ {
+ $task_link_inverse_id = $this->getInverseTaskLinkId($task_link_id);
+ $this->db->startTransaction();
+ if (! $this->db->table(self::TABLE)
+ ->eq('id', $task_link_id)
+ ->remove()) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ if (! $this->db->table(self::TABLE)
+ ->eq('id', $task_link_inverse_id)
+ ->remove()) {
+ $this->db->cancelTransaction();
+ return false;
+ }
+ $this->db->closeTransaction();
+ return true;
+ }
+
+ /**
+ * Duplicate all links to another task
+ *
+ * @access public
+ * @param integer $src_task_id
+ * Source task id
+ * @param integer $dst_task_id
+ * Destination task id
+ * @return bool
+ */
+ public function duplicate($src_task_id, $dst_task_id)
+ {
+ return $this->db->transaction(function ($db) use($src_task_id, $dst_task_id)
+ {
+ $links = $db->table(TaskLink::TABLE)
+ ->columns('link_label_id', 'task_id', 'task_inverse_id')
+ ->eq('task_id', $src_task_id)
+ ->asc('id')
+ ->findAll();
+ foreach ($links as &$link) {
+ $link['task_id'] = $dst_task_id;
+ if (! $db->table(TaskLink::TABLE)
+ ->save($link)) {
+ return false;
+ }
+ }
+
+ $links = $db->table(TaskLink::TABLE)
+ ->columns('link_label_id', 'task_id', 'task_inverse_id')
+ ->eq('task_inverse_id', $src_task_id)
+ ->asc('id')
+ ->findAll();
+ foreach ($links as &$link) {
+ $link['task_inverse_id'] = $dst_task_id;
+ if (! $db->table(TaskLink::TABLE)
+ ->save($link)) {
+ return false;
+ }
+ }
+ });
+ }
+
+ /**
+ * Move a task link from a link label to an other
+ *
+ * @access public
+ * @param integer $link_id
+ * Link id
+ * @param integer $dst_link_label_id
+ * Destination link label id
+ * @return bool
+ */
+ public function changeLinkLabel($link_id, $dst_link_label_id, $alternate=false)
+ {
+ $taskLinks = $this->db->table(Link::TABLE_LABEL)
+ ->eq('link_id', $link_id)
+ ->neq(Link::TABLE_LABEL.'.id', $dst_link_label_id)
+ ->join(self::TABLE, 'link_label_id', 'id')
+ ->asc(self::TABLE.'.id')
+ ->findAllByColumn(self::TABLE.'.id');
+ foreach ($taskLinks as $i => $taskLinkId) {
+ if (null == $taskLinkId || ($alternate && 0 != $i % 2)) {
+ continue;
+ }
+ if (! $this->db->table(self::TABLE)
+ ->eq('id', $taskLinkId)
+ ->save(array('link_label_id' => $dst_link_label_id))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Validate link creation
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateCreation(array $values)
+ {
+ $v = new Validator($values, $this->commonValidationRules());
+ $res = array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ return $res;
+ }
+
+ /**
+ * Validate link modification
+ *
+ * @access public
+ * @param array $values
+ * Form values
+ * @return array $valid, $errors [0] = Success or not, [1] = List of errors
+ */
+ public function validateModification(array $values)
+ {
+ $rules = array(
+ new Validators\Required('id', t('The id is required'))
+ );
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+ $res = array(
+ $v->execute(),
+ $v->getErrors()
+ );
+ return $res;
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access private
+ * @return array
+ */
+ private function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('link_label_id', t('The link type is required')),
+ new Validators\Required('task_id', t('The task id is required')),
+ new Validators\Required('task_inverse_id', t('The linked task id is required')),
+ new Validators\Integer('id', t('The id must be an integer')),
+ new Validators\Integer('link_label_id', t('The link id must be an integer')),
+ new Validators\Integer('task_id', t('The task id must be an integer')),
+ new Validators\Integer('task_inverse_id', t('The related task id must be an integer')),
+ new Validators\Integer('task_link_inverse_id', t('The related task link id must be an integer')),
+ new Validators\NotEquals('task_inverse_id', 'task_id', t('A task can not be linked to itself')),
+ new Validators\Exists('task_inverse_id', t('This linked task id doesn\'t exist'), $this->db->getConnection(), Task::TABLE, 'id'),
+ new Validators\Unique(array(
+ 'task_inverse_id',
+ 'link_label_id',
+ 'task_id'
+ ), t('The exact same link already exists'), $this->db->getConnection(), self::TABLE)
+ );
+ }
+}
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 24bc2baf..82dd8428 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -4,8 +4,60 @@ namespace Schema;
use PDO;
use Core\Security;
+use Model\Link;
-const VERSION = 45;
+const VERSION = 46;
+
+function version_46($pdo)
+{
+ $pdo->exec("CREATE TABLE link
+ (
+ link_id INT NOT NULL AUTO_INCREMENT,
+ project_id INT NOT NULL DEFAULT -1,
+ PRIMARY KEY(link_id)
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE TABLE link_label
+ (
+ id INT NOT NULL AUTO_INCREMENT,
+ link_id INT NOT NULL,
+ label TEXT NOT NULL,
+ behaviour INT DEFAULT 2,
+ PRIMARY KEY(id),
+ FOREIGN KEY(link_id) REFERENCES link(link_id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE TABLE task_has_links
+ (
+ id INT NOT NULL AUTO_INCREMENT,
+ link_label_id INT NOT NULL,
+ task_id INT NOT NULL,
+ task_inverse_id INT NOT NULL,
+ PRIMARY KEY(id),
+ FOREIGN KEY(link_label_id) REFERENCES link_label(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_inverse_id) REFERENCES tasks(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_label_id, task_id, task_inverse_id)");
+ $rq = $pdo->prepare('INSERT INTO link (project_id) VALUES (?)');
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq = $pdo->prepare('INSERT INTO link_label (link_id, label, behaviour) VALUES (?, ?, ?)');
+ $rq->execute(array(1, t('relates to'), Link::BEHAVIOUR_BOTH));
+ $rq->execute(array(2, t('blocks'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(2, t('is blocked by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(3, t('duplicates'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(3, t('is duplicated by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(4, t('is a child of'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(4, t('is a parent of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(5, t('targets milestone'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(5, t('is a milestone of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(6, t('fixes'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(6, t('is fixed by'), Link::BEHAVIOUR_RIGHT2LEFT));
+}
function version_45($pdo)
{
diff --git a/app/Schema/Postgres.php b/app/Schema/Postgres.php
index d3fb9fc4..af1f3170 100644
--- a/app/Schema/Postgres.php
+++ b/app/Schema/Postgres.php
@@ -4,8 +4,57 @@ namespace Schema;
use PDO;
use Core\Security;
+use Model\Link;
-const VERSION = 26;
+const VERSION = 27;
+
+function version_27($pdo)
+{
+ $pdo->exec("CREATE TABLE link
+ (
+ link_id SERIAL PRIMARY KEY,
+ project_id INTEGER NOT NULL DEFAULT -1
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE TABLE link_label
+ (
+ id SERIAL PRIMARY KEY,
+ link_id INTEGER NOT NULL,
+ label TEXT NOT NULL,
+ behaviour INTEGER NOT NULL DEFAULT 2,
+ FOREIGN KEY(link_id) REFERENCES link(link_id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE TABLE task_has_links
+ (
+ id SERIAL PRIMARY KEY,
+ link_label_id INTEGER NOT NULL,
+ task_id INTEGER NOT NULL,
+ task_inverse_id INTEGER NOT NULL,
+ FOREIGN KEY(link_label_id) REFERENCES link_label(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_inverse_id) REFERENCES tasks(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8");
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_label_id, task_id, task_inverse_id)");
+ $rq = $pdo->prepare('INSERT INTO link (project_id) VALUES (?)');
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq = $pdo->prepare('INSERT INTO link_label (link_id, label, behaviour) VALUES (?, ?, ?)');
+ $rq->execute(array(1, t('relates to'), Link::BEHAVIOUR_BOTH));
+ $rq->execute(array(2, t('blocks'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(2, t('is blocked by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(3, t('duplicates'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(3, t('is duplicated by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(4, t('is a child of'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(4, t('is a parent of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(5, t('targets milestone'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(5, t('is a milestone of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(6, t('fixes'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(6, t('is fixed by'), Link::BEHAVIOUR_RIGHT2LEFT));
+}
function version_26($pdo)
{
@@ -66,7 +115,7 @@ function version_24($pdo)
function version_23($pdo)
{
- $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
+ $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
}
function version_22($pdo)
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index f027cf91..727a32cd 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -4,8 +4,57 @@ namespace Schema;
use Core\Security;
use PDO;
+use Model\Link;
-const VERSION = 44;
+const VERSION = 45;
+
+function version_45($pdo)
+{
+ $pdo->exec("CREATE TABLE link
+ (
+ link_id INTEGER PRIMARY KEY,
+ project_id INTEGER NOT NULL DEFAULT -1
+ )");
+ $pdo->exec("CREATE TABLE link_label
+ (
+ id INTEGER PRIMARY KEY,
+ link_id INTEGER NOT NULL,
+ label TEXT NOT NULL,
+ behaviour INTEGER DEFAULT '2',
+ FOREIGN KEY(link_id) REFERENCES link(link_id) ON DELETE CASCADE
+ )");
+ $pdo->exec("CREATE TABLE task_has_links
+ (
+ id INTEGER PRIMARY KEY,
+ link_label_id INTEGER NOT NULL,
+ task_id INTEGER NOT NULL,
+ task_inverse_id INTEGER NOT NULL,
+ FOREIGN KEY(link_label_id) REFERENCES link_label(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+ FOREIGN KEY(task_inverse_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )");
+ $pdo->exec("CREATE INDEX task_has_links_task_index ON task_has_links(task_id)");
+ $pdo->exec("CREATE UNIQUE INDEX task_has_links_unique ON task_has_links(link_label_id, task_id, task_inverse_id)");
+ $rq = $pdo->prepare('INSERT INTO link (project_id) VALUES (?)');
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq->execute(array(-1));
+ $rq = $pdo->prepare('INSERT INTO link_label (link_id, label, behaviour) VALUES (?, ?, ?)');
+ $rq->execute(array(1, t('relates to'), Link::BEHAVIOUR_BOTH));
+ $rq->execute(array(2, t('blocks'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(2, t('is blocked by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(3, t('duplicates'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(3, t('is duplicated by'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(4, t('is a child of'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(4, t('is a parent of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(5, t('targets milestone'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(5, t('is a milestone of'), Link::BEHAVIOUR_RIGHT2LEFT));
+ $rq->execute(array(6, t('fixes'), Link::BEHAVIOUR_LEFT2RIGTH));
+ $rq->execute(array(6, t('is fixed by'), Link::BEHAVIOUR_RIGHT2LEFT));
+}
function version_44($pdo)
{
@@ -66,7 +115,7 @@ function version_42($pdo)
function version_41($pdo)
{
- $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
+ $pdo->exec('ALTER TABLE columns ADD COLUMN description TEXT');
}
function version_40($pdo)
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index bee03184..213972ed 100644
--- a/app/ServiceProvider/ClassProvider.php
+++ b/app/ServiceProvider/ClassProvider.php
@@ -24,6 +24,7 @@ class ClassProvider implements ServiceProviderInterface
'DateParser',
'File',
'LastLogin',
+ 'Link',
'Notification',
'Project',
'ProjectActivity',
@@ -41,6 +42,7 @@ class ClassProvider implements ServiceProviderInterface
'TaskExport',
'TaskFinder',
'TaskFilter',
+ 'TaskLink',
'TaskModification',
'TaskPermission',
'TaskPosition',
diff --git a/app/Template/board/task.php b/app/Template/board/task.php
index 5cad4004..41bde065 100644
--- a/app/Template/board/task.php
+++ b/app/Template/board/task.php
@@ -100,7 +100,7 @@
<?php endif ?>
-<?php if (! empty($task['date_due']) || ! empty($task['nb_files']) || ! empty($task['nb_comments']) || ! empty($task['description']) || ! empty($task['nb_subtasks'])): ?>
+<?php if (! empty($task['date_due']) || ! empty($task['nb_files']) || ! empty($task['nb_comments']) || ! empty($task['description']) || ! empty($task['nb_subtasks']) || ! empty($task['nb_links'])): ?>
<div class="task-board-footer">
<?php if (! empty($task['date_due'])): ?>
@@ -110,7 +110,10 @@
<?php endif ?>
<div class="task-board-icons">
-
+ <?php if (! empty($task['nb_links'])): ?>
+ <span title="<?= t('Links') ?>" class="task-board-tooltip" data-href="<?= $this->u('board', 'tasklinks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= $task['nb_links'] ?> <i class="fa fa-code-fork"></i></span>
+ <?php endif ?>
+
<?php if (! empty($task['nb_subtasks'])): ?>
<span title="<?= t('Sub-Tasks') ?>" class="task-board-tooltip" data-href="<?= $this->u('board', 'subtasks', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>"><?= round($task['nb_completed_subtasks']/$task['nb_subtasks']*100, 0).'%' ?> <i class="fa fa-bars"></i></span>
<?php endif ?>
@@ -132,4 +135,4 @@
</div>
<?php endif ?>
-</div> \ No newline at end of file
+</div>
diff --git a/app/Template/board/tasklinks.php b/app/Template/board/tasklinks.php
new file mode 100644
index 00000000..d7b64e1d
--- /dev/null
+++ b/app/Template/board/tasklinks.php
@@ -0,0 +1,28 @@
+<section class="tooltip-tasklinks">
+<div>
+<ul>
+<?php
+$previous_link = null;
+foreach ($links as $link): ?>
+ <?php if (null == $previous_link || $previous_link != $link['label']): ?>
+ <?php if (null != $previous_link): ?>
+ </ul>
+ </li>
+ <?php endif ?>
+ <?php $previous_link = $link['label']; ?>
+ <li><?= t($this->e($link['label'])) ?>
+ <ul>
+ <?php endif ?>
+ <li<?php if (0 == $link['task_inverse_is_active']): ?> class="task-closed"<?php endif ?>>
+ <?= $this->e($link['task_inverse_category']) ?>
+ <?= $this->a('#'.$this->e($link['task_inverse_id']).' - '.trim($this->e($link['task_inverse_title'])),
+ 'task',
+ 'show',
+ array('task_id' => $link['task_inverse_id'], 'project_id' => $link['task_inverse_project_id'])) ?>
+ </li>
+<?php endforeach ?>
+ </ul>
+ </li>
+</ul>
+</div>
+</section>
diff --git a/app/Template/config/sidebar.php b/app/Template/config/sidebar.php
index 8e6fa379..a0ec8b36 100644
--- a/app/Template/config/sidebar.php
+++ b/app/Template/config/sidebar.php
@@ -11,6 +11,9 @@
<?= $this->a(t('Board settings'), 'config', 'board') ?>
</li>
<li>
+ <?= $this->a(t('Links settings'), 'link', 'index') ?>
+ </li>
+ <li>
<?= $this->a(t('Webhooks'), 'config', 'webhook') ?>
</li>
<li>
diff --git a/app/Template/link/edit.php b/app/Template/link/edit.php
new file mode 100644
index 00000000..71d4f3ca
--- /dev/null
+++ b/app/Template/link/edit.php
@@ -0,0 +1,49 @@
+<section id="link-edit-section">
+<?php use Model\Link;
+if (! isset($edit)): ?>
+ <h3><?= t('Add a new link label') ?></h3>
+<?php else: ?>
+<div class="page-header">
+ <h2><?= t('Edit the link label') ?></h2>
+</div>
+<?php endif ?>
+
+<form method="post" action="<?= $this->u('link', isset($edit) ? 'update' : 'save', array('project_id' => $project['id'], 'link_id' => @$values['id'])) ?>" autocomplete="off">
+ <?= $this->formCsrf() ?>
+
+ <?php if (isset($edit)): ?>
+ <?= $this->formHidden('link_id', $values) ?>
+ <?= $this->formHidden('id[0]', $values[0]) ?>
+ <?php if (isset($values[1])): ?>
+ <?= $this->formHidden('id[1]', $values[1]) ?>
+ <?php endif ?>
+ <?php endif ?>
+ <?= $this->formHidden('project_id', $values) ?>
+
+ <?= $this->formLabel(t('Link Label'), 'label[0]') ?>
+ <?= $this->formText('label[0]', $values[0], $errors, array('required', 'autofocus', 'placeholder="'.t('precedes').'"')) ?> &raquo;
+
+ <?= $this->formCheckbox('behaviour[0]', t('Bidrectional link label'), Link::BEHAVIOUR_BOTH, (isset($values[0]['behaviour']) && Link::BEHAVIOUR_BOTH == $values[0]['behaviour']), 'behaviour') ?>
+
+ <div class="link-inverse-label">
+ <?= $this->formLabel(t('Link Inverse Label'), 'label[1]') ?>
+ &laquo; <?= $this->formText('label[1]', isset($values[1]) ? $values[1] : $values, $errors, array('placeholder="'.t('follows').'"')) ?>
+ </div>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?php if (isset($edit)): ?>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'link', 'index', array('project_id' => $project['id'])) ?>
+ <?php endif ?>
+ </div>
+ <?php if (! isset($edit)): ?>
+ <div class="alert alert-info">
+ <strong><?= t('Example:') ?></strong>
+ <i><?= t('#9 precedes #10') ?></i>
+ <?= t('and therefore') ?>
+ <i><?= t('#10 follows #9') ?></i>
+ </div>
+ <?php endif ?>
+</form>
+</section> \ No newline at end of file
diff --git a/app/Template/link/index.php b/app/Template/link/index.php
new file mode 100644
index 00000000..0c19b614
--- /dev/null
+++ b/app/Template/link/index.php
@@ -0,0 +1,30 @@
+<div class="page-header">
+ <h2><?= t('Link labels') ?></h2>
+</div>
+
+<section>
+<?php if (! empty($links)): ?>
+<table>
+ <tr>
+ <th width="70%"><?= t('Link labels') ?></th>
+ <th><?= t('Actions') ?></th>
+ </tr>
+ <?php foreach ($links as $link): ?>
+ <tr>
+ <td><?= t($this->e($link['label'])) ?><?php if (isset($link['label_inverse']) && !empty($link['label_inverse'])): ?> | <?= t($this->e($link['label_inverse'])) ?><?php endif ?></td>
+ <td>
+ <ul>
+ <?= $this->a(t('Edit'), 'link', 'edit', array('link_id' => $link['link_id'], 'project_id' => $link['project_id'])) ?>
+ <?= t('or') ?>
+ <?= $this->a(t('Remove'), 'link', 'confirm', array('link_id' => $link['link_id'], 'project_id' => $link['project_id'])) ?>
+ </ul>
+ </td>
+ </tr>
+ <?php endforeach ?>
+</table>
+<?php else: ?>
+ <?= t('There is no link yet.') ?>
+<?php endif ?>
+</section>
+
+<?= $this->render('link/edit', array('values' => $values, 'errors' => $errors, 'project' => $project)) ?>
diff --git a/app/Template/link/remove.php b/app/Template/link/remove.php
new file mode 100644
index 00000000..d0b14b08
--- /dev/null
+++ b/app/Template/link/remove.php
@@ -0,0 +1,17 @@
+<section id="main">
+ <div class="page-header">
+ <h2><?= t('Remove a link') ?></h2>
+ </div>
+
+ <div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this link: "%s"?', t($link[0]['label']).(isset($link[1]['label']) ? ' | '.t($link[1]['label']) : '')) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->a(t('Yes'), 'link', 'remove', array('project_id' => $project['id'], 'link_id' => $link[0]['link_id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'link', 'index', array('project_id' => $project['id'])) ?>
+ </div>
+ </div>
+</section> \ No newline at end of file
diff --git a/app/Template/task/public.php b/app/Template/task/public.php
index 2d95e6db..c66b2433 100644
--- a/app/Template/task/public.php
+++ b/app/Template/task/public.php
@@ -16,6 +16,13 @@
'not_editable' => true
)) ?>
+ <?= $this->render('tasklink/show', array(
+ 'task' => $task,
+ 'links' => $links,
+ 'project' => $project,
+ 'not_editable' => true
+ )) ?>
+
<?= $this->render('task/comments', array(
'task' => $task,
'comments' => $comments,
diff --git a/app/Template/task/show.php b/app/Template/task/show.php
index b8243cc6..f968a409 100644
--- a/app/Template/task/show.php
+++ b/app/Template/task/show.php
@@ -3,5 +3,6 @@
<?= $this->render('task/show_description', array('task' => $task)) ?>
<?= $this->render('subtask/show', array('task' => $task, 'subtasks' => $subtasks)) ?>
<?= $this->render('task/timesheet', array('task' => $task)) ?>
+<?= $this->render('tasklink/show', array('task' => $task, 'links' => $links, 'link_list' => $link_list, 'task_list' => $task_list)) ?>
<?= $this->render('file/show', array('task' => $task, 'files' => $files)) ?>
-<?= $this->render('task/comments', array('task' => $task, 'comments' => $comments, 'project' => $project)) ?> \ No newline at end of file
+<?= $this->render('task/comments', array('task' => $task, 'comments' => $comments, 'project' => $project)) ?>
diff --git a/app/Template/task/sidebar.php b/app/Template/task/sidebar.php
index e85a1671..f41be14d 100644
--- a/app/Template/task/sidebar.php
+++ b/app/Template/task/sidebar.php
@@ -19,6 +19,9 @@
<?= $this->a(t('Add a sub-task'), 'subtask', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
+ <?= $this->a(t('Add a link'), 'tasklink', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </li>
+ <li>
<?= $this->a(t('Add a comment'), 'comment', 'create', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
</li>
<li>
diff --git a/app/Template/tasklink/edit.php b/app/Template/tasklink/edit.php
new file mode 100644
index 00000000..e1fcded2
--- /dev/null
+++ b/app/Template/tasklink/edit.php
@@ -0,0 +1,53 @@
+<div class="page-header">
+ <?php if (! isset($edit)): ?>
+ <h2><?= t('Add a link') ?></h2>
+ <?php else: ?>
+ <h2><?= t('Edit a link') ?></h2>
+ <?php endif ?>
+</div>
+
+<?php if (!empty($link_list)): ?>
+<form method="post" action="<?= $this->u('tasklink', isset($edit) ? 'update' : 'save', array('task_id' => $task['id'], 'link_id' => @$values['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+
+ <?= $this->formCsrf() ?>
+
+ <?php if (isset($edit)): ?>
+ <?= $this->formHidden('id', $values) ?>
+ <?= $this->formHidden('task_link_inverse_id', $values) ?>
+ <?php endif ?>
+ <?= $this->formHidden('task_id', $values) ?>
+
+ #<?= $task['id'] ?>
+ &#160;
+ <?= $this->formSelect('link_label_id', $link_list, $values, $errors, 'required autofocus') ?>
+ &#160;
+ #<?= $this->formNumeric('task_inverse_id', $values, $errors, array('required', 'placeholder="'.t('Task id').'"', 'title="'.t('Linked task id').'"', 'list="task_inverse_ids"')) ?>
+ <?php if (!empty($task_list)): ?>
+ <datalist id="task_inverse_ids">
+ <select>
+ <?php foreach ($task_list as $task_inverse_id => $task_inverse_title): ?>
+ <option value="<?= $task_inverse_id ?>">#<?= $task_inverse_id.' '.$task_inverse_title ?></option>
+ <?php endforeach ?>
+ </select>
+ </datalist>
+ <?php endif ?>
+ <br/>
+
+ <?php if (! isset($edit)): ?>
+ <?= $this->formCheckbox('another_link', t('Create another link'), 1, isset($values['another_link']) && $values['another_link'] == 1) ?>
+ <?php endif ?>
+
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </div>
+</form>
+<?php else: ?>
+<div class="alert alert-info">
+ <?= t('You need to add link labels to this project before to link this task to another one.') ?>
+ <ul>
+ <li><?= $this->a(t('Add link labels'), 'link', 'index', array('project_id' => $task['project_id'])) ?></li>
+ </ul>
+</div>
+<?php endif ?>
diff --git a/app/Template/tasklink/remove.php b/app/Template/tasklink/remove.php
new file mode 100644
index 00000000..2ed87be7
--- /dev/null
+++ b/app/Template/tasklink/remove.php
@@ -0,0 +1,17 @@
+<div class="page-header">
+ <h2><?= t('Remove a link') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this link with task #%s?', $link['task_inverse_id']) ?>
+ <br />
+
+ </p>
+
+ <div class="form-actions">
+ <?= $this->a(t('Yes'), 'tasklink', 'remove', array('task_id' => $task['id'], 'link_id' => $link['id'], 'project_id' => $task['project_id']), true, 'btn btn-red') ?>
+ <?= t('or') ?>
+ <?= $this->a(t('cancel'), 'task', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Template/tasklink/show.php b/app/Template/tasklink/show.php
new file mode 100644
index 00000000..ac49d070
--- /dev/null
+++ b/app/Template/tasklink/show.php
@@ -0,0 +1,68 @@
+<?php if (! empty($links)): ?>
+<aside id="links" class="task-show-section">
+ <div class="page-header">
+ <h2><?= t('Links') ?></h2>
+ </div>
+
+ <table class="link-table">
+ <tr>
+ <th><?= t('Label') ?></th>
+ <th width="70%"><?= t('Task') ?></th>
+ <?php if (! isset($not_editable)): ?>
+ <th><?= t('Actions') ?></th>
+ <?php endif ?>
+ </tr>
+ <?php $previous_link = null;
+ foreach ($links as $link): ?>
+ <tr>
+ <td>
+ <?php if (null == $previous_link || $previous_link != $link['label']):
+ $previous_link = $link['label']; ?>
+ <?= t($this->e($link['label'])) ?>
+ <?php endif ?>
+ </td>
+ <td>
+ <?php if (0 == $link['task_inverse_is_active']): ?><span class="task-closed"><?php endif ?>
+ <?= $this->e($link['task_inverse_category']) ?>
+ <?php if (! isset($not_editable)): ?>
+ <?= $this->a('#'.$this->e($link['task_inverse_id']).' - '.trim($this->e($link['task_inverse_title'])), 'task', 'show', array('task_id' => $link['task_inverse_id'], 'project_id' => $link['task_inverse_project_id'])) ?>
+ <?php else: ?>
+ <?= $this->a('#'.$this->e($link['task_inverse_id']).' - '.trim($this->e($link['task_inverse_title'])), 'task', 'readonly', array('task_id' => $link['task_inverse_id'], 'project_id' => $link['task_inverse_project_id'], 'token' => $project['token'])) ?>
+ <?php endif ?>
+ <?php if (0 == $link['task_inverse_is_active']): ?></span><?php endif ?>
+ </td>
+ <?php if (! isset($not_editable)): ?>
+ <td>
+ <ul>
+ <li><?= $this->a(t('Edit'), 'tasklink', 'edit', array('task_id' => $task['id'], 'link_id' => $link['id'], 'project_id' => $task['project_id'])) ?></li>
+ <li><?= $this->a(t('Remove'), 'tasklink', 'confirm', array('task_id' => $task['id'], 'link_id' => $link['id'], 'project_id' => $task['project_id'])) ?></li>
+ </ul>
+ </td>
+ <?php endif ?>
+ </tr>
+ <?php endforeach ?>
+ </table>
+
+ <?php if (! isset($not_editable) && !empty($link_list)): ?>
+ <form method="post" action="<?= $this->u('tasklink', 'save', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) ?>" autocomplete="off">
+ <?= $this->formCsrf() ?>
+ <?= $this->formHidden('task_id', array('task_id' => $task['id'])) ?>
+ #<?= $this->e($task['id']) ?>
+ &#160;
+ <?= $this->formSelect('link_label_id', $link_list, array(), array(), 'required autofocus') ?>
+ &#160;
+ #<?= $this->formNumeric('task_inverse_id', array(), array(), array('required', 'placeholder="'.t('Task id').'"', 'title="'.t('Linked task id').'"', 'list="task_inverse_ids"')) ?>
+ <?php if (!empty($task_list)): ?>
+ <datalist id="task_inverse_ids">
+ <select>
+ <?php foreach ($task_list as $task_inverse_id => $task_inverse_title): ?>
+ <option value="<?= $task_inverse_id ?>">#<?= $task_inverse_id.' '.$task_inverse_title ?></option>
+ <?php endforeach ?>
+ </select>
+ </datalist>
+ <?php endif ?>
+ <input type="submit" value="<?= t('Add') ?>" class="btn btn-blue"/>
+ </form>
+ <?php endif ?>
+</aside>
+<?php endif ?>