summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/Controller/ProjectTagController.php134
-rw-r--r--app/Controller/TagController.php121
-rw-r--r--app/Core/Base.php3
-rw-r--r--app/Model/TagModel.php23
-rw-r--r--app/ServiceProvider/AuthenticationProvider.php2
-rw-r--r--app/ServiceProvider/ClassProvider.php3
-rw-r--r--app/ServiceProvider/RouteProvider.php2
-rw-r--r--app/Template/config/sidebar.php3
-rw-r--r--app/Template/project/sidebar.php3
-rw-r--r--app/Template/project_tag/create.php16
-rw-r--r--app/Template/project_tag/edit.php17
-rw-r--r--app/Template/project_tag/index.php31
-rw-r--r--app/Template/project_tag/remove.php15
-rw-r--r--app/Template/tag/create.php16
-rw-r--r--app/Template/tag/edit.php17
-rw-r--r--app/Template/tag/index.php31
-rw-r--r--app/Template/tag/remove.php15
-rw-r--r--app/Validator/TagValidator.php77
18 files changed, 527 insertions, 2 deletions
diff --git a/app/Controller/ProjectTagController.php b/app/Controller/ProjectTagController.php
new file mode 100644
index 00000000..acf514d3
--- /dev/null
+++ b/app/Controller/ProjectTagController.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Core\Controller\AccessForbiddenException;
+
+/**
+ * Class ProjectTagController
+ *
+ * @package Kanboard\Controller
+ * @author Frederic Guillot
+ */
+class ProjectTagController extends BaseController
+{
+ public function index()
+ {
+ $project = $this->getProject();
+
+ $this->response->html($this->helper->layout->project('project_tag/index', array(
+ 'project' => $project,
+ 'tags' => $this->tagModel->getAllByProject($project['id']),
+ 'title' => t('Project tags management'),
+ )));
+ }
+
+ public function create(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+
+ if (empty($values)) {
+ $values['project_id'] = $project['id'];
+ }
+
+ $this->response->html($this->template->render('project_tag/create', array(
+ 'project' => $project,
+ 'values' => $values,
+ 'errors' => $errors,
+ )));
+ }
+
+ public function save()
+ {
+ $project = $this->getProject();
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->tagValidator->validateCreation($values);
+
+ if ($valid) {
+ if ($this->tagModel->create($project['id'], $values['name']) > 0) {
+ $this->flash->success(t('Tag created successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to create this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectTagController', 'index', array('project_id' => $project['id'])));
+ } else {
+ $this->create($values, $errors);
+ }
+ }
+
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $project = $this->getProject();
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ if (empty($values)) {
+ $values = $tag;
+ }
+
+ $this->response->html($this->template->render('project_tag/edit', array(
+ 'project' => $project,
+ 'tag' => $tag,
+ 'values' => $values,
+ 'errors' => $errors,
+ )));
+ }
+
+ public function update()
+ {
+ $project = $this->getProject();
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->tagValidator->validateModification($values);
+
+ if ($tag['project_id'] != $project['id']) {
+ throw new AccessForbiddenException();
+ }
+
+ if ($valid) {
+ if ($this->tagModel->update($values['id'], $values['name'])) {
+ $this->flash->success(t('Tag updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectTagController', 'index', array('project_id' => $project['id'])));
+ } else {
+ $this->edit($values, $errors);
+ }
+ }
+
+ public function confirm()
+ {
+ $project = $this->getProject();
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ $this->response->html($this->template->render('project_tag/remove', array(
+ 'tag' => $tag,
+ 'project' => $project,
+ )));
+ }
+
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $project = $this->getProject();
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ if ($tag['project_id'] != $project['id']) {
+ throw new AccessForbiddenException();
+ }
+
+ if ($this->tagModel->remove($tag_id)) {
+ $this->flash->success(t('Tag removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('ProjectTagController', 'index', array('project_id' => $project['id'])));
+ }
+}
diff --git a/app/Controller/TagController.php b/app/Controller/TagController.php
new file mode 100644
index 00000000..b8389910
--- /dev/null
+++ b/app/Controller/TagController.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Kanboard\Controller;
+
+use Kanboard\Core\Controller\AccessForbiddenException;
+
+/**
+ * Class TagController
+ *
+ * @package Kanboard\Controller
+ * @author Frederic Guillot
+ */
+class TagController extends BaseController
+{
+ public function index()
+ {
+ $this->response->html($this->helper->layout->config('tag/index', array(
+ 'tags' => $this->tagModel->getAllByProject(0),
+ 'title' => t('Settings').' &gt; '.t('Global tags management'),
+ )));
+ }
+
+ public function create(array $values = array(), array $errors = array())
+ {
+ if (empty($values)) {
+ $values['project_id'] = 0;
+ }
+
+ $this->response->html($this->template->render('tag/create', array(
+ 'values' => $values,
+ 'errors' => $errors,
+ )));
+ }
+
+ public function save()
+ {
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->tagValidator->validateCreation($values);
+
+ if ($valid) {
+ if ($this->tagModel->create(0, $values['name']) > 0) {
+ $this->flash->success(t('Tag created successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to create this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('TagController', 'index'));
+ } else {
+ $this->create($values, $errors);
+ }
+ }
+
+ public function edit(array $values = array(), array $errors = array())
+ {
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ if (empty($values)) {
+ $values = $tag;
+ }
+
+ $this->response->html($this->template->render('tag/edit', array(
+ 'tag' => $tag,
+ 'values' => $values,
+ 'errors' => $errors,
+ )));
+ }
+
+ public function update()
+ {
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+ $values = $this->request->getValues();
+ list($valid, $errors) = $this->tagValidator->validateModification($values);
+
+ if ($tag['project_id'] != 0) {
+ throw new AccessForbiddenException();
+ }
+
+ if ($valid) {
+ if ($this->tagModel->update($values['id'], $values['name'])) {
+ $this->flash->success(t('Tag updated successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to update this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('TagController', 'index'));
+ } else {
+ $this->edit($values, $errors);
+ }
+ }
+
+ public function confirm()
+ {
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ $this->response->html($this->template->render('tag/remove', array(
+ 'tag' => $tag,
+ )));
+ }
+
+ public function remove()
+ {
+ $this->checkCSRFParam();
+ $tag_id = $this->request->getIntegerParam('tag_id');
+ $tag = $this->tagModel->getById($tag_id);
+
+ if ($tag['project_id'] != 0) {
+ throw new AccessForbiddenException();
+ }
+
+ if ($this->tagModel->remove($tag_id)) {
+ $this->flash->success(t('Tag removed successfully.'));
+ } else {
+ $this->flash->failure(t('Unable to remove this tag.'));
+ }
+
+ $this->response->redirect($this->helper->url->to('TagController', 'index'));
+ }
+}
diff --git a/app/Core/Base.php b/app/Core/Base.php
index 6712cbce..e5dd6ad9 100644
--- a/app/Core/Base.php
+++ b/app/Core/Base.php
@@ -116,14 +116,15 @@ use Pimple\Container;
* @property \Kanboard\Validator\CommentValidator $commentValidator
* @property \Kanboard\Validator\CurrencyValidator $currencyValidator
* @property \Kanboard\Validator\CustomFilterValidator $customFilterValidator
+ * @property \Kanboard\Validator\ExternalLinkValidator $externalLinkValidator
* @property \Kanboard\Validator\GroupValidator $groupValidator
* @property \Kanboard\Validator\LinkValidator $linkValidator
* @property \Kanboard\Validator\PasswordResetValidator $passwordResetValidator
* @property \Kanboard\Validator\ProjectValidator $projectValidator
* @property \Kanboard\Validator\SubtaskValidator $subtaskValidator
* @property \Kanboard\Validator\SwimlaneValidator $swimlaneValidator
+ * @property \Kanboard\Validator\TagValidator $tagValidator
* @property \Kanboard\Validator\TaskLinkValidator $taskLinkValidator
- * @property \Kanboard\Validator\ExternalLinkValidator $externalLinkValidator
* @property \Kanboard\Validator\TaskValidator $taskValidator
* @property \Kanboard\Validator\UserValidator $userValidator
* @property \Kanboard\Import\TaskImport $taskImport
diff --git a/app/Model/TagModel.php b/app/Model/TagModel.php
index 8eb5e5ba..e85c5a87 100644
--- a/app/Model/TagModel.php
+++ b/app/Model/TagModel.php
@@ -94,6 +94,29 @@ class TagModel extends Base
}
/**
+ * Return true if the tag exists
+ *
+ * @access public
+ * @param integer $project_id
+ * @param string $tag
+ * @param integer $tag_id
+ * @return boolean
+ */
+ public function exists($project_id, $tag, $tag_id = 0)
+ {
+ return $this->db
+ ->table(self::TABLE)
+ ->neq('id', $tag_id)
+ ->beginOr()
+ ->eq('project_id', 0)
+ ->eq('project_id', $project_id)
+ ->closeOr()
+ ->ilike('name', $tag)
+ ->asc('project_id')
+ ->exists();
+ }
+
+ /**
* Return tag id and create a new tag if necessary
*
* @access public
diff --git a/app/ServiceProvider/AuthenticationProvider.php b/app/ServiceProvider/AuthenticationProvider.php
index 2fad8a3a..84e4354d 100644
--- a/app/ServiceProvider/AuthenticationProvider.php
+++ b/app/ServiceProvider/AuthenticationProvider.php
@@ -88,6 +88,7 @@ class AuthenticationProvider implements ServiceProviderInterface
$acl->add('ProjectFileController', '*', Role::PROJECT_MEMBER);
$acl->add('ProjectUserOverviewController', '*', Role::PROJECT_MANAGER);
$acl->add('ProjectStatusController', '*', Role::PROJECT_MANAGER);
+ $acl->add('ProjectTagController', '*', Role::PROJECT_MANAGER);
$acl->add('SubtaskController', '*', Role::PROJECT_MEMBER);
$acl->add('SubtaskRestrictionController', '*', Role::PROJECT_MEMBER);
$acl->add('SubtaskStatusController', '*', Role::PROJECT_MEMBER);
@@ -131,6 +132,7 @@ class AuthenticationProvider implements ServiceProviderInterface
$acl->add('AvatarFileController', 'show', Role::APP_PUBLIC);
$acl->add('ConfigController', '*', Role::APP_ADMIN);
+ $acl->add('TagController', '*', Role::APP_ADMIN);
$acl->add('PluginController', '*', Role::APP_ADMIN);
$acl->add('CurrencyController', '*', Role::APP_ADMIN);
$acl->add('ProjectGanttController', '*', Role::APP_MANAGER);
diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php
index 778b4f9e..c0fb93bd 100644
--- a/app/ServiceProvider/ClassProvider.php
+++ b/app/ServiceProvider/ClassProvider.php
@@ -99,8 +99,9 @@ class ClassProvider implements ServiceProviderInterface
'ProjectValidator',
'SubtaskValidator',
'SwimlaneValidator',
- 'TaskValidator',
+ 'TagValidator',
'TaskLinkValidator',
+ 'TaskValidator',
'UserValidator',
),
'Import' => array(
diff --git a/app/ServiceProvider/RouteProvider.php b/app/ServiceProvider/RouteProvider.php
index 3d1391df..8801e3d0 100644
--- a/app/ServiceProvider/RouteProvider.php
+++ b/app/ServiceProvider/RouteProvider.php
@@ -59,6 +59,7 @@ class RouteProvider implements ServiceProviderInterface
$container['route']->addRoute('project/:project_id/duplicate', 'ProjectViewController', 'duplicate');
$container['route']->addRoute('project/:project_id/permissions', 'ProjectPermissionController', 'index');
$container['route']->addRoute('project/:project_id/activity', 'ActivityController', 'project');
+ $container['route']->addRoute('project/:project_id/tags', 'ProjectTagController', 'index');
// Project Overview
$container['route']->addRoute('project/:project_id/overview', 'ProjectOverviewController', 'show');
@@ -174,6 +175,7 @@ class RouteProvider implements ServiceProviderInterface
$container['route']->addRoute('settings/api', 'ConfigController', 'api');
$container['route']->addRoute('settings/links', 'LinkController', 'index');
$container['route']->addRoute('settings/currencies', 'CurrencyController', 'index');
+ $container['route']->addRoute('settings/tags', 'TagController', 'index');
// Plugins
$container['route']->addRoute('extensions', 'PluginController', 'show');
diff --git a/app/Template/config/sidebar.php b/app/Template/config/sidebar.php
index 29caa0ef..e304f0d0 100644
--- a/app/Template/config/sidebar.php
+++ b/app/Template/config/sidebar.php
@@ -19,6 +19,9 @@
<li <?= $this->app->checkMenuSelection('ConfigController', 'calendar') ?>>
<?= $this->url->link(t('Calendar settings'), 'ConfigController', 'calendar') ?>
</li>
+ <li <?= $this->app->checkMenuSelection('TagController', 'index') ?>>
+ <?= $this->url->link(t('Tags management'), 'TagController', 'index') ?>
+ </li>
<li <?= $this->app->checkMenuSelection('LinkController') ?>>
<?= $this->url->link(t('Link settings'), 'LinkController', 'index') ?>
</li>
diff --git a/app/Template/project/sidebar.php b/app/Template/project/sidebar.php
index 9bc0c9c4..d0f50596 100644
--- a/app/Template/project/sidebar.php
+++ b/app/Template/project/sidebar.php
@@ -32,6 +32,9 @@
<li <?= $this->app->checkMenuSelection('CategoryController') ?>>
<?= $this->url->link(t('Categories'), 'CategoryController', 'index', array('project_id' => $project['id'])) ?>
</li>
+ <li <?= $this->app->checkMenuSelection('ProjectTagController') ?>>
+ <?= $this->url->link(t('Tags'), 'ProjectTagController', 'index', array('project_id' => $project['id'])) ?>
+ </li>
<?php if ($project['is_private'] == 0): ?>
<li <?= $this->app->checkMenuSelection('ProjectPermissionController') ?>>
<?= $this->url->link(t('Permissions'), 'ProjectPermissionController', 'index', array('project_id' => $project['id'])) ?>
diff --git a/app/Template/project_tag/create.php b/app/Template/project_tag/create.php
new file mode 100644
index 00000000..bfd1084a
--- /dev/null
+++ b/app/Template/project_tag/create.php
@@ -0,0 +1,16 @@
+<div class="page-header">
+ <h2><?= t('Add new tag') ?></h2>
+</div>
+<form method="post" class="popover-form" action="<?= $this->url->href('ProjectTagController', 'save', array('project_id' => $project['id'])) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="255"')) ?>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'ProjectTagController', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/project_tag/edit.php b/app/Template/project_tag/edit.php
new file mode 100644
index 00000000..9bf261bd
--- /dev/null
+++ b/app/Template/project_tag/edit.php
@@ -0,0 +1,17 @@
+<div class="page-header">
+ <h2><?= t('Edit a tag') ?></h2>
+</div>
+<form method="post" class="popover-form" action="<?= $this->url->href('ProjectTagController', 'update', array('tag_id' => $tag['id'], 'project_id' => $project['id'])) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="255"')) ?>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'ProjectTagController', 'index', array(), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/project_tag/index.php b/app/Template/project_tag/index.php
new file mode 100644
index 00000000..8e8dd96c
--- /dev/null
+++ b/app/Template/project_tag/index.php
@@ -0,0 +1,31 @@
+<div class="page-header">
+ <h2><?= t('Project tags') ?></h2>
+ <ul>
+ <li>
+ <i class="fa fa-plus" aria-hidden="true"></i>
+ <?= $this->url->link(t('Add new tag'), 'ProjectTagController', 'create', array('project_id' => $project['id']), false, 'popover') ?>
+ </li>
+ </ul>
+</div>
+
+<?php if (empty($tags)): ?>
+ <p class="alert"><?= t('There is no specific tag for this project at the moment.') ?></p>
+<?php else: ?>
+ <table class="table-striped">
+ <tr>
+ <th class="column-80"><?= t('Tag') ?></th>
+ <th><?= t('Action') ?></th>
+ </tr>
+ <?php foreach ($tags as $tag): ?>
+ <tr>
+ <td><?= $this->text->e($tag['name']) ?></td>
+ <td>
+ <i class="fa fa-times" aria-hidden="true"></i>
+ <?= $this->url->link(t('Remove'), 'ProjectTagController', 'confirm', array('tag_id' => $tag['id'], 'project_id' => $project['id']), false, 'popover') ?>
+ <i class="fa fa-pencil-square-o" aria-hidden="true"></i>
+ <?= $this->url->link(t('Edit'), 'ProjectTagController', 'edit', array('tag_id' => $tag['id'], 'project_id' => $project['id']), false, 'popover') ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+<?php endif ?>
diff --git a/app/Template/project_tag/remove.php b/app/Template/project_tag/remove.php
new file mode 100644
index 00000000..f4aadab1
--- /dev/null
+++ b/app/Template/project_tag/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a tag') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this tag: "%s"?', $tag['name']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'ProjectTagController', 'remove', array('tag_id' => $tag['id'], 'project_id' => $project['id']), true, 'btn btn-red popover-link') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'ProjectTagController', 'index', array('project_id' => $project['id']), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/app/Template/tag/create.php b/app/Template/tag/create.php
new file mode 100644
index 00000000..9b32bc46
--- /dev/null
+++ b/app/Template/tag/create.php
@@ -0,0 +1,16 @@
+<div class="page-header">
+ <h2><?= t('Add new tag') ?></h2>
+</div>
+<form method="post" class="popover-form" action="<?= $this->url->href('TagController', 'save') ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="255"')) ?>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TagController', 'index', array(), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/tag/edit.php b/app/Template/tag/edit.php
new file mode 100644
index 00000000..f751ff49
--- /dev/null
+++ b/app/Template/tag/edit.php
@@ -0,0 +1,17 @@
+<div class="page-header">
+ <h2><?= t('Edit a tag') ?></h2>
+</div>
+<form method="post" class="popover-form" action="<?= $this->url->href('TagController', 'update', array('tag_id' => $tag['id'])) ?>" autocomplete="off">
+ <?= $this->form->csrf() ?>
+ <?= $this->form->hidden('id', $values) ?>
+ <?= $this->form->hidden('project_id', $values) ?>
+
+ <?= $this->form->label(t('Name'), 'name') ?>
+ <?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="255"')) ?>
+
+ <div class="form-actions">
+ <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TagController', 'index', array(), false, 'close-popover') ?>
+ </div>
+</form>
diff --git a/app/Template/tag/index.php b/app/Template/tag/index.php
new file mode 100644
index 00000000..2a495eb3
--- /dev/null
+++ b/app/Template/tag/index.php
@@ -0,0 +1,31 @@
+<div class="page-header">
+ <h2><?= t('Global tags') ?></h2>
+ <ul>
+ <li>
+ <i class="fa fa-plus" aria-hidden="true"></i>
+ <?= $this->url->link(t('Add new tag'), 'TagController', 'create', array(), false, 'popover') ?>
+ </li>
+ </ul>
+</div>
+
+<?php if (empty($tags)): ?>
+ <p class="alert"><?= t('There is no global tag at the moment.') ?></p>
+<?php else: ?>
+ <table class="table-striped">
+ <tr>
+ <th class="column-80"><?= t('Tag') ?></th>
+ <th><?= t('Action') ?></th>
+ </tr>
+ <?php foreach ($tags as $tag): ?>
+ <tr>
+ <td><?= $this->text->e($tag['name']) ?></td>
+ <td>
+ <i class="fa fa-times" aria-hidden="true"></i>
+ <?= $this->url->link(t('Remove'), 'TagController', 'confirm', array('tag_id' => $tag['id']), false, 'popover') ?>
+ <i class="fa fa-pencil-square-o" aria-hidden="true"></i>
+ <?= $this->url->link(t('Edit'), 'TagController', 'edit', array('tag_id' => $tag['id']), false, 'popover') ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+ </table>
+<?php endif ?>
diff --git a/app/Template/tag/remove.php b/app/Template/tag/remove.php
new file mode 100644
index 00000000..46ea3f99
--- /dev/null
+++ b/app/Template/tag/remove.php
@@ -0,0 +1,15 @@
+<div class="page-header">
+ <h2><?= t('Remove a tag') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this tag: "%s"?', $tag['name']) ?>
+ </p>
+
+ <div class="form-actions">
+ <?= $this->url->link(t('Yes'), 'TagController', 'remove', array('tag_id' => $tag['id']), true, 'btn btn-red popover-link') ?>
+ <?= t('or') ?>
+ <?= $this->url->link(t('cancel'), 'TagController', 'index', array(), false, 'close-popover') ?>
+ </div>
+</div>
diff --git a/app/Validator/TagValidator.php b/app/Validator/TagValidator.php
new file mode 100644
index 00000000..32622583
--- /dev/null
+++ b/app/Validator/TagValidator.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Kanboard\Validator;
+
+use Kanboard\Model\TagModel;
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * Tag Validator
+ *
+ * @package Kanboard\Validator
+ * @author Frederic Guillot
+ */
+class TagValidator extends BaseValidator
+{
+ /**
+ * Validate 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());
+ $result = $v->execute();
+ $errors = $v->getErrors();
+
+ if ($result && $this->tagModel->exists($values['project_id'], $values['name'])) {
+ $result = false;
+ $errors = array('name' => array(t('The name must be unique')));
+ }
+
+ return array($result, $errors);
+ }
+
+ /**
+ * Validate 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('Field required')),
+ );
+
+ $v = new Validator($values, array_merge($rules, $this->commonValidationRules()));
+ $result = $v->execute();
+ $errors = $v->getErrors();
+
+ if ($result && $this->tagModel->exists($values['project_id'], $values['name'], $values['id'])) {
+ $result = false;
+ $errors = array('name' => array(t('The name must be unique')));
+ }
+
+ return array($result, $errors);
+ }
+
+ /**
+ * Common validation rules
+ *
+ * @access protected
+ * @return array
+ */
+ protected function commonValidationRules()
+ {
+ return array(
+ new Validators\Required('project_id', t('Field required')),
+ new Validators\Required('name', t('Field required')),
+ new Validators\MaxLength('name', t('The maximum length is %d characters', 255), 255),
+ );
+ }
+}