diff options
Diffstat (limited to 'app')
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').' > '.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'].' > '.$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 = ' »'; + } + if (self::BEHAVIOUR_BOTH == $link['behaviour'] || self::BEHAVIOUR_RIGHT2LEFT == $link['behaviour']) { + $prefix = '« '; + } + $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').'"')) ?> » + + <?= $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]') ?> + « <?= $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'] ?> +   + <?= $this->formSelect('link_label_id', $link_list, $values, $errors, 'required autofocus') ?> +   + #<?= $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']) ?> +   + <?= $this->formSelect('link_label_id', $link_list, array(), array(), 'required autofocus') ?> +   + #<?= $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 ?> |