From 1d58b33950e1fe87cf7caa9b16849813ac939b5c Mon Sep 17 00:00:00 2001 From: Valentin Yakovenkov Date: Wed, 6 Jul 2016 15:00:21 +0300 Subject: Hide empty parts of task details page --- app/Template/task/show.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/Template/task/show.php b/app/Template/task/show.php index 80786715..76c572a1 100644 --- a/app/Template/task/show.php +++ b/app/Template/task/show.php @@ -7,9 +7,12 @@ 'editable' => $this->user->hasProjectAccess('TaskModificationController', 'edit', $project['id']), )) ?> + hook->render('template:task:show:before-description', array('task' => $task, 'project' => $project)) ?> render('task/description', array('task' => $task)) ?> + + hook->render('template:task:show:before-subtasks', array('task' => $task, 'project' => $project)) ?> render('subtask/show', array( 'task' => $task, @@ -17,7 +20,9 @@ 'project' => $project, 'editable' => true, )) ?> + + hook->render('template:task:show:before-internal-links', array('task' => $task, 'project' => $project)) ?> render('task_internal_link/show', array( 'task' => $task, @@ -27,21 +32,27 @@ 'editable' => true, 'is_public' => false, )) ?> + + hook->render('template:task:show:before-external-links', array('task' => $task, 'project' => $project)) ?> render('task_external_link/show', array( 'task' => $task, 'links' => $external_links, 'project' => $project, )) ?> + + hook->render('template:task:show:before-attachments', array('task' => $task, 'project' => $project)) ?> render('task_file/show', array( 'task' => $task, 'files' => $files, 'images' => $images )) ?> + + hook->render('template:task:show:before-comments', array('task' => $task, 'project' => $project)) ?> render('comments/show', array( 'task' => $task, @@ -49,5 +60,6 @@ 'project' => $project, 'editable' => $this->user->hasProjectAccess('CommentController', 'edit', $project['id']), )) ?> + hook->render('template:task:show:bottom', array('task' => $task, 'project' => $project)) ?> -- cgit v1.2.3 From 48ee733f9e2d0519150a881b18bf5510d6ed8e29 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 14 Jul 2016 11:29:15 -0400 Subject: Added search filter for task priority --- ChangeLog | 1 + app/Filter/TaskPriorityFilter.php | 38 ++++++++++++++++++++++ app/ServiceProvider/FilterProvider.php | 2 ++ tests/units/Filter/TaskPriorityFilterTest.php | 47 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 app/Filter/TaskPriorityFilter.php create mode 100644 tests/units/Filter/TaskPriorityFilterTest.php diff --git a/ChangeLog b/ChangeLog index 881c3020..097ec68e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,7 @@ Version 1.0.32 (unreleased) New features: * New automated action to close tasks without activity in a specific column +* Added search filter for task priority * Added the possibility to hide tasks in dashboard for a specific column Improvements: diff --git a/app/Filter/TaskPriorityFilter.php b/app/Filter/TaskPriorityFilter.php new file mode 100644 index 00000000..75f6ae3d --- /dev/null +++ b/app/Filter/TaskPriorityFilter.php @@ -0,0 +1,38 @@ +query->eq(TaskModel::TABLE.'.priority', $this->value); + return $this; + } +} diff --git a/app/ServiceProvider/FilterProvider.php b/app/ServiceProvider/FilterProvider.php index 20281a09..436288dc 100644 --- a/app/ServiceProvider/FilterProvider.php +++ b/app/ServiceProvider/FilterProvider.php @@ -21,6 +21,7 @@ use Kanboard\Filter\TaskDueDateFilter; use Kanboard\Filter\TaskIdFilter; use Kanboard\Filter\TaskLinkFilter; use Kanboard\Filter\TaskModificationDateFilter; +use Kanboard\Filter\TaskPriorityFilter; use Kanboard\Filter\TaskProjectFilter; use Kanboard\Filter\TaskReferenceFilter; use Kanboard\Filter\TaskStatusFilter; @@ -137,6 +138,7 @@ class FilterProvider implements ServiceProviderInterface ->withFilter(TaskColorFilter::getInstance() ->setColorModel($c['colorModel']) ) + ->withFilter(new TaskPriorityFilter()) ->withFilter(new TaskColumnFilter()) ->withFilter(new TaskCommentFilter()) ->withFilter(TaskCreationDateFilter::getInstance() diff --git a/tests/units/Filter/TaskPriorityFilterTest.php b/tests/units/Filter/TaskPriorityFilterTest.php new file mode 100644 index 00000000..4c95ddce --- /dev/null +++ b/tests/units/Filter/TaskPriorityFilterTest.php @@ -0,0 +1,47 @@ +container); + $taskCreation = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $query = $taskFinder->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreation->create(array('title' => 'Test', 'project_id' => 1, 'priority' => 2))); + + $filter = new TaskPriorityFilter(); + $filter->withQuery($query); + $filter->withValue(2); + $filter->apply(); + + $this->assertCount(1, $query->findAll()); + } + + public function testWithNoPriority() + { + $taskFinder = new TaskFinderModel($this->container); + $taskCreation = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $query = $taskFinder->getExtendedQuery(); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreation->create(array('title' => 'Test', 'project_id' => 1))); + + $filter = new TaskPriorityFilter(); + $filter->withQuery($query); + $filter->withValue(2); + $filter->apply(); + + $this->assertCount(0, $query->findAll()); + } +} -- cgit v1.2.3 From 9496dfdb6df0266b6b7c99c01a4eb9055322b581 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 14 Jul 2016 11:39:59 -0400 Subject: Make search attributes not case sensitive --- ChangeLog | 1 + app/Core/Filter/LexerBuilder.php | 2 +- tests/units/Core/Filter/LexerBuilderTest.php | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index 097ec68e..3fcb6ed6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -9,6 +9,7 @@ New features: Improvements: +* Make search attributes not case sensitive * Display TOTP issuer for 2FA * Make sure that the table schema_version use InnoDB for Mysql diff --git a/app/Core/Filter/LexerBuilder.php b/app/Core/Filter/LexerBuilder.php index 7a9a714f..626d7614 100644 --- a/app/Core/Filter/LexerBuilder.php +++ b/app/Core/Filter/LexerBuilder.php @@ -69,7 +69,7 @@ class LexerBuilder foreach ($attributes as $attribute) { $this->filters[$attribute] = $filter; - $this->lexer->addToken(sprintf("/^(%s:)/", $attribute), $attribute); + $this->lexer->addToken(sprintf("/^(%s:)/i", $attribute), $attribute); if ($default) { $this->lexer->setDefaultToken($attribute); diff --git a/tests/units/Core/Filter/LexerBuilderTest.php b/tests/units/Core/Filter/LexerBuilderTest.php index 23726f32..65bb87b9 100644 --- a/tests/units/Core/Filter/LexerBuilderTest.php +++ b/tests/units/Core/Filter/LexerBuilderTest.php @@ -103,4 +103,24 @@ class LexerBuilderTest extends Base $this->assertFalse($builder === $clone); $this->assertFalse($builder->build('test')->getQuery() === $clone->build('test')->getQuery()); } + + public function testBuilderWithMixedCaseSearchAttribute() + { + $project = new ProjectModel($this->container); + $taskCreation = new TaskCreationModel($this->container); + $taskFinder = new TaskFinderModel($this->container); + $query = $taskFinder->getExtendedQuery(); + + $this->assertEquals(1, $project->create(array('name' => 'Project'))); + $this->assertNotFalse($taskCreation->create(array('project_id' => 1, 'title' => 'Test'))); + + $builder = new LexerBuilder(); + $builder->withFilter(new TaskAssigneeFilter()); + $builder->withFilter(new TaskTitleFilter(), true); + $builder->withQuery($query); + $tasks = $builder->build('AsSignEe:nobody')->toArray(); + + $this->assertCount(1, $tasks); + $this->assertEquals('Test', $tasks[0]['title']); + } } -- cgit v1.2.3 From 6e35d8f22aa828b9dcd72d749034754e1a00a525 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 14 Jul 2016 13:46:06 -0400 Subject: Display project name for task auto-complete fields --- ChangeLog | 1 + app/Formatter/TaskAutoCompleteFormatter.php | 9 ++++-- .../Formatter/TaskAutoCompleteFormatterTest.php | 34 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 tests/units/Formatter/TaskAutoCompleteFormatterTest.php diff --git a/ChangeLog b/ChangeLog index 3fcb6ed6..fc0cdac6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -9,6 +9,7 @@ New features: Improvements: +* Display project name for task auto-complete fields * Make search attributes not case sensitive * Display TOTP issuer for 2FA * Make sure that the table schema_version use InnoDB for Mysql diff --git a/app/Formatter/TaskAutoCompleteFormatter.php b/app/Formatter/TaskAutoCompleteFormatter.php index 4f1c4c69..2d9f7341 100644 --- a/app/Formatter/TaskAutoCompleteFormatter.php +++ b/app/Formatter/TaskAutoCompleteFormatter.php @@ -3,6 +3,7 @@ namespace Kanboard\Formatter; use Kanboard\Core\Filter\FormatterInterface; +use Kanboard\Model\ProjectModel; use Kanboard\Model\TaskModel; /** @@ -21,11 +22,15 @@ class TaskAutoCompleteFormatter extends BaseFormatter implements FormatterInterf */ public function format() { - $tasks = $this->query->columns(TaskModel::TABLE.'.id', TaskModel::TABLE.'.title')->findAll(); + $tasks = $this->query->columns( + TaskModel::TABLE.'.id', + TaskModel::TABLE.'.title', + ProjectModel::TABLE.'.name AS project_name' + )->asc(TaskModel::TABLE.'.id')->findAll(); foreach ($tasks as &$task) { $task['value'] = $task['title']; - $task['label'] = '#'.$task['id'].' - '.$task['title']; + $task['label'] = $task['project_name'].' > #'.$task['id'].' '.$task['title']; } return $tasks; diff --git a/tests/units/Formatter/TaskAutoCompleteFormatterTest.php b/tests/units/Formatter/TaskAutoCompleteFormatterTest.php new file mode 100644 index 00000000..20baf549 --- /dev/null +++ b/tests/units/Formatter/TaskAutoCompleteFormatterTest.php @@ -0,0 +1,34 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'My Project'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task 2', 'project_id' => 1))); + + $tasks = TaskAutoCompleteFormatter::getInstance($this->container) + ->withQuery($taskFinderModel->getExtendedQuery()) + ->format(); + + $this->assertCount(2, $tasks); + $this->assertEquals('My Project > #1 Task 1', $tasks[0]['label']); + $this->assertEquals('Task 1', $tasks[0]['value']); + $this->assertEquals(1, $tasks[0]['id']); + $this->assertEquals('My Project > #2 Task 2', $tasks[1]['label']); + $this->assertEquals('Task 2', $tasks[1]['value']); + $this->assertEquals(2, $tasks[1]['id']); + } +} -- cgit v1.2.3 From 5969eb8e3030c822333872f24daa23b9eac1f4f7 Mon Sep 17 00:00:00 2001 From: Dj Padzensky Date: Thu, 14 Jul 2016 13:20:56 -0700 Subject: Added tighter access controls to profile section --- app/Template/user_modification/show.php | 8 ++-- app/Template/user_view/sidebar.php | 68 ++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 30 deletions(-) diff --git a/app/Template/user_modification/show.php b/app/Template/user_modification/show.php index 396d550d..506c9161 100644 --- a/app/Template/user_modification/show.php +++ b/app/Template/user_modification/show.php @@ -11,16 +11,16 @@ form->text('username', $values, $errors, array('required', isset($values['is_ldap_user']) && $values['is_ldap_user'] == 1 ? 'readonly' : '', 'maxlength="50"')) ?> form->label(t('Name'), 'name') ?> - form->text('name', $values, $errors) ?> + form->text('name', $values, $errors, array($this->user->hasAccess('UserModificationController', 'show/edit_name') ? '' : 'readonly')) ?> form->label(t('Email'), 'email') ?> - form->email('email', $values, $errors) ?> + form->email('email', $values, $errors, array($this->user->hasAccess('UserModificationController', 'show/edit_email') ? '' : 'readonly')) ?> form->label(t('Timezone'), 'timezone') ?> - form->select('timezone', $timezones, $values, $errors) ?> + form->select('timezone', $timezones, $values, $errors, array($this->user->hasAccess('UserModificationController', 'show/edit_timezone') ? '' : 'disabled')) ?> form->label(t('Language'), 'language') ?> - form->select('language', $languages, $values, $errors) ?> + form->select('language', $languages, $values, $errors, array($this->user->hasAccess('UserModificationController', 'show/edit_language') ? '' : 'disabled')) ?> user->isAdmin()): ?> form->label(t('Role'), 'role') ?> diff --git a/app/Template/user_view/sidebar.php b/app/Template/user_view/sidebar.php index d200a7f5..3dc6b7bc 100644 --- a/app/Template/user_view/sidebar.php +++ b/app/Template/user_view/sidebar.php @@ -12,18 +12,26 @@ user->isAdmin() || $this->user->isCurrentUser($user['id'])): ?> -
  • app->checkMenuSelection('UserViewController', 'timesheet') ?>> - url->link(t('Time tracking'), 'UserViewController', 'timesheet', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'lastLogin') ?>> - url->link(t('Last logins'), 'UserViewController', 'lastLogin', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'sessions') ?>> - url->link(t('Persistent connections'), 'UserViewController', 'sessions', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'passwordReset') ?>> - url->link(t('Password reset history'), 'UserViewController', 'passwordReset', array('user_id' => $user['id'])) ?> -
  • + user->hasAccess('UserViewController', 'timesheet')): ?> +
  • app->checkMenuSelection('UserViewController', 'timesheet') ?>> + url->link(t('Time tracking'), 'UserViewController', 'timesheet', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'lastLogin')): ?> +
  • app->checkMenuSelection('UserViewController', 'lastLogin') ?>> + url->link(t('Last logins'), 'UserViewController', 'lastLogin', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'sessions')): ?> +
  • app->checkMenuSelection('UserViewController', 'sessions') ?>> + url->link(t('Persistent connections'), 'UserViewController', 'sessions', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'passwordReset')): ?> +
  • app->checkMenuSelection('UserViewController', 'passwordReset') ?>> + url->link(t('Password reset history'), 'UserViewController', 'passwordReset', array('user_id' => $user['id'])) ?> +
  • + hook->render('template:user:sidebar:information', array('user' => $user)) ?> @@ -42,13 +50,13 @@ - + user->hasAccess('UserCredentialController', 'changePassword')): ?>
  • app->checkMenuSelection('UserCredentialController', 'changePassword') ?>> url->link(t('Change password'), 'UserCredentialController', 'changePassword', array('user_id' => $user['id'])) ?>
  • - user->isCurrentUser($user['id'])): ?> + user->isCurrentUser($user['id']) && $this->user->hasAccess('TwoFactorController', 'index')): ?>
  • app->checkMenuSelection('TwoFactorController', 'index') ?>> url->link(t('Two factor authentication'), 'TwoFactorController', 'index', array('user_id' => $user['id'])) ?>
  • @@ -58,18 +66,26 @@ -
  • app->checkMenuSelection('UserViewController', 'share') ?>> - url->link(t('Public access'), 'UserViewController', 'share', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'notifications') ?>> - url->link(t('Notifications'), 'UserViewController', 'notifications', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'external') ?>> - url->link(t('External accounts'), 'UserViewController', 'external', array('user_id' => $user['id'])) ?> -
  • -
  • app->checkMenuSelection('UserViewController', 'integrations') ?>> - url->link(t('Integrations'), 'UserViewController', 'integrations', array('user_id' => $user['id'])) ?> -
  • + user->hasAccess('UserViewController', 'share')): ?> +
  • app->checkMenuSelection('UserViewController', 'share') ?>> + url->link(t('Public access'), 'UserViewController', 'share', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'notifications')): ?> +
  • app->checkMenuSelection('UserViewController', 'notifications') ?>> + url->link(t('Notifications'), 'UserViewController', 'notifications', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'external')): ?> +
  • app->checkMenuSelection('UserViewController', 'external') ?>> + url->link(t('External accounts'), 'UserViewController', 'external', array('user_id' => $user['id'])) ?> +
  • + + user->hasAccess('UserViewController', 'integrations')): ?> +
  • app->checkMenuSelection('UserViewController', 'integrations') ?>> + url->link(t('Integrations'), 'UserViewController', 'integrations', array('user_id' => $user['id'])) ?> +
  • + user->hasAccess('UserCredentialController', 'changeAuthentication')): ?> -- cgit v1.2.3 From 6cfce7720a88f383f85f9f7ea88a5dcdae87daa7 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 15 Jul 2016 21:05:15 -0400 Subject: Fixed Markdown editor auto-grow on the task form --- ChangeLog | 1 + app/Helper/TaskHelper.php | 2 ++ assets/css/app.min.css | 2 +- assets/css/print.min.css | 2 +- assets/css/src/markdown.css | 4 ++++ assets/css/vendor.min.css | 2 +- assets/js/vendor.min.js | 5 +++-- 7 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ChangeLog b/ChangeLog index fc0cdac6..6c5c5107 100644 --- a/ChangeLog +++ b/ChangeLog @@ -16,6 +16,7 @@ Improvements: Bug fixes: +* Fixed Markdown editor auto-grow on the task form (Safari) * Fixed compatibility issue with PHP 5.3 for OAuthUserProvider class Version 1.0.31 diff --git a/app/Helper/TaskHelper.php b/app/Helper/TaskHelper.php index e1d65cca..481a5efb 100644 --- a/app/Helper/TaskHelper.php +++ b/app/Helper/TaskHelper.php @@ -50,6 +50,7 @@ class TaskHelper extends Base public function selectDescription(array $values, array $errors) { $html = $this->helper->form->label(t('Description'), 'description'); + $html .= '
    '; $html .= $this->helper->form->textarea( 'description', $values, @@ -62,6 +63,7 @@ class TaskHelper extends Base 'markdown-editor' ); + $html .= '
    '; return $html; } diff --git a/assets/css/app.min.css b/assets/css/app.min.css index 88acd0a0..2ddd4a8b 100644 --- a/assets/css/app.min.css +++ b/assets/css/app.min.css @@ -1 +1 @@ -a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file +a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file diff --git a/assets/css/print.min.css b/assets/css/print.min.css index 9bc2d615..080626b3 100644 --- a/assets/css/print.min.css +++ b/assets/css/print.min.css @@ -1 +1 @@ -a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file +a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file diff --git a/assets/css/src/markdown.css b/assets/css/src/markdown.css index 90fd48ea..520961a1 100644 --- a/assets/css/src/markdown.css +++ b/assets/css/src/markdown.css @@ -1,4 +1,8 @@ /* markdown editor */ +.markdown-editor-container { + max-width: 400px; +} + div.CodeMirror, div.CodeMirror-scroll { max-height: 250px; diff --git a/assets/css/vendor.min.css b/assets/css/vendor.min.css index 9d81051d..edd97352 100644 --- a/assets/css/vendor.min.css +++ b/assets/css/vendor.min.css @@ -459,7 +459,7 @@ This file is generated by `grunt build`, do not edit it by hand. } /* @end */ -.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;height:1px !important;margin:-1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__placeholder{color:#999;margin-top:5px;float:left}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;height:1px !important;margin:-1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__placeholder{color:#999;margin-top:5px;float:left}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} /*! * FullCalendar v2.7.1 Stylesheet diff --git a/assets/js/vendor.min.js b/assets/js/vendor.min.js index 4b53fb27..08f82895 100644 --- a/assets/js/vendor.min.js +++ b/assets/js/vendor.min.js @@ -1638,8 +1638,9 @@ if(c&&c._defaults.timeOnly&&b.input.val()!==b.lastVal)try{$.datepicker._updateDa }).call(this); -/*! Select2 4.0.2 | https://github.com/select2/select2/blob/master/LICENSE.md */!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports?require("jquery"):jQuery)}(function(a){var b=function(){if(a&&a.fn&&a.fn.select2&&a.fn.select2.amd)var b=a.fn.select2.amd;var b;return function(){if(!b||!b.requirejs){b?c=b:b={};var a,c,d;!function(b){function e(a,b){return u.call(a,b)}function f(a,b){var c,d,e,f,g,h,i,j,k,l,m,n=b&&b.split("/"),o=s.map,p=o&&o["*"]||{};if(a&&"."===a.charAt(0))if(b){for(a=a.split("/"),g=a.length-1,s.nodeIdCompat&&w.test(a[g])&&(a[g]=a[g].replace(w,"")),a=n.slice(0,n.length-1).concat(a),k=0;k0&&(a.splice(k-1,2),k-=2)}a=a.join("/")}else 0===a.indexOf("./")&&(a=a.substring(2));if((n||p)&&o){for(c=a.split("/"),k=c.length;k>0;k-=1){if(d=c.slice(0,k).join("/"),n)for(l=n.length;l>0;l-=1)if(e=o[n.slice(0,l).join("/")],e&&(e=e[d])){f=e,h=k;break}if(f)break;!i&&p&&p[d]&&(i=p[d],j=k)}!f&&i&&(f=i,h=j),f&&(c.splice(0,h,f),a=c.join("/"))}return a}function g(a,c){return function(){var d=v.call(arguments,0);return"string"!=typeof d[0]&&1===d.length&&d.push(null),n.apply(b,d.concat([a,c]))}}function h(a){return function(b){return f(b,a)}}function i(a){return function(b){q[a]=b}}function j(a){if(e(r,a)){var c=r[a];delete r[a],t[a]=!0,m.apply(b,c)}if(!e(q,a)&&!e(t,a))throw new Error("No "+a);return q[a]}function k(a){var b,c=a?a.indexOf("!"):-1;return c>-1&&(b=a.substring(0,c),a=a.substring(c+1,a.length)),[b,a]}function l(a){return function(){return s&&s.config&&s.config[a]||{}}}var m,n,o,p,q={},r={},s={},t={},u=Object.prototype.hasOwnProperty,v=[].slice,w=/\.js$/;o=function(a,b){var c,d=k(a),e=d[0];return a=d[1],e&&(e=f(e,b),c=j(e)),e?a=c&&c.normalize?c.normalize(a,h(b)):f(a,b):(a=f(a,b),d=k(a),e=d[0],a=d[1],e&&(c=j(e))),{f:e?e+"!"+a:a,n:a,pr:e,p:c}},p={require:function(a){return g(a)},exports:function(a){var b=q[a];return"undefined"!=typeof b?b:q[a]={}},module:function(a){return{id:a,uri:"",exports:q[a],config:l(a)}}},m=function(a,c,d,f){var h,k,l,m,n,s,u=[],v=typeof d;if(f=f||a,"undefined"===v||"function"===v){for(c=!c.length&&d.length?["require","exports","module"]:c,n=0;n0&&(b.call(arguments,a.prototype.constructor),e=c.prototype.constructor),e.apply(this,arguments)}function e(){this.constructor=d}var f=b(c),g=b(a);c.displayName=a.displayName,d.prototype=new e;for(var h=0;hc;c++)a[c].apply(this,b)},c.Observable=d,c.generateChars=function(a){for(var b="",c=0;a>c;c++){var d=Math.floor(36*Math.random());b+=d.toString(36)}return b},c.bind=function(a,b){return function(){a.apply(b,arguments)}},c._convertData=function(a){for(var b in a){var c=b.split("-"),d=a;if(1!==c.length){for(var e=0;e":">",'"':""","'":"'","/":"/"};return"string"!=typeof a?a:String(a).replace(/[&<>"'\/\\]/g,function(a){return b[a]})},c.appendMany=function(b,c){if("1.7"===a.fn.jquery.substr(0,3)){var d=a();a.map(c,function(a){d=d.add(a)}),c=d}b.append(c)},c}),b.define("select2/results",["jquery","./utils"],function(a,b){function c(a,b,d){this.$element=a,this.data=d,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('
      ');return this.options.get("multiple")&&b.attr("aria-multiselectable","true"),this.$results=b,b},c.prototype.clear=function(){this.$results.empty()},c.prototype.displayMessage=function(b){var c=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var d=a('
    • '),e=this.options.get("translations").get(b.message);d.append(c(e(b.args))),d[0].className+=" select2-results__message",this.$results.append(d)},c.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},c.prototype.append=function(a){this.hideLoading();var b=[];if(null==a.results||0===a.results.length)return void(0===this.$results.children().length&&this.trigger("results:message",{message:"noResults"}));a.results=this.sort(a.results);for(var c=0;c-1?b.attr("aria-selected","true"):b.attr("aria-selected","false")});var f=e.filter("[aria-selected=true]");f.length>0?f.first().trigger("mouseenter"):e.first().trigger("mouseenter")})},c.prototype.showLoading=function(a){this.hideLoading();var b=this.options.get("translations").get("searching"),c={disabled:!0,loading:!0,text:b(a)},d=this.option(c);d.className+=" loading-results",this.$results.prepend(d)},c.prototype.hideLoading=function(){this.$results.find(".loading-results").remove()},c.prototype.option=function(b){var c=document.createElement("li");c.className="select2-results__option";var d={role:"treeitem","aria-selected":"false"};b.disabled&&(delete d["aria-selected"],d["aria-disabled"]="true"),null==b.id&&delete d["aria-selected"],null!=b._resultId&&(c.id=b._resultId),b.title&&(c.title=b.title),b.children&&(d.role="group",d["aria-label"]=b.text,delete d["aria-selected"]);for(var e in d){var f=d[e];c.setAttribute(e,f)}if(b.children){var g=a(c),h=document.createElement("strong");h.className="select2-results__group";a(h);this.template(b,h);for(var i=[],j=0;j",{"class":"select2-results__options select2-results__options--nested"});m.append(i),g.append(h),g.append(m)}else this.template(b,c);return a.data(c,"data",b),c},c.prototype.bind=function(b,c){var d=this,e=b.id+"-results";this.$results.attr("id",e),b.on("results:all",function(a){d.clear(),d.append(a.data),b.isOpen()&&d.setClasses()}),b.on("results:append",function(a){d.append(a.data),b.isOpen()&&d.setClasses()}),b.on("query",function(a){d.hideMessages(),d.showLoading(a)}),b.on("select",function(){b.isOpen()&&d.setClasses()}),b.on("unselect",function(){b.isOpen()&&d.setClasses()}),b.on("open",function(){d.$results.attr("aria-expanded","true"),d.$results.attr("aria-hidden","false"),d.setClasses(),d.ensureHighlightVisible()}),b.on("close",function(){d.$results.attr("aria-expanded","false"),d.$results.attr("aria-hidden","true"),d.$results.removeAttr("aria-activedescendant")}),b.on("results:toggle",function(){var a=d.getHighlightedResults();0!==a.length&&a.trigger("mouseup")}),b.on("results:select",function(){var a=d.getHighlightedResults();if(0!==a.length){var b=a.data("data");"true"==a.attr("aria-selected")?d.trigger("close",{}):d.trigger("select",{data:b})}}),b.on("results:previous",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a);if(0!==c){var e=c-1;0===a.length&&(e=0);var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top,h=f.offset().top,i=d.$results.scrollTop()+(h-g);0===e?d.$results.scrollTop(0):0>h-g&&d.$results.scrollTop(i)}}),b.on("results:next",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a),e=c+1;if(!(e>=b.length)){var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top+d.$results.outerHeight(!1),h=f.offset().top+f.outerHeight(!1),i=d.$results.scrollTop()+h-g;0===e?d.$results.scrollTop(0):h>g&&d.$results.scrollTop(i)}}),b.on("results:focus",function(a){a.element.addClass("select2-results__option--highlighted")}),b.on("results:message",function(a){d.displayMessage(a)}),a.fn.mousewheel&&this.$results.on("mousewheel",function(a){var b=d.$results.scrollTop(),c=d.$results.get(0).scrollHeight-b+a.deltaY,e=a.deltaY>0&&b-a.deltaY<=0,f=a.deltaY<0&&c<=d.$results.height();e?(d.$results.scrollTop(0),a.preventDefault(),a.stopPropagation()):f&&(d.$results.scrollTop(d.$results.get(0).scrollHeight-d.$results.height()),a.preventDefault(),a.stopPropagation())}),this.$results.on("mouseup",".select2-results__option[aria-selected]",function(b){var c=a(this),e=c.data("data");return"true"===c.attr("aria-selected")?void(d.options.get("multiple")?d.trigger("unselect",{originalEvent:b,data:e}):d.trigger("close",{})):void d.trigger("select",{originalEvent:b,data:e})}),this.$results.on("mouseenter",".select2-results__option[aria-selected]",function(b){var c=a(this).data("data");d.getHighlightedResults().removeClass("select2-results__option--highlighted"),d.trigger("results:focus",{data:c,element:a(this)})})},c.prototype.getHighlightedResults=function(){var a=this.$results.find(".select2-results__option--highlighted");return a},c.prototype.destroy=function(){this.$results.remove()},c.prototype.ensureHighlightVisible=function(){var a=this.getHighlightedResults();if(0!==a.length){var b=this.$results.find("[aria-selected]"),c=b.index(a),d=this.$results.offset().top,e=a.offset().top,f=this.$results.scrollTop()+(e-d),g=e-d;f-=2*a.outerHeight(!1),2>=c?this.$results.scrollTop(0):(g>this.$results.outerHeight()||0>g)&&this.$results.scrollTop(f)}},c.prototype.template=function(b,c){var d=this.options.get("templateResult"),e=this.options.get("escapeMarkup"),f=d(b,c);null==f?c.style.display="none":"string"==typeof f?c.innerHTML=e(f):a(c).append(f)},c}),b.define("select2/keys",[],function(){var a={BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46};return a}),b.define("select2/selection/base",["jquery","../utils","../keys"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,b.Observable),d.prototype.render=function(){var b=a('');return this._tabindex=0,null!=this.$element.data("old-tabindex")?this._tabindex=this.$element.data("old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),b.attr("title",this.$element.attr("title")),b.attr("tabindex",this._tabindex),this.$selection=b,b},d.prototype.bind=function(a,b){var d=this,e=(a.id+"-container",a.id+"-results");this.container=a,this.$selection.on("focus",function(a){d.trigger("focus",a)}),this.$selection.on("blur",function(a){d._handleBlur(a)}),this.$selection.on("keydown",function(a){d.trigger("keypress",a),a.which===c.SPACE&&a.preventDefault()}),a.on("results:focus",function(a){d.$selection.attr("aria-activedescendant",a.data._resultId)}),a.on("selection:update",function(a){d.update(a.data)}),a.on("open",function(){d.$selection.attr("aria-expanded","true"),d.$selection.attr("aria-owns",e),d._attachCloseHandler(a)}),a.on("close",function(){d.$selection.attr("aria-expanded","false"),d.$selection.removeAttr("aria-activedescendant"),d.$selection.removeAttr("aria-owns"),d.$selection.focus(),d._detachCloseHandler(a)}),a.on("enable",function(){d.$selection.attr("tabindex",d._tabindex)}),a.on("disable",function(){d.$selection.attr("tabindex","-1")})},d.prototype._handleBlur=function(b){var c=this;window.setTimeout(function(){document.activeElement==c.$selection[0]||a.contains(c.$selection[0],document.activeElement)||c.trigger("blur",b)},1)},d.prototype._attachCloseHandler=function(b){a(document.body).on("mousedown.select2."+b.id,function(b){var c=a(b.target),d=c.closest(".select2"),e=a(".select2.select2-container--open");e.each(function(){var b=a(this);if(this!=d[0]){var c=b.data("element");c.select2("close")}})})},d.prototype._detachCloseHandler=function(b){a(document.body).off("mousedown.select2."+b.id)},d.prototype.position=function(a,b){var c=b.find(".selection");c.append(a)},d.prototype.destroy=function(){this._detachCloseHandler(this.container)},d.prototype.update=function(a){throw new Error("The `update` method must be defined in child classes.")},d}),b.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(a,b,c,d){function e(){e.__super__.constructor.apply(this,arguments)}return c.Extend(e,b),e.prototype.render=function(){var a=e.__super__.render.call(this);return a.addClass("select2-selection--single"),a.html(''),a},e.prototype.bind=function(a,b){var c=this;e.__super__.bind.apply(this,arguments);var d=a.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",d),this.$selection.attr("aria-labelledby",d),this.$selection.on("mousedown",function(a){1===a.which&&c.trigger("toggle",{originalEvent:a})}),this.$selection.on("focus",function(a){}),this.$selection.on("blur",function(a){}),a.on("selection:update",function(a){c.update(a.data)})},e.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},e.prototype.display=function(a,b){var c=this.options.get("templateSelection"),d=this.options.get("escapeMarkup");return d(c(a,b))},e.prototype.selectionContainer=function(){return a("")},e.prototype.update=function(a){if(0===a.length)return void this.clear();var b=a[0],c=this.$selection.find(".select2-selection__rendered"),d=this.display(b,c);c.empty().append(d),c.prop("title",b.title||b.text)},e}),b.define("select2/selection/multiple",["jquery","./base","../utils"],function(a,b,c){function d(a,b){d.__super__.constructor.apply(this,arguments)}return c.Extend(d,b),d.prototype.render=function(){var a=d.__super__.render.call(this);return a.addClass("select2-selection--multiple"),a.html('
        '),a},d.prototype.bind=function(b,c){var e=this;d.__super__.bind.apply(this,arguments),this.$selection.on("click",function(a){e.trigger("toggle",{originalEvent:a})}),this.$selection.on("click",".select2-selection__choice__remove",function(b){if(!e.options.get("disabled")){var c=a(this),d=c.parent(),f=d.data("data");e.trigger("unselect",{originalEvent:b,data:f})}})},d.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},d.prototype.display=function(a,b){var c=this.options.get("templateSelection"),d=this.options.get("escapeMarkup");return d(c(a,b))},d.prototype.selectionContainer=function(){var b=a('
      • ×
      • ');return b},d.prototype.update=function(a){if(this.clear(),0!==a.length){for(var b=[],d=0;d1;if(d||c)return a.call(this,b);this.clear();var e=this.createPlaceholder(this.placeholder);this.$selection.find(".select2-selection__rendered").append(e)},b}),b.define("select2/selection/allowClear",["jquery","../keys"],function(a,b){function c(){}return c.prototype.bind=function(a,b,c){var d=this;a.call(this,b,c),null==this.placeholder&&this.options.get("debug")&&window.console&&console.error&&console.error("Select2: The `allowClear` option should be used in combination with the `placeholder` option."),this.$selection.on("mousedown",".select2-selection__clear",function(a){d._handleClear(a)}),b.on("keypress",function(a){d._handleKeyboardClear(a,b)})},c.prototype._handleClear=function(a,b){if(!this.options.get("disabled")){var c=this.$selection.find(".select2-selection__clear");if(0!==c.length){b.stopPropagation();for(var d=c.data("data"),e=0;e0||0===c.length)){var d=a('×');d.data("data",c),this.$selection.find(".select2-selection__rendered").prepend(d)}},c}),b.define("select2/selection/search",["jquery","../utils","../keys"],function(a,b,c){function d(a,b,c){a.call(this,b,c)}return d.prototype.render=function(b){var c=a('');this.$searchContainer=c,this.$search=c.find("input");var d=b.call(this);return this._transferTabIndex(),d},d.prototype.bind=function(a,b,d){var e=this;a.call(this,b,d),b.on("open",function(){e.$search.trigger("focus")}),b.on("close",function(){e.$search.val(""),e.$search.removeAttr("aria-activedescendant"),e.$search.trigger("focus")}),b.on("enable",function(){e.$search.prop("disabled",!1),e._transferTabIndex()}),b.on("disable",function(){e.$search.prop("disabled",!0)}),b.on("focus",function(a){e.$search.trigger("focus")}),b.on("results:focus",function(a){e.$search.attr("aria-activedescendant",a.id)}),this.$selection.on("focusin",".select2-search--inline",function(a){e.trigger("focus",a)}),this.$selection.on("focusout",".select2-search--inline",function(a){e._handleBlur(a)}),this.$selection.on("keydown",".select2-search--inline",function(a){a.stopPropagation(),e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented();var b=a.which;if(b===c.BACKSPACE&&""===e.$search.val()){var d=e.$searchContainer.prev(".select2-selection__choice");if(d.length>0){var f=d.data("data");e.searchRemoveChoice(f),a.preventDefault()}}});var f=document.documentMode,g=f&&11>=f;this.$selection.on("input.searchcheck",".select2-search--inline",function(a){return g?void e.$selection.off("input.search input.searchcheck"):void e.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(a){if(g&&"input"===a.type)return void e.$selection.off("input.search input.searchcheck");var b=a.which;b!=c.SHIFT&&b!=c.CTRL&&b!=c.ALT&&b!=c.TAB&&e.handleSearch(a)})},d.prototype._transferTabIndex=function(a){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},d.prototype.createPlaceholder=function(a,b){this.$search.attr("placeholder",b.text)},d.prototype.update=function(a,b){var c=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),a.call(this,b),this.$selection.find(".select2-selection__rendered").append(this.$searchContainer),this.resizeSearch(),c&&this.$search.focus()},d.prototype.handleSearch=function(){if(this.resizeSearch(),!this._keyUpPrevented){var a=this.$search.val();this.trigger("query",{term:a})}this._keyUpPrevented=!1},d.prototype.searchRemoveChoice=function(a,b){this.trigger("unselect",{data:b}),this.$search.val(b.text),this.handleSearch()},d.prototype.resizeSearch=function(){this.$search.css("width","25px");var a="";if(""!==this.$search.attr("placeholder"))a=this.$selection.find(".select2-selection__rendered").innerWidth();else{var b=this.$search.val().length+1;a=.75*b+"em"}this.$search.css("width",a)},d}),b.define("select2/selection/eventRelay",["jquery"],function(a){function b(){}return b.prototype.bind=function(b,c,d){var e=this,f=["open","opening","close","closing","select","selecting","unselect","unselecting"],g=["opening","closing","selecting","unselecting"];b.call(this,c,d),c.on("*",function(b,c){if(-1!==a.inArray(b,f)){c=c||{};var d=a.Event("select2:"+b,{params:c});e.$element.trigger(d),-1!==a.inArray(b,g)&&(c.prevented=d.isDefaultPrevented())}})},b}),b.define("select2/translation",["jquery","require"],function(a,b){function c(a){this.dict=a||{}}return c.prototype.all=function(){return this.dict},c.prototype.get=function(a){return this.dict[a]},c.prototype.extend=function(b){this.dict=a.extend({},b.all(),this.dict)},c._cache={},c.loadPath=function(a){if(!(a in c._cache)){var d=b(a);c._cache[a]=d}return new c(c._cache[a])},c}),b.define("select2/diacritics",[],function(){var a={"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ω":"ω","ς":"σ"};return a}),b.define("select2/data/base",["../utils"],function(a){function b(a,c){b.__super__.constructor.call(this)}return a.Extend(b,a.Observable),b.prototype.current=function(a){throw new Error("The `current` method must be defined in child classes.")},b.prototype.query=function(a,b){throw new Error("The `query` method must be defined in child classes.")},b.prototype.bind=function(a,b){},b.prototype.destroy=function(){},b.prototype.generateResultId=function(b,c){var d=b.id+"-result-";return d+=a.generateChars(4),d+=null!=c.id?"-"+c.id.toString():"-"+a.generateChars(4)},b}),b.define("select2/data/select",["./base","../utils","jquery"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,a),d.prototype.current=function(a){var b=[],d=this;this.$element.find(":selected").each(function(){var a=c(this),e=d.item(a);b.push(e)}),a(b)},d.prototype.select=function(a){var b=this;if(a.selected=!0,c(a.element).is("option"))return a.element.selected=!0,void this.$element.trigger("change");if(this.$element.prop("multiple"))this.current(function(d){var e=[];a=[a],a.push.apply(a,d);for(var f=0;f=0){var k=f.filter(d(j)),l=this.item(k),m=c.extend(!0,{},j,l),n=this.option(m);k.replaceWith(n)}else{var o=this.option(j);if(j.children){var p=this.convertToOptions(j.children);b.appendMany(o,p)}h.push(o)}}return h},d}),b.define("select2/data/ajax",["./array","../utils","jquery"],function(a,b,c){function d(a,b){this.ajaxOptions=this._applyDefaults(b.get("ajax")),null!=this.ajaxOptions.processResults&&(this.processResults=this.ajaxOptions.processResults),d.__super__.constructor.call(this,a,b)}return b.Extend(d,a),d.prototype._applyDefaults=function(a){var b={data:function(a){return c.extend({},a,{q:a.term})},transport:function(a,b,d){var e=c.ajax(a);return e.then(b),e.fail(d),e}};return c.extend({},b,a,!0)},d.prototype.processResults=function(a){return a},d.prototype.query=function(a,b){function d(){var d=f.transport(f,function(d){var f=e.processResults(d,a);e.options.get("debug")&&window.console&&console.error&&(f&&f.results&&c.isArray(f.results)||console.error("Select2: The AJAX results did not return an array in the `results` key of the response.")),b(f)},function(){e.trigger("results:message",{message:"errorLoading"})});e._request=d}var e=this;null!=this._request&&(c.isFunction(this._request.abort)&&this._request.abort(),this._request=null);var f=c.extend({type:"GET"},this.ajaxOptions);"function"==typeof f.url&&(f.url=f.url.call(this.$element,a)),"function"==typeof f.data&&(f.data=f.data.call(this.$element,a)),this.ajaxOptions.delay&&""!==a.term?(this._queryTimeout&&window.clearTimeout(this._queryTimeout),this._queryTimeout=window.setTimeout(d,this.ajaxOptions.delay)):d()},d}),b.define("select2/data/tags",["jquery"],function(a){function b(b,c,d){var e=d.get("tags"),f=d.get("createTag");void 0!==f&&(this.createTag=f);var g=d.get("insertTag");if(void 0!==g&&(this.insertTag=g),b.call(this,c,d),a.isArray(e))for(var h=0;h0&&b.term.length>this.maximumInputLength?void this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:b.term,params:b}}):void a.call(this,b,c)},a}),b.define("select2/data/maximumSelectionLength",[],function(){function a(a,b,c){this.maximumSelectionLength=c.get("maximumSelectionLength"),a.call(this,b,c)}return a.prototype.query=function(a,b,c){var d=this;this.current(function(e){var f=null!=e?e.length:0;return d.maximumSelectionLength>0&&f>=d.maximumSelectionLength?void d.trigger("results:message",{message:"maximumSelected",args:{maximum:d.maximumSelectionLength}}):void a.call(d,b,c)})},a}),b.define("select2/dropdown",["jquery","./utils"],function(a,b){function c(a,b){this.$element=a,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$dropdown=b,b},c.prototype.bind=function(){},c.prototype.position=function(a,b){},c.prototype.destroy=function(){this.$dropdown.remove()},c}),b.define("select2/dropdown/search",["jquery","../utils"],function(a,b){function c(){}return c.prototype.render=function(b){var c=b.call(this),d=a('');return this.$searchContainer=d,this.$search=d.find("input"),c.prepend(d),c},c.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),this.$search.on("keydown",function(a){e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented()}),this.$search.on("input",function(b){a(this).off("keyup")}),this.$search.on("keyup input",function(a){e.handleSearch(a)}),c.on("open",function(){e.$search.attr("tabindex",0),e.$search.focus(),window.setTimeout(function(){e.$search.focus()},0)}),c.on("close",function(){e.$search.attr("tabindex",-1),e.$search.val("")}),c.on("results:all",function(a){if(null==a.query.term||""===a.query.term){var b=e.showSearch(a);b?e.$searchContainer.removeClass("select2-search--hide"):e.$searchContainer.addClass("select2-search--hide")}})},c.prototype.handleSearch=function(a){if(!this._keyUpPrevented){var b=this.$search.val();this.trigger("query",{term:b})}this._keyUpPrevented=!1},c.prototype.showSearch=function(a,b){return!0},c}),b.define("select2/dropdown/hidePlaceholder",[],function(){function a(a,b,c,d){this.placeholder=this.normalizePlaceholder(c.get("placeholder")),a.call(this,b,c,d)}return a.prototype.append=function(a,b){b.results=this.removePlaceholder(b.results),a.call(this,b)},a.prototype.normalizePlaceholder=function(a,b){return"string"==typeof b&&(b={id:"",text:b}),b},a.prototype.removePlaceholder=function(a,b){for(var c=b.slice(0),d=b.length-1;d>=0;d--){var e=b[d];this.placeholder.id===e.id&&c.splice(d,1)}return c},a}),b.define("select2/dropdown/infiniteScroll",["jquery"],function(a){function b(a,b,c,d){this.lastParams={},a.call(this,b,c,d),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return b.prototype.append=function(a,b){this.$loadingMore.remove(),this.loading=!1,a.call(this,b),this.showLoadingMore(b)&&this.$results.append(this.$loadingMore)},b.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),c.on("query",function(a){e.lastParams=a,e.loading=!0}),c.on("query:append",function(a){e.lastParams=a,e.loading=!0}),this.$results.on("scroll",function(){var b=a.contains(document.documentElement,e.$loadingMore[0]);if(!e.loading&&b){var c=e.$results.offset().top+e.$results.outerHeight(!1),d=e.$loadingMore.offset().top+e.$loadingMore.outerHeight(!1);c+50>=d&&e.loadMore()}})},b.prototype.loadMore=function(){this.loading=!0;var b=a.extend({},{page:1},this.lastParams);b.page++,this.trigger("query:append",b)},b.prototype.showLoadingMore=function(a,b){return b.pagination&&b.pagination.more},b.prototype.createLoadingMore=function(){var b=a('
      • '),c=this.options.get("translations").get("loadingMore");return b.html(c(this.lastParams)),b},b}),b.define("select2/dropdown/attachBody",["jquery","../utils"],function(a,b){function c(b,c,d){this.$dropdownParent=d.get("dropdownParent")||a(document.body),b.call(this,c,d)}return c.prototype.bind=function(a,b,c){var d=this,e=!1;a.call(this,b,c),b.on("open",function(){d._showDropdown(),d._attachPositioningHandler(b),e||(e=!0,b.on("results:all",function(){d._positionDropdown(),d._resizeDropdown()}),b.on("results:append",function(){d._positionDropdown(),d._resizeDropdown()}))}),b.on("close",function(){d._hideDropdown(),d._detachPositioningHandler(b)}),this.$dropdownContainer.on("mousedown",function(a){a.stopPropagation()})},c.prototype.destroy=function(a){a.call(this),this.$dropdownContainer.remove()},c.prototype.position=function(a,b,c){b.attr("class",c.attr("class")),b.removeClass("select2"),b.addClass("select2-container--open"),b.css({position:"absolute",top:-999999}),this.$container=c},c.prototype.render=function(b){var c=a(""),d=b.call(this);return c.append(d),this.$dropdownContainer=c,c},c.prototype._hideDropdown=function(a){this.$dropdownContainer.detach()},c.prototype._attachPositioningHandler=function(c,d){var e=this,f="scroll.select2."+d.id,g="resize.select2."+d.id,h="orientationchange.select2."+d.id,i=this.$container.parents().filter(b.hasScroll);i.each(function(){a(this).data("select2-scroll-position",{x:a(this).scrollLeft(),y:a(this).scrollTop()})}),i.on(f,function(b){var c=a(this).data("select2-scroll-position");a(this).scrollTop(c.y)}),a(window).on(f+" "+g+" "+h,function(a){e._positionDropdown(),e._resizeDropdown()})},c.prototype._detachPositioningHandler=function(c,d){var e="scroll.select2."+d.id,f="resize.select2."+d.id,g="orientationchange.select2."+d.id,h=this.$container.parents().filter(b.hasScroll);h.off(e),a(window).off(e+" "+f+" "+g)},c.prototype._positionDropdown=function(){var b=a(window),c=this.$dropdown.hasClass("select2-dropdown--above"),d=this.$dropdown.hasClass("select2-dropdown--below"),e=null,f=this.$container.offset();f.bottom=f.top+this.$container.outerHeight(!1);var g={height:this.$container.outerHeight(!1)};g.top=f.top,g.bottom=f.top+g.height;var h={height:this.$dropdown.outerHeight(!1)},i={top:b.scrollTop(),bottom:b.scrollTop()+b.height()},j=i.topf.bottom+h.height,l={left:f.left,top:g.bottom},m=this.$dropdownParent;"static"===m.css("position")&&(m=m.offsetParent());var n=m.offset();l.top-=n.top,l.left-=n.left,c||d||(e="below"),k||!j||c?!j&&k&&c&&(e="below"):e="above",("above"==e||c&&"below"!==e)&&(l.top=g.top-h.height),null!=e&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+e),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+e)),this.$dropdownContainer.css(l)},c.prototype._resizeDropdown=function(){var a={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(a.minWidth=a.width,a.width="auto"),this.$dropdown.css(a)},c.prototype._showDropdown=function(a){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},c}),b.define("select2/dropdown/minimumResultsForSearch",[],function(){function a(b){for(var c=0,d=0;d0&&(l.dataAdapter=j.Decorate(l.dataAdapter,r)),l.maximumInputLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,s)),l.maximumSelectionLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,t)),l.tags&&(l.dataAdapter=j.Decorate(l.dataAdapter,p)),(null!=l.tokenSeparators||null!=l.tokenizer)&&(l.dataAdapter=j.Decorate(l.dataAdapter,q)),null!=l.query){var C=b(l.amdBase+"compat/query");l.dataAdapter=j.Decorate(l.dataAdapter,C)}if(null!=l.initSelection){var D=b(l.amdBase+"compat/initSelection");l.dataAdapter=j.Decorate(l.dataAdapter,D)}}if(null==l.resultsAdapter&&(l.resultsAdapter=c,null!=l.ajax&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,x)),null!=l.placeholder&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,w)),l.selectOnClose&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,A))),null==l.dropdownAdapter){if(l.multiple)l.dropdownAdapter=u;else{var E=j.Decorate(u,v);l.dropdownAdapter=E}if(0!==l.minimumResultsForSearch&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,z)),l.closeOnSelect&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,B)),null!=l.dropdownCssClass||null!=l.dropdownCss||null!=l.adaptDropdownCssClass){var F=b(l.amdBase+"compat/dropdownCss");l.dropdownAdapter=j.Decorate(l.dropdownAdapter,F)}l.dropdownAdapter=j.Decorate(l.dropdownAdapter,y)}if(null==l.selectionAdapter){if(l.multiple?l.selectionAdapter=e:l.selectionAdapter=d,null!=l.placeholder&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,f)),l.allowClear&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,g)),l.multiple&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,h)),null!=l.containerCssClass||null!=l.containerCss||null!=l.adaptContainerCssClass){var G=b(l.amdBase+"compat/containerCss");l.selectionAdapter=j.Decorate(l.selectionAdapter,G)}l.selectionAdapter=j.Decorate(l.selectionAdapter,i)}if("string"==typeof l.language)if(l.language.indexOf("-")>0){var H=l.language.split("-"),I=H[0];l.language=[l.language,I]}else l.language=[l.language];if(a.isArray(l.language)){var J=new k;l.language.push("en");for(var K=l.language,L=0;L0){for(var f=a.extend(!0,{},e),g=e.children.length-1;g>=0;g--){var h=e.children[g],i=c(d,h);null==i&&f.children.splice(g,1)}return f.children.length>0?f:c(d,f)}var j=b(e.text).toUpperCase(),k=b(d.term).toUpperCase();return j.indexOf(k)>-1?e:null}this.defaults={amdBase:"./",amdLanguageBase:"./i18n/",closeOnSelect:!0,debug:!1,dropdownAutoWidth:!1,escapeMarkup:j.escapeMarkup,language:C,matcher:c,minimumInputLength:0,maximumInputLength:0,maximumSelectionLength:0,minimumResultsForSearch:0,selectOnClose:!1,sorter:function(a){return a},templateResult:function(a){return a.text},templateSelection:function(a){return a.text},theme:"default",width:"resolve"}},D.prototype.set=function(b,c){var d=a.camelCase(b),e={};e[d]=c;var f=j._convertData(e);a.extend(this.defaults,f)};var E=new D;return E}),b.define("select2/options",["require","jquery","./defaults","./utils"],function(a,b,c,d){function e(b,e){if(this.options=b,null!=e&&this.fromElement(e),this.options=c.apply(this.options),e&&e.is("input")){var f=a(this.get("amdBase")+"compat/inputData");this.options.dataAdapter=d.Decorate(this.options.dataAdapter,f)}}return e.prototype.fromElement=function(a){var c=["select2"];null==this.options.multiple&&(this.options.multiple=a.prop("multiple")),null==this.options.disabled&&(this.options.disabled=a.prop("disabled")),null==this.options.language&&(a.prop("lang")?this.options.language=a.prop("lang").toLowerCase():a.closest("[lang]").prop("lang")&&(this.options.language=a.closest("[lang]").prop("lang"))),null==this.options.dir&&(a.prop("dir")?this.options.dir=a.prop("dir"):a.closest("[dir]").prop("dir")?this.options.dir=a.closest("[dir]").prop("dir"):this.options.dir="ltr"),a.prop("disabled",this.options.disabled),a.prop("multiple",this.options.multiple),a.data("select2Tags")&&(this.options.debug&&window.console&&console.warn&&console.warn('Select2: The `data-select2-tags` attribute has been changed to use the `data-data` and `data-tags="true"` attributes and will be removed in future versions of Select2.'),a.data("data",a.data("select2Tags")),a.data("tags",!0)),a.data("ajaxUrl")&&(this.options.debug&&window.console&&console.warn&&console.warn("Select2: The `data-ajax-url` attribute has been changed to `data-ajax--url` and support for the old attribute will be removed in future versions of Select2."),a.attr("ajax--url",a.data("ajaxUrl")),a.data("ajax--url",a.data("ajaxUrl")));var e={};e=b.fn.jquery&&"1."==b.fn.jquery.substr(0,2)&&a[0].dataset?b.extend(!0,{},a[0].dataset,a.data()):a.data();var f=b.extend(!0,{},e);f=d._convertData(f);for(var g in f)b.inArray(g,c)>-1||(b.isPlainObject(this.options[g])?b.extend(this.options[g],f[g]):this.options[g]=f[g]);return this},e.prototype.get=function(a){return this.options[a]},e.prototype.set=function(a,b){this.options[a]=b},e}),b.define("select2/core",["jquery","./options","./utils","./keys"],function(a,b,c,d){var e=function(a,c){null!=a.data("select2")&&a.data("select2").destroy(),this.$element=a,this.id=this._generateId(a),c=c||{},this.options=new b(c,a),e.__super__.constructor.call(this);var d=a.attr("tabindex")||0;a.data("old-tabindex",d),a.attr("tabindex","-1");var f=this.options.get("dataAdapter");this.dataAdapter=new f(a,this.options);var g=this.render();this._placeContainer(g);var h=this.options.get("selectionAdapter");this.selection=new h(a,this.options),this.$selection=this.selection.render(),this.selection.position(this.$selection,g);var i=this.options.get("dropdownAdapter");this.dropdown=new i(a,this.options),this.$dropdown=this.dropdown.render(),this.dropdown.position(this.$dropdown,g);var j=this.options.get("resultsAdapter");this.results=new j(a,this.options,this.dataAdapter),this.$results=this.results.render(),this.results.position(this.$results,this.$dropdown);var k=this;this._bindAdapters(),this._registerDomEvents(),this._registerDataEvents(),this._registerSelectionEvents(),this._registerDropdownEvents(),this._registerResultsEvents(),this._registerEvents(),this.dataAdapter.current(function(a){k.trigger("selection:update",{data:a})}),a.addClass("select2-hidden-accessible"),a.attr("aria-hidden","true"),this._syncAttributes(),a.data("select2",this)};return c.Extend(e,c.Observable),e.prototype._generateId=function(a){var b="";return b=null!=a.attr("id")?a.attr("id"):null!=a.attr("name")?a.attr("name")+"-"+c.generateChars(2):c.generateChars(4),b=b.replace(/(:|\.|\[|\]|,)/g,""),b="select2-"+b},e.prototype._placeContainer=function(a){a.insertAfter(this.$element);var b=this._resolveWidth(this.$element,this.options.get("width"));null!=b&&a.css("width",b)},e.prototype._resolveWidth=function(a,b){var c=/^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;if("resolve"==b){var d=this._resolveWidth(a,"style");return null!=d?d:this._resolveWidth(a,"element")}if("element"==b){var e=a.outerWidth(!1);return 0>=e?"auto":e+"px"}if("style"==b){var f=a.attr("style");if("string"!=typeof f)return null;for(var g=f.split(";"),h=0,i=g.length;i>h;h+=1){var j=g[h].replace(/\s/g,""),k=j.match(c);if(null!==k&&k.length>=1)return k[1]}return null}return b},e.prototype._bindAdapters=function(){this.dataAdapter.bind(this,this.$container),this.selection.bind(this,this.$container),this.dropdown.bind(this,this.$container),this.results.bind(this,this.$container)},e.prototype._registerDomEvents=function(){var b=this;this.$element.on("change.select2",function(){b.dataAdapter.current(function(a){b.trigger("selection:update",{data:a})})}),this._sync=c.bind(this._syncAttributes,this),this.$element[0].attachEvent&&this.$element[0].attachEvent("onpropertychange",this._sync);var d=window.MutationObserver||window.WebKitMutationObserver||window.MozMutationObserver;null!=d?(this._observer=new d(function(c){a.each(c,b._sync)}),this._observer.observe(this.$element[0],{attributes:!0,subtree:!1})):this.$element[0].addEventListener&&this.$element[0].addEventListener("DOMAttrModified",b._sync,!1)},e.prototype._registerDataEvents=function(){var a=this;this.dataAdapter.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerSelectionEvents=function(){var b=this,c=["toggle","focus"];this.selection.on("toggle",function(){b.toggleDropdown()}),this.selection.on("focus",function(a){b.focus(a)}),this.selection.on("*",function(d,e){-1===a.inArray(d,c)&&b.trigger(d,e)})},e.prototype._registerDropdownEvents=function(){var a=this;this.dropdown.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerResultsEvents=function(){var a=this;this.results.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerEvents=function(){var a=this;this.on("open",function(){a.$container.addClass("select2-container--open")}),this.on("close",function(){a.$container.removeClass("select2-container--open")}),this.on("enable",function(){a.$container.removeClass("select2-container--disabled")}),this.on("disable",function(){a.$container.addClass("select2-container--disabled")}),this.on("blur",function(){a.$container.removeClass("select2-container--focus")}),this.on("query",function(b){a.isOpen()||a.trigger("open",{}),this.dataAdapter.query(b,function(c){a.trigger("results:all",{data:c,query:b})})}),this.on("query:append",function(b){this.dataAdapter.query(b,function(c){a.trigger("results:append",{data:c,query:b})})}),this.on("keypress",function(b){var c=b.which;a.isOpen()?c===d.ESC||c===d.TAB||c===d.UP&&b.altKey?(a.close(),b.preventDefault()):c===d.ENTER?(a.trigger("results:select",{}),b.preventDefault()):c===d.SPACE&&b.ctrlKey?(a.trigger("results:toggle",{}),b.preventDefault()):c===d.UP?(a.trigger("results:previous",{}),b.preventDefault()):c===d.DOWN&&(a.trigger("results:next",{}),b.preventDefault()):(c===d.ENTER||c===d.SPACE||c===d.DOWN&&b.altKey)&&(a.open(),b.preventDefault())})},e.prototype._syncAttributes=function(){this.options.set("disabled",this.$element.prop("disabled")),this.options.get("disabled")?(this.isOpen()&&this.close(),this.trigger("disable",{})):this.trigger("enable",{})},e.prototype.trigger=function(a,b){var c=e.__super__.trigger,d={open:"opening",close:"closing",select:"selecting",unselect:"unselecting"};if(void 0===b&&(b={}),a in d){var f=d[a],g={prevented:!1,name:a,args:b};if(c.call(this,f,g),g.prevented)return void(b.prevented=!0)}c.call(this,a,b)},e.prototype.toggleDropdown=function(){this.options.get("disabled")||(this.isOpen()?this.close():this.open())},e.prototype.open=function(){this.isOpen()||this.trigger("query",{})},e.prototype.close=function(){this.isOpen()&&this.trigger("close",{})},e.prototype.isOpen=function(){return this.$container.hasClass("select2-container--open")},e.prototype.hasFocus=function(){return this.$container.hasClass("select2-container--focus")},e.prototype.focus=function(a){this.hasFocus()||(this.$container.addClass("select2-container--focus"),this.trigger("focus",{}))},e.prototype.enable=function(a){this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("enable")` method has been deprecated and will be removed in later Select2 versions. Use $element.prop("disabled") instead.'),(null==a||0===a.length)&&(a=[!0]);var b=!a[0];this.$element.prop("disabled",b)},e.prototype.data=function(){this.options.get("debug")&&arguments.length>0&&window.console&&console.warn&&console.warn('Select2: Data can no longer be set using `select2("data")`. You should consider setting the value instead using `$element.val()`.');var a=[];return this.dataAdapter.current(function(b){a=b}),a},e.prototype.val=function(b){if(this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("val")` method has been deprecated and will be removed in later Select2 versions. Use $element.val() instead.'),null==b||0===b.length)return this.$element.val();var c=b[0];a.isArray(c)&&(c=a.map(c,function(a){return a.toString()})),this.$element.val(c).trigger("change")},e.prototype.destroy=function(){this.$container.remove(),this.$element[0].detachEvent&&this.$element[0].detachEvent("onpropertychange",this._sync),null!=this._observer?(this._observer.disconnect(),this._observer=null):this.$element[0].removeEventListener&&this.$element[0].removeEventListener("DOMAttrModified",this._sync,!1),this._sync=null,this.$element.off(".select2"),this.$element.attr("tabindex",this.$element.data("old-tabindex")),this.$element.removeClass("select2-hidden-accessible"),this.$element.attr("aria-hidden","false"),this.$element.removeData("select2"),this.dataAdapter.destroy(),this.selection.destroy(),this.dropdown.destroy(),this.results.destroy(),this.dataAdapter=null,this.selection=null,this.dropdown=null,this.results=null},e.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$container=b,this.$container.addClass("select2-container--"+this.options.get("theme")),b.data("element",this.$element),b},e}),b.define("jquery-mousewheel",["jquery"],function(a){return a}),b.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults"],function(a,b,c,d){if(null==a.fn.select2){var e=["open","close","destroy"];a.fn.select2=function(b){if(b=b||{},"object"==typeof b)return this.each(function(){var d=a.extend(!0,{},b);new c(a(this),d)}),this;if("string"==typeof b){var d;return this.each(function(){var c=a(this).data("select2");null==c&&window.console&&console.error&&console.error("The select2('"+b+"') method was called on an element that is not using Select2.");var e=Array.prototype.slice.call(arguments,1);d=c[b].apply(c,e)}),a.inArray(b,e)>-1?this:d}throw new Error("Invalid arguments for Select2: "+b)}}return null==a.fn.select2.defaults&&(a.fn.select2.defaults=d),c}),{define:b.define,require:b.require}}(),c=b.require("jquery.select2");return a.fn.select2.amd=b,c}); +/*! Select2 4.0.3 | https://github.com/select2/select2/blob/master/LICENSE.md */!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):a("object"==typeof exports?require("jquery"):jQuery)}(function(a){var b=function(){if(a&&a.fn&&a.fn.select2&&a.fn.select2.amd)var b=a.fn.select2.amd;var b;return function(){if(!b||!b.requirejs){b?c=b:b={};var a,c,d;!function(b){function e(a,b){return u.call(a,b)}function f(a,b){var c,d,e,f,g,h,i,j,k,l,m,n=b&&b.split("/"),o=s.map,p=o&&o["*"]||{};if(a&&"."===a.charAt(0))if(b){for(a=a.split("/"),g=a.length-1,s.nodeIdCompat&&w.test(a[g])&&(a[g]=a[g].replace(w,"")),a=n.slice(0,n.length-1).concat(a),k=0;k0&&(a.splice(k-1,2),k-=2)}a=a.join("/")}else 0===a.indexOf("./")&&(a=a.substring(2));if((n||p)&&o){for(c=a.split("/"),k=c.length;k>0;k-=1){if(d=c.slice(0,k).join("/"),n)for(l=n.length;l>0;l-=1)if(e=o[n.slice(0,l).join("/")],e&&(e=e[d])){f=e,h=k;break}if(f)break;!i&&p&&p[d]&&(i=p[d],j=k)}!f&&i&&(f=i,h=j),f&&(c.splice(0,h,f),a=c.join("/"))}return a}function g(a,c){return function(){var d=v.call(arguments,0);return"string"!=typeof d[0]&&1===d.length&&d.push(null),n.apply(b,d.concat([a,c]))}}function h(a){return function(b){return f(b,a)}}function i(a){return function(b){q[a]=b}}function j(a){if(e(r,a)){var c=r[a];delete r[a],t[a]=!0,m.apply(b,c)}if(!e(q,a)&&!e(t,a))throw new Error("No "+a);return q[a]}function k(a){var b,c=a?a.indexOf("!"):-1;return c>-1&&(b=a.substring(0,c),a=a.substring(c+1,a.length)),[b,a]}function l(a){return function(){return s&&s.config&&s.config[a]||{}}}var m,n,o,p,q={},r={},s={},t={},u=Object.prototype.hasOwnProperty,v=[].slice,w=/\.js$/;o=function(a,b){var c,d=k(a),e=d[0];return a=d[1],e&&(e=f(e,b),c=j(e)),e?a=c&&c.normalize?c.normalize(a,h(b)):f(a,b):(a=f(a,b),d=k(a),e=d[0],a=d[1],e&&(c=j(e))),{f:e?e+"!"+a:a,n:a,pr:e,p:c}},p={require:function(a){return g(a)},exports:function(a){var b=q[a];return"undefined"!=typeof b?b:q[a]={}},module:function(a){return{id:a,uri:"",exports:q[a],config:l(a)}}},m=function(a,c,d,f){var h,k,l,m,n,s,u=[],v=typeof d;if(f=f||a,"undefined"===v||"function"===v){for(c=!c.length&&d.length?["require","exports","module"]:c,n=0;n0&&(b.call(arguments,a.prototype.constructor),e=c.prototype.constructor),e.apply(this,arguments)}function e(){this.constructor=d}var f=b(c),g=b(a);c.displayName=a.displayName,d.prototype=new e;for(var h=0;hc;c++)a[c].apply(this,b)},c.Observable=d,c.generateChars=function(a){for(var b="",c=0;a>c;c++){var d=Math.floor(36*Math.random());b+=d.toString(36)}return b},c.bind=function(a,b){return function(){a.apply(b,arguments)}},c._convertData=function(a){for(var b in a){var c=b.split("-"),d=a;if(1!==c.length){for(var e=0;e":">",'"':""","'":"'","/":"/"};return"string"!=typeof a?a:String(a).replace(/[&<>"'\/\\]/g,function(a){return b[a]})},c.appendMany=function(b,c){if("1.7"===a.fn.jquery.substr(0,3)){var d=a();a.map(c,function(a){d=d.add(a)}),c=d}b.append(c)},c}),b.define("select2/results",["jquery","./utils"],function(a,b){function c(a,b,d){this.$element=a,this.data=d,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('
          ');return this.options.get("multiple")&&b.attr("aria-multiselectable","true"),this.$results=b,b},c.prototype.clear=function(){this.$results.empty()},c.prototype.displayMessage=function(b){var c=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var d=a('
        • '),e=this.options.get("translations").get(b.message);d.append(c(e(b.args))),d[0].className+=" select2-results__message",this.$results.append(d)},c.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},c.prototype.append=function(a){this.hideLoading();var b=[];if(null==a.results||0===a.results.length)return void(0===this.$results.children().length&&this.trigger("results:message",{message:"noResults"}));a.results=this.sort(a.results);for(var c=0;c0?b.first().trigger("mouseenter"):a.first().trigger("mouseenter"),this.ensureHighlightVisible()},c.prototype.setClasses=function(){var b=this;this.data.current(function(c){var d=a.map(c,function(a){return a.id.toString()}),e=b.$results.find(".select2-results__option[aria-selected]");e.each(function(){var b=a(this),c=a.data(this,"data"),e=""+c.id;null!=c.element&&c.element.selected||null==c.element&&a.inArray(e,d)>-1?b.attr("aria-selected","true"):b.attr("aria-selected","false")})})},c.prototype.showLoading=function(a){this.hideLoading();var b=this.options.get("translations").get("searching"),c={disabled:!0,loading:!0,text:b(a)},d=this.option(c);d.className+=" loading-results",this.$results.prepend(d)},c.prototype.hideLoading=function(){this.$results.find(".loading-results").remove()},c.prototype.option=function(b){var c=document.createElement("li");c.className="select2-results__option";var d={role:"treeitem","aria-selected":"false"};b.disabled&&(delete d["aria-selected"],d["aria-disabled"]="true"),null==b.id&&delete d["aria-selected"],null!=b._resultId&&(c.id=b._resultId),b.title&&(c.title=b.title),b.children&&(d.role="group",d["aria-label"]=b.text,delete d["aria-selected"]);for(var e in d){var f=d[e];c.setAttribute(e,f)}if(b.children){var g=a(c),h=document.createElement("strong");h.className="select2-results__group";a(h);this.template(b,h);for(var i=[],j=0;j",{"class":"select2-results__options select2-results__options--nested"});m.append(i),g.append(h),g.append(m)}else this.template(b,c);return a.data(c,"data",b),c},c.prototype.bind=function(b,c){var d=this,e=b.id+"-results";this.$results.attr("id",e),b.on("results:all",function(a){d.clear(),d.append(a.data),b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("results:append",function(a){d.append(a.data),b.isOpen()&&d.setClasses()}),b.on("query",function(a){d.hideMessages(),d.showLoading(a)}),b.on("select",function(){b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("unselect",function(){b.isOpen()&&(d.setClasses(),d.highlightFirstItem())}),b.on("open",function(){d.$results.attr("aria-expanded","true"),d.$results.attr("aria-hidden","false"),d.setClasses(),d.ensureHighlightVisible()}),b.on("close",function(){d.$results.attr("aria-expanded","false"),d.$results.attr("aria-hidden","true"),d.$results.removeAttr("aria-activedescendant")}),b.on("results:toggle",function(){var a=d.getHighlightedResults();0!==a.length&&a.trigger("mouseup")}),b.on("results:select",function(){var a=d.getHighlightedResults();if(0!==a.length){var b=a.data("data");"true"==a.attr("aria-selected")?d.trigger("close",{}):d.trigger("select",{data:b})}}),b.on("results:previous",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a);if(0!==c){var e=c-1;0===a.length&&(e=0);var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top,h=f.offset().top,i=d.$results.scrollTop()+(h-g);0===e?d.$results.scrollTop(0):0>h-g&&d.$results.scrollTop(i)}}),b.on("results:next",function(){var a=d.getHighlightedResults(),b=d.$results.find("[aria-selected]"),c=b.index(a),e=c+1;if(!(e>=b.length)){var f=b.eq(e);f.trigger("mouseenter");var g=d.$results.offset().top+d.$results.outerHeight(!1),h=f.offset().top+f.outerHeight(!1),i=d.$results.scrollTop()+h-g;0===e?d.$results.scrollTop(0):h>g&&d.$results.scrollTop(i)}}),b.on("results:focus",function(a){a.element.addClass("select2-results__option--highlighted")}),b.on("results:message",function(a){d.displayMessage(a)}),a.fn.mousewheel&&this.$results.on("mousewheel",function(a){var b=d.$results.scrollTop(),c=d.$results.get(0).scrollHeight-b+a.deltaY,e=a.deltaY>0&&b-a.deltaY<=0,f=a.deltaY<0&&c<=d.$results.height();e?(d.$results.scrollTop(0),a.preventDefault(),a.stopPropagation()):f&&(d.$results.scrollTop(d.$results.get(0).scrollHeight-d.$results.height()),a.preventDefault(),a.stopPropagation())}),this.$results.on("mouseup",".select2-results__option[aria-selected]",function(b){var c=a(this),e=c.data("data");return"true"===c.attr("aria-selected")?void(d.options.get("multiple")?d.trigger("unselect",{originalEvent:b,data:e}):d.trigger("close",{})):void d.trigger("select",{originalEvent:b,data:e})}),this.$results.on("mouseenter",".select2-results__option[aria-selected]",function(b){var c=a(this).data("data");d.getHighlightedResults().removeClass("select2-results__option--highlighted"),d.trigger("results:focus",{data:c,element:a(this)})})},c.prototype.getHighlightedResults=function(){var a=this.$results.find(".select2-results__option--highlighted");return a},c.prototype.destroy=function(){this.$results.remove()},c.prototype.ensureHighlightVisible=function(){var a=this.getHighlightedResults();if(0!==a.length){var b=this.$results.find("[aria-selected]"),c=b.index(a),d=this.$results.offset().top,e=a.offset().top,f=this.$results.scrollTop()+(e-d),g=e-d;f-=2*a.outerHeight(!1),2>=c?this.$results.scrollTop(0):(g>this.$results.outerHeight()||0>g)&&this.$results.scrollTop(f)}},c.prototype.template=function(b,c){var d=this.options.get("templateResult"),e=this.options.get("escapeMarkup"),f=d(b,c);null==f?c.style.display="none":"string"==typeof f?c.innerHTML=e(f):a(c).append(f)},c}),b.define("select2/keys",[],function(){var a={BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46};return a}),b.define("select2/selection/base",["jquery","../utils","../keys"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,b.Observable),d.prototype.render=function(){var b=a('');return this._tabindex=0,null!=this.$element.data("old-tabindex")?this._tabindex=this.$element.data("old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),b.attr("title",this.$element.attr("title")),b.attr("tabindex",this._tabindex),this.$selection=b,b},d.prototype.bind=function(a,b){var d=this,e=(a.id+"-container",a.id+"-results");this.container=a,this.$selection.on("focus",function(a){d.trigger("focus",a)}),this.$selection.on("blur",function(a){d._handleBlur(a)}),this.$selection.on("keydown",function(a){d.trigger("keypress",a),a.which===c.SPACE&&a.preventDefault()}),a.on("results:focus",function(a){d.$selection.attr("aria-activedescendant",a.data._resultId)}),a.on("selection:update",function(a){d.update(a.data)}),a.on("open",function(){d.$selection.attr("aria-expanded","true"),d.$selection.attr("aria-owns",e),d._attachCloseHandler(a)}),a.on("close",function(){d.$selection.attr("aria-expanded","false"),d.$selection.removeAttr("aria-activedescendant"),d.$selection.removeAttr("aria-owns"),d.$selection.focus(),d._detachCloseHandler(a)}),a.on("enable",function(){d.$selection.attr("tabindex",d._tabindex)}),a.on("disable",function(){d.$selection.attr("tabindex","-1")})},d.prototype._handleBlur=function(b){var c=this;window.setTimeout(function(){document.activeElement==c.$selection[0]||a.contains(c.$selection[0],document.activeElement)||c.trigger("blur",b)},1)},d.prototype._attachCloseHandler=function(b){a(document.body).on("mousedown.select2."+b.id,function(b){var c=a(b.target),d=c.closest(".select2"),e=a(".select2.select2-container--open");e.each(function(){var b=a(this);if(this!=d[0]){var c=b.data("element");c.select2("close")}})})},d.prototype._detachCloseHandler=function(b){a(document.body).off("mousedown.select2."+b.id)},d.prototype.position=function(a,b){var c=b.find(".selection");c.append(a)},d.prototype.destroy=function(){this._detachCloseHandler(this.container)},d.prototype.update=function(a){throw new Error("The `update` method must be defined in child classes.")},d}),b.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(a,b,c,d){function e(){e.__super__.constructor.apply(this,arguments)}return c.Extend(e,b),e.prototype.render=function(){var a=e.__super__.render.call(this);return a.addClass("select2-selection--single"),a.html(''),a},e.prototype.bind=function(a,b){var c=this;e.__super__.bind.apply(this,arguments);var d=a.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",d),this.$selection.attr("aria-labelledby",d),this.$selection.on("mousedown",function(a){1===a.which&&c.trigger("toggle",{originalEvent:a})}),this.$selection.on("focus",function(a){}),this.$selection.on("blur",function(a){}),a.on("focus",function(b){a.isOpen()||c.$selection.focus()}),a.on("selection:update",function(a){c.update(a.data)})},e.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},e.prototype.display=function(a,b){var c=this.options.get("templateSelection"),d=this.options.get("escapeMarkup");return d(c(a,b))},e.prototype.selectionContainer=function(){return a("")},e.prototype.update=function(a){if(0===a.length)return void this.clear();var b=a[0],c=this.$selection.find(".select2-selection__rendered"),d=this.display(b,c);c.empty().append(d),c.prop("title",b.title||b.text)},e}),b.define("select2/selection/multiple",["jquery","./base","../utils"],function(a,b,c){function d(a,b){d.__super__.constructor.apply(this,arguments)}return c.Extend(d,b),d.prototype.render=function(){var a=d.__super__.render.call(this);return a.addClass("select2-selection--multiple"),a.html('
            '),a},d.prototype.bind=function(b,c){var e=this;d.__super__.bind.apply(this,arguments),this.$selection.on("click",function(a){e.trigger("toggle",{originalEvent:a})}),this.$selection.on("click",".select2-selection__choice__remove",function(b){if(!e.options.get("disabled")){var c=a(this),d=c.parent(),f=d.data("data");e.trigger("unselect",{originalEvent:b,data:f})}})},d.prototype.clear=function(){this.$selection.find(".select2-selection__rendered").empty()},d.prototype.display=function(a,b){var c=this.options.get("templateSelection"),d=this.options.get("escapeMarkup");return d(c(a,b))},d.prototype.selectionContainer=function(){var b=a('
          • ×
          • ');return b},d.prototype.update=function(a){if(this.clear(),0!==a.length){for(var b=[],d=0;d1;if(d||c)return a.call(this,b);this.clear();var e=this.createPlaceholder(this.placeholder);this.$selection.find(".select2-selection__rendered").append(e)},b}),b.define("select2/selection/allowClear",["jquery","../keys"],function(a,b){function c(){}return c.prototype.bind=function(a,b,c){var d=this;a.call(this,b,c),null==this.placeholder&&this.options.get("debug")&&window.console&&console.error&&console.error("Select2: The `allowClear` option should be used in combination with the `placeholder` option."),this.$selection.on("mousedown",".select2-selection__clear",function(a){d._handleClear(a)}),b.on("keypress",function(a){d._handleKeyboardClear(a,b)})},c.prototype._handleClear=function(a,b){if(!this.options.get("disabled")){var c=this.$selection.find(".select2-selection__clear");if(0!==c.length){b.stopPropagation();for(var d=c.data("data"),e=0;e0||0===c.length)){var d=a('×');d.data("data",c),this.$selection.find(".select2-selection__rendered").prepend(d)}},c}),b.define("select2/selection/search",["jquery","../utils","../keys"],function(a,b,c){function d(a,b,c){a.call(this,b,c)}return d.prototype.render=function(b){var c=a('');this.$searchContainer=c,this.$search=c.find("input");var d=b.call(this);return this._transferTabIndex(),d},d.prototype.bind=function(a,b,d){var e=this;a.call(this,b,d),b.on("open",function(){e.$search.trigger("focus")}),b.on("close",function(){e.$search.val(""),e.$search.removeAttr("aria-activedescendant"),e.$search.trigger("focus")}),b.on("enable",function(){e.$search.prop("disabled",!1),e._transferTabIndex()}),b.on("disable",function(){e.$search.prop("disabled",!0)}),b.on("focus",function(a){e.$search.trigger("focus")}),b.on("results:focus",function(a){e.$search.attr("aria-activedescendant",a.id)}),this.$selection.on("focusin",".select2-search--inline",function(a){e.trigger("focus",a)}),this.$selection.on("focusout",".select2-search--inline",function(a){e._handleBlur(a)}),this.$selection.on("keydown",".select2-search--inline",function(a){a.stopPropagation(),e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented();var b=a.which;if(b===c.BACKSPACE&&""===e.$search.val()){var d=e.$searchContainer.prev(".select2-selection__choice");if(d.length>0){var f=d.data("data");e.searchRemoveChoice(f),a.preventDefault()}}});var f=document.documentMode,g=f&&11>=f;this.$selection.on("input.searchcheck",".select2-search--inline",function(a){return g?void e.$selection.off("input.search input.searchcheck"):void e.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(a){if(g&&"input"===a.type)return void e.$selection.off("input.search input.searchcheck");var b=a.which;b!=c.SHIFT&&b!=c.CTRL&&b!=c.ALT&&b!=c.TAB&&e.handleSearch(a)})},d.prototype._transferTabIndex=function(a){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},d.prototype.createPlaceholder=function(a,b){this.$search.attr("placeholder",b.text)},d.prototype.update=function(a,b){var c=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),a.call(this,b),this.$selection.find(".select2-selection__rendered").append(this.$searchContainer),this.resizeSearch(),c&&this.$search.focus()},d.prototype.handleSearch=function(){if(this.resizeSearch(),!this._keyUpPrevented){var a=this.$search.val();this.trigger("query",{term:a})}this._keyUpPrevented=!1},d.prototype.searchRemoveChoice=function(a,b){this.trigger("unselect",{data:b}),this.$search.val(b.text),this.handleSearch()},d.prototype.resizeSearch=function(){this.$search.css("width","25px");var a="";if(""!==this.$search.attr("placeholder"))a=this.$selection.find(".select2-selection__rendered").innerWidth();else{var b=this.$search.val().length+1;a=.75*b+"em"}this.$search.css("width",a)},d}),b.define("select2/selection/eventRelay",["jquery"],function(a){function b(){}return b.prototype.bind=function(b,c,d){var e=this,f=["open","opening","close","closing","select","selecting","unselect","unselecting"],g=["opening","closing","selecting","unselecting"];b.call(this,c,d),c.on("*",function(b,c){if(-1!==a.inArray(b,f)){c=c||{};var d=a.Event("select2:"+b,{params:c});e.$element.trigger(d),-1!==a.inArray(b,g)&&(c.prevented=d.isDefaultPrevented())}})},b}),b.define("select2/translation",["jquery","require"],function(a,b){function c(a){this.dict=a||{}}return c.prototype.all=function(){return this.dict},c.prototype.get=function(a){return this.dict[a]},c.prototype.extend=function(b){this.dict=a.extend({},b.all(),this.dict)},c._cache={},c.loadPath=function(a){if(!(a in c._cache)){var d=b(a);c._cache[a]=d}return new c(c._cache[a])},c}),b.define("select2/diacritics",[],function(){var a={"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ω":"ω","ς":"σ"};return a}),b.define("select2/data/base",["../utils"],function(a){function b(a,c){b.__super__.constructor.call(this)}return a.Extend(b,a.Observable),b.prototype.current=function(a){throw new Error("The `current` method must be defined in child classes.")},b.prototype.query=function(a,b){throw new Error("The `query` method must be defined in child classes.")},b.prototype.bind=function(a,b){},b.prototype.destroy=function(){},b.prototype.generateResultId=function(b,c){var d=b.id+"-result-";return d+=a.generateChars(4),d+=null!=c.id?"-"+c.id.toString():"-"+a.generateChars(4)},b}),b.define("select2/data/select",["./base","../utils","jquery"],function(a,b,c){function d(a,b){this.$element=a,this.options=b,d.__super__.constructor.call(this)}return b.Extend(d,a),d.prototype.current=function(a){var b=[],d=this;this.$element.find(":selected").each(function(){var a=c(this),e=d.item(a);b.push(e)}),a(b)},d.prototype.select=function(a){var b=this;if(a.selected=!0,c(a.element).is("option"))return a.element.selected=!0,void this.$element.trigger("change"); +if(this.$element.prop("multiple"))this.current(function(d){var e=[];a=[a],a.push.apply(a,d);for(var f=0;f=0){var k=f.filter(d(j)),l=this.item(k),m=c.extend(!0,{},j,l),n=this.option(m);k.replaceWith(n)}else{var o=this.option(j);if(j.children){var p=this.convertToOptions(j.children);b.appendMany(o,p)}h.push(o)}}return h},d}),b.define("select2/data/ajax",["./array","../utils","jquery"],function(a,b,c){function d(a,b){this.ajaxOptions=this._applyDefaults(b.get("ajax")),null!=this.ajaxOptions.processResults&&(this.processResults=this.ajaxOptions.processResults),d.__super__.constructor.call(this,a,b)}return b.Extend(d,a),d.prototype._applyDefaults=function(a){var b={data:function(a){return c.extend({},a,{q:a.term})},transport:function(a,b,d){var e=c.ajax(a);return e.then(b),e.fail(d),e}};return c.extend({},b,a,!0)},d.prototype.processResults=function(a){return a},d.prototype.query=function(a,b){function d(){var d=f.transport(f,function(d){var f=e.processResults(d,a);e.options.get("debug")&&window.console&&console.error&&(f&&f.results&&c.isArray(f.results)||console.error("Select2: The AJAX results did not return an array in the `results` key of the response.")),b(f)},function(){d.status&&"0"===d.status||e.trigger("results:message",{message:"errorLoading"})});e._request=d}var e=this;null!=this._request&&(c.isFunction(this._request.abort)&&this._request.abort(),this._request=null);var f=c.extend({type:"GET"},this.ajaxOptions);"function"==typeof f.url&&(f.url=f.url.call(this.$element,a)),"function"==typeof f.data&&(f.data=f.data.call(this.$element,a)),this.ajaxOptions.delay&&null!=a.term?(this._queryTimeout&&window.clearTimeout(this._queryTimeout),this._queryTimeout=window.setTimeout(d,this.ajaxOptions.delay)):d()},d}),b.define("select2/data/tags",["jquery"],function(a){function b(b,c,d){var e=d.get("tags"),f=d.get("createTag");void 0!==f&&(this.createTag=f);var g=d.get("insertTag");if(void 0!==g&&(this.insertTag=g),b.call(this,c,d),a.isArray(e))for(var h=0;h0&&b.term.length>this.maximumInputLength?void this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:b.term,params:b}}):void a.call(this,b,c)},a}),b.define("select2/data/maximumSelectionLength",[],function(){function a(a,b,c){this.maximumSelectionLength=c.get("maximumSelectionLength"),a.call(this,b,c)}return a.prototype.query=function(a,b,c){var d=this;this.current(function(e){var f=null!=e?e.length:0;return d.maximumSelectionLength>0&&f>=d.maximumSelectionLength?void d.trigger("results:message",{message:"maximumSelected",args:{maximum:d.maximumSelectionLength}}):void a.call(d,b,c)})},a}),b.define("select2/dropdown",["jquery","./utils"],function(a,b){function c(a,b){this.$element=a,this.options=b,c.__super__.constructor.call(this)}return b.Extend(c,b.Observable),c.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$dropdown=b,b},c.prototype.bind=function(){},c.prototype.position=function(a,b){},c.prototype.destroy=function(){this.$dropdown.remove()},c}),b.define("select2/dropdown/search",["jquery","../utils"],function(a,b){function c(){}return c.prototype.render=function(b){var c=b.call(this),d=a('');return this.$searchContainer=d,this.$search=d.find("input"),c.prepend(d),c},c.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),this.$search.on("keydown",function(a){e.trigger("keypress",a),e._keyUpPrevented=a.isDefaultPrevented()}),this.$search.on("input",function(b){a(this).off("keyup")}),this.$search.on("keyup input",function(a){e.handleSearch(a)}),c.on("open",function(){e.$search.attr("tabindex",0),e.$search.focus(),window.setTimeout(function(){e.$search.focus()},0)}),c.on("close",function(){e.$search.attr("tabindex",-1),e.$search.val("")}),c.on("focus",function(){c.isOpen()&&e.$search.focus()}),c.on("results:all",function(a){if(null==a.query.term||""===a.query.term){var b=e.showSearch(a);b?e.$searchContainer.removeClass("select2-search--hide"):e.$searchContainer.addClass("select2-search--hide")}})},c.prototype.handleSearch=function(a){if(!this._keyUpPrevented){var b=this.$search.val();this.trigger("query",{term:b})}this._keyUpPrevented=!1},c.prototype.showSearch=function(a,b){return!0},c}),b.define("select2/dropdown/hidePlaceholder",[],function(){function a(a,b,c,d){this.placeholder=this.normalizePlaceholder(c.get("placeholder")),a.call(this,b,c,d)}return a.prototype.append=function(a,b){b.results=this.removePlaceholder(b.results),a.call(this,b)},a.prototype.normalizePlaceholder=function(a,b){return"string"==typeof b&&(b={id:"",text:b}),b},a.prototype.removePlaceholder=function(a,b){for(var c=b.slice(0),d=b.length-1;d>=0;d--){var e=b[d];this.placeholder.id===e.id&&c.splice(d,1)}return c},a}),b.define("select2/dropdown/infiniteScroll",["jquery"],function(a){function b(a,b,c,d){this.lastParams={},a.call(this,b,c,d),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return b.prototype.append=function(a,b){this.$loadingMore.remove(),this.loading=!1,a.call(this,b),this.showLoadingMore(b)&&this.$results.append(this.$loadingMore)},b.prototype.bind=function(b,c,d){var e=this;b.call(this,c,d),c.on("query",function(a){e.lastParams=a,e.loading=!0}),c.on("query:append",function(a){e.lastParams=a,e.loading=!0}),this.$results.on("scroll",function(){var b=a.contains(document.documentElement,e.$loadingMore[0]);if(!e.loading&&b){var c=e.$results.offset().top+e.$results.outerHeight(!1),d=e.$loadingMore.offset().top+e.$loadingMore.outerHeight(!1);c+50>=d&&e.loadMore()}})},b.prototype.loadMore=function(){this.loading=!0;var b=a.extend({},{page:1},this.lastParams);b.page++,this.trigger("query:append",b)},b.prototype.showLoadingMore=function(a,b){return b.pagination&&b.pagination.more},b.prototype.createLoadingMore=function(){var b=a('
          • '),c=this.options.get("translations").get("loadingMore");return b.html(c(this.lastParams)),b},b}),b.define("select2/dropdown/attachBody",["jquery","../utils"],function(a,b){function c(b,c,d){this.$dropdownParent=d.get("dropdownParent")||a(document.body),b.call(this,c,d)}return c.prototype.bind=function(a,b,c){var d=this,e=!1;a.call(this,b,c),b.on("open",function(){d._showDropdown(),d._attachPositioningHandler(b),e||(e=!0,b.on("results:all",function(){d._positionDropdown(),d._resizeDropdown()}),b.on("results:append",function(){d._positionDropdown(),d._resizeDropdown()}))}),b.on("close",function(){d._hideDropdown(),d._detachPositioningHandler(b)}),this.$dropdownContainer.on("mousedown",function(a){a.stopPropagation()})},c.prototype.destroy=function(a){a.call(this),this.$dropdownContainer.remove()},c.prototype.position=function(a,b,c){b.attr("class",c.attr("class")),b.removeClass("select2"),b.addClass("select2-container--open"),b.css({position:"absolute",top:-999999}),this.$container=c},c.prototype.render=function(b){var c=a(""),d=b.call(this);return c.append(d),this.$dropdownContainer=c,c},c.prototype._hideDropdown=function(a){this.$dropdownContainer.detach()},c.prototype._attachPositioningHandler=function(c,d){var e=this,f="scroll.select2."+d.id,g="resize.select2."+d.id,h="orientationchange.select2."+d.id,i=this.$container.parents().filter(b.hasScroll);i.each(function(){a(this).data("select2-scroll-position",{x:a(this).scrollLeft(),y:a(this).scrollTop()})}),i.on(f,function(b){var c=a(this).data("select2-scroll-position");a(this).scrollTop(c.y)}),a(window).on(f+" "+g+" "+h,function(a){e._positionDropdown(),e._resizeDropdown()})},c.prototype._detachPositioningHandler=function(c,d){var e="scroll.select2."+d.id,f="resize.select2."+d.id,g="orientationchange.select2."+d.id,h=this.$container.parents().filter(b.hasScroll);h.off(e),a(window).off(e+" "+f+" "+g)},c.prototype._positionDropdown=function(){var b=a(window),c=this.$dropdown.hasClass("select2-dropdown--above"),d=this.$dropdown.hasClass("select2-dropdown--below"),e=null,f=this.$container.offset();f.bottom=f.top+this.$container.outerHeight(!1);var g={height:this.$container.outerHeight(!1)};g.top=f.top,g.bottom=f.top+g.height;var h={height:this.$dropdown.outerHeight(!1)},i={top:b.scrollTop(),bottom:b.scrollTop()+b.height()},j=i.topf.bottom+h.height,l={left:f.left,top:g.bottom},m=this.$dropdownParent;"static"===m.css("position")&&(m=m.offsetParent());var n=m.offset();l.top-=n.top,l.left-=n.left,c||d||(e="below"),k||!j||c?!j&&k&&c&&(e="below"):e="above",("above"==e||c&&"below"!==e)&&(l.top=g.top-n.top-h.height),null!=e&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+e),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+e)),this.$dropdownContainer.css(l)},c.prototype._resizeDropdown=function(){var a={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(a.minWidth=a.width,a.position="relative",a.width="auto"),this.$dropdown.css(a)},c.prototype._showDropdown=function(a){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},c}),b.define("select2/dropdown/minimumResultsForSearch",[],function(){function a(b){for(var c=0,d=0;d0&&(l.dataAdapter=j.Decorate(l.dataAdapter,r)),l.maximumInputLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,s)),l.maximumSelectionLength>0&&(l.dataAdapter=j.Decorate(l.dataAdapter,t)),l.tags&&(l.dataAdapter=j.Decorate(l.dataAdapter,p)),(null!=l.tokenSeparators||null!=l.tokenizer)&&(l.dataAdapter=j.Decorate(l.dataAdapter,q)),null!=l.query){var C=b(l.amdBase+"compat/query");l.dataAdapter=j.Decorate(l.dataAdapter,C)}if(null!=l.initSelection){var D=b(l.amdBase+"compat/initSelection");l.dataAdapter=j.Decorate(l.dataAdapter,D)}}if(null==l.resultsAdapter&&(l.resultsAdapter=c,null!=l.ajax&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,x)),null!=l.placeholder&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,w)),l.selectOnClose&&(l.resultsAdapter=j.Decorate(l.resultsAdapter,A))),null==l.dropdownAdapter){if(l.multiple)l.dropdownAdapter=u;else{var E=j.Decorate(u,v);l.dropdownAdapter=E}if(0!==l.minimumResultsForSearch&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,z)),l.closeOnSelect&&(l.dropdownAdapter=j.Decorate(l.dropdownAdapter,B)),null!=l.dropdownCssClass||null!=l.dropdownCss||null!=l.adaptDropdownCssClass){var F=b(l.amdBase+"compat/dropdownCss");l.dropdownAdapter=j.Decorate(l.dropdownAdapter,F)}l.dropdownAdapter=j.Decorate(l.dropdownAdapter,y)}if(null==l.selectionAdapter){if(l.multiple?l.selectionAdapter=e:l.selectionAdapter=d,null!=l.placeholder&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,f)),l.allowClear&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,g)),l.multiple&&(l.selectionAdapter=j.Decorate(l.selectionAdapter,h)),null!=l.containerCssClass||null!=l.containerCss||null!=l.adaptContainerCssClass){var G=b(l.amdBase+"compat/containerCss");l.selectionAdapter=j.Decorate(l.selectionAdapter,G)}l.selectionAdapter=j.Decorate(l.selectionAdapter,i)}if("string"==typeof l.language)if(l.language.indexOf("-")>0){var H=l.language.split("-"),I=H[0];l.language=[l.language,I]}else l.language=[l.language];if(a.isArray(l.language)){var J=new k;l.language.push("en");for(var K=l.language,L=0;L0){for(var f=a.extend(!0,{},e),g=e.children.length-1;g>=0;g--){var h=e.children[g],i=c(d,h);null==i&&f.children.splice(g,1)}return f.children.length>0?f:c(d,f)}var j=b(e.text).toUpperCase(),k=b(d.term).toUpperCase();return j.indexOf(k)>-1?e:null}this.defaults={amdBase:"./",amdLanguageBase:"./i18n/",closeOnSelect:!0,debug:!1,dropdownAutoWidth:!1,escapeMarkup:j.escapeMarkup,language:C,matcher:c,minimumInputLength:0,maximumInputLength:0,maximumSelectionLength:0,minimumResultsForSearch:0,selectOnClose:!1,sorter:function(a){return a},templateResult:function(a){return a.text},templateSelection:function(a){return a.text},theme:"default",width:"resolve"}},D.prototype.set=function(b,c){var d=a.camelCase(b),e={};e[d]=c;var f=j._convertData(e);a.extend(this.defaults,f)};var E=new D;return E}),b.define("select2/options",["require","jquery","./defaults","./utils"],function(a,b,c,d){function e(b,e){if(this.options=b,null!=e&&this.fromElement(e),this.options=c.apply(this.options),e&&e.is("input")){var f=a(this.get("amdBase")+"compat/inputData");this.options.dataAdapter=d.Decorate(this.options.dataAdapter,f)}}return e.prototype.fromElement=function(a){var c=["select2"];null==this.options.multiple&&(this.options.multiple=a.prop("multiple")),null==this.options.disabled&&(this.options.disabled=a.prop("disabled")),null==this.options.language&&(a.prop("lang")?this.options.language=a.prop("lang").toLowerCase():a.closest("[lang]").prop("lang")&&(this.options.language=a.closest("[lang]").prop("lang"))),null==this.options.dir&&(a.prop("dir")?this.options.dir=a.prop("dir"):a.closest("[dir]").prop("dir")?this.options.dir=a.closest("[dir]").prop("dir"):this.options.dir="ltr"),a.prop("disabled",this.options.disabled),a.prop("multiple",this.options.multiple),a.data("select2Tags")&&(this.options.debug&&window.console&&console.warn&&console.warn('Select2: The `data-select2-tags` attribute has been changed to use the `data-data` and `data-tags="true"` attributes and will be removed in future versions of Select2.'),a.data("data",a.data("select2Tags")),a.data("tags",!0)),a.data("ajaxUrl")&&(this.options.debug&&window.console&&console.warn&&console.warn("Select2: The `data-ajax-url` attribute has been changed to `data-ajax--url` and support for the old attribute will be removed in future versions of Select2."),a.attr("ajax--url",a.data("ajaxUrl")),a.data("ajax--url",a.data("ajaxUrl")));var e={};e=b.fn.jquery&&"1."==b.fn.jquery.substr(0,2)&&a[0].dataset?b.extend(!0,{},a[0].dataset,a.data()):a.data();var f=b.extend(!0,{},e);f=d._convertData(f);for(var g in f)b.inArray(g,c)>-1||(b.isPlainObject(this.options[g])?b.extend(this.options[g],f[g]):this.options[g]=f[g]);return this},e.prototype.get=function(a){return this.options[a]},e.prototype.set=function(a,b){this.options[a]=b},e}),b.define("select2/core",["jquery","./options","./utils","./keys"],function(a,b,c,d){var e=function(a,c){null!=a.data("select2")&&a.data("select2").destroy(),this.$element=a,this.id=this._generateId(a),c=c||{},this.options=new b(c,a),e.__super__.constructor.call(this);var d=a.attr("tabindex")||0;a.data("old-tabindex",d),a.attr("tabindex","-1");var f=this.options.get("dataAdapter");this.dataAdapter=new f(a,this.options);var g=this.render();this._placeContainer(g);var h=this.options.get("selectionAdapter");this.selection=new h(a,this.options),this.$selection=this.selection.render(),this.selection.position(this.$selection,g);var i=this.options.get("dropdownAdapter");this.dropdown=new i(a,this.options),this.$dropdown=this.dropdown.render(),this.dropdown.position(this.$dropdown,g);var j=this.options.get("resultsAdapter");this.results=new j(a,this.options,this.dataAdapter),this.$results=this.results.render(),this.results.position(this.$results,this.$dropdown);var k=this;this._bindAdapters(),this._registerDomEvents(),this._registerDataEvents(),this._registerSelectionEvents(),this._registerDropdownEvents(),this._registerResultsEvents(),this._registerEvents(),this.dataAdapter.current(function(a){k.trigger("selection:update",{data:a})}),a.addClass("select2-hidden-accessible"),a.attr("aria-hidden","true"),this._syncAttributes(),a.data("select2",this)};return c.Extend(e,c.Observable),e.prototype._generateId=function(a){var b="";return b=null!=a.attr("id")?a.attr("id"):null!=a.attr("name")?a.attr("name")+"-"+c.generateChars(2):c.generateChars(4),b=b.replace(/(:|\.|\[|\]|,)/g,""),b="select2-"+b},e.prototype._placeContainer=function(a){a.insertAfter(this.$element);var b=this._resolveWidth(this.$element,this.options.get("width"));null!=b&&a.css("width",b)},e.prototype._resolveWidth=function(a,b){var c=/^width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i;if("resolve"==b){var d=this._resolveWidth(a,"style");return null!=d?d:this._resolveWidth(a,"element")}if("element"==b){var e=a.outerWidth(!1);return 0>=e?"auto":e+"px"}if("style"==b){var f=a.attr("style");if("string"!=typeof f)return null;for(var g=f.split(";"),h=0,i=g.length;i>h;h+=1){var j=g[h].replace(/\s/g,""),k=j.match(c);if(null!==k&&k.length>=1)return k[1]}return null}return b},e.prototype._bindAdapters=function(){this.dataAdapter.bind(this,this.$container),this.selection.bind(this,this.$container),this.dropdown.bind(this,this.$container),this.results.bind(this,this.$container)},e.prototype._registerDomEvents=function(){var b=this;this.$element.on("change.select2",function(){b.dataAdapter.current(function(a){b.trigger("selection:update",{data:a})})}),this.$element.on("focus.select2",function(a){b.trigger("focus",a)}),this._syncA=c.bind(this._syncAttributes,this),this._syncS=c.bind(this._syncSubtree,this),this.$element[0].attachEvent&&this.$element[0].attachEvent("onpropertychange",this._syncA);var d=window.MutationObserver||window.WebKitMutationObserver||window.MozMutationObserver;null!=d?(this._observer=new d(function(c){a.each(c,b._syncA),a.each(c,b._syncS)}),this._observer.observe(this.$element[0],{attributes:!0,childList:!0,subtree:!1})):this.$element[0].addEventListener&&(this.$element[0].addEventListener("DOMAttrModified",b._syncA,!1),this.$element[0].addEventListener("DOMNodeInserted",b._syncS,!1),this.$element[0].addEventListener("DOMNodeRemoved",b._syncS,!1))},e.prototype._registerDataEvents=function(){var a=this;this.dataAdapter.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerSelectionEvents=function(){var b=this,c=["toggle","focus"];this.selection.on("toggle",function(){b.toggleDropdown()}),this.selection.on("focus",function(a){b.focus(a)}),this.selection.on("*",function(d,e){-1===a.inArray(d,c)&&b.trigger(d,e)})},e.prototype._registerDropdownEvents=function(){var a=this;this.dropdown.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerResultsEvents=function(){var a=this;this.results.on("*",function(b,c){a.trigger(b,c)})},e.prototype._registerEvents=function(){var a=this;this.on("open",function(){a.$container.addClass("select2-container--open")}),this.on("close",function(){a.$container.removeClass("select2-container--open")}),this.on("enable",function(){a.$container.removeClass("select2-container--disabled")}),this.on("disable",function(){a.$container.addClass("select2-container--disabled")}),this.on("blur",function(){a.$container.removeClass("select2-container--focus")}),this.on("query",function(b){a.isOpen()||a.trigger("open",{}),this.dataAdapter.query(b,function(c){a.trigger("results:all",{data:c,query:b})})}),this.on("query:append",function(b){this.dataAdapter.query(b,function(c){a.trigger("results:append",{data:c,query:b})})}),this.on("keypress",function(b){var c=b.which;a.isOpen()?c===d.ESC||c===d.TAB||c===d.UP&&b.altKey?(a.close(),b.preventDefault()):c===d.ENTER?(a.trigger("results:select",{}),b.preventDefault()):c===d.SPACE&&b.ctrlKey?(a.trigger("results:toggle",{}),b.preventDefault()):c===d.UP?(a.trigger("results:previous",{}),b.preventDefault()):c===d.DOWN&&(a.trigger("results:next",{}),b.preventDefault()):(c===d.ENTER||c===d.SPACE||c===d.DOWN&&b.altKey)&&(a.open(),b.preventDefault())})},e.prototype._syncAttributes=function(){this.options.set("disabled",this.$element.prop("disabled")),this.options.get("disabled")?(this.isOpen()&&this.close(),this.trigger("disable",{})):this.trigger("enable",{})},e.prototype._syncSubtree=function(a,b){var c=!1,d=this;if(!a||!a.target||"OPTION"===a.target.nodeName||"OPTGROUP"===a.target.nodeName){if(b)if(b.addedNodes&&b.addedNodes.length>0)for(var e=0;e0&&(c=!0);else c=!0;c&&this.dataAdapter.current(function(a){d.trigger("selection:update",{data:a})})}},e.prototype.trigger=function(a,b){var c=e.__super__.trigger,d={open:"opening",close:"closing",select:"selecting",unselect:"unselecting"};if(void 0===b&&(b={}),a in d){var f=d[a],g={prevented:!1,name:a,args:b};if(c.call(this,f,g),g.prevented)return void(b.prevented=!0)}c.call(this,a,b)},e.prototype.toggleDropdown=function(){this.options.get("disabled")||(this.isOpen()?this.close():this.open())},e.prototype.open=function(){this.isOpen()||this.trigger("query",{})},e.prototype.close=function(){this.isOpen()&&this.trigger("close",{})},e.prototype.isOpen=function(){return this.$container.hasClass("select2-container--open")},e.prototype.hasFocus=function(){return this.$container.hasClass("select2-container--focus")},e.prototype.focus=function(a){this.hasFocus()||(this.$container.addClass("select2-container--focus"),this.trigger("focus",{}))},e.prototype.enable=function(a){this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("enable")` method has been deprecated and will be removed in later Select2 versions. Use $element.prop("disabled") instead.'),(null==a||0===a.length)&&(a=[!0]);var b=!a[0];this.$element.prop("disabled",b)},e.prototype.data=function(){this.options.get("debug")&&arguments.length>0&&window.console&&console.warn&&console.warn('Select2: Data can no longer be set using `select2("data")`. You should consider setting the value instead using `$element.val()`.');var a=[];return this.dataAdapter.current(function(b){a=b}),a},e.prototype.val=function(b){if(this.options.get("debug")&&window.console&&console.warn&&console.warn('Select2: The `select2("val")` method has been deprecated and will be removed in later Select2 versions. Use $element.val() instead.'),null==b||0===b.length)return this.$element.val();var c=b[0];a.isArray(c)&&(c=a.map(c,function(a){return a.toString()})),this.$element.val(c).trigger("change")},e.prototype.destroy=function(){this.$container.remove(),this.$element[0].detachEvent&&this.$element[0].detachEvent("onpropertychange",this._syncA),null!=this._observer?(this._observer.disconnect(),this._observer=null):this.$element[0].removeEventListener&&(this.$element[0].removeEventListener("DOMAttrModified",this._syncA,!1),this.$element[0].removeEventListener("DOMNodeInserted",this._syncS,!1),this.$element[0].removeEventListener("DOMNodeRemoved",this._syncS,!1)),this._syncA=null,this._syncS=null,this.$element.off(".select2"),this.$element.attr("tabindex",this.$element.data("old-tabindex")),this.$element.removeClass("select2-hidden-accessible"),this.$element.attr("aria-hidden","false"),this.$element.removeData("select2"),this.dataAdapter.destroy(),this.selection.destroy(),this.dropdown.destroy(),this.results.destroy(),this.dataAdapter=null,this.selection=null,this.dropdown=null,this.results=null; +},e.prototype.render=function(){var b=a('');return b.attr("dir",this.options.get("dir")),this.$container=b,this.$container.addClass("select2-container--"+this.options.get("theme")),b.data("element",this.$element),b},e}),b.define("jquery-mousewheel",["jquery"],function(a){return a}),b.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults"],function(a,b,c,d){if(null==a.fn.select2){var e=["open","close","destroy"];a.fn.select2=function(b){if(b=b||{},"object"==typeof b)return this.each(function(){var d=a.extend(!0,{},b);new c(a(this),d)}),this;if("string"==typeof b){var d,f=Array.prototype.slice.call(arguments,1);return this.each(function(){var c=a(this).data("select2");null==c&&window.console&&console.error&&console.error("The select2('"+b+"') method was called on an element that is not using Select2."),d=c[b].apply(c,f)}),a.inArray(b,e)>-1?this:d}throw new Error("Invalid arguments for Select2: "+b)}}return null==a.fn.select2.defaults&&(a.fn.select2.defaults=d),c}),{define:b.define,require:b.require}}(),c=b.require("jquery.select2");return a.fn.select2.amd=b,c}); !function(a,b){"object"==typeof exports&&"undefined"!=typeof module?module.exports=b():"function"==typeof define&&define.amd?define(b):a.moment=b()}(this,function(){"use strict";function a(){return ce.apply(null,arguments)}function b(a){ce=a}function c(a){return a instanceof Array||"[object Array]"===Object.prototype.toString.call(a)}function d(a){return a instanceof Date||"[object Date]"===Object.prototype.toString.call(a)}function e(a,b){var c,d=[];for(c=0;c0)for(c in ee)d=ee[c],e=b[d],m(e)||(a[d]=e);return a}function o(b){n(this,b),this._d=new Date(null!=b._d?b._d.getTime():NaN),fe===!1&&(fe=!0,a.updateOffset(this),fe=!1)}function p(a){return a instanceof o||null!=a&&null!=a._isAMomentObject}function q(a){return 0>a?Math.ceil(a):Math.floor(a)}function r(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=q(b)),c}function s(a,b,c){var d,e=Math.min(a.length,b.length),f=Math.abs(a.length-b.length),g=0;for(d=0;e>d;d++)(c&&a[d]!==b[d]||!c&&r(a[d])!==r(b[d]))&&g++;return g+f}function t(b){a.suppressDeprecationWarnings===!1&&"undefined"!=typeof console&&console.warn&&console.warn("Deprecation warning: "+b)}function u(b,c){var d=!0;return g(function(){return null!=a.deprecationHandler&&a.deprecationHandler(null,b),d&&(t(b+"\nArguments: "+Array.prototype.slice.call(arguments).join(", ")+"\n"+(new Error).stack),d=!1),c.apply(this,arguments)},c)}function v(b,c){null!=a.deprecationHandler&&a.deprecationHandler(b,c),ge[b]||(t(c),ge[b]=!0)}function w(a){return a instanceof Function||"[object Function]"===Object.prototype.toString.call(a)}function x(a){return"[object Object]"===Object.prototype.toString.call(a)}function y(a){var b,c;for(c in a)b=a[c],w(b)?this[c]=b:this["_"+c]=b;this._config=a,this._ordinalParseLenient=new RegExp(this._ordinalParse.source+"|"+/\d{1,2}/.source)}function z(a,b){var c,d=g({},a);for(c in b)f(b,c)&&(x(a[c])&&x(b[c])?(d[c]={},g(d[c],a[c]),g(d[c],b[c])):null!=b[c]?d[c]=b[c]:delete d[c]);return d}function A(a){null!=a&&this.set(a)}function B(a){return a?a.toLowerCase().replace("_","-"):a}function C(a){for(var b,c,d,e,f=0;f0;){if(d=D(e.slice(0,b).join("-")))return d;if(c&&c.length>=b&&s(e,c,!0)>=b-1)break;b--}f++}return null}function D(a){var b=null;if(!ke[a]&&"undefined"!=typeof module&&module&&module.exports)try{b=ie._abbr,require("./locale/"+a),E(b)}catch(c){}return ke[a]}function E(a,b){var c;return a&&(c=m(b)?H(a):F(a,b),c&&(ie=c)),ie._abbr}function F(a,b){return null!==b?(b.abbr=a,null!=ke[a]?(v("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale"),b=z(ke[a]._config,b)):null!=b.parentLocale&&(null!=ke[b.parentLocale]?b=z(ke[b.parentLocale]._config,b):v("parentLocaleUndefined","specified parentLocale is not defined yet")),ke[a]=new A(b),E(a),ke[a]):(delete ke[a],null)}function G(a,b){if(null!=b){var c;null!=ke[a]&&(b=z(ke[a]._config,b)),c=new A(b),c.parentLocale=ke[a],ke[a]=c,E(a)}else null!=ke[a]&&(null!=ke[a].parentLocale?ke[a]=ke[a].parentLocale:null!=ke[a]&&delete ke[a]);return ke[a]}function H(a){var b;if(a&&a._locale&&a._locale._abbr&&(a=a._locale._abbr),!a)return ie;if(!c(a)){if(b=D(a))return b;a=[a]}return C(a)}function I(){return he(ke)}function J(a,b){var c=a.toLowerCase();le[c]=le[c+"s"]=le[b]=a}function K(a){return"string"==typeof a?le[a]||le[a.toLowerCase()]:void 0}function L(a){var b,c,d={};for(c in a)f(a,c)&&(b=K(c),b&&(d[b]=a[c]));return d}function M(b,c){return function(d){return null!=d?(O(this,b,d),a.updateOffset(this,c),this):N(this,b)}}function N(a,b){return a.isValid()?a._d["get"+(a._isUTC?"UTC":"")+b]():NaN}function O(a,b,c){a.isValid()&&a._d["set"+(a._isUTC?"UTC":"")+b](c)}function P(a,b){var c;if("object"==typeof a)for(c in a)this.set(c,a[c]);else if(a=K(a),w(this[a]))return this[a](b);return this}function Q(a,b,c){var d=""+Math.abs(a),e=b-d.length,f=a>=0;return(f?c?"+":"":"-")+Math.pow(10,Math.max(0,e)).toString().substr(1)+d}function R(a,b,c,d){var e=d;"string"==typeof d&&(e=function(){return this[d]()}),a&&(pe[a]=e),b&&(pe[b[0]]=function(){return Q(e.apply(this,arguments),b[1],b[2])}),c&&(pe[c]=function(){return this.localeData().ordinal(e.apply(this,arguments),a)})}function S(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function T(a){var b,c,d=a.match(me);for(b=0,c=d.length;c>b;b++)pe[d[b]]?d[b]=pe[d[b]]:d[b]=S(d[b]);return function(b){var e,f="";for(e=0;c>e;e++)f+=d[e]instanceof Function?d[e].call(b,a):d[e];return f}}function U(a,b){return a.isValid()?(b=V(b,a.localeData()),oe[b]=oe[b]||T(b),oe[b](a)):a.localeData().invalidDate()}function V(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(ne.lastIndex=0;d>=0&&ne.test(a);)a=a.replace(ne,c),ne.lastIndex=0,d-=1;return a}function W(a,b,c){He[a]=w(b)?b:function(a,d){return a&&c?c:b}}function X(a,b){return f(He,a)?He[a](b._strict,b._locale):new RegExp(Y(a))}function Y(a){return Z(a.replace("\\","").replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e}))}function Z(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function $(a,b){var c,d=b;for("string"==typeof a&&(a=[a]),"number"==typeof b&&(d=function(a,c){c[b]=r(a)}),c=0;cd;++d)f=h([2e3,d]),this._shortMonthsParse[d]=this.monthsShort(f,"").toLocaleLowerCase(),this._longMonthsParse[d]=this.months(f,"").toLocaleLowerCase();return c?"MMM"===b?(e=je.call(this._shortMonthsParse,g),-1!==e?e:null):(e=je.call(this._longMonthsParse,g),-1!==e?e:null):"MMM"===b?(e=je.call(this._shortMonthsParse,g),-1!==e?e:(e=je.call(this._longMonthsParse,g),-1!==e?e:null)):(e=je.call(this._longMonthsParse,g),-1!==e?e:(e=je.call(this._shortMonthsParse,g),-1!==e?e:null))}function fa(a,b,c){var d,e,f;if(this._monthsParseExact)return ea.call(this,a,b,c);for(this._monthsParse||(this._monthsParse=[],this._longMonthsParse=[],this._shortMonthsParse=[]),d=0;12>d;d++){if(e=h([2e3,d]),c&&!this._longMonthsParse[d]&&(this._longMonthsParse[d]=new RegExp("^"+this.months(e,"").replace(".","")+"$","i"),this._shortMonthsParse[d]=new RegExp("^"+this.monthsShort(e,"").replace(".","")+"$","i")),c||this._monthsParse[d]||(f="^"+this.months(e,"")+"|^"+this.monthsShort(e,""),this._monthsParse[d]=new RegExp(f.replace(".",""),"i")),c&&"MMMM"===b&&this._longMonthsParse[d].test(a))return d;if(c&&"MMM"===b&&this._shortMonthsParse[d].test(a))return d;if(!c&&this._monthsParse[d].test(a))return d}}function ga(a,b){var c;if(!a.isValid())return a;if("string"==typeof b)if(/^\d+$/.test(b))b=r(b);else if(b=a.localeData().monthsParse(b),"number"!=typeof b)return a;return c=Math.min(a.date(),ba(a.year(),b)),a._d["set"+(a._isUTC?"UTC":"")+"Month"](b,c),a}function ha(b){return null!=b?(ga(this,b),a.updateOffset(this,!0),this):N(this,"Month")}function ia(){return ba(this.year(),this.month())}function ja(a){return this._monthsParseExact?(f(this,"_monthsRegex")||la.call(this),a?this._monthsShortStrictRegex:this._monthsShortRegex):this._monthsShortStrictRegex&&a?this._monthsShortStrictRegex:this._monthsShortRegex}function ka(a){return this._monthsParseExact?(f(this,"_monthsRegex")||la.call(this),a?this._monthsStrictRegex:this._monthsRegex):this._monthsStrictRegex&&a?this._monthsStrictRegex:this._monthsRegex}function la(){function a(a,b){return b.length-a.length}var b,c,d=[],e=[],f=[];for(b=0;12>b;b++)c=h([2e3,b]),d.push(this.monthsShort(c,"")),e.push(this.months(c,"")),f.push(this.months(c,"")),f.push(this.monthsShort(c,""));for(d.sort(a),e.sort(a),f.sort(a),b=0;12>b;b++)d[b]=Z(d[b]),e[b]=Z(e[b]),f[b]=Z(f[b]);this._monthsRegex=new RegExp("^("+f.join("|")+")","i"),this._monthsShortRegex=this._monthsRegex,this._monthsStrictRegex=new RegExp("^("+e.join("|")+")","i"),this._monthsShortStrictRegex=new RegExp("^("+d.join("|")+")","i")}function ma(a){var b,c=a._a;return c&&-2===j(a).overflow&&(b=c[Ke]<0||c[Ke]>11?Ke:c[Le]<1||c[Le]>ba(c[Je],c[Ke])?Le:c[Me]<0||c[Me]>24||24===c[Me]&&(0!==c[Ne]||0!==c[Oe]||0!==c[Pe])?Me:c[Ne]<0||c[Ne]>59?Ne:c[Oe]<0||c[Oe]>59?Oe:c[Pe]<0||c[Pe]>999?Pe:-1,j(a)._overflowDayOfYear&&(Je>b||b>Le)&&(b=Le),j(a)._overflowWeeks&&-1===b&&(b=Qe),j(a)._overflowWeekday&&-1===b&&(b=Re),j(a).overflow=b),a}function na(a){var b,c,d,e,f,g,h=a._i,i=Xe.exec(h)||Ye.exec(h);if(i){for(j(a).iso=!0,b=0,c=$e.length;c>b;b++)if($e[b][1].exec(i[1])){e=$e[b][0],d=$e[b][2]!==!1;break}if(null==e)return void(a._isValid=!1);if(i[3]){for(b=0,c=_e.length;c>b;b++)if(_e[b][1].exec(i[3])){f=(i[2]||" ")+_e[b][0];break}if(null==f)return void(a._isValid=!1)}if(!d&&null!=f)return void(a._isValid=!1);if(i[4]){if(!Ze.exec(i[4]))return void(a._isValid=!1);g="Z"}a._f=e+(f||"")+(g||""),Ca(a)}else a._isValid=!1}function oa(b){var c=af.exec(b._i);return null!==c?void(b._d=new Date(+c[1])):(na(b),void(b._isValid===!1&&(delete b._isValid,a.createFromInputFallback(b))))}function pa(a,b,c,d,e,f,g){var h=new Date(a,b,c,d,e,f,g);return 100>a&&a>=0&&isFinite(h.getFullYear())&&h.setFullYear(a),h}function qa(a){var b=new Date(Date.UTC.apply(null,arguments));return 100>a&&a>=0&&isFinite(b.getUTCFullYear())&&b.setUTCFullYear(a),b}function ra(a){return sa(a)?366:365}function sa(a){return a%4===0&&a%100!==0||a%400===0}function ta(){return sa(this.year())}function ua(a,b,c){var d=7+b-c,e=(7+qa(a,0,d).getUTCDay()-b)%7;return-e+d-1}function va(a,b,c,d,e){var f,g,h=(7+c-d)%7,i=ua(a,d,e),j=1+7*(b-1)+h+i;return 0>=j?(f=a-1,g=ra(f)+j):j>ra(a)?(f=a+1,g=j-ra(a)):(f=a,g=j),{year:f,dayOfYear:g}}function wa(a,b,c){var d,e,f=ua(a.year(),b,c),g=Math.floor((a.dayOfYear()-f-1)/7)+1;return 1>g?(e=a.year()-1,d=g+xa(e,b,c)):g>xa(a.year(),b,c)?(d=g-xa(a.year(),b,c),e=a.year()+1):(e=a.year(),d=g),{week:d,year:e}}function xa(a,b,c){var d=ua(a,b,c),e=ua(a+1,b,c);return(ra(a)-d+e)/7}function ya(a,b,c){return null!=a?a:null!=b?b:c}function za(b){var c=new Date(a.now());return b._useUTC?[c.getUTCFullYear(),c.getUTCMonth(),c.getUTCDate()]:[c.getFullYear(),c.getMonth(),c.getDate()]}function Aa(a){var b,c,d,e,f=[];if(!a._d){for(d=za(a),a._w&&null==a._a[Le]&&null==a._a[Ke]&&Ba(a),a._dayOfYear&&(e=ya(a._a[Je],d[Je]),a._dayOfYear>ra(e)&&(j(a)._overflowDayOfYear=!0),c=qa(e,0,a._dayOfYear),a._a[Ke]=c.getUTCMonth(),a._a[Le]=c.getUTCDate()),b=0;3>b&&null==a._a[b];++b)a._a[b]=f[b]=d[b];for(;7>b;b++)a._a[b]=f[b]=null==a._a[b]?2===b?1:0:a._a[b];24===a._a[Me]&&0===a._a[Ne]&&0===a._a[Oe]&&0===a._a[Pe]&&(a._nextDay=!0,a._a[Me]=0),a._d=(a._useUTC?qa:pa).apply(null,f),null!=a._tzm&&a._d.setUTCMinutes(a._d.getUTCMinutes()-a._tzm),a._nextDay&&(a._a[Me]=24)}}function Ba(a){var b,c,d,e,f,g,h,i;b=a._w,null!=b.GG||null!=b.W||null!=b.E?(f=1,g=4,c=ya(b.GG,a._a[Je],wa(Ka(),1,4).year),d=ya(b.W,1),e=ya(b.E,1),(1>e||e>7)&&(i=!0)):(f=a._locale._week.dow,g=a._locale._week.doy,c=ya(b.gg,a._a[Je],wa(Ka(),f,g).year),d=ya(b.w,1),null!=b.d?(e=b.d,(0>e||e>6)&&(i=!0)):null!=b.e?(e=b.e+f,(b.e<0||b.e>6)&&(i=!0)):e=f),1>d||d>xa(c,f,g)?j(a)._overflowWeeks=!0:null!=i?j(a)._overflowWeekday=!0:(h=va(c,d,e,f,g),a._a[Je]=h.year,a._dayOfYear=h.dayOfYear)}function Ca(b){if(b._f===a.ISO_8601)return void na(b);b._a=[],j(b).empty=!0;var c,d,e,f,g,h=""+b._i,i=h.length,k=0;for(e=V(b._f,b._locale).match(me)||[],c=0;c0&&j(b).unusedInput.push(g),h=h.slice(h.indexOf(d)+d.length),k+=d.length),pe[f]?(d?j(b).empty=!1:j(b).unusedTokens.push(f),aa(f,d,b)):b._strict&&!d&&j(b).unusedTokens.push(f);j(b).charsLeftOver=i-k,h.length>0&&j(b).unusedInput.push(h),j(b).bigHour===!0&&b._a[Me]<=12&&b._a[Me]>0&&(j(b).bigHour=void 0),j(b).parsedDateParts=b._a.slice(0),j(b).meridiem=b._meridiem,b._a[Me]=Da(b._locale,b._a[Me],b._meridiem),Aa(b),ma(b)}function Da(a,b,c){var d;return null==c?b:null!=a.meridiemHour?a.meridiemHour(b,c):null!=a.isPM?(d=a.isPM(c),d&&12>b&&(b+=12),d||12!==b||(b=0),b):b}function Ea(a){var b,c,d,e,f;if(0===a._f.length)return j(a).invalidFormat=!0,void(a._d=new Date(NaN));for(e=0;ef)&&(d=f,c=b));g(a,c||b)}function Fa(a){if(!a._d){var b=L(a._i);a._a=e([b.year,b.month,b.day||b.date,b.hour,b.minute,b.second,b.millisecond],function(a){return a&&parseInt(a,10)}),Aa(a)}}function Ga(a){var b=new o(ma(Ha(a)));return b._nextDay&&(b.add(1,"d"),b._nextDay=void 0),b}function Ha(a){var b=a._i,e=a._f;return a._locale=a._locale||H(a._l),null===b||void 0===e&&""===b?l({nullInput:!0}):("string"==typeof b&&(a._i=b=a._locale.preparse(b)),p(b)?new o(ma(b)):(c(e)?Ea(a):e?Ca(a):d(b)?a._d=b:Ia(a),k(a)||(a._d=null),a))}function Ia(b){var f=b._i;void 0===f?b._d=new Date(a.now()):d(f)?b._d=new Date(f.valueOf()):"string"==typeof f?oa(b):c(f)?(b._a=e(f.slice(0),function(a){return parseInt(a,10)}),Aa(b)):"object"==typeof f?Fa(b):"number"==typeof f?b._d=new Date(f):a.createFromInputFallback(b)}function Ja(a,b,c,d,e){var f={};return"boolean"==typeof c&&(d=c,c=void 0),f._isAMomentObject=!0,f._useUTC=f._isUTC=e,f._l=c,f._i=a,f._f=b,f._strict=d,Ga(f)}function Ka(a,b,c,d){return Ja(a,b,c,d,!1)}function La(a,b){var d,e;if(1===b.length&&c(b[0])&&(b=b[0]),!b.length)return Ka();for(d=b[0],e=1;ea&&(a=-a,c="-"),c+Q(~~(a/60),2)+b+Q(~~a%60,2)})}function Ra(a,b){var c=(b||"").match(a)||[],d=c[c.length-1]||[],e=(d+"").match(ff)||["-",0,0],f=+(60*e[1])+r(e[2]);return"+"===e[0]?f:-f}function Sa(b,c){var e,f;return c._isUTC?(e=c.clone(),f=(p(b)||d(b)?b.valueOf():Ka(b).valueOf())-e.valueOf(),e._d.setTime(e._d.valueOf()+f),a.updateOffset(e,!1),e):Ka(b).local()}function Ta(a){return 15*-Math.round(a._d.getTimezoneOffset()/15)}function Ua(b,c){var d,e=this._offset||0;return this.isValid()?null!=b?("string"==typeof b?b=Ra(Ee,b):Math.abs(b)<16&&(b=60*b),!this._isUTC&&c&&(d=Ta(this)),this._offset=b,this._isUTC=!0,null!=d&&this.add(d,"m"),e!==b&&(!c||this._changeInProgress?jb(this,db(b-e,"m"),1,!1):this._changeInProgress||(this._changeInProgress=!0,a.updateOffset(this,!0),this._changeInProgress=null)),this):this._isUTC?e:Ta(this):null!=b?this:NaN}function Va(a,b){return null!=a?("string"!=typeof a&&(a=-a),this.utcOffset(a,b),this):-this.utcOffset()}function Wa(a){return this.utcOffset(0,a)}function Xa(a){return this._isUTC&&(this.utcOffset(0,a),this._isUTC=!1,a&&this.subtract(Ta(this),"m")),this}function Ya(){return this._tzm?this.utcOffset(this._tzm):"string"==typeof this._i&&this.utcOffset(Ra(De,this._i)),this}function Za(a){return this.isValid()?(a=a?Ka(a).utcOffset():0,(this.utcOffset()-a)%60===0):!1}function $a(){return this.utcOffset()>this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()}function _a(){if(!m(this._isDSTShifted))return this._isDSTShifted;var a={};if(n(a,this),a=Ha(a),a._a){var b=a._isUTC?h(a._a):Ka(a._a);this._isDSTShifted=this.isValid()&&s(a._a,b.toArray())>0}else this._isDSTShifted=!1;return this._isDSTShifted}function ab(){return this.isValid()?!this._isUTC:!1}function bb(){return this.isValid()?this._isUTC:!1}function cb(){return this.isValid()?this._isUTC&&0===this._offset:!1}function db(a,b){var c,d,e,g=a,h=null;return Pa(a)?g={ms:a._milliseconds,d:a._days,M:a._months}:"number"==typeof a?(g={},b?g[b]=a:g.milliseconds=a):(h=gf.exec(a))?(c="-"===h[1]?-1:1,g={y:0,d:r(h[Le])*c,h:r(h[Me])*c,m:r(h[Ne])*c,s:r(h[Oe])*c,ms:r(h[Pe])*c}):(h=hf.exec(a))?(c="-"===h[1]?-1:1,g={y:eb(h[2],c),M:eb(h[3],c),w:eb(h[4],c),d:eb(h[5],c),h:eb(h[6],c),m:eb(h[7],c),s:eb(h[8],c)}):null==g?g={}:"object"==typeof g&&("from"in g||"to"in g)&&(e=gb(Ka(g.from),Ka(g.to)),g={},g.ms=e.milliseconds,g.M=e.months),d=new Oa(g),Pa(a)&&f(a,"_locale")&&(d._locale=a._locale),d}function eb(a,b){var c=a&&parseFloat(a.replace(",","."));return(isNaN(c)?0:c)*b}function fb(a,b){var c={milliseconds:0,months:0};return c.months=b.month()-a.month()+12*(b.year()-a.year()),a.clone().add(c.months,"M").isAfter(b)&&--c.months,c.milliseconds=+b-+a.clone().add(c.months,"M"),c}function gb(a,b){var c;return a.isValid()&&b.isValid()?(b=Sa(b,a),a.isBefore(b)?c=fb(a,b):(c=fb(b,a),c.milliseconds=-c.milliseconds,c.months=-c.months),c):{milliseconds:0,months:0}}function hb(a){return 0>a?-1*Math.round(-1*a):Math.round(a)}function ib(a,b){return function(c,d){var e,f;return null===d||isNaN(+d)||(v(b,"moment()."+b+"(period, number) is deprecated. Please use moment()."+b+"(number, period)."),f=c,c=d,d=f),c="string"==typeof c?+c:c,e=db(c,d),jb(this,e,a),this}}function jb(b,c,d,e){var f=c._milliseconds,g=hb(c._days),h=hb(c._months);b.isValid()&&(e=null==e?!0:e,f&&b._d.setTime(b._d.valueOf()+f*d),g&&O(b,"Date",N(b,"Date")+g*d),h&&ga(b,N(b,"Month")+h*d),e&&a.updateOffset(b,g||h))}function kb(a,b){var c=a||Ka(),d=Sa(c,this).startOf("day"),e=this.diff(d,"days",!0),f=-6>e?"sameElse":-1>e?"lastWeek":0>e?"lastDay":1>e?"sameDay":2>e?"nextDay":7>e?"nextWeek":"sameElse",g=b&&(w(b[f])?b[f]():b[f]);return this.format(g||this.localeData().calendar(f,this,Ka(c)))}function lb(){return new o(this)}function mb(a,b){var c=p(a)?a:Ka(a);return this.isValid()&&c.isValid()?(b=K(m(b)?"millisecond":b),"millisecond"===b?this.valueOf()>c.valueOf():c.valueOf()b-f?(c=a.clone().add(e-1,"months"),d=(b-f)/(f-c)):(c=a.clone().add(e+1,"months"),d=(b-f)/(c-f)),-(e+d)||0}function ub(){return this.clone().locale("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")}function vb(){var a=this.clone().utc();return 0f&&(b=f),Vb.call(this,a,b,c,d,e))}function Vb(a,b,c,d,e){var f=va(a,b,c,d,e),g=qa(f.year,0,f.dayOfYear);return this.year(g.getUTCFullYear()),this.month(g.getUTCMonth()),this.date(g.getUTCDate()),this}function Wb(a){return null==a?Math.ceil((this.month()+1)/3):this.month(3*(a-1)+this.month()%3)}function Xb(a){return wa(a,this._week.dow,this._week.doy).week}function Yb(){return this._week.dow}function Zb(){return this._week.doy}function $b(a){var b=this.localeData().week(this);return null==a?b:this.add(7*(a-b),"d")}function _b(a){var b=wa(this,1,4).week;return null==a?b:this.add(7*(a-b),"d")}function ac(a,b){return"string"!=typeof a?a:isNaN(a)?(a=b.weekdaysParse(a),"number"==typeof a?a:null):parseInt(a,10)}function bc(a,b){return c(this._weekdays)?this._weekdays[a.day()]:this._weekdays[this._weekdays.isFormat.test(b)?"format":"standalone"][a.day()]}function cc(a){return this._weekdaysShort[a.day()]}function dc(a){return this._weekdaysMin[a.day()]}function ec(a,b,c){var d,e,f,g=a.toLocaleLowerCase();if(!this._weekdaysParse)for(this._weekdaysParse=[],this._shortWeekdaysParse=[],this._minWeekdaysParse=[],d=0;7>d;++d)f=h([2e3,1]).day(d),this._minWeekdaysParse[d]=this.weekdaysMin(f,"").toLocaleLowerCase(),this._shortWeekdaysParse[d]=this.weekdaysShort(f,"").toLocaleLowerCase(),this._weekdaysParse[d]=this.weekdays(f,"").toLocaleLowerCase();return c?"dddd"===b?(e=je.call(this._weekdaysParse,g),-1!==e?e:null):"ddd"===b?(e=je.call(this._shortWeekdaysParse,g),-1!==e?e:null):(e=je.call(this._minWeekdaysParse,g),-1!==e?e:null):"dddd"===b?(e=je.call(this._weekdaysParse,g),-1!==e?e:(e=je.call(this._shortWeekdaysParse,g),-1!==e?e:(e=je.call(this._minWeekdaysParse,g),-1!==e?e:null))):"ddd"===b?(e=je.call(this._shortWeekdaysParse,g),-1!==e?e:(e=je.call(this._weekdaysParse,g),-1!==e?e:(e=je.call(this._minWeekdaysParse,g),-1!==e?e:null))):(e=je.call(this._minWeekdaysParse,g),-1!==e?e:(e=je.call(this._weekdaysParse,g),-1!==e?e:(e=je.call(this._shortWeekdaysParse,g),-1!==e?e:null)))}function fc(a,b,c){var d,e,f;if(this._weekdaysParseExact)return ec.call(this,a,b,c);for(this._weekdaysParse||(this._weekdaysParse=[],this._minWeekdaysParse=[],this._shortWeekdaysParse=[],this._fullWeekdaysParse=[]),d=0;7>d;d++){if(e=h([2e3,1]).day(d),c&&!this._fullWeekdaysParse[d]&&(this._fullWeekdaysParse[d]=new RegExp("^"+this.weekdays(e,"").replace(".",".?")+"$","i"),this._shortWeekdaysParse[d]=new RegExp("^"+this.weekdaysShort(e,"").replace(".",".?")+"$","i"),this._minWeekdaysParse[d]=new RegExp("^"+this.weekdaysMin(e,"").replace(".",".?")+"$","i")),this._weekdaysParse[d]||(f="^"+this.weekdays(e,"")+"|^"+this.weekdaysShort(e,"")+"|^"+this.weekdaysMin(e,""),this._weekdaysParse[d]=new RegExp(f.replace(".",""),"i")),c&&"dddd"===b&&this._fullWeekdaysParse[d].test(a))return d;if(c&&"ddd"===b&&this._shortWeekdaysParse[d].test(a))return d;if(c&&"dd"===b&&this._minWeekdaysParse[d].test(a))return d;if(!c&&this._weekdaysParse[d].test(a))return d}}function gc(a){if(!this.isValid())return null!=a?this:NaN;var b=this._isUTC?this._d.getUTCDay():this._d.getDay();return null!=a?(a=ac(a,this.localeData()),this.add(a-b,"d")):b}function hc(a){if(!this.isValid())return null!=a?this:NaN;var b=(this.day()+7-this.localeData()._week.dow)%7;return null==a?b:this.add(a-b,"d")}function ic(a){return this.isValid()?null==a?this.day()||7:this.day(this.day()%7?a:a-7):null!=a?this:NaN}function jc(a){return this._weekdaysParseExact?(f(this,"_weekdaysRegex")||mc.call(this),a?this._weekdaysStrictRegex:this._weekdaysRegex):this._weekdaysStrictRegex&&a?this._weekdaysStrictRegex:this._weekdaysRegex}function kc(a){return this._weekdaysParseExact?(f(this,"_weekdaysRegex")||mc.call(this),a?this._weekdaysShortStrictRegex:this._weekdaysShortRegex):this._weekdaysShortStrictRegex&&a?this._weekdaysShortStrictRegex:this._weekdaysShortRegex}function lc(a){return this._weekdaysParseExact?(f(this,"_weekdaysRegex")||mc.call(this),a?this._weekdaysMinStrictRegex:this._weekdaysMinRegex):this._weekdaysMinStrictRegex&&a?this._weekdaysMinStrictRegex:this._weekdaysMinRegex}function mc(){function a(a,b){return b.length-a.length}var b,c,d,e,f,g=[],i=[],j=[],k=[];for(b=0;7>b;b++)c=h([2e3,1]).day(b),d=this.weekdaysMin(c,""),e=this.weekdaysShort(c,""),f=this.weekdays(c,""),g.push(d),i.push(e),j.push(f),k.push(d),k.push(e),k.push(f);for(g.sort(a),i.sort(a),j.sort(a),k.sort(a),b=0;7>b;b++)i[b]=Z(i[b]),j[b]=Z(j[b]),k[b]=Z(k[b]);this._weekdaysRegex=new RegExp("^("+k.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+j.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+i.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+g.join("|")+")","i")}function nc(a){var b=Math.round((this.clone().startOf("day")-this.clone().startOf("year"))/864e5)+1;return null==a?b:this.add(a-b,"d")}function oc(){return this.hours()%12||12}function pc(){return this.hours()||24}function qc(a,b){R(a,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),b)})}function rc(a,b){return b._meridiemParse}function sc(a){return"p"===(a+"").toLowerCase().charAt(0)}function tc(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"}function uc(a,b){b[Pe]=r(1e3*("0."+a))}function vc(){return this._isUTC?"UTC":""}function wc(){return this._isUTC?"Coordinated Universal Time":""}function xc(a){return Ka(1e3*a)}function yc(){return Ka.apply(null,arguments).parseZone()}function zc(a,b,c){var d=this._calendar[a];return w(d)?d.call(b,c):d}function Ac(a){var b=this._longDateFormat[a],c=this._longDateFormat[a.toUpperCase()];return b||!c?b:(this._longDateFormat[a]=c.replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a])}function Bc(){return this._invalidDate}function Cc(a){return this._ordinal.replace("%d",a)}function Dc(a){return a}function Ec(a,b,c,d){var e=this._relativeTime[c];return w(e)?e(a,b,c,d):e.replace(/%d/i,a)}function Fc(a,b){var c=this._relativeTime[a>0?"future":"past"];return w(c)?c(b):c.replace(/%s/i,b)}function Gc(a,b,c,d){var e=H(),f=h().set(d,b);return e[c](f,a)}function Hc(a,b,c){if("number"==typeof a&&(b=a,a=void 0),a=a||"",null!=b)return Gc(a,b,c,"month");var d,e=[];for(d=0;12>d;d++)e[d]=Gc(a,d,c,"month");return e}function Ic(a,b,c,d){"boolean"==typeof a?("number"==typeof b&&(c=b,b=void 0),b=b||""):(b=a,c=b,a=!1,"number"==typeof b&&(c=b,b=void 0),b=b||"");var e=H(),f=a?e._week.dow:0;if(null!=c)return Gc(b,(c+f)%7,d,"day");var g,h=[];for(g=0;7>g;g++)h[g]=Gc(b,(g+f)%7,d,"day");return h}function Jc(a,b){return Hc(a,b,"months")}function Kc(a,b){return Hc(a,b,"monthsShort")}function Lc(a,b,c){return Ic(a,b,c,"weekdays")}function Mc(a,b,c){return Ic(a,b,c,"weekdaysShort")}function Nc(a,b,c){return Ic(a,b,c,"weekdaysMin")}function Oc(){var a=this._data;return this._milliseconds=Jf(this._milliseconds),this._days=Jf(this._days),this._months=Jf(this._months),a.milliseconds=Jf(a.milliseconds),a.seconds=Jf(a.seconds),a.minutes=Jf(a.minutes),a.hours=Jf(a.hours),a.months=Jf(a.months),a.years=Jf(a.years),this}function Pc(a,b,c,d){var e=db(b,c);return a._milliseconds+=d*e._milliseconds,a._days+=d*e._days,a._months+=d*e._months,a._bubble()}function Qc(a,b){return Pc(this,a,b,1)}function Rc(a,b){return Pc(this,a,b,-1)}function Sc(a){return 0>a?Math.floor(a):Math.ceil(a)}function Tc(){var a,b,c,d,e,f=this._milliseconds,g=this._days,h=this._months,i=this._data;return f>=0&&g>=0&&h>=0||0>=f&&0>=g&&0>=h||(f+=864e5*Sc(Vc(h)+g),g=0,h=0),i.milliseconds=f%1e3,a=q(f/1e3),i.seconds=a%60,b=q(a/60),i.minutes=b%60,c=q(b/60),i.hours=c%24,g+=q(c/24),e=q(Uc(g)),h+=e,g-=Sc(Vc(e)),d=q(h/12),h%=12,i.days=g,i.months=h,i.years=d,this}function Uc(a){return 4800*a/146097}function Vc(a){return 146097*a/4800}function Wc(a){var b,c,d=this._milliseconds;if(a=K(a),"month"===a||"year"===a)return b=this._days+d/864e5,c=this._months+Uc(b),"month"===a?c:c/12;switch(b=this._days+Math.round(Vc(this._months)),a){case"week":return b/7+d/6048e5;case"day":return b+d/864e5;case"hour":return 24*b+d/36e5;case"minute":return 1440*b+d/6e4;case"second":return 86400*b+d/1e3;case"millisecond":return Math.floor(864e5*b)+d;default:throw new Error("Unknown unit "+a)}}function Xc(){return this._milliseconds+864e5*this._days+this._months%12*2592e6+31536e6*r(this._months/12)}function Yc(a){return function(){return this.as(a)}}function Zc(a){ return a=K(a),this[a+"s"]()}function $c(a){return function(){return this._data[a]}}function _c(){return q(this.days()/7)}function ad(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function bd(a,b,c){var d=db(a).abs(),e=Zf(d.as("s")),f=Zf(d.as("m")),g=Zf(d.as("h")),h=Zf(d.as("d")),i=Zf(d.as("M")),j=Zf(d.as("y")),k=e<$f.s&&["s",e]||1>=f&&["m"]||f<$f.m&&["mm",f]||1>=g&&["h"]||g<$f.h&&["hh",g]||1>=h&&["d"]||h<$f.d&&["dd",h]||1>=i&&["M"]||i<$f.M&&["MM",i]||1>=j&&["y"]||["yy",j];return k[2]=b,k[3]=+a>0,k[4]=c,ad.apply(null,k)}function cd(a,b){return void 0===$f[a]?!1:void 0===b?$f[a]:($f[a]=b,!0)}function dd(a){var b=this.localeData(),c=bd(this,!a,b);return a&&(c=b.pastFuture(+this,c)),b.postformat(c)}function ed(){var a,b,c,d=_f(this._milliseconds)/1e3,e=_f(this._days),f=_f(this._months);a=q(d/60),b=q(a/60),d%=60,a%=60,c=q(f/12),f%=12;var g=c,h=f,i=e,j=b,k=a,l=d,m=this.asSeconds();return m?(0>m?"-":"")+"P"+(g?g+"Y":"")+(h?h+"M":"")+(i?i+"D":"")+(j||k||l?"T":"")+(j?j+"H":"")+(k?k+"M":"")+(l?l+"S":""):"P0D"} //! moment.js locale configuration -- cgit v1.2.3 From 4364559805245260f89d70d590d0686250aa5f93 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 16 Jul 2016 14:11:14 -0400 Subject: Handle HTTP header X-Real-IP to get IP address --- ChangeLog | 1 + app/Core/Http/Request.php | 1 + tests/units/Core/Http/RequestTest.php | 3 +++ 3 files changed, 5 insertions(+) diff --git a/ChangeLog b/ChangeLog index 6c5c5107..64476247 100644 --- a/ChangeLog +++ b/ChangeLog @@ -9,6 +9,7 @@ New features: Improvements: +* Handle header X-Real-IP to get IP address * Display project name for task auto-complete fields * Make search attributes not case sensitive * Display TOTP issuer for 2FA diff --git a/app/Core/Http/Request.php b/app/Core/Http/Request.php index e0df2d3c..2e84958d 100644 --- a/app/Core/Http/Request.php +++ b/app/Core/Http/Request.php @@ -301,6 +301,7 @@ class Request extends Base public function getIpAddress() { $keys = array( + 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', diff --git a/tests/units/Core/Http/RequestTest.php b/tests/units/Core/Http/RequestTest.php index 6fa796f7..1db0100c 100644 --- a/tests/units/Core/Http/RequestTest.php +++ b/tests/units/Core/Http/RequestTest.php @@ -169,6 +169,9 @@ class RequestTest extends Base $request = new Request($this->container, array(), array(), array(), array(), array()); $this->assertEquals('Unknown', $request->getIpAddress()); + $request = new Request($this->container, array('HTTP_X_REAL_IP' => '192.168.1.1,127.0.0.1'), array(), array(), array(), array()); + $this->assertEquals('192.168.1.1', $request->getIpAddress()); + $request = new Request($this->container, array('HTTP_X_FORWARDED_FOR' => '192.168.0.1,127.0.0.1'), array(), array(), array(), array()); $this->assertEquals('192.168.0.1', $request->getIpAddress()); -- cgit v1.2.3 From 3aa0f8574898876518dddf29ced43dd32efa2375 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 16 Jul 2016 19:15:26 -0400 Subject: Fixed search query with multiple assignees (nested OR conditions) --- ChangeLog | 1 + composer.json | 2 +- composer.lock | 14 +++++++------- tests/units/Core/Filter/LexerBuilderTest.php | 26 ++++++++++++++++++++++++++ tests/units/Core/Filter/OrCriteriaTest.php | 5 +++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/ChangeLog b/ChangeLog index 64476247..e87c0965 100644 --- a/ChangeLog +++ b/ChangeLog @@ -17,6 +17,7 @@ Improvements: Bug fixes: +* Fixed search query with multiple assignees (nested OR conditions) * Fixed Markdown editor auto-grow on the task form (Safari) * Fixed compatibility issue with PHP 5.3 for OAuthUserProvider class diff --git a/composer.json b/composer.json index a619dc0a..443fb826 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "eluceo/ical": "0.10.1", "erusev/parsedown" : "1.6.0", "fguillot/json-rpc" : "1.2.1", - "fguillot/picodb" : "1.0.13", + "fguillot/picodb" : "1.0.14", "fguillot/simpleLogger" : "1.0.1", "fguillot/simple-validator" : "1.0.1", "fguillot/simple-queue" : "1.0.1", diff --git a/composer.lock b/composer.lock index a7697470..33c2ca71 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "77e9464e13331b1dec5c810d1c186ebf", - "content-hash": "0d13e80c805e296f3765cc329d51358a", + "hash": "daa76b43d528f87e3bed91133fdb9259", + "content-hash": "dde1b92fc6f9ca106cf927f4cd141a21", "packages": [ { "name": "christian-riesen/base32", @@ -245,16 +245,16 @@ }, { "name": "fguillot/picodb", - "version": "v1.0.13", + "version": "v1.0.14", "source": { "type": "git", "url": "https://github.com/fguillot/picoDb.git", - "reference": "e8e02cd6a170811eed6c70d1edcbb419818d303a" + "reference": "86a831302ab10af800c83dbe4b3b01c88d5433f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fguillot/picoDb/zipball/e8e02cd6a170811eed6c70d1edcbb419818d303a", - "reference": "e8e02cd6a170811eed6c70d1edcbb419818d303a", + "url": "https://api.github.com/repos/fguillot/picoDb/zipball/86a831302ab10af800c83dbe4b3b01c88d5433f1", + "reference": "86a831302ab10af800c83dbe4b3b01c88d5433f1", "shasum": "" }, "require": { @@ -281,7 +281,7 @@ ], "description": "Minimalist database query builder", "homepage": "https://github.com/fguillot/picoDb", - "time": "2016-07-13 02:11:01" + "time": "2016-07-16 22:59:59" }, { "name": "fguillot/simple-queue", diff --git a/tests/units/Core/Filter/LexerBuilderTest.php b/tests/units/Core/Filter/LexerBuilderTest.php index 65bb87b9..31e237dc 100644 --- a/tests/units/Core/Filter/LexerBuilderTest.php +++ b/tests/units/Core/Filter/LexerBuilderTest.php @@ -8,6 +8,7 @@ use Kanboard\Filter\TaskTitleFilter; use Kanboard\Model\ProjectModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; +use Kanboard\Model\UserModel; class LexerBuilderTest extends Base { @@ -123,4 +124,29 @@ class LexerBuilderTest extends Base $this->assertCount(1, $tasks); $this->assertEquals('Test', $tasks[0]['title']); } + + public function testWithOrCriteria() + { + $taskFinder = new TaskFinderModel($this->container); + $taskCreation = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $userModel = new UserModel($this->container); + $query = $taskFinder->getExtendedQuery(); + + $this->assertEquals(2, $userModel->create(array('username' => 'foobar', 'name' => 'Foo Bar'))); + $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); + $this->assertEquals(1, $taskCreation->create(array('title' => 'Test 1', 'project_id' => 1, 'owner_id' => 2))); + $this->assertEquals(2, $taskCreation->create(array('title' => 'Test 2', 'project_id' => 1, 'owner_id' => 1))); + $this->assertEquals(3, $taskCreation->create(array('title' => 'Test 3', 'project_id' => 1, 'owner_id' => 0))); + + $builder = new LexerBuilder(); + $builder->withFilter(new TaskAssigneeFilter()); + $builder->withFilter(new TaskTitleFilter(), true); + $builder->withQuery($query); + $tasks = $builder->build('assignee:admin assignee:foobar')->toArray(); + + $this->assertCount(2, $tasks); + $this->assertEquals('Test 1', $tasks[0]['title']); + $this->assertEquals('Test 2', $tasks[1]['title']); + } } diff --git a/tests/units/Core/Filter/OrCriteriaTest.php b/tests/units/Core/Filter/OrCriteriaTest.php index a46726c3..cf520f36 100644 --- a/tests/units/Core/Filter/OrCriteriaTest.php +++ b/tests/units/Core/Filter/OrCriteriaTest.php @@ -22,8 +22,9 @@ class OrCriteriaTest extends Base $this->assertEquals(2, $userModel->create(array('username' => 'foobar', 'name' => 'Foo Bar'))); $this->assertEquals(1, $projectModel->create(array('name' => 'Test'))); - $this->assertEquals(1, $taskCreation->create(array('title' => 'Test', 'project_id' => 1, 'owner_id' => 2))); - $this->assertEquals(2, $taskCreation->create(array('title' => 'Test', 'project_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $taskCreation->create(array('title' => 'Test 1', 'project_id' => 1, 'owner_id' => 2))); + $this->assertEquals(2, $taskCreation->create(array('title' => 'Test 2', 'project_id' => 1, 'owner_id' => 1))); + $this->assertEquals(3, $taskCreation->create(array('title' => 'Test 3', 'project_id' => 1, 'owner_id' => 0))); $criteria = new OrCriteria(); $criteria->withQuery($query); -- cgit v1.2.3 From ec0ecc5b0387924f061865f4ec12dbfc5b7018fe Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 17 Jul 2016 17:15:14 -0400 Subject: Added event for removed comments with some refactoring --- ChangeLog | 1 + app/Core/Base.php | 2 + app/Core/Queue/JobHandler.php | 24 +++-- app/Core/Queue/QueueManager.php | 6 +- app/EventBuilder/BaseEventBuilder.php | 23 +++++ app/EventBuilder/CommentEventBuilder.php | 48 ++++++++++ app/Job/CommentEventJob.php | 50 ++++++++++ app/Job/NotificationJob.php | 3 +- app/Model/CommentModel.php | 9 +- app/Model/NotificationModel.php | 5 + app/ServiceProvider/ClassProvider.php | 4 + app/Subscriber/NotificationSubscriber.php | 1 + app/Template/event/comment_remove.php | 11 +++ app/Template/event/comment_update.php | 3 + app/Template/notification/comment_remove.php | 7 ++ .../units/EventBuilder/CommentEventBuilderTest.php | 37 +++++++ tests/units/Job/CommentEventJobTest.php | 52 ++++++++++ tests/units/Model/CommentModelTest.php | 106 +++++++++++++++++++++ tests/units/Model/CommentTest.php | 106 --------------------- 19 files changed, 376 insertions(+), 122 deletions(-) create mode 100644 app/EventBuilder/BaseEventBuilder.php create mode 100644 app/EventBuilder/CommentEventBuilder.php create mode 100644 app/Job/CommentEventJob.php create mode 100644 app/Template/event/comment_remove.php create mode 100644 app/Template/notification/comment_remove.php create mode 100644 tests/units/EventBuilder/CommentEventBuilderTest.php create mode 100644 tests/units/Job/CommentEventJobTest.php create mode 100644 tests/units/Model/CommentModelTest.php delete mode 100644 tests/units/Model/CommentTest.php diff --git a/ChangeLog b/ChangeLog index e87c0965..a1e39436 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,7 @@ Version 1.0.32 (unreleased) New features: * New automated action to close tasks without activity in a specific column +* Added new event for removed comments * Added search filter for task priority * Added the possibility to hide tasks in dashboard for a specific column diff --git a/app/Core/Base.php b/app/Core/Base.php index 8103ec14..e413a4ac 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -150,6 +150,8 @@ use Pimple\Container; * @property \Kanboard\Core\Filter\QueryBuilder $taskQuery * @property \Kanboard\Core\Filter\LexerBuilder $taskLexer * @property \Kanboard\Core\Filter\LexerBuilder $projectActivityLexer + * @property \Kanboard\Job\CommentEventJob $commentEventJob + * @property \Kanboard\Job\NotificationJob $notificationJob * @property \Psr\Log\LoggerInterface $logger * @property \PicoDb\Database $db * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher diff --git a/app/Core/Queue/JobHandler.php b/app/Core/Queue/JobHandler.php index 7ca36328..326f3cef 100644 --- a/app/Core/Queue/JobHandler.php +++ b/app/Core/Queue/JobHandler.php @@ -2,6 +2,7 @@ namespace Kanboard\Core\Queue; +use Exception; use Kanboard\Core\Base; use Kanboard\Job\BaseJob; use SimpleQueue\Job; @@ -39,16 +40,23 @@ class JobHandler extends Base public function executeJob(Job $job) { $payload = $job->getBody(); - $className = $payload['class']; - $this->memoryCache->flush(); - $this->prepareJobSession($payload['user_id']); - if (DEBUG) { - $this->logger->debug(__METHOD__.' Received job => '.$className.' ('.getmypid().')'); - } + try { + $className = $payload['class']; + $this->memoryCache->flush(); + $this->prepareJobSession($payload['user_id']); + + if (DEBUG) { + $this->logger->debug(__METHOD__.' Received job => '.$className.' ('.getmypid().')'); + $this->logger->debug(__METHOD__.' => '.json_encode($payload)); + } - $worker = new $className($this->container); - call_user_func_array(array($worker, 'execute'), $payload['params']); + $worker = new $className($this->container); + call_user_func_array(array($worker, 'execute'), $payload['params']); + } catch (Exception $e) { + $this->logger->error(__METHOD__.': Error during job execution: '.$e->getMessage()); + $this->logger->error(__METHOD__ .' => '.json_encode($payload)); + } } /** diff --git a/app/Core/Queue/QueueManager.php b/app/Core/Queue/QueueManager.php index f34cb220..dcf0ebf5 100644 --- a/app/Core/Queue/QueueManager.php +++ b/app/Core/Queue/QueueManager.php @@ -42,9 +42,13 @@ class QueueManager extends Base */ public function push(BaseJob $job) { + $jobClassName = get_class($job); + if ($this->queue !== null) { + $this->logger->debug(__METHOD__.': Job pushed in queue: '.$jobClassName); $this->queue->push(JobHandler::getInstance($this->container)->serializeJob($job)); } else { + $this->logger->debug(__METHOD__.': Job executed synchronously: '.$jobClassName); call_user_func_array(array($job, 'execute'), $job->getJobParams()); } @@ -60,7 +64,7 @@ class QueueManager extends Base public function listen() { if ($this->queue === null) { - throw new LogicException('No Queue Driver defined!'); + throw new LogicException('No queue driver defined!'); } while ($job = $this->queue->pull()) { diff --git a/app/EventBuilder/BaseEventBuilder.php b/app/EventBuilder/BaseEventBuilder.php new file mode 100644 index 00000000..c677563e --- /dev/null +++ b/app/EventBuilder/BaseEventBuilder.php @@ -0,0 +1,23 @@ +commentId = $commentId; + return $this; + } + + /** + * Build event data + * + * @access public + * @return CommentEvent|null + */ + public function build() + { + $comment = $this->commentModel->getById($this->commentId); + + if (empty($comment)) { + return null; + } + + return new CommentEvent(array( + 'comment' => $comment, + 'task' => $this->taskFinderModel->getDetails($comment['task_id']), + )); + } +} diff --git a/app/Job/CommentEventJob.php b/app/Job/CommentEventJob.php new file mode 100644 index 00000000..c89350ed --- /dev/null +++ b/app/Job/CommentEventJob.php @@ -0,0 +1,50 @@ +jobParams = array($commentId, $eventName); + return $this; + } + + /** + * Execute job + * + * @param int $commentId + * @param string $eventName + * @return $this + */ + public function execute($commentId, $eventName) + { + $event = CommentEventBuilder::getInstance($this->container) + ->withCommentId($commentId) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + + if ($eventName === CommentModel::EVENT_CREATE) { + $this->userMentionModel->fireEvents($event['comment']['comment'], CommentModel::EVENT_USER_MENTION, $event); + } + } + } +} diff --git a/app/Job/NotificationJob.php b/app/Job/NotificationJob.php index 904a9273..cfd0699d 100644 --- a/app/Job/NotificationJob.php +++ b/app/Job/NotificationJob.php @@ -75,8 +75,7 @@ class NotificationJob extends BaseJob $values['task'] = $this->taskFinderModel->getDetails($values['file']['task_id']); break; case 'Kanboard\Event\CommentEvent': - $values['comment'] = $this->commentModel->getById($event['id']); - $values['task'] = $this->taskFinderModel->getDetails($values['comment']['task_id']); + $values = $event; break; } diff --git a/app/Model/CommentModel.php b/app/Model/CommentModel.php index 4231f29d..6b983858 100644 --- a/app/Model/CommentModel.php +++ b/app/Model/CommentModel.php @@ -2,7 +2,6 @@ namespace Kanboard\Model; -use Kanboard\Event\CommentEvent; use Kanboard\Core\Base; /** @@ -27,6 +26,7 @@ class CommentModel extends Base */ const EVENT_UPDATE = 'comment.update'; const EVENT_CREATE = 'comment.create'; + const EVENT_REMOVE = 'comment.remove'; const EVENT_USER_MENTION = 'comment.user.mention'; /** @@ -130,9 +130,7 @@ class CommentModel extends Base $comment_id = $this->db->table(self::TABLE)->persist($values); if ($comment_id !== false) { - $event = new CommentEvent(array('id' => $comment_id) + $values); - $this->dispatcher->dispatch(self::EVENT_CREATE, $event); - $this->userMentionModel->fireEvents($values['comment'], self::EVENT_USER_MENTION, $event); + $this->queueManager->push($this->commentEventJob->withParams($comment_id, self::EVENT_CREATE)); } return $comment_id; @@ -153,7 +151,7 @@ class CommentModel extends Base ->update(array('comment' => $values['comment'])); if ($result) { - $this->container['dispatcher']->dispatch(self::EVENT_UPDATE, new CommentEvent($values)); + $this->queueManager->push($this->commentEventJob->withParams($values['id'], self::EVENT_UPDATE)); } return $result; @@ -168,6 +166,7 @@ class CommentModel extends Base */ public function remove($comment_id) { + $this->commentEventJob->execute($comment_id, self::EVENT_REMOVE); return $this->db->table(self::TABLE)->eq('id', $comment_id)->remove(); } } diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index 4d697b5e..df481fc7 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -74,6 +74,8 @@ class NotificationModel extends Base return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); case CommentModel::EVENT_CREATE: return e('%s commented on the task #%d', $event_author, $event_data['task']['id']); + case CommentModel::EVENT_REMOVE: + return e('%s removed a comment on the task #%d', $event_author, $event_data['task']['id']); case TaskFileModel::EVENT_CREATE: return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']); case TaskModel::EVENT_USER_MENTION: @@ -102,6 +104,8 @@ class NotificationModel extends Base return e('New comment on task #%d', $event_data['comment']['task_id']); case CommentModel::EVENT_UPDATE: return e('Comment updated on task #%d', $event_data['comment']['task_id']); + case CommentModel::EVENT_REMOVE: + return e('Comment removed on task #%d', $event_data['comment']['task_id']); case SubtaskModel::EVENT_CREATE: return e('New subtask on task #%d', $event_data['subtask']['task_id']); case SubtaskModel::EVENT_UPDATE: @@ -149,6 +153,7 @@ class NotificationModel extends Base return $event_data['file']['task_id']; case CommentModel::EVENT_CREATE: case CommentModel::EVENT_UPDATE: + case CommentModel::EVENT_REMOVE: return $event_data['comment']['task_id']; case SubtaskModel::EVENT_CREATE: case SubtaskModel::EVENT_UPDATE: diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index e32c0d43..43ade56e 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -26,6 +26,10 @@ class ClassProvider implements ServiceProviderInterface 'AverageLeadCycleTimeAnalytic', 'AverageTimeSpentColumnAnalytic', ), + 'Job' => array( + 'CommentEventJob', + 'NotificationJob', + ), 'Model' => array( 'ActionModel', 'ActionParameterModel', diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index db11e585..6cd7675c 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -28,6 +28,7 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn SubtaskModel::EVENT_UPDATE => 'handleEvent', CommentModel::EVENT_CREATE => 'handleEvent', CommentModel::EVENT_UPDATE => 'handleEvent', + CommentModel::EVENT_REMOVE => 'handleEvent', CommentModel::EVENT_USER_MENTION => 'handleEvent', TaskFileModel::EVENT_CREATE => 'handleEvent', ); diff --git a/app/Template/event/comment_remove.php b/app/Template/event/comment_remove.php new file mode 100644 index 00000000..ead7d56a --- /dev/null +++ b/app/Template/event/comment_remove.php @@ -0,0 +1,11 @@ +

            + text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

            +
            +

            text->e($task['title']) ?>

            +
            text->markdown($comment['comment']) ?>
            +
            diff --git a/app/Template/event/comment_update.php b/app/Template/event/comment_update.php index 5a0821bd..5be598ac 100644 --- a/app/Template/event/comment_update.php +++ b/app/Template/event/comment_update.php @@ -7,4 +7,7 @@

            text->e($task['title']) ?>

            + +
            text->markdown($comment['comment']) ?>
            +
            diff --git a/app/Template/notification/comment_remove.php b/app/Template/notification/comment_remove.php new file mode 100644 index 00000000..928623ec --- /dev/null +++ b/app/Template/notification/comment_remove.php @@ -0,0 +1,7 @@ +

            text->e($task['title']) ?> (#)

            + +

            + +text->markdown($comment['comment']) ?> + +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/tests/units/EventBuilder/CommentEventBuilderTest.php b/tests/units/EventBuilder/CommentEventBuilderTest.php new file mode 100644 index 00000000..a490799e --- /dev/null +++ b/tests/units/EventBuilder/CommentEventBuilderTest.php @@ -0,0 +1,37 @@ +container); + $commentEventBuilder->withCommentId(42); + $this->assertNull($commentEventBuilder->build()); + } + + public function testBuild() + { + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $commentEventBuilder = new CommentEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla', 'user_id' => 1))); + + $commentEventBuilder->withCommentId(1); + $event = $commentEventBuilder->build(); + + $this->assertInstanceOf('Kanboard\Event\CommentEvent', $event); + $this->assertNotEmpty($event['comment']); + $this->assertNotEmpty($event['task']); + } +} diff --git a/tests/units/Job/CommentEventJobTest.php b/tests/units/Job/CommentEventJobTest.php new file mode 100644 index 00000000..88742feb --- /dev/null +++ b/tests/units/Job/CommentEventJobTest.php @@ -0,0 +1,52 @@ +container); + $commentEventJob->withParams(123, 'foobar'); + + $this->assertSame(array(123, 'foobar'), $commentEventJob->getJobParams()); + } + + public function testWithMissingComment() + { + $this->container['dispatcher']->addListener(CommentModel::EVENT_CREATE, function() {}); + + $commentEventJob = new CommentEventJob($this->container); + $commentEventJob->execute(42, CommentModel::EVENT_CREATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerEvents() + { + $this->container['dispatcher']->addListener(CommentModel::EVENT_CREATE, function() {}); + $this->container['dispatcher']->addListener(CommentModel::EVENT_UPDATE, function() {}); + $this->container['dispatcher']->addListener(CommentModel::EVENT_REMOVE, function() {}); + + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'foobar', 'user_id' => 1))); + $this->assertTrue($commentModel->update(array('id' => 1, 'comment' => 'test'))); + $this->assertTrue($commentModel->remove(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(CommentModel::EVENT_CREATE.'.closure', $called); + $this->assertArrayHasKey(CommentModel::EVENT_UPDATE.'.closure', $called); + $this->assertArrayHasKey(CommentModel::EVENT_REMOVE.'.closure', $called); + } +} diff --git a/tests/units/Model/CommentModelTest.php b/tests/units/Model/CommentModelTest.php new file mode 100644 index 00000000..4178a839 --- /dev/null +++ b/tests/units/Model/CommentModelTest.php @@ -0,0 +1,106 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla', 'user_id' => 1))); + $this->assertEquals(2, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla'))); + + $comment = $commentModel->getById(1); + $this->assertNotEmpty($comment); + $this->assertEquals('bla bla', $comment['comment']); + $this->assertEquals(1, $comment['task_id']); + $this->assertEquals(1, $comment['user_id']); + $this->assertEquals('admin', $comment['username']); + $this->assertEquals(time(), $comment['date_creation'], '', 3); + + $comment = $commentModel->getById(2); + $this->assertNotEmpty($comment); + $this->assertEquals('bla bla', $comment['comment']); + $this->assertEquals(1, $comment['task_id']); + $this->assertEquals(0, $comment['user_id']); + $this->assertEquals('', $comment['username']); + $this->assertEquals(time(), $comment['date_creation'], '', 3); + } + + public function testGetAll() + { + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); + $this->assertEquals(2, $commentModel->create(array('task_id' => 1, 'comment' => 'c2', 'user_id' => 1))); + $this->assertEquals(3, $commentModel->create(array('task_id' => 1, 'comment' => 'c3', 'user_id' => 1))); + + $comments = $commentModel->getAll(1); + + $this->assertNotEmpty($comments); + $this->assertEquals(3, count($comments)); + $this->assertEquals(1, $comments[0]['id']); + $this->assertEquals(2, $comments[1]['id']); + $this->assertEquals(3, $comments[2]['id']); + + $this->assertEquals(3, $commentModel->count(1)); + } + + public function testUpdate() + { + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); + $this->assertTrue($commentModel->update(array('id' => 1, 'comment' => 'bla'))); + + $comment = $commentModel->getById(1); + $this->assertNotEmpty($comment); + $this->assertEquals('bla', $comment['comment']); + } + + public function testRemove() + { + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); + + $this->assertTrue($commentModel->remove(1)); + $this->assertFalse($commentModel->remove(1)); + $this->assertFalse($commentModel->remove(1111)); + } + + public function testGetProjectId() + { + $commentModel = new CommentModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); + + $this->assertEquals(1, $commentModel->getProjectId(1)); + $this->assertSame(0, $commentModel->getProjectId(2)); + } +} diff --git a/tests/units/Model/CommentTest.php b/tests/units/Model/CommentTest.php deleted file mode 100644 index 574b5a87..00000000 --- a/tests/units/Model/CommentTest.php +++ /dev/null @@ -1,106 +0,0 @@ -container); - $taskCreationModel = new TaskCreationModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); - $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla', 'user_id' => 1))); - $this->assertEquals(2, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla'))); - - $comment = $commentModel->getById(1); - $this->assertNotEmpty($comment); - $this->assertEquals('bla bla', $comment['comment']); - $this->assertEquals(1, $comment['task_id']); - $this->assertEquals(1, $comment['user_id']); - $this->assertEquals('admin', $comment['username']); - $this->assertEquals(time(), $comment['date_creation'], '', 3); - - $comment = $commentModel->getById(2); - $this->assertNotEmpty($comment); - $this->assertEquals('bla bla', $comment['comment']); - $this->assertEquals(1, $comment['task_id']); - $this->assertEquals(0, $comment['user_id']); - $this->assertEquals('', $comment['username']); - $this->assertEquals(time(), $comment['date_creation'], '', 3); - } - - public function testGetAll() - { - $commentModel = new CommentModel($this->container); - $taskCreationModel = new TaskCreationModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); - $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); - $this->assertEquals(2, $commentModel->create(array('task_id' => 1, 'comment' => 'c2', 'user_id' => 1))); - $this->assertEquals(3, $commentModel->create(array('task_id' => 1, 'comment' => 'c3', 'user_id' => 1))); - - $comments = $commentModel->getAll(1); - - $this->assertNotEmpty($comments); - $this->assertEquals(3, count($comments)); - $this->assertEquals(1, $comments[0]['id']); - $this->assertEquals(2, $comments[1]['id']); - $this->assertEquals(3, $comments[2]['id']); - - $this->assertEquals(3, $commentModel->count(1)); - } - - public function testUpdate() - { - $commentModel = new CommentModel($this->container); - $taskCreationModel = new TaskCreationModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); - $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); - $this->assertTrue($commentModel->update(array('id' => 1, 'comment' => 'bla'))); - - $comment = $commentModel->getById(1); - $this->assertNotEmpty($comment); - $this->assertEquals('bla', $comment['comment']); - } - - public function validateRemove() - { - $commentModel = new CommentModel($this->container); - $taskCreationModel = new TaskCreationModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); - $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); - - $this->assertTrue($commentModel->remove(1)); - $this->assertFalse($commentModel->remove(1)); - $this->assertFalse($commentModel->remove(1111)); - } - - public function testGetProjectId() - { - $commentModel = new CommentModel($this->container); - $taskCreationModel = new TaskCreationModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1, 'column_id' => 3, 'owner_id' => 1))); - $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'c1', 'user_id' => 1))); - - $this->assertEquals(1, $commentModel->getProjectId(1)); - $this->assertSame(0, $commentModel->getProjectId(2)); - } -} -- cgit v1.2.3 From cbe52e57200a66522d88dfc5d5f46de8eb87ffb2 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 17 Jul 2016 18:47:06 -0400 Subject: File events refactoring --- app/Core/Base.php | 2 + app/Event/FileEvent.php | 7 --- app/Event/ProjectFileEvent.php | 7 +++ app/Event/TaskFileEvent.php | 7 +++ app/EventBuilder/ProjectFileEventBuilder.php | 50 ++++++++++++++++++++++ app/EventBuilder/TaskFileEventBuilder.php | 50 ++++++++++++++++++++++ app/Job/NotificationJob.php | 7 +-- app/Job/ProjectFileEventJob.php | 45 +++++++++++++++++++ app/Job/TaskFileEventJob.php | 45 +++++++++++++++++++ app/Model/FileModel.php | 22 +++++----- app/Model/ProjectFileModel.php | 9 ++-- app/Model/TaskFileModel.php | 23 +++++----- app/ServiceProvider/ClassProvider.php | 2 + app/Subscriber/BaseSubscriber.php | 24 ----------- app/Subscriber/NotificationSubscriber.php | 10 ++--- app/Subscriber/ProjectDailySummarySubscriber.php | 2 +- .../ProjectModificationDateSubscriber.php | 2 +- .../EventBuilder/ProjectFileEventBuilderTest.php | 33 ++++++++++++++ .../EventBuilder/TaskFileEventBuilderTest.php | 36 ++++++++++++++++ tests/units/Job/ProjectFileEventJobTest.php | 43 +++++++++++++++++++ tests/units/Job/TaskFileEventJobTest.php | 46 ++++++++++++++++++++ 21 files changed, 398 insertions(+), 74 deletions(-) delete mode 100644 app/Event/FileEvent.php create mode 100644 app/Event/ProjectFileEvent.php create mode 100644 app/Event/TaskFileEvent.php create mode 100644 app/EventBuilder/ProjectFileEventBuilder.php create mode 100644 app/EventBuilder/TaskFileEventBuilder.php create mode 100644 app/Job/ProjectFileEventJob.php create mode 100644 app/Job/TaskFileEventJob.php create mode 100644 tests/units/EventBuilder/ProjectFileEventBuilderTest.php create mode 100644 tests/units/EventBuilder/TaskFileEventBuilderTest.php create mode 100644 tests/units/Job/ProjectFileEventJobTest.php create mode 100644 tests/units/Job/TaskFileEventJobTest.php diff --git a/app/Core/Base.php b/app/Core/Base.php index e413a4ac..09e04456 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -151,6 +151,8 @@ use Pimple\Container; * @property \Kanboard\Core\Filter\LexerBuilder $taskLexer * @property \Kanboard\Core\Filter\LexerBuilder $projectActivityLexer * @property \Kanboard\Job\CommentEventJob $commentEventJob + * @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob + * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob * @property \Psr\Log\LoggerInterface $logger * @property \PicoDb\Database $db diff --git a/app/Event/FileEvent.php b/app/Event/FileEvent.php deleted file mode 100644 index 482a4eab..00000000 --- a/app/Event/FileEvent.php +++ /dev/null @@ -1,7 +0,0 @@ -fileId = $fileId; + return $this; + } + + /** + * Build event data + * + * @access public + * @return GenericEvent|null + */ + public function build() + { + $file = $this->projectFileModel->getById($this->fileId); + + if (empty($file)) { + $this->logger->debug(__METHOD__.': File not found'); + return null; + } + + return new ProjectFileEvent(array( + 'file' => $file, + 'project' => $this->projectModel->getById($file['project_id']), + )); + } +} diff --git a/app/EventBuilder/TaskFileEventBuilder.php b/app/EventBuilder/TaskFileEventBuilder.php new file mode 100644 index 00000000..7f1ce3b3 --- /dev/null +++ b/app/EventBuilder/TaskFileEventBuilder.php @@ -0,0 +1,50 @@ +fileId = $fileId; + return $this; + } + + /** + * Build event data + * + * @access public + * @return GenericEvent|null + */ + public function build() + { + $file = $this->taskFileModel->getById($this->fileId); + + if (empty($file)) { + $this->logger->debug(__METHOD__.': File not found'); + return null; + } + + return new TaskFileEvent(array( + 'file' => $file, + 'task' => $this->taskFinderModel->getDetails($file['task_id']), + )); + } +} diff --git a/app/Job/NotificationJob.php b/app/Job/NotificationJob.php index cfd0699d..ed568e47 100644 --- a/app/Job/NotificationJob.php +++ b/app/Job/NotificationJob.php @@ -70,13 +70,8 @@ class NotificationJob extends BaseJob $values['subtask'] = $this->subtaskModel->getById($event['id'], true); $values['task'] = $this->taskFinderModel->getDetails($values['subtask']['task_id']); break; - case 'Kanboard\Event\FileEvent': - $values['file'] = $event; - $values['task'] = $this->taskFinderModel->getDetails($values['file']['task_id']); - break; - case 'Kanboard\Event\CommentEvent': + default: $values = $event; - break; } return $values; diff --git a/app/Job/ProjectFileEventJob.php b/app/Job/ProjectFileEventJob.php new file mode 100644 index 00000000..d68949c5 --- /dev/null +++ b/app/Job/ProjectFileEventJob.php @@ -0,0 +1,45 @@ +jobParams = array($fileId, $eventName); + return $this; + } + + /** + * Execute job + * + * @param int $fileId + * @param string $eventName + * @return $this + */ + public function execute($fileId, $eventName) + { + $event = ProjectFileEventBuilder::getInstance($this->container) + ->withFileId($fileId) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/app/Job/TaskFileEventJob.php b/app/Job/TaskFileEventJob.php new file mode 100644 index 00000000..de2c40db --- /dev/null +++ b/app/Job/TaskFileEventJob.php @@ -0,0 +1,45 @@ +jobParams = array($fileId, $eventName); + return $this; + } + + /** + * Execute job + * + * @param int $fileId + * @param string $eventName + * @return $this + */ + public function execute($fileId, $eventName) + { + $event = TaskFileEventBuilder::getInstance($this->container) + ->withFileId($fileId) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/app/Model/FileModel.php b/app/Model/FileModel.php index 8cdea9a0..98032f9d 100644 --- a/app/Model/FileModel.php +++ b/app/Model/FileModel.php @@ -5,7 +5,6 @@ namespace Kanboard\Model; use Exception; use Kanboard\Core\Base; use Kanboard\Core\Thumbnail; -use Kanboard\Event\FileEvent; use Kanboard\Core\ObjectStorage\ObjectStorageException; /** @@ -44,13 +43,13 @@ abstract class FileModel extends Base abstract protected function getPathPrefix(); /** - * Get event name + * Fire file creation event * * @abstract * @access protected - * @return string + * @param integer $file_id */ - abstract protected function getEventName(); + abstract protected function fireCreationEvent($file_id); /** * Get PicoDb query to get all files @@ -130,16 +129,16 @@ abstract class FileModel extends Base * Create a file entry in the database * * @access public - * @param integer $id Foreign key - * @param string $name Filename - * @param string $path Path on the disk - * @param integer $size File size + * @param integer $foreign_key_id Foreign key + * @param string $name Filename + * @param string $path Path on the disk + * @param integer $size File size * @return bool|integer */ - public function create($id, $name, $path, $size) + public function create($foreign_key_id, $name, $path, $size) { $values = array( - $this->getForeignKey() => $id, + $this->getForeignKey() => $foreign_key_id, 'name' => substr($name, 0, 255), 'path' => $path, 'is_image' => $this->isImage($name) ? 1 : 0, @@ -152,8 +151,7 @@ abstract class FileModel extends Base if ($result) { $file_id = (int) $this->db->getLastId(); - $event = new FileEvent($values + array('file_id' => $file_id)); - $this->dispatcher->dispatch($this->getEventName(), $event); + $this->fireCreationEvent($file_id); return $file_id; } diff --git a/app/Model/ProjectFileModel.php b/app/Model/ProjectFileModel.php index b464bb2a..4de4d66d 100644 --- a/app/Model/ProjectFileModel.php +++ b/app/Model/ProjectFileModel.php @@ -61,14 +61,13 @@ class ProjectFileModel extends FileModel } /** - * Get event name + * Fire file creation event * - * @abstract * @access protected - * @return string + * @param integer $file_id */ - protected function getEventName() + protected function fireCreationEvent($file_id) { - return self::EVENT_CREATE; + $this->queueManager->push($this->projectFileEventJob->withParams($file_id, self::EVENT_CREATE)); } } diff --git a/app/Model/TaskFileModel.php b/app/Model/TaskFileModel.php index 7603019a..0163da28 100644 --- a/app/Model/TaskFileModel.php +++ b/app/Model/TaskFileModel.php @@ -60,18 +60,6 @@ class TaskFileModel extends FileModel return 'tasks'; } - /** - * Get event name - * - * @abstract - * @access protected - * @return string - */ - protected function getEventName() - { - return self::EVENT_CREATE; - } - /** * Get projectId from fileId * @@ -101,4 +89,15 @@ class TaskFileModel extends FileModel $original_filename = e('Screenshot taken %s', $this->helper->dt->datetime(time())).'.png'; return $this->uploadContent($task_id, $original_filename, $blob); } + + /** + * Fire file creation event + * + * @access protected + * @param integer $file_id + */ + protected function fireCreationEvent($file_id) + { + $this->queueManager->push($this->taskFileEventJob->withParams($file_id, self::EVENT_CREATE)); + } } diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 43ade56e..428a8015 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -28,6 +28,8 @@ class ClassProvider implements ServiceProviderInterface ), 'Job' => array( 'CommentEventJob', + 'TaskFileEventJob', + 'ProjectFileEventJob', 'NotificationJob', ), 'Model' => array( diff --git a/app/Subscriber/BaseSubscriber.php b/app/Subscriber/BaseSubscriber.php index fdea29f6..92441962 100644 --- a/app/Subscriber/BaseSubscriber.php +++ b/app/Subscriber/BaseSubscriber.php @@ -12,28 +12,4 @@ use Kanboard\Core\Base; */ class BaseSubscriber extends Base { - /** - * Method called - * - * @access private - * @var array - */ - private $called = array(); - - /** - * Check if a listener has been executed - * - * @access public - * @param string $key - * @return boolean - */ - public function isExecuted($key = '') - { - if (isset($this->called[$key])) { - return true; - } - - $this->called[$key] = true; - return false; - } } diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 6cd7675c..47222f73 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -36,12 +36,10 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn public function handleEvent(GenericEvent $event, $eventName) { - if (!$this->isExecuted($eventName)) { - $this->logger->debug('Subscriber executed: ' . __METHOD__); + $this->logger->debug('Subscriber executed: ' . __METHOD__); - $this->queueManager->push(NotificationJob::getInstance($this->container) - ->withParams($event, $eventName, get_class($event)) - ); - } + $this->queueManager->push(NotificationJob::getInstance($this->container) + ->withParams($event, $eventName, get_class($event)) + ); } } diff --git a/app/Subscriber/ProjectDailySummarySubscriber.php b/app/Subscriber/ProjectDailySummarySubscriber.php index 6971a121..7e3c11c3 100644 --- a/app/Subscriber/ProjectDailySummarySubscriber.php +++ b/app/Subscriber/ProjectDailySummarySubscriber.php @@ -22,7 +22,7 @@ class ProjectDailySummarySubscriber extends BaseSubscriber implements EventSubsc public function execute(TaskEvent $event) { - if (isset($event['project_id']) && !$this->isExecuted()) { + if (isset($event['project_id'])) { $this->logger->debug('Subscriber executed: '.__METHOD__); $this->queueManager->push(ProjectMetricJob::getInstance($this->container)->withParams($event['project_id'])); } diff --git a/app/Subscriber/ProjectModificationDateSubscriber.php b/app/Subscriber/ProjectModificationDateSubscriber.php index fee04eaa..97923af9 100644 --- a/app/Subscriber/ProjectModificationDateSubscriber.php +++ b/app/Subscriber/ProjectModificationDateSubscriber.php @@ -24,7 +24,7 @@ class ProjectModificationDateSubscriber extends BaseSubscriber implements EventS public function execute(GenericEvent $event) { - if (isset($event['project_id']) && !$this->isExecuted()) { + if (isset($event['project_id'])) { $this->logger->debug('Subscriber executed: '.__METHOD__); $this->projectModel->updateModificationDate($event['project_id']); } diff --git a/tests/units/EventBuilder/ProjectFileEventBuilderTest.php b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php new file mode 100644 index 00000000..bfe22719 --- /dev/null +++ b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php @@ -0,0 +1,33 @@ +container); + $projectFileEventBuilder->withFileId(42); + $this->assertNull($projectFileEventBuilder->build()); + } + + public function testBuild() + { + $projectModel = new ProjectModel($this->container); + $projectFileModel = new ProjectFileModel($this->container); + $projectFileEventBuilder = new ProjectFileEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $projectFileModel->create(1, 'Test', '/tmp/test', 123)); + + $event = $projectFileEventBuilder->withFileId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\ProjectFileEvent', $event); + $this->assertNotEmpty($event['file']); + $this->assertNotEmpty($event['project']); + } +} diff --git a/tests/units/EventBuilder/TaskFileEventBuilderTest.php b/tests/units/EventBuilder/TaskFileEventBuilderTest.php new file mode 100644 index 00000000..c253b913 --- /dev/null +++ b/tests/units/EventBuilder/TaskFileEventBuilderTest.php @@ -0,0 +1,36 @@ +container); + $taskFileEventBuilder->withFileId(42); + $this->assertNull($taskFileEventBuilder->build()); + } + + public function testBuild() + { + $taskFileModel = new TaskFileModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskFileEventBuilder = new TaskFileEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $taskFileModel->create(1, 'Test', '/tmp/test', 123)); + + $event = $taskFileEventBuilder->withFileId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskFileEvent', $event); + $this->assertNotEmpty($event['file']); + $this->assertNotEmpty($event['task']); + } +} diff --git a/tests/units/Job/ProjectFileEventJobTest.php b/tests/units/Job/ProjectFileEventJobTest.php new file mode 100644 index 00000000..f266d293 --- /dev/null +++ b/tests/units/Job/ProjectFileEventJobTest.php @@ -0,0 +1,43 @@ +container); + $projectFileEventJob->withParams(123, 'foobar'); + + $this->assertSame(array(123, 'foobar'), $projectFileEventJob->getJobParams()); + } + + public function testWithMissingFile() + { + $this->container['dispatcher']->addListener(ProjectFileModel::EVENT_CREATE, function() {}); + + $projectFileEventJob = new ProjectFileEventJob($this->container); + $projectFileEventJob->execute(42, ProjectFileModel::EVENT_CREATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerEvents() + { + $this->container['dispatcher']->addListener(ProjectFileModel::EVENT_CREATE, function() {}); + + $projectModel = new ProjectModel($this->container); + $projectFileModel = new ProjectFileModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $projectFileModel->create(1, 'Test', '/tmp/test', 123)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(ProjectFileModel::EVENT_CREATE.'.closure', $called); + } +} diff --git a/tests/units/Job/TaskFileEventJobTest.php b/tests/units/Job/TaskFileEventJobTest.php new file mode 100644 index 00000000..921fe801 --- /dev/null +++ b/tests/units/Job/TaskFileEventJobTest.php @@ -0,0 +1,46 @@ +container); + $taskFileEventJob->withParams(123, 'foobar'); + + $this->assertSame(array(123, 'foobar'), $taskFileEventJob->getJobParams()); + } + + public function testWithMissingFile() + { + $this->container['dispatcher']->addListener(TaskFileModel::EVENT_CREATE, function() {}); + + $taskFileEventJob = new TaskFileEventJob($this->container); + $taskFileEventJob->execute(42, TaskFileModel::EVENT_CREATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerEvents() + { + $this->container['dispatcher']->addListener(TaskFileModel::EVENT_CREATE, function() {}); + + $taskFileModel = new TaskFileModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $taskFileModel->create(1, 'Test', '/tmp/test', 123)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskFileModel::EVENT_CREATE.'.closure', $called); + } +} -- cgit v1.2.3 From d9d37882228771bca0c7f53f5ffcef90ba7ac1c5 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 17 Jul 2016 20:33:27 -0400 Subject: Subtasks events refactoring and show delete in activity stream --- app/Core/Base.php | 1 + app/EventBuilder/SubtaskEventBuilder.php | 79 ++++++++++++++++++++++ app/Job/NotificationJob.php | 4 -- app/Job/SubtaskEventJob.php | 48 +++++++++++++ app/Model/NotificationModel.php | 5 ++ app/Model/SubtaskModel.php | 22 ++---- app/ServiceProvider/ClassProvider.php | 6 -- app/ServiceProvider/JobProvider.php | 52 ++++++++++++++ app/ServiceProvider/QueueProvider.php | 6 +- app/Subscriber/NotificationSubscriber.php | 1 + app/Template/event/subtask_delete.php | 15 ++++ app/Template/notification/subtask_delete.php | 11 +++ app/common.php | 1 + tests/units/Base.php | 1 + .../units/EventBuilder/SubtaskEventBuilderTest.php | 62 +++++++++++++++++ tests/units/Job/SubtaskEventJobTest.php | 52 ++++++++++++++ tests/units/Model/SubtaskModelTest.php | 70 +------------------ 17 files changed, 339 insertions(+), 97 deletions(-) create mode 100644 app/EventBuilder/SubtaskEventBuilder.php create mode 100644 app/Job/SubtaskEventJob.php create mode 100644 app/ServiceProvider/JobProvider.php create mode 100644 app/Template/event/subtask_delete.php create mode 100644 app/Template/notification/subtask_delete.php create mode 100644 tests/units/EventBuilder/SubtaskEventBuilderTest.php create mode 100644 tests/units/Job/SubtaskEventJobTest.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 09e04456..6650680b 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -151,6 +151,7 @@ use Pimple\Container; * @property \Kanboard\Core\Filter\LexerBuilder $taskLexer * @property \Kanboard\Core\Filter\LexerBuilder $projectActivityLexer * @property \Kanboard\Job\CommentEventJob $commentEventJob + * @property \Kanboard\Job\SubtaskEventJob $subtaskEventJob * @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob diff --git a/app/EventBuilder/SubtaskEventBuilder.php b/app/EventBuilder/SubtaskEventBuilder.php new file mode 100644 index 00000000..f0271257 --- /dev/null +++ b/app/EventBuilder/SubtaskEventBuilder.php @@ -0,0 +1,79 @@ +subtaskId = $subtaskId; + return $this; + } + + /** + * Set values + * + * @param array $values + * @return $this + */ + public function withValues(array $values) + { + $this->values = $values; + return $this; + } + + /** + * Build event data + * + * @access public + * @return GenericEvent|null + */ + public function build() + { + $eventData = array(); + $eventData['subtask'] = $this->subtaskModel->getById($this->subtaskId, true); + + if (empty($eventData['subtask'])) { + $this->logger->debug(__METHOD__.': Subtask not found'); + return null; + } + + if (! empty($this->values)) { + $eventData['changes'] = array_diff_assoc($this->values, $eventData['subtask']); + } + + $eventData['task'] = $this->taskFinderModel->getDetails($eventData['subtask']['task_id']); + return new SubtaskEvent($eventData); + } +} diff --git a/app/Job/NotificationJob.php b/app/Job/NotificationJob.php index ed568e47..5a52eb31 100644 --- a/app/Job/NotificationJob.php +++ b/app/Job/NotificationJob.php @@ -66,10 +66,6 @@ class NotificationJob extends BaseJob case 'Kanboard\Event\TaskEvent': $values['task'] = $this->taskFinderModel->getDetails($event['task_id']); break; - case 'Kanboard\Event\SubtaskEvent': - $values['subtask'] = $this->subtaskModel->getById($event['id'], true); - $values['task'] = $this->taskFinderModel->getDetails($values['subtask']['task_id']); - break; default: $values = $event; } diff --git a/app/Job/SubtaskEventJob.php b/app/Job/SubtaskEventJob.php new file mode 100644 index 00000000..1dc243ef --- /dev/null +++ b/app/Job/SubtaskEventJob.php @@ -0,0 +1,48 @@ +jobParams = array($subtaskId, $eventName, $values); + return $this; + } + + /** + * Execute job + * + * @param int $subtaskId + * @param string $eventName + * @param array $values + * @return $this + */ + public function execute($subtaskId, $eventName, array $values = array()) + { + $event = SubtaskEventBuilder::getInstance($this->container) + ->withSubtaskId($subtaskId) + ->withValues($values) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index df481fc7..ac8d9bae 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -70,6 +70,8 @@ class NotificationModel extends Base return e('%s updated a subtask for the task #%d', $event_author, $event_data['task']['id']); case SubtaskModel::EVENT_CREATE: return e('%s created a subtask for the task #%d', $event_author, $event_data['task']['id']); + case SubtaskModel::EVENT_DELETE: + return e('%s removed a subtask for the task #%d', $event_author, $event_data['task']['id']); case CommentModel::EVENT_UPDATE: return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); case CommentModel::EVENT_CREATE: @@ -110,6 +112,8 @@ class NotificationModel extends Base return e('New subtask on task #%d', $event_data['subtask']['task_id']); case SubtaskModel::EVENT_UPDATE: return e('Subtask updated on task #%d', $event_data['subtask']['task_id']); + case SubtaskModel::EVENT_DELETE: + return e('Subtask removed on task #%d', $event_data['subtask']['task_id']); case TaskModel::EVENT_CREATE: return e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']); case TaskModel::EVENT_UPDATE: @@ -157,6 +161,7 @@ class NotificationModel extends Base return $event_data['comment']['task_id']; case SubtaskModel::EVENT_CREATE: case SubtaskModel::EVENT_UPDATE: + case SubtaskModel::EVENT_DELETE: return $event_data['subtask']['task_id']; case TaskModel::EVENT_CREATE: case TaskModel::EVENT_UPDATE: diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index a97bddbf..6dd1f26a 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -66,7 +66,7 @@ class SubtaskModel extends Base ->join(TaskModel::TABLE, 'id', 'task_id') ->findOneColumn(TaskModel::TABLE . '.project_id') ?: 0; } - + /** * Get available status * @@ -235,10 +235,7 @@ class SubtaskModel extends Base $subtask_id = $this->db->table(self::TABLE)->persist($values); if ($subtask_id !== false) { - $this->container['dispatcher']->dispatch( - self::EVENT_CREATE, - new SubtaskEvent(array('id' => $subtask_id) + $values) - ); + $this->queueManager->push($this->subtaskEventJob->withParams($subtask_id, self::EVENT_CREATE)); } return $subtask_id; @@ -255,13 +252,10 @@ class SubtaskModel extends Base public function update(array $values, $fire_events = true) { $this->prepare($values); - $subtask = $this->getById($values['id']); $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values); if ($result && $fire_events) { - $event = $subtask; - $event['changes'] = array_diff_assoc($values, $subtask); - $this->container['dispatcher']->dispatch(self::EVENT_UPDATE, new SubtaskEvent($event)); + $this->queueManager->push($this->subtaskEventJob->withParams($values['id'], self::EVENT_UPDATE, $values)); } return $result; @@ -377,14 +371,8 @@ class SubtaskModel extends Base */ public function remove($subtask_id) { - $subtask = $this->getById($subtask_id); - $result = $this->db->table(self::TABLE)->eq('id', $subtask_id)->remove(); - - if ($result) { - $this->container['dispatcher']->dispatch(self::EVENT_DELETE, new SubtaskEvent($subtask)); - } - - return $result; + $this->subtaskEventJob->execute($subtask_id, self::EVENT_DELETE); + return $this->db->table(self::TABLE)->eq('id', $subtask_id)->remove(); } /** diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 428a8015..e32c0d43 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -26,12 +26,6 @@ class ClassProvider implements ServiceProviderInterface 'AverageLeadCycleTimeAnalytic', 'AverageTimeSpentColumnAnalytic', ), - 'Job' => array( - 'CommentEventJob', - 'TaskFileEventJob', - 'ProjectFileEventJob', - 'NotificationJob', - ), 'Model' => array( 'ActionModel', 'ActionParameterModel', diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php new file mode 100644 index 00000000..bfea6e6e --- /dev/null +++ b/app/ServiceProvider/JobProvider.php @@ -0,0 +1,52 @@ +factory(function ($c) { + return new CommentEventJob($c); + }); + + $container['subtaskEventJob'] = $container->factory(function ($c) { + return new SubtaskEventJob($c); + }); + + $container['taskFileEventJob'] = $container->factory(function ($c) { + return new TaskFileEventJob($c); + }); + + $container['projectFileEventJob'] = $container->factory(function ($c) { + return new ProjectFileEventJob($c); + }); + + $container['notificationJob'] = $container->factory(function ($c) { + return new NotificationJob($c); + }); + + return $container; + } +} diff --git a/app/ServiceProvider/QueueProvider.php b/app/ServiceProvider/QueueProvider.php index 946b436a..570f2e77 100644 --- a/app/ServiceProvider/QueueProvider.php +++ b/app/ServiceProvider/QueueProvider.php @@ -15,9 +15,11 @@ use Pimple\ServiceProviderInterface; class QueueProvider implements ServiceProviderInterface { /** - * Registers services on the given container. + * Register providers * - * @param Container $container + * @access public + * @param \Pimple\Container $container + * @return \Pimple\Container */ public function register(Container $container) { diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 47222f73..0b3760c4 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -26,6 +26,7 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn TaskModel::EVENT_ASSIGNEE_CHANGE => 'handleEvent', SubtaskModel::EVENT_CREATE => 'handleEvent', SubtaskModel::EVENT_UPDATE => 'handleEvent', + SubtaskModel::EVENT_DELETE => 'handleEvent', CommentModel::EVENT_CREATE => 'handleEvent', CommentModel::EVENT_UPDATE => 'handleEvent', CommentModel::EVENT_REMOVE => 'handleEvent', diff --git a/app/Template/event/subtask_delete.php b/app/Template/event/subtask_delete.php new file mode 100644 index 00000000..8ac11853 --- /dev/null +++ b/app/Template/event/subtask_delete.php @@ -0,0 +1,15 @@ +

            + text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

            +
            +

            text->e($task['title']) ?>

            +
              +
            • + text->e($subtask['title']) ?> (text->e($subtask['status_name']) ?>) +
            • +
            +
            diff --git a/app/Template/notification/subtask_delete.php b/app/Template/notification/subtask_delete.php new file mode 100644 index 00000000..8c5f262c --- /dev/null +++ b/app/Template/notification/subtask_delete.php @@ -0,0 +1,11 @@ +

            text->e($task['title']) ?> (#)

            + +

            + +
              +
            • text->e($subtask['title']) ?>
            • +
            • text->e($subtask['status_name']) ?>
            • +
            • text->e($subtask['name'] ?: $subtask['username'] ?: '?') ?>
            • +
            + +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/common.php b/app/common.php index 72be3603..15fd7a75 100644 --- a/app/common.php +++ b/app/common.php @@ -46,6 +46,7 @@ $container->register(new Kanboard\ServiceProvider\ActionProvider()); $container->register(new Kanboard\ServiceProvider\ExternalLinkProvider()); $container->register(new Kanboard\ServiceProvider\AvatarProvider()); $container->register(new Kanboard\ServiceProvider\FilterProvider()); +$container->register(new Kanboard\ServiceProvider\JobProvider()); $container->register(new Kanboard\ServiceProvider\QueueProvider()); $container->register(new Kanboard\ServiceProvider\ApiProvider()); $container->register(new Kanboard\ServiceProvider\CommandProvider()); diff --git a/tests/units/Base.php b/tests/units/Base.php index 9dbfb280..c471ee31 100644 --- a/tests/units/Base.php +++ b/tests/units/Base.php @@ -41,6 +41,7 @@ abstract class Base extends PHPUnit_Framework_TestCase $this->container->register(new Kanboard\ServiceProvider\RouteProvider()); $this->container->register(new Kanboard\ServiceProvider\AvatarProvider()); $this->container->register(new Kanboard\ServiceProvider\FilterProvider()); + $this->container->register(new Kanboard\ServiceProvider\JobProvider()); $this->container->register(new Kanboard\ServiceProvider\QueueProvider()); $this->container['dispatcher'] = new TraceableEventDispatcher( diff --git a/tests/units/EventBuilder/SubtaskEventBuilderTest.php b/tests/units/EventBuilder/SubtaskEventBuilderTest.php new file mode 100644 index 00000000..062bdfb4 --- /dev/null +++ b/tests/units/EventBuilder/SubtaskEventBuilderTest.php @@ -0,0 +1,62 @@ +container); + $subtaskEventBuilder->withSubtaskId(42); + $this->assertNull($subtaskEventBuilder->build()); + } + + public function testBuildWithoutChanges() + { + $subtaskModel = new SubtaskModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskEventBuilder = new SubtaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'test'))); + + $event = $subtaskEventBuilder->withSubtaskId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); + $this->assertNotEmpty($event['subtask']); + $this->assertNotEmpty($event['task']); + $this->assertArrayNotHasKey('changes', $event); + } + + public function testBuildWithChanges() + { + $subtaskModel = new SubtaskModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskEventBuilder = new SubtaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'test'))); + + $event = $subtaskEventBuilder + ->withSubtaskId(1) + ->withValues(array('title' => 'new title', 'user_id' => 1)) + ->build(); + + $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); + $this->assertNotEmpty($event['subtask']); + $this->assertNotEmpty($event['task']); + $this->assertNotEmpty($event['changes']); + $this->assertCount(2, $event['changes']); + $this->assertEquals('new title', $event['changes']['title']); + $this->assertEquals(1, $event['changes']['user_id']); + } +} diff --git a/tests/units/Job/SubtaskEventJobTest.php b/tests/units/Job/SubtaskEventJobTest.php new file mode 100644 index 00000000..265a8e2d --- /dev/null +++ b/tests/units/Job/SubtaskEventJobTest.php @@ -0,0 +1,52 @@ +container); + $subtaskEventJob->withParams(123, 'foobar', array('k' => 'v')); + + $this->assertSame(array(123, 'foobar', array('k' => 'v')), $subtaskEventJob->getJobParams()); + } + + public function testWithMissingSubtask() + { + $this->container['dispatcher']->addListener(SubtaskModel::EVENT_CREATE, function() {}); + + $SubtaskEventJob = new SubtaskEventJob($this->container); + $SubtaskEventJob->execute(42, SubtaskModel::EVENT_CREATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerEvents() + { + $this->container['dispatcher']->addListener(SubtaskModel::EVENT_CREATE, function() {}); + $this->container['dispatcher']->addListener(SubtaskModel::EVENT_UPDATE, function() {}); + $this->container['dispatcher']->addListener(SubtaskModel::EVENT_DELETE, function() {}); + + $subtaskModel = new SubtaskModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'before'))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'title' => 'after'))); + $this->assertTrue($subtaskModel->remove(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(SubtaskModel::EVENT_CREATE.'.closure', $called); + $this->assertArrayHasKey(SubtaskModel::EVENT_UPDATE.'.closure', $called); + $this->assertArrayHasKey(SubtaskModel::EVENT_DELETE.'.closure', $called); + } +} diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 6451189d..7e438651 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -9,64 +9,6 @@ use Kanboard\Model\TaskFinderModel; class SubtaskModelTest extends Base { - public function onSubtaskCreated($event) - { - $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); - $data = $event->getAll(); - - $this->assertArrayHasKey('id', $data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('time_estimated', $data); - $this->assertArrayHasKey('time_spent', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('task_id', $data); - $this->assertArrayHasKey('user_id', $data); - $this->assertArrayHasKey('position', $data); - $this->assertNotEmpty($data['task_id']); - $this->assertNotEmpty($data['id']); - } - - public function onSubtaskUpdated($event) - { - $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); - $data = $event->getAll(); - - $this->assertArrayHasKey('id', $data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('time_estimated', $data); - $this->assertArrayHasKey('time_spent', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('task_id', $data); - $this->assertArrayHasKey('user_id', $data); - $this->assertArrayHasKey('position', $data); - $this->assertArrayHasKey('changes', $data); - $this->assertArrayHasKey('user_id', $data['changes']); - $this->assertArrayHasKey('status', $data['changes']); - - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $data['changes']['status']); - $this->assertEquals(1, $data['changes']['user_id']); - } - - public function onSubtaskDeleted($event) - { - $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); - $data = $event->getAll(); - - $this->assertArrayHasKey('id', $data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('time_estimated', $data); - $this->assertArrayHasKey('time_spent', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('task_id', $data); - $this->assertArrayHasKey('user_id', $data); - $this->assertArrayHasKey('position', $data); - $this->assertNotEmpty($data['task_id']); - $this->assertNotEmpty($data['id']); - } - public function testCreation() { $taskCreationModel = new TaskCreationModel($this->container); @@ -75,9 +17,6 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->container['dispatcher']->addListener(SubtaskModel::EVENT_CREATE, array($this, 'onSubtaskCreated')); - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); $subtask = $subtaskModel->getById(1); @@ -101,8 +40,6 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - $this->container['dispatcher']->addListener(SubtaskModel::EVENT_UPDATE, array($this, 'onSubtaskUpdated')); - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); $this->assertTrue($subtaskModel->update(array('id' => 1, 'user_id' => 1, 'status' => SubtaskModel::STATUS_INPROGRESS))); @@ -128,8 +65,6 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->container['dispatcher']->addListener(SubtaskModel::EVENT_DELETE, array($this, 'onSubtaskDeleted')); - $subtask = $subtaskModel->getById(1); $this->assertNotEmpty($subtask); @@ -269,7 +204,6 @@ class SubtaskModelTest extends Base $this->assertTrue($subtaskModel->duplicate(1, 2)); $subtasks = $subtaskModel->getAll(2); - $this->assertNotFalse($subtasks); $this->assertNotEmpty($subtasks); $this->assertEquals(2, count($subtasks)); @@ -383,7 +317,7 @@ class SubtaskModelTest extends Base $this->assertEquals(2, $task['time_spent']); $this->assertEquals(3, $task['time_estimated']); } - + public function testGetProjectId() { $taskCreationModel = new TaskCreationModel($this->container); @@ -393,7 +327,7 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - + $this->assertEquals(1, $subtaskModel->getProjectId(1)); $this->assertEquals(0, $subtaskModel->getProjectId(2)); } -- cgit v1.2.3 From 57f66cc855458d9fd76c9a3073de2852a6e0712f Mon Sep 17 00:00:00 2001 From: Dj Padzensky Date: Tue, 19 Jul 2016 19:16:07 -0700 Subject: ProjectPermissionController: make sure groups don't exist before creating them Adding logic to check for a group by external ID, before trying to create it. --- app/Controller/ProjectPermissionController.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Controller/ProjectPermissionController.php b/app/Controller/ProjectPermissionController.php index f3ca6ed9..cbcdf77d 100644 --- a/app/Controller/ProjectPermissionController.php +++ b/app/Controller/ProjectPermissionController.php @@ -147,7 +147,13 @@ class ProjectPermissionController extends BaseController $values = $this->request->getValues(); if (empty($values['group_id']) && ! empty($values['external_id'])) { - $values['group_id'] = $this->groupModel->create($values['name'], $values['external_id']); + $group = $this->groupModel->getByExternalId($values['external_id']); + if ($group) { + $values['group_id'] = $group['id']; + } + else { + $values['group_id'] = $this->groupModel->create($values['name'], $values['external_id']); + } } if ($this->projectGroupRoleModel->addGroup($project['id'], $values['group_id'], $values['role'])) { -- cgit v1.2.3 From 390082aa41cb81610089163b1cc3a256f3b3c513 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Tue, 19 Jul 2016 22:38:30 -0400 Subject: Refactoring of internal task events --- app/Action/Base.php | 13 +- app/Action/CommentCreationMoveTaskColumn.php | 12 +- app/Action/TaskAssignCategoryColor.php | 7 +- app/Action/TaskAssignColorCategory.php | 7 +- app/Action/TaskAssignColorColumn.php | 7 +- app/Action/TaskAssignColorPriority.php | 7 +- app/Action/TaskAssignColorUser.php | 7 +- app/Action/TaskAssignCurrentUserColumn.php | 7 +- app/Action/TaskAssignSpecificUser.php | 7 +- app/Action/TaskCloseColumn.php | 10 +- app/Action/TaskCreation.php | 1 + app/Action/TaskDuplicateAnotherProject.php | 14 +- app/Action/TaskEmail.php | 14 +- app/Action/TaskMoveAnotherProject.php | 8 +- app/Action/TaskMoveColumnAssigned.php | 19 +- app/Action/TaskMoveColumnCategoryChange.php | 19 +- app/Action/TaskMoveColumnUnAssigned.php | 19 +- app/Action/TaskUpdateStartDate.php | 7 +- app/Core/Base.php | 1 + app/EventBuilder/TaskEventBuilder.php | 123 ++++ app/Job/NotificationJob.php | 50 +- app/Job/TaskEventJob.php | 75 +++ app/Model/SubtaskModel.php | 1 - app/Model/TaskCreationModel.php | 32 +- app/Model/TaskModificationModel.php | 62 +- app/Model/TaskPositionModel.php | 28 +- app/Model/TaskProjectMoveModel.php | 7 +- app/Model/TaskStatusModel.php | 8 +- app/ServiceProvider/JobProvider.php | 5 + app/Subscriber/NotificationSubscriber.php | 6 +- .../ProjectModificationDateSubscriber.php | 6 +- tests/units/Action/BaseActionTest.php | 10 +- .../Action/CommentCreationMoveTaskColumnTest.php | 6 +- tests/units/Action/TaskAssignCategoryColorTest.php | 18 +- tests/units/Action/TaskAssignCategoryLinkTest.php | 43 +- tests/units/Action/TaskAssignColorCategoryTest.php | 18 +- tests/units/Action/TaskAssignColorColumnTest.php | 18 +- tests/units/Action/TaskAssignColorPriorityTest.php | 18 +- tests/units/Action/TaskAssignColorUserTest.php | 18 +- .../Action/TaskAssignCurrentUserColumnTest.php | 26 +- tests/units/Action/TaskAssignSpecificUserTest.php | 17 +- tests/units/Action/TaskCloseColumnTest.php | 18 +- tests/units/Action/TaskCloseTest.php | 15 +- .../Action/TaskDuplicateAnotherProjectTest.php | 18 +- tests/units/Action/TaskEmailTest.php | 19 +- tests/units/Action/TaskMoveAnotherProjectTest.php | 17 +- tests/units/Action/TaskMoveColumnAssignedTest.php | 18 +- .../Action/TaskMoveColumnCategoryChangeTest.php | 31 +- .../units/Action/TaskMoveColumnUnAssignedTest.php | 31 +- tests/units/Action/TaskOpenTest.php | 15 +- tests/units/Action/TaskUpdateStartDateTest.php | 17 +- tests/units/EventBuilder/TaskEventBuilderTest.php | 100 ++++ tests/units/Job/SubtaskEventJobTest.php | 4 +- tests/units/Job/TaskEventJobTest.php | 189 ++++++ tests/units/Model/TaskCreationModelTest.php | 2 +- tests/units/Model/TaskModificationModelTest.php | 13 +- tests/units/Model/TaskPositionModelTest.php | 631 +++++++++++++++++++++ tests/units/Model/TaskPositionTest.php | 631 --------------------- tests/units/Model/TaskProjectMoveModelTest.php | 2 +- 59 files changed, 1647 insertions(+), 905 deletions(-) create mode 100644 app/EventBuilder/TaskEventBuilder.php create mode 100644 app/Job/TaskEventJob.php create mode 100644 tests/units/EventBuilder/TaskEventBuilderTest.php create mode 100644 tests/units/Job/TaskEventJobTest.php create mode 100644 tests/units/Model/TaskPositionModelTest.php delete mode 100644 tests/units/Model/TaskPositionTest.php diff --git a/app/Action/Base.php b/app/Action/Base.php index e5c65a17..e0ed8bde 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -216,7 +216,8 @@ abstract class Base extends \Kanboard\Core\Base */ public function hasRequiredProject(array $data) { - return isset($data['project_id']) && $data['project_id'] == $this->getProjectId(); + return (isset($data['project_id']) && $data['project_id'] == $this->getProjectId()) || + (isset($data['task']['project_id']) && $data['task']['project_id'] == $this->getProjectId()); } /** @@ -226,10 +227,14 @@ abstract class Base extends \Kanboard\Core\Base * @param array $data Event data dictionary * @return bool True if all keys are there */ - public function hasRequiredParameters(array $data) + public function hasRequiredParameters(array $data, array $parameters = array()) { - foreach ($this->getEventRequiredParameters() as $parameter) { - if (! isset($data[$parameter])) { + $parameters = $parameters ?: $this->getEventRequiredParameters(); + + foreach ($parameters as $key => $value) { + if (is_array($value)) { + return isset($data[$key]) && $this->hasRequiredParameters($data[$key], $value); + } else if (! isset($data[$value])) { return false; } } diff --git a/app/Action/CommentCreationMoveTaskColumn.php b/app/Action/CommentCreationMoveTaskColumn.php index 1b16f481..8ab792ad 100644 --- a/app/Action/CommentCreationMoveTaskColumn.php +++ b/app/Action/CommentCreationMoveTaskColumn.php @@ -55,7 +55,13 @@ class CommentCreationMoveTaskColumn extends Base */ public function getEventRequiredParameters() { - return array('task_id', 'column_id'); + return array( + 'task_id', + 'task' => array( + 'column_id', + 'project_id', + ), + ); } /** @@ -71,7 +77,7 @@ class CommentCreationMoveTaskColumn extends Base return false; } - $column = $this->columnModel->getById($data['column_id']); + $column = $this->columnModel->getById($data['task']['column_id']); return (bool) $this->commentModel->create(array( 'comment' => t('Moved to column %s', $column['title']), @@ -89,6 +95,6 @@ class CommentCreationMoveTaskColumn extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskAssignCategoryColor.php b/app/Action/TaskAssignCategoryColor.php index fc486870..2df90b2c 100644 --- a/app/Action/TaskAssignCategoryColor.php +++ b/app/Action/TaskAssignCategoryColor.php @@ -60,7 +60,10 @@ class TaskAssignCategoryColor extends Base { return array( 'task_id', - 'color_id', + 'task' => array( + 'project_id', + 'color_id', + ), ); } @@ -90,6 +93,6 @@ class TaskAssignCategoryColor extends Base */ public function hasRequiredCondition(array $data) { - return $data['color_id'] == $this->getParam('color_id'); + return $data['task']['color_id'] == $this->getParam('color_id'); } } diff --git a/app/Action/TaskAssignColorCategory.php b/app/Action/TaskAssignColorCategory.php index 284b8f40..91860be4 100644 --- a/app/Action/TaskAssignColorCategory.php +++ b/app/Action/TaskAssignColorCategory.php @@ -60,7 +60,10 @@ class TaskAssignColorCategory extends Base { return array( 'task_id', - 'category_id', + 'task' => array( + 'project_id', + 'category_id', + ), ); } @@ -90,6 +93,6 @@ class TaskAssignColorCategory extends Base */ public function hasRequiredCondition(array $data) { - return $data['category_id'] == $this->getParam('category_id'); + return $data['task']['category_id'] == $this->getParam('category_id'); } } diff --git a/app/Action/TaskAssignColorColumn.php b/app/Action/TaskAssignColorColumn.php index 57fd6f44..6c674b1f 100644 --- a/app/Action/TaskAssignColorColumn.php +++ b/app/Action/TaskAssignColorColumn.php @@ -61,7 +61,10 @@ class TaskAssignColorColumn extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ), ); } @@ -91,6 +94,6 @@ class TaskAssignColorColumn extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskAssignColorPriority.php b/app/Action/TaskAssignColorPriority.php index eae1b771..57000ba8 100644 --- a/app/Action/TaskAssignColorPriority.php +++ b/app/Action/TaskAssignColorPriority.php @@ -60,7 +60,10 @@ class TaskAssignColorPriority extends Base { return array( 'task_id', - 'priority', + 'task' => array( + 'project_id', + 'priority', + ), ); } @@ -90,6 +93,6 @@ class TaskAssignColorPriority extends Base */ public function hasRequiredCondition(array $data) { - return $data['priority'] == $this->getParam('priority'); + return $data['task']['priority'] == $this->getParam('priority'); } } diff --git a/app/Action/TaskAssignColorUser.php b/app/Action/TaskAssignColorUser.php index 4bcf7a5c..385db793 100644 --- a/app/Action/TaskAssignColorUser.php +++ b/app/Action/TaskAssignColorUser.php @@ -61,7 +61,10 @@ class TaskAssignColorUser extends Base { return array( 'task_id', - 'owner_id', + 'task' => array( + 'project_id', + 'owner_id', + ), ); } @@ -91,6 +94,6 @@ class TaskAssignColorUser extends Base */ public function hasRequiredCondition(array $data) { - return $data['owner_id'] == $this->getParam('user_id'); + return $data['task']['owner_id'] == $this->getParam('user_id'); } } diff --git a/app/Action/TaskAssignCurrentUserColumn.php b/app/Action/TaskAssignCurrentUserColumn.php index bc28a90b..e4eade33 100644 --- a/app/Action/TaskAssignCurrentUserColumn.php +++ b/app/Action/TaskAssignCurrentUserColumn.php @@ -59,7 +59,10 @@ class TaskAssignCurrentUserColumn extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ), ); } @@ -93,6 +96,6 @@ class TaskAssignCurrentUserColumn extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskAssignSpecificUser.php b/app/Action/TaskAssignSpecificUser.php index 50a2b2ae..2c7dcacd 100644 --- a/app/Action/TaskAssignSpecificUser.php +++ b/app/Action/TaskAssignSpecificUser.php @@ -61,7 +61,10 @@ class TaskAssignSpecificUser extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ), ); } @@ -91,6 +94,6 @@ class TaskAssignSpecificUser extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskCloseColumn.php b/app/Action/TaskCloseColumn.php index 1edce8fa..4f1ffc92 100644 --- a/app/Action/TaskCloseColumn.php +++ b/app/Action/TaskCloseColumn.php @@ -55,7 +55,13 @@ class TaskCloseColumn extends Base */ public function getEventRequiredParameters() { - return array('task_id', 'column_id'); + return array( + 'task_id', + 'task' => array( + 'project_id', + 'column_id', + ) + ); } /** @@ -79,6 +85,6 @@ class TaskCloseColumn extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskCreation.php b/app/Action/TaskCreation.php index e9e5c5f3..0620afd3 100644 --- a/app/Action/TaskCreation.php +++ b/app/Action/TaskCreation.php @@ -52,6 +52,7 @@ class TaskCreation extends Base public function getEventRequiredParameters() { return array( + 'project_id', 'reference', 'title', ); diff --git a/app/Action/TaskDuplicateAnotherProject.php b/app/Action/TaskDuplicateAnotherProject.php index d70d2ee8..d6d8d51f 100644 --- a/app/Action/TaskDuplicateAnotherProject.php +++ b/app/Action/TaskDuplicateAnotherProject.php @@ -62,7 +62,10 @@ class TaskDuplicateAnotherProject extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ) ); } @@ -76,7 +79,12 @@ class TaskDuplicateAnotherProject extends Base public function doAction(array $data) { $destination_column_id = $this->columnModel->getFirstColumnId($this->getParam('project_id')); - return (bool) $this->taskProjectDuplicationModel->duplicateToProject($data['task_id'], $this->getParam('project_id'), null, $destination_column_id); + return (bool) $this->taskProjectDuplicationModel->duplicateToProject( + $data['task_id'], + $this->getParam('project_id'), + null, + $destination_column_id + ); } /** @@ -88,6 +96,6 @@ class TaskDuplicateAnotherProject extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id') && $data['project_id'] != $this->getParam('project_id'); + return $data['task']['column_id'] == $this->getParam('column_id') && $data['task']['project_id'] != $this->getParam('project_id'); } } diff --git a/app/Action/TaskEmail.php b/app/Action/TaskEmail.php index 7f9ba416..526e9aa8 100644 --- a/app/Action/TaskEmail.php +++ b/app/Action/TaskEmail.php @@ -62,7 +62,10 @@ class TaskEmail extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ), ); } @@ -78,13 +81,14 @@ class TaskEmail extends Base $user = $this->userModel->getById($this->getParam('user_id')); if (! empty($user['email'])) { - $task = $this->taskFinderModel->getDetails($data['task_id']); - $this->emailClient->send( $user['email'], $user['name'] ?: $user['username'], $this->getParam('subject'), - $this->template->render('notification/task_create', array('task' => $task, 'application_url' => $this->configModel->get('application_url'))) + $this->template->render('notification/task_create', array( + 'task' => $data['task'], + 'application_url' => $this->configModel->get('application_url'), + )) ); return true; @@ -102,6 +106,6 @@ class TaskEmail extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Action/TaskMoveAnotherProject.php b/app/Action/TaskMoveAnotherProject.php index 66635a63..148b6b0c 100644 --- a/app/Action/TaskMoveAnotherProject.php +++ b/app/Action/TaskMoveAnotherProject.php @@ -61,8 +61,10 @@ class TaskMoveAnotherProject extends Base { return array( 'task_id', - 'column_id', - 'project_id', + 'task' => array( + 'project_id', + 'column_id', + ) ); } @@ -87,6 +89,6 @@ class TaskMoveAnotherProject extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id') && $data['project_id'] != $this->getParam('project_id'); + return $data['task']['column_id'] == $this->getParam('column_id') && $data['task']['project_id'] != $this->getParam('project_id'); } } diff --git a/app/Action/TaskMoveColumnAssigned.php b/app/Action/TaskMoveColumnAssigned.php index 7e3db9c5..1c1f657a 100644 --- a/app/Action/TaskMoveColumnAssigned.php +++ b/app/Action/TaskMoveColumnAssigned.php @@ -61,8 +61,13 @@ class TaskMoveColumnAssigned extends Base { return array( 'task_id', - 'column_id', - 'owner_id' + 'task' => array( + 'project_id', + 'column_id', + 'owner_id', + 'position', + 'swimlane_id', + ) ); } @@ -75,14 +80,12 @@ class TaskMoveColumnAssigned extends Base */ public function doAction(array $data) { - $original_task = $this->taskFinderModel->getById($data['task_id']); - return $this->taskPositionModel->movePosition( - $data['project_id'], + $data['task']['project_id'], $data['task_id'], $this->getParam('dest_column_id'), - $original_task['position'], - $original_task['swimlane_id'], + $data['task']['position'], + $data['task']['swimlane_id'], false ); } @@ -96,6 +99,6 @@ class TaskMoveColumnAssigned extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('src_column_id') && $data['owner_id'] > 0; + return $data['task']['column_id'] == $this->getParam('src_column_id') && $data['task']['owner_id'] > 0; } } diff --git a/app/Action/TaskMoveColumnCategoryChange.php b/app/Action/TaskMoveColumnCategoryChange.php index e4f88760..4c2b289a 100644 --- a/app/Action/TaskMoveColumnCategoryChange.php +++ b/app/Action/TaskMoveColumnCategoryChange.php @@ -60,8 +60,13 @@ class TaskMoveColumnCategoryChange extends Base { return array( 'task_id', - 'column_id', - 'category_id', + 'task' => array( + 'project_id', + 'column_id', + 'category_id', + 'position', + 'swimlane_id', + ) ); } @@ -74,14 +79,12 @@ class TaskMoveColumnCategoryChange extends Base */ public function doAction(array $data) { - $original_task = $this->taskFinderModel->getById($data['task_id']); - return $this->taskPositionModel->movePosition( - $data['project_id'], + $data['task']['project_id'], $data['task_id'], $this->getParam('dest_column_id'), - $original_task['position'], - $original_task['swimlane_id'], + $data['task']['position'], + $data['task']['swimlane_id'], false ); } @@ -95,6 +98,6 @@ class TaskMoveColumnCategoryChange extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] != $this->getParam('dest_column_id') && $data['category_id'] == $this->getParam('category_id'); + return $data['task']['column_id'] != $this->getParam('dest_column_id') && $data['task']['category_id'] == $this->getParam('category_id'); } } diff --git a/app/Action/TaskMoveColumnUnAssigned.php b/app/Action/TaskMoveColumnUnAssigned.php index c3ae9e1d..0e9a8a16 100644 --- a/app/Action/TaskMoveColumnUnAssigned.php +++ b/app/Action/TaskMoveColumnUnAssigned.php @@ -61,8 +61,13 @@ class TaskMoveColumnUnAssigned extends Base { return array( 'task_id', - 'column_id', - 'owner_id' + 'task' => array( + 'project_id', + 'column_id', + 'owner_id', + 'position', + 'swimlane_id', + ) ); } @@ -75,14 +80,12 @@ class TaskMoveColumnUnAssigned extends Base */ public function doAction(array $data) { - $original_task = $this->taskFinderModel->getById($data['task_id']); - return $this->taskPositionModel->movePosition( - $data['project_id'], + $data['task']['project_id'], $data['task_id'], $this->getParam('dest_column_id'), - $original_task['position'], - $original_task['swimlane_id'], + $data['task']['position'], + $data['task']['swimlane_id'], false ); } @@ -96,6 +99,6 @@ class TaskMoveColumnUnAssigned extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('src_column_id') && $data['owner_id'] == 0; + return $data['task']['column_id'] == $this->getParam('src_column_id') && $data['task']['owner_id'] == 0; } } diff --git a/app/Action/TaskUpdateStartDate.php b/app/Action/TaskUpdateStartDate.php index e5410a87..cc016da1 100644 --- a/app/Action/TaskUpdateStartDate.php +++ b/app/Action/TaskUpdateStartDate.php @@ -59,7 +59,10 @@ class TaskUpdateStartDate extends Base { return array( 'task_id', - 'column_id', + 'task' => array( + 'project_id', + 'column_id', + ), ); } @@ -89,6 +92,6 @@ class TaskUpdateStartDate extends Base */ public function hasRequiredCondition(array $data) { - return $data['column_id'] == $this->getParam('column_id'); + return $data['task']['column_id'] == $this->getParam('column_id'); } } diff --git a/app/Core/Base.php b/app/Core/Base.php index 6650680b..098bd880 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -152,6 +152,7 @@ use Pimple\Container; * @property \Kanboard\Core\Filter\LexerBuilder $projectActivityLexer * @property \Kanboard\Job\CommentEventJob $commentEventJob * @property \Kanboard\Job\SubtaskEventJob $subtaskEventJob + * @property \Kanboard\Job\TaskEventJob $taskEventJob * @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob diff --git a/app/EventBuilder/TaskEventBuilder.php b/app/EventBuilder/TaskEventBuilder.php new file mode 100644 index 00000000..e7a5653d --- /dev/null +++ b/app/EventBuilder/TaskEventBuilder.php @@ -0,0 +1,123 @@ +taskId = $taskId; + return $this; + } + + /** + * Set task + * + * @param array $task + * @return $this + */ + public function withTask(array $task) + { + $this->task = $task; + return $this; + } + + /** + * Set values + * + * @param array $values + * @return $this + */ + public function withValues(array $values) + { + $this->values = $values; + return $this; + } + + /** + * Set changes + * + * @param array $changes + * @return $this + */ + public function withChanges(array $changes) + { + $this->changes = $changes; + return $this; + } + + /** + * Build event data + * + * @access public + * @return TaskEvent|null + */ + public function build() + { + $eventData = array(); + $eventData['task_id'] = $this->taskId; + $eventData['task'] = $this->taskFinderModel->getDetails($this->taskId); + + if (empty($eventData['task'])) { + $this->logger->debug(__METHOD__.': Task not found'); + return null; + } + + if (! empty($this->changes)) { + if (empty($this->task)) { + $this->task = $eventData['task']; + } + + $eventData['changes'] = array_diff_assoc($this->changes, $this->task); + unset($eventData['changes']['date_modification']); + } + + return new TaskEvent(array_merge($eventData, $this->values)); + } +} diff --git a/app/Job/NotificationJob.php b/app/Job/NotificationJob.php index 5a52eb31..8fb260e8 100644 --- a/app/Job/NotificationJob.php +++ b/app/Job/NotificationJob.php @@ -17,59 +17,27 @@ class NotificationJob extends BaseJob * * @param GenericEvent $event * @param string $eventName - * @param string $eventObjectName * @return $this */ - public function withParams(GenericEvent $event, $eventName, $eventObjectName) + public function withParams(GenericEvent $event, $eventName) { - $this->jobParams = array($event->getAll(), $eventName, $eventObjectName); + $this->jobParams = array($event->getAll(), $eventName); return $this; } /** * Execute job * - * @param array $event + * @param array $eventData * @param string $eventName - * @param string $eventObjectName */ - public function execute(array $event, $eventName, $eventObjectName) + public function execute(array $eventData, $eventName) { - $eventData = $this->getEventData($event, $eventObjectName); - - if (! empty($eventData)) { - if (! empty($event['mention'])) { - $this->userNotificationModel->sendUserNotification($event['mention'], $eventName, $eventData); - } else { - $this->userNotificationModel->sendNotifications($eventName, $eventData); - $this->projectNotificationModel->sendNotifications($eventData['task']['project_id'], $eventName, $eventData); - } - } - } - - /** - * Get event data - * - * @param array $event - * @param string $eventObjectName - * @return array - */ - public function getEventData(array $event, $eventObjectName) - { - $values = array(); - - if (! empty($event['changes'])) { - $values['changes'] = $event['changes']; + if (! empty($eventData['mention'])) { + $this->userNotificationModel->sendUserNotification($eventData['mention'], $eventName, $eventData); + } else { + $this->userNotificationModel->sendNotifications($eventName, $eventData); + $this->projectNotificationModel->sendNotifications($eventData['task']['project_id'], $eventName, $eventData); } - - switch ($eventObjectName) { - case 'Kanboard\Event\TaskEvent': - $values['task'] = $this->taskFinderModel->getDetails($event['task_id']); - break; - default: - $values = $event; - } - - return $values; } } diff --git a/app/Job/TaskEventJob.php b/app/Job/TaskEventJob.php new file mode 100644 index 00000000..46f7a16c --- /dev/null +++ b/app/Job/TaskEventJob.php @@ -0,0 +1,75 @@ +jobParams = array($taskId, $eventNames, $changes, $values, $task); + return $this; + } + + /** + * Execute job + * + * @param int $taskId + * @param array $eventNames + * @param array $changes + * @param array $values + * @param array $task + * @return $this + */ + public function execute($taskId, array $eventNames, array $changes = array(), array $values = array(), array $task = array()) + { + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId($taskId) + ->withChanges($changes) + ->withValues($values) + ->withTask($task) + ->build(); + + if ($event !== null) { + foreach ($eventNames as $eventName) { + $this->fireEvent($eventName, $event); + } + } + } + + /** + * Trigger event + * + * @access protected + * @param string $eventName + * @param TaskEvent $event + */ + protected function fireEvent($eventName, TaskEvent $event) + { + $this->logger->debug(__METHOD__.' Event fired: '.$eventName); + $this->dispatcher->dispatch($eventName, $event); + + if ($eventName === TaskModel::EVENT_CREATE) { + $this->userMentionModel->fireEvents($event['task']['description'], TaskModel::EVENT_USER_MENTION, $event); + } + } +} diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 6dd1f26a..f3fc72ba 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -4,7 +4,6 @@ namespace Kanboard\Model; use PicoDb\Database; use Kanboard\Core\Base; -use Kanboard\Event\SubtaskEvent; /** * Subtask Model diff --git a/app/Model/TaskCreationModel.php b/app/Model/TaskCreationModel.php index cd70a028..1c0fd7d9 100644 --- a/app/Model/TaskCreationModel.php +++ b/app/Model/TaskCreationModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskEvent; /** * Task Creation @@ -42,7 +41,10 @@ class TaskCreationModel extends Base $this->taskTagModel->save($values['project_id'], $task_id, $tags); } - $this->fireEvents($task_id, $values); + $this->queueManager->push($this->taskEventJob->withParams( + $task_id, + array(TaskModel::EVENT_CREATE_UPDATE, TaskModel::EVENT_CREATE) + )); } return (int) $task_id; @@ -51,10 +53,10 @@ class TaskCreationModel extends Base /** * Prepare data * - * @access public + * @access protected * @param array $values Form values */ - public function prepare(array &$values) + protected function prepare(array &$values) { $values = $this->dateParser->convert($values, array('date_due')); $values = $this->dateParser->convert($values, array('date_started'), true); @@ -84,26 +86,4 @@ class TaskCreationModel extends Base $values['date_moved'] = $values['date_creation']; $values['position'] = $this->taskFinderModel->countByColumnAndSwimlaneId($values['project_id'], $values['column_id'], $values['swimlane_id']) + 1; } - - /** - * Fire events - * - * @access private - * @param integer $task_id Task id - * @param array $values Form values - */ - private function fireEvents($task_id, array $values) - { - $event = new TaskEvent(array('task_id' => $task_id) + $values); - - $this->logger->debug('Event fired: '.TaskModel::EVENT_CREATE_UPDATE); - $this->logger->debug('Event fired: '.TaskModel::EVENT_CREATE); - - $this->dispatcher->dispatch(TaskModel::EVENT_CREATE_UPDATE, $event); - $this->dispatcher->dispatch(TaskModel::EVENT_CREATE, $event); - - if (! empty($values['description'])) { - $this->userMentionModel->fireEvents($values['description'], TaskModel::EVENT_USER_MENTION, $event); - } - } } diff --git a/app/Model/TaskModificationModel.php b/app/Model/TaskModificationModel.php index be5f53c8..16b48f3d 100644 --- a/app/Model/TaskModificationModel.php +++ b/app/Model/TaskModificationModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskEvent; /** * Task Modification @@ -23,14 +22,14 @@ class TaskModificationModel extends Base */ public function update(array $values, $fire_events = true) { - $original_task = $this->taskFinderModel->getById($values['id']); + $task = $this->taskFinderModel->getById($values['id']); - $this->updateTags($values, $original_task); + $this->updateTags($values, $task); $this->prepare($values); - $result = $this->db->table(TaskModel::TABLE)->eq('id', $original_task['id'])->update($values); + $result = $this->db->table(TaskModel::TABLE)->eq('id', $task['id'])->update($values); if ($fire_events && $result) { - $this->fireEvents($original_task, $values); + $this->fireEvents($task, $values); } return $result; @@ -39,43 +38,56 @@ class TaskModificationModel extends Base /** * Fire events * - * @access public - * @param array $task - * @param array $new_values + * @access protected + * @param array $task + * @param array $changes */ - public function fireEvents(array $task, array $new_values) + protected function fireEvents(array $task, array $changes) { $events = array(); - $event_data = array_merge($task, $new_values, array('task_id' => $task['id'])); - // Values changed - $event_data['changes'] = array_diff_assoc($new_values, $task); - unset($event_data['changes']['date_modification']); - - if ($this->isFieldModified('owner_id', $event_data['changes'])) { + if ($this->isAssigneeChanged($task, $changes)) { $events[] = TaskModel::EVENT_ASSIGNEE_CHANGE; - } elseif (! empty($event_data['changes'])) { + } elseif ($this->isModified($task, $changes)) { $events[] = TaskModel::EVENT_CREATE_UPDATE; $events[] = TaskModel::EVENT_UPDATE; } - foreach ($events as $event) { - $this->logger->debug('Event fired: '.$event); - $this->dispatcher->dispatch($event, new TaskEvent($event_data)); + if (! empty($events)) { + $this->queueManager->push($this->taskEventJob + ->withParams($task['id'], $events, $changes, array(), $task) + ); } } + /** + * Return true if the task have been modified + * + * @access protected + * @param array $task + * @param array $changes + * @return bool + */ + protected function isModified(array $task, array $changes) + { + $diff = array_diff_assoc($changes, $task); + unset($diff['date_modification']); + return count($diff) > 0; + } + /** * Return true if the field is the only modified value * - * @access public - * @param string $field - * @param array $changes - * @return boolean + * @access protected + * @param array $task + * @param array $changes + * @return bool */ - public function isFieldModified($field, array $changes) + protected function isAssigneeChanged(array $task, array $changes) { - return isset($changes[$field]) && count($changes) === 1; + $diff = array_diff_assoc($changes, $task); + unset($diff['date_modification']); + return isset($changes['owner_id']) && $task['owner_id'] != $changes['owner_id'] && count($diff) === 1; } /** diff --git a/app/Model/TaskPositionModel.php b/app/Model/TaskPositionModel.php index 9fdb8f7d..d6d2a0af 100644 --- a/app/Model/TaskPositionModel.php +++ b/app/Model/TaskPositionModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskEvent; /** * Task Position @@ -212,8 +211,7 @@ class TaskPositionModel extends Base */ private function fireEvents(array $task, $new_column_id, $new_position, $new_swimlane_id) { - $event_data = array( - 'task_id' => $task['id'], + $changes = array( 'project_id' => $task['project_id'], 'position' => $new_position, 'column_id' => $new_column_id, @@ -226,14 +224,26 @@ class TaskPositionModel extends Base ); if ($task['swimlane_id'] != $new_swimlane_id) { - $this->logger->debug('Event fired: '.TaskModel::EVENT_MOVE_SWIMLANE); - $this->dispatcher->dispatch(TaskModel::EVENT_MOVE_SWIMLANE, new TaskEvent($event_data)); + $this->queueManager->push($this->taskEventJob->withParams( + $task['id'], + array(TaskModel::EVENT_MOVE_SWIMLANE), + $changes, + $changes + )); } elseif ($task['column_id'] != $new_column_id) { - $this->logger->debug('Event fired: '.TaskModel::EVENT_MOVE_COLUMN); - $this->dispatcher->dispatch(TaskModel::EVENT_MOVE_COLUMN, new TaskEvent($event_data)); + $this->queueManager->push($this->taskEventJob->withParams( + $task['id'], + array(TaskModel::EVENT_MOVE_COLUMN), + $changes, + $changes + )); } elseif ($task['position'] != $new_position) { - $this->logger->debug('Event fired: '.TaskModel::EVENT_MOVE_POSITION); - $this->dispatcher->dispatch(TaskModel::EVENT_MOVE_POSITION, new TaskEvent($event_data)); + $this->queueManager->push($this->taskEventJob->withParams( + $task['id'], + array(TaskModel::EVENT_MOVE_POSITION), + $changes, + $changes + )); } } } diff --git a/app/Model/TaskProjectMoveModel.php b/app/Model/TaskProjectMoveModel.php index eda23c0b..ae3ae084 100644 --- a/app/Model/TaskProjectMoveModel.php +++ b/app/Model/TaskProjectMoveModel.php @@ -2,8 +2,6 @@ namespace Kanboard\Model; -use Kanboard\Event\TaskEvent; - /** * Task Project Move * @@ -32,9 +30,8 @@ class TaskProjectMoveModel extends TaskDuplicationModel $this->checkDestinationProjectValues($values); $this->tagDuplicationModel->syncTaskTagsToAnotherProject($task_id, $project_id); - if ($this->db->table(TaskModel::TABLE)->eq('id', $task['id'])->update($values)) { - $event = new TaskEvent(array_merge($task, $values, array('task_id' => $task['id']))); - $this->dispatcher->dispatch(TaskModel::EVENT_MOVE_PROJECT, $event); + if ($this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update($values)) { + $this->queueManager->push($this->taskEventJob->withParams($task_id, array(TaskModel::EVENT_MOVE_PROJECT), $values)); } return true; diff --git a/app/Model/TaskStatusModel.php b/app/Model/TaskStatusModel.php index 4d573f0e..ea304beb 100644 --- a/app/Model/TaskStatusModel.php +++ b/app/Model/TaskStatusModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskEvent; /** * Task Status @@ -101,10 +100,10 @@ class TaskStatusModel extends Base * @param integer $task_id Task id * @param integer $status Task status * @param integer $date_completed Timestamp - * @param string $event Event name + * @param string $event_name Event name * @return boolean */ - private function changeStatus($task_id, $status, $date_completed, $event) + private function changeStatus($task_id, $status, $date_completed, $event_name) { if (! $this->taskFinderModel->exists($task_id)) { return false; @@ -120,8 +119,7 @@ class TaskStatusModel extends Base )); if ($result) { - $this->logger->debug('Event fired: '.$event); - $this->dispatcher->dispatch($event, new TaskEvent(array('task_id' => $task_id) + $this->taskFinderModel->getById($task_id))); + $this->queueManager->push($this->taskEventJob->withParams($task_id, array($event_name))); } return $result; diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php index bfea6e6e..c7f323f1 100644 --- a/app/ServiceProvider/JobProvider.php +++ b/app/ServiceProvider/JobProvider.php @@ -6,6 +6,7 @@ use Kanboard\Job\CommentEventJob; use Kanboard\Job\NotificationJob; use Kanboard\Job\ProjectFileEventJob; use Kanboard\Job\SubtaskEventJob; +use Kanboard\Job\TaskEventJob; use Kanboard\Job\TaskFileEventJob; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -35,6 +36,10 @@ class JobProvider implements ServiceProviderInterface return new SubtaskEventJob($c); }); + $container['taskEventJob'] = $container->factory(function ($c) { + return new TaskEventJob($c); + }); + $container['taskFileEventJob'] = $container->factory(function ($c) { return new TaskFileEventJob($c); }); diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 0b3760c4..104927a0 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -3,7 +3,6 @@ namespace Kanboard\Subscriber; use Kanboard\Event\GenericEvent; -use Kanboard\Job\NotificationJob; use Kanboard\Model\TaskModel; use Kanboard\Model\CommentModel; use Kanboard\Model\SubtaskModel; @@ -38,9 +37,6 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn public function handleEvent(GenericEvent $event, $eventName) { $this->logger->debug('Subscriber executed: ' . __METHOD__); - - $this->queueManager->push(NotificationJob::getInstance($this->container) - ->withParams($event, $eventName, get_class($event)) - ); + $this->queueManager->push($this->notificationJob->withParams($event, $eventName)); } } diff --git a/app/Subscriber/ProjectModificationDateSubscriber.php b/app/Subscriber/ProjectModificationDateSubscriber.php index 97923af9..1ffe0248 100644 --- a/app/Subscriber/ProjectModificationDateSubscriber.php +++ b/app/Subscriber/ProjectModificationDateSubscriber.php @@ -24,9 +24,7 @@ class ProjectModificationDateSubscriber extends BaseSubscriber implements EventS public function execute(GenericEvent $event) { - if (isset($event['project_id'])) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $this->projectModel->updateModificationDate($event['project_id']); - } + $this->logger->debug('Subscriber executed: '.__METHOD__); + $this->projectModel->updateModificationDate($event['task']['project_id']); } } diff --git a/tests/units/Action/BaseActionTest.php b/tests/units/Action/BaseActionTest.php index 1d50c70e..feeba3f9 100644 --- a/tests/units/Action/BaseActionTest.php +++ b/tests/units/Action/BaseActionTest.php @@ -23,7 +23,7 @@ class DummyAction extends Kanboard\Action\Base public function getEventRequiredParameters() { - return array('p1', 'p2'); + return array('p1', 'p2', 'p3' => array('p4')); } public function doAction(array $data) @@ -60,7 +60,7 @@ class BaseActionTest extends Base public function testGetEventRequiredParameters() { $dummyAction = new DummyAction($this->container); - $this->assertEquals(array('p1', 'p2'), $dummyAction->getEventRequiredParameters()); + $this->assertEquals(array('p1', 'p2', 'p3' => array('p4')), $dummyAction->getEventRequiredParameters()); } public function testGetCompatibleEvents() @@ -113,7 +113,7 @@ class BaseActionTest extends Base $dummyAction = new DummyAction($this->container); $dummyAction->setProjectId(1234); - $this->assertTrue($dummyAction->hasRequiredParameters(array('p1' => 12, 'p2' => 34))); + $this->assertTrue($dummyAction->hasRequiredParameters(array('p1' => 12, 'p2' => 34, 'p3' => array('p4' => 'foobar')))); $this->assertFalse($dummyAction->hasRequiredParameters(array('p1' => 12))); $this->assertFalse($dummyAction->hasRequiredParameters(array())); } @@ -125,7 +125,7 @@ class BaseActionTest extends Base $dummyAction->addEvent('my.event', 'My Event Overrided'); $events = $dummyAction->getEvents(); - $this->assertcount(2, $events); + $this->assertCount(2, $events); $this->assertEquals(array('my.event', 'foobar'), $events); } @@ -136,7 +136,7 @@ class BaseActionTest extends Base $dummyAction->setParam('p1', 'something'); $dummyAction->addEvent('foobar', 'FooBar'); - $event = new GenericEvent(array('project_id' => 1234, 'p1' => 'something', 'p2' => 'abc')); + $event = new GenericEvent(array('project_id' => 1234, 'p1' => 'something', 'p2' => 'abc', 'p3' => array('p4' => 'a'))); $this->assertTrue($dummyAction->execute($event, 'foobar')); $this->assertFalse($dummyAction->execute($event, 'foobar')); diff --git a/tests/units/Action/CommentCreationMoveTaskColumnTest.php b/tests/units/Action/CommentCreationMoveTaskColumnTest.php index 5eaf515e..b3d21287 100644 --- a/tests/units/Action/CommentCreationMoveTaskColumnTest.php +++ b/tests/units/Action/CommentCreationMoveTaskColumnTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\CommentModel; @@ -22,7 +22,7 @@ class CommentCreationMoveTaskColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array('task' => array('project_id' => 1, 'column_id' => 2), 'task_id' => 1)); $action = new CommentCreationMoveTaskColumn($this->container); $action->setProjectId(1); @@ -45,7 +45,7 @@ class CommentCreationMoveTaskColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array('task' => array('project_id' => 1, 'column_id' => 3), 'task_id' => 1)); $action = new CommentCreationMoveTaskColumn($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignCategoryColorTest.php b/tests/units/Action/TaskAssignCategoryColorTest.php index 09c08264..5a0f7d03 100644 --- a/tests/units/Action/TaskAssignCategoryColorTest.php +++ b/tests/units/Action/TaskAssignCategoryColorTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\CategoryModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; @@ -23,7 +23,13 @@ class TaskAssignCategoryColorTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'color_id' => 'red')); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'color_id' => 'red', + ) + )); $action = new TaskAssignCategoryColor($this->container); $action->setProjectId(1); @@ -47,7 +53,13 @@ class TaskAssignCategoryColorTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'color_id' => 'blue')); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'color_id' => 'blue', + ) + )); $action = new TaskAssignCategoryColor($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php index 712c3c02..d7e68f72 100644 --- a/tests/units/Action/TaskAssignCategoryLinkTest.php +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -14,19 +14,19 @@ class TaskAssignCategoryLinkTest extends Base { public function testAssignCategory() { - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $c = new CategoryModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $categoryModel = new CategoryModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); $action->setParam('category_id', 1); $action->setParam('link_id', 2); - $this->assertEquals(1, $p->create(array('name' => 'P1'))); - $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); - $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); $event = new TaskLinkEvent(array( 'project_id' => 1, @@ -37,25 +37,24 @@ class TaskAssignCategoryLinkTest extends Base $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); - $task = $tf->getById(1); + $task = $taskFinderModel->getById(1); $this->assertEquals(1, $task['category_id']); } public function testWhenLinkDontMatch() { - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $c = new CategoryModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $categoryModel = new CategoryModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); $action->setParam('category_id', 1); $action->setParam('link_id', 1); - $this->assertEquals(1, $p->create(array('name' => 'P1'))); - $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); - $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); $event = new TaskLinkEvent(array( 'project_id' => 1, @@ -69,19 +68,19 @@ class TaskAssignCategoryLinkTest extends Base public function testThatExistingCategoryWillNotChange() { - $tc = new TaskCreationModel($this->container); - $p = new ProjectModel($this->container); - $c = new CategoryModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $categoryModel = new CategoryModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); $action->setParam('category_id', 2); $action->setParam('link_id', 2); - $this->assertEquals(1, $p->create(array('name' => 'P1'))); - $this->assertEquals(1, $c->create(array('name' => 'C1', 'project_id' => 1))); - $this->assertEquals(2, $c->create(array('name' => 'C2', 'project_id' => 1))); - $this->assertEquals(1, $tc->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1))); + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); + $this->assertEquals(2, $categoryModel->create(array('name' => 'C2', 'project_id' => 1))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1))); $event = new TaskLinkEvent(array( 'project_id' => 1, diff --git a/tests/units/Action/TaskAssignColorCategoryTest.php b/tests/units/Action/TaskAssignColorCategoryTest.php index 6502035f..16ad1290 100644 --- a/tests/units/Action/TaskAssignColorCategoryTest.php +++ b/tests/units/Action/TaskAssignColorCategoryTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\CategoryModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; @@ -23,7 +23,13 @@ class TaskAssignColorCategoryTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'category_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'category_id' => 1, + ) + )); $action = new TaskAssignColorCategory($this->container); $action->setProjectId(1); @@ -45,7 +51,13 @@ class TaskAssignColorCategoryTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'category_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'category_id' => 2, + ) + )); $action = new TaskAssignColorCategory($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignColorColumnTest.php b/tests/units/Action/TaskAssignColorColumnTest.php index d4ba8e01..ccfb9e88 100644 --- a/tests/units/Action/TaskAssignColorColumnTest.php +++ b/tests/units/Action/TaskAssignColorColumnTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -20,7 +20,13 @@ class TaskAssignColorColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskAssignColorColumn($this->container); $action->setProjectId(1); @@ -42,7 +48,13 @@ class TaskAssignColorColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskAssignColorColumn($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignColorPriorityTest.php b/tests/units/Action/TaskAssignColorPriorityTest.php index 2fce8e66..0ea874cd 100644 --- a/tests/units/Action/TaskAssignColorPriorityTest.php +++ b/tests/units/Action/TaskAssignColorPriorityTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\CategoryModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; @@ -23,7 +23,13 @@ class TaskAssignColorPriorityTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'priority' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'priority' => 1, + ) + )); $action = new TaskAssignColorPriority($this->container); $action->setProjectId(1); @@ -45,7 +51,13 @@ class TaskAssignColorPriorityTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'priority' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'priority' => 2, + ) + )); $action = new TaskAssignColorPriority($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignColorUserTest.php b/tests/units/Action/TaskAssignColorUserTest.php index 370f9070..45faa3ff 100644 --- a/tests/units/Action/TaskAssignColorUserTest.php +++ b/tests/units/Action/TaskAssignColorUserTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -20,7 +20,13 @@ class TaskAssignColorUserTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'owner_id' => 1, + ) + )); $action = new TaskAssignColorUser($this->container); $action->setProjectId(1); @@ -42,7 +48,13 @@ class TaskAssignColorUserTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'owner_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'owner_id' => 2, + ) + )); $action = new TaskAssignColorUser($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignCurrentUserColumnTest.php b/tests/units/Action/TaskAssignCurrentUserColumnTest.php index 6fdbda63..3b64d718 100644 --- a/tests/units/Action/TaskAssignCurrentUserColumnTest.php +++ b/tests/units/Action/TaskAssignCurrentUserColumnTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -22,7 +22,13 @@ class TaskAssignCurrentUserColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskAssignCurrentUserColumn($this->container); $action->setProjectId(1); @@ -45,7 +51,13 @@ class TaskAssignCurrentUserColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskAssignCurrentUserColumn($this->container); $action->setProjectId(1); @@ -62,7 +74,13 @@ class TaskAssignCurrentUserColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskAssignCurrentUserColumn($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskAssignSpecificUserTest.php b/tests/units/Action/TaskAssignSpecificUserTest.php index 78ec314f..0e63fc13 100644 --- a/tests/units/Action/TaskAssignSpecificUserTest.php +++ b/tests/units/Action/TaskAssignSpecificUserTest.php @@ -3,6 +3,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -20,7 +21,13 @@ class TaskAssignSpecificUserTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => 0))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskAssignSpecificUser($this->container); $action->setProjectId(1); @@ -42,7 +49,13 @@ class TaskAssignSpecificUserTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskAssignSpecificUser($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskCloseColumnTest.php b/tests/units/Action/TaskCloseColumnTest.php index f9a938f0..7afb0478 100644 --- a/tests/units/Action/TaskCloseColumnTest.php +++ b/tests/units/Action/TaskCloseColumnTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -20,7 +20,13 @@ class TaskCloseColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskCloseColumn($this->container); $action->setProjectId(1); @@ -41,7 +47,13 @@ class TaskCloseColumnTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskCloseColumn($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskCloseTest.php b/tests/units/Action/TaskCloseTest.php index 3df10cb8..589ef133 100644 --- a/tests/units/Action/TaskCloseTest.php +++ b/tests/units/Action/TaskCloseTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -19,7 +19,12 @@ class TaskCloseTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + ) + )); $action = new TaskClose($this->container); $action->setProjectId(1); @@ -40,7 +45,11 @@ class TaskCloseTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1)); + $event = new TaskEvent(array( + 'task' => array( + 'project_id' => 1, + ) + )); $action = new TaskClose($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskDuplicateAnotherProjectTest.php b/tests/units/Action/TaskDuplicateAnotherProjectTest.php index 98ff187f..5cd0c977 100644 --- a/tests/units/Action/TaskDuplicateAnotherProjectTest.php +++ b/tests/units/Action/TaskDuplicateAnotherProjectTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; @@ -21,7 +21,13 @@ class TaskDuplicateAnotherProjectTest extends Base $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskDuplicateAnotherProject($this->container); $action->setProjectId(1); @@ -43,7 +49,13 @@ class TaskDuplicateAnotherProjectTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskDuplicateAnotherProject($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskEmailTest.php b/tests/units/Action/TaskEmailTest.php index df71aaf8..421c89ca 100644 --- a/tests/units/Action/TaskEmailTest.php +++ b/tests/units/Action/TaskEmailTest.php @@ -2,7 +2,8 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; +use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\ProjectModel; @@ -16,16 +17,20 @@ class TaskEmailTest extends Base $userModel = new UserModel($this->container); $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); $this->assertTrue($userModel->update(array('id' => 1, 'email' => 'admin@localhost'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => $taskFinderModel->getDetails(1) + )); $action = new TaskEmail($this->container); $action->setProjectId(1); - $action->setParam('column_id', 2); + $action->setParam('column_id', 1); $action->setParam('user_id', 1); $action->setParam('subject', 'My email subject'); @@ -47,7 +52,13 @@ class TaskEmailTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskEmail($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskMoveAnotherProjectTest.php b/tests/units/Action/TaskMoveAnotherProjectTest.php index d36df47b..a41fd03f 100644 --- a/tests/units/Action/TaskMoveAnotherProjectTest.php +++ b/tests/units/Action/TaskMoveAnotherProjectTest.php @@ -3,6 +3,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; @@ -21,7 +22,13 @@ class TaskMoveAnotherProjectTest extends Base $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskMoveAnotherProject($this->container); $action->setProjectId(1); @@ -44,7 +51,13 @@ class TaskMoveAnotherProjectTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskMoveAnotherProject($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskMoveColumnAssignedTest.php b/tests/units/Action/TaskMoveColumnAssignedTest.php index f8982969..aa9d3592 100644 --- a/tests/units/Action/TaskMoveColumnAssignedTest.php +++ b/tests/units/Action/TaskMoveColumnAssignedTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; @@ -19,9 +19,12 @@ class TaskMoveColumnAssignedTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'owner_id' => 1))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => $taskFinderModel->getDetails(1), + )); $action = new TaskMoveColumnAssigned($this->container); $action->setProjectId(1); @@ -43,7 +46,14 @@ class TaskMoveColumnAssignedTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3, 'owner_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + 'owner_id' => 1, + ) + )); $action = new TaskMoveColumnAssigned($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskMoveColumnCategoryChangeTest.php b/tests/units/Action/TaskMoveColumnCategoryChangeTest.php index c42383f8..7e0856df 100644 --- a/tests/units/Action/TaskMoveColumnCategoryChangeTest.php +++ b/tests/units/Action/TaskMoveColumnCategoryChangeTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\CategoryModel; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskFinderModel; @@ -24,7 +24,16 @@ class TaskMoveColumnCategoryChangeTest extends Base $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'category_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 1, + 'category_id' => 1, + 'position' => 1, + 'swimlane_id' => 0, + ) + )); $action = new TaskMoveColumnCategoryChange($this->container); $action->setProjectId(1); @@ -50,7 +59,14 @@ class TaskMoveColumnCategoryChangeTest extends Base $this->assertEquals(1, $categoryModel->create(array('name' => 'c1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2, 'category_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + 'category_id' => 1, + ) + )); $action = new TaskMoveColumnCategoryChange($this->container); $action->setProjectId(1); @@ -72,7 +88,14 @@ class TaskMoveColumnCategoryChangeTest extends Base $this->assertEquals(2, $categoryModel->create(array('name' => 'c2', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'category_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 1, + 'category_id' => 2, + ) + )); $action = new TaskMoveColumnCategoryChange($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskMoveColumnUnAssignedTest.php b/tests/units/Action/TaskMoveColumnUnAssignedTest.php index befae36b..b45dec08 100644 --- a/tests/units/Action/TaskMoveColumnUnAssignedTest.php +++ b/tests/units/Action/TaskMoveColumnUnAssignedTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; @@ -21,7 +21,16 @@ class TaskMoveColumnUnAssignedTest extends Base $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 0)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 1, + 'owner_id' => 0, + 'position' => 1, + 'swimlane_id' => 0, + ) + )); $action = new TaskMoveColumnUnAssigned($this->container); $action->setProjectId(1); @@ -43,7 +52,14 @@ class TaskMoveColumnUnAssignedTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2, 'owner_id' => 0)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + 'owner_id' => 0, + ) + )); $action = new TaskMoveColumnUnAssigned($this->container); $action->setProjectId(1); @@ -60,7 +76,14 @@ class TaskMoveColumnUnAssignedTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 1, 'owner_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 1, + 'owner_id' => 1, + ) + )); $action = new TaskMoveColumnUnAssigned($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskOpenTest.php b/tests/units/Action/TaskOpenTest.php index 1018e2ea..825c6ac9 100644 --- a/tests/units/Action/TaskOpenTest.php +++ b/tests/units/Action/TaskOpenTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -19,7 +19,12 @@ class TaskOpenTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + ) + )); $action = new TaskOpen($this->container); $action->setProjectId(1); @@ -40,7 +45,11 @@ class TaskOpenTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1)); + $event = new TaskEvent(array( + 'task' => array( + 'project_id' => 1, + ) + )); $action = new TaskOpen($this->container); $action->setProjectId(1); diff --git a/tests/units/Action/TaskUpdateStartDateTest.php b/tests/units/Action/TaskUpdateStartDateTest.php index ddd9eafd..8d609b3e 100644 --- a/tests/units/Action/TaskUpdateStartDateTest.php +++ b/tests/units/Action/TaskUpdateStartDateTest.php @@ -3,6 +3,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Event\GenericEvent; +use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -20,7 +21,13 @@ class TaskUpdateStartDateTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 2)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 2, + ) + )); $action = new TaskUpdateStartDate($this->container); $action->setProjectId(1); @@ -41,7 +48,13 @@ class TaskUpdateStartDateTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'column_id' => 3)); + $event = new TaskEvent(array( + 'task_id' => 1, + 'task' => array( + 'project_id' => 1, + 'column_id' => 3, + ) + )); $action = new TaskUpdateStartDate($this->container); $action->setProjectId(1); diff --git a/tests/units/EventBuilder/TaskEventBuilderTest.php b/tests/units/EventBuilder/TaskEventBuilderTest.php new file mode 100644 index 00000000..e6334fe2 --- /dev/null +++ b/tests/units/EventBuilder/TaskEventBuilderTest.php @@ -0,0 +1,100 @@ +container); + $taskEventBuilder->withTaskId(42); + $this->assertNull($taskEventBuilder->build()); + } + + public function testBuildWithTask() + { + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskEventBuilder = new TaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'before', 'project_id' => 1))); + + $event = $taskEventBuilder + ->withTaskId(1) + ->withTask(array('title' => 'before')) + ->withChanges(array('title' => 'after')) + ->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + $this->assertNotEmpty($event['task']); + $this->assertEquals(1, $event['task_id']); + $this->assertEquals(array('title' => 'after'), $event['changes']); + } + + public function testBuildWithoutChanges() + { + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskEventBuilder = new TaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $event = $taskEventBuilder->withTaskId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + $this->assertNotEmpty($event['task']); + $this->assertEquals(1, $event['task_id']); + $this->assertArrayNotHasKey('changes', $event); + } + + public function testBuildWithChanges() + { + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskEventBuilder = new TaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $event = $taskEventBuilder + ->withTaskId(1) + ->withChanges(array('title' => 'new title')) + ->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + $this->assertNotEmpty($event['task']); + $this->assertNotEmpty($event['changes']); + $this->assertEquals('new title', $event['changes']['title']); + } + + public function testBuildWithChangesAndValues() + { + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskEventBuilder = new TaskEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $event = $taskEventBuilder + ->withTaskId(1) + ->withChanges(array('title' => 'new title', 'project_id' => 1)) + ->withValues(array('key' => 'value')) + ->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + $this->assertNotEmpty($event['task']); + $this->assertNotEmpty($event['changes']); + $this->assertNotEmpty($event['key']); + $this->assertEquals('value', $event['key']); + + $this->assertCount(1, $event['changes']); + $this->assertEquals('new title', $event['changes']['title']); + } +} diff --git a/tests/units/Job/SubtaskEventJobTest.php b/tests/units/Job/SubtaskEventJobTest.php index 265a8e2d..66c3db05 100644 --- a/tests/units/Job/SubtaskEventJobTest.php +++ b/tests/units/Job/SubtaskEventJobTest.php @@ -21,8 +21,8 @@ class SubtaskEventJobTest extends Base { $this->container['dispatcher']->addListener(SubtaskModel::EVENT_CREATE, function() {}); - $SubtaskEventJob = new SubtaskEventJob($this->container); - $SubtaskEventJob->execute(42, SubtaskModel::EVENT_CREATE); + $subtaskEventJob = new SubtaskEventJob($this->container); + $subtaskEventJob->execute(42, SubtaskModel::EVENT_CREATE); $called = $this->container['dispatcher']->getCalledListeners(); $this->assertEmpty($called); diff --git a/tests/units/Job/TaskEventJobTest.php b/tests/units/Job/TaskEventJobTest.php new file mode 100644 index 00000000..c399faad --- /dev/null +++ b/tests/units/Job/TaskEventJobTest.php @@ -0,0 +1,189 @@ +container); + $taskEventJob->withParams(123, array('foobar'), array('k' => 'v'), array('k1' => 'v1'), array('k2' => 'v2')); + + $this->assertSame( + array(123, array('foobar'), array('k' => 'v'), array('k1' => 'v1'), array('k2' => 'v2')), + $taskEventJob->getJobParams() + ); + } + + public function testWithMissingTask() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_CREATE, function() {}); + + $taskEventJob = new TaskEventJob($this->container); + $taskEventJob->execute(42, array(TaskModel::EVENT_CREATE)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerCreateEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_CREATE, function() {}); + $this->container['dispatcher']->addListener(TaskModel::EVENT_CREATE_UPDATE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_CREATE.'.closure', $called); + $this->assertArrayHasKey(TaskModel::EVENT_CREATE_UPDATE.'.closure', $called); + } + + public function testTriggerUpdateEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_UPDATE, function() {}); + $this->container['dispatcher']->addListener(TaskModel::EVENT_CREATE_UPDATE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskModificationModel = new TaskModificationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($taskModificationModel->update(array('id' => 1, 'title' => 'new title'))); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_UPDATE.'.closure', $called); + $this->assertArrayHasKey(TaskModel::EVENT_CREATE_UPDATE.'.closure', $called); + } + + public function testTriggerAssigneeChangeEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_ASSIGNEE_CHANGE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskModificationModel = new TaskModificationModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($taskModificationModel->update(array('id' => 1, 'owner_id' => 1))); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_ASSIGNEE_CHANGE.'.closure', $called); + } + + public function testTriggerCloseEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_CLOSE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskStatusModel = new TaskStatusModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($taskStatusModel->close(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_CLOSE.'.closure', $called); + } + + public function testTriggerOpenEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_OPEN, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskStatusModel = new TaskStatusModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($taskStatusModel->close(1)); + $this->assertTrue($taskStatusModel->open(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_OPEN.'.closure', $called); + } + + public function testTriggerMovePositionEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_POSITION, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskPositionModel = new TaskPositionModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test 2', 'project_id' => 1))); + $this->assertTrue($taskPositionModel->movePosition(1, 1, 1, 2)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_POSITION.'.closure', $called); + } + + public function testTriggerMoveColumnEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_COLUMN, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskPositionModel = new TaskPositionModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertTrue($taskPositionModel->movePosition(1, 1, 2, 2)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_COLUMN.'.closure', $called); + } + + public function testTriggerMoveSwimlaneEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_SWIMLANE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $taskPositionModel = new TaskPositionModel($this->container); + $projectModel = new ProjectModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $swimlaneModel->create(array('name' => 'S1', 'project_id' => 1))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertTrue($taskPositionModel->movePosition(1, 1, 1, 1, 1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_SWIMLANE.'.closure', $called); + } + + public function testTriggerMoveProjectEvent() + { + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_PROJECT, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskProjectMoveModel = new TaskProjectMoveModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertTrue($taskProjectMoveModel->moveToProject(1, 1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_PROJECT.'.closure', $called); + } +} diff --git a/tests/units/Model/TaskCreationModelTest.php b/tests/units/Model/TaskCreationModelTest.php index f97c61dc..ce9996d9 100644 --- a/tests/units/Model/TaskCreationModelTest.php +++ b/tests/units/Model/TaskCreationModelTest.php @@ -17,7 +17,7 @@ class TaskCreationModelTest extends Base $event_data = $event->getAll(); $this->assertNotEmpty($event_data); $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals('test', $event_data['title']); + $this->assertEquals('test', $event_data['task']['title']); } public function testNoTitle() diff --git a/tests/units/Model/TaskModificationModelTest.php b/tests/units/Model/TaskModificationModelTest.php index c81f968b..f70561b3 100644 --- a/tests/units/Model/TaskModificationModelTest.php +++ b/tests/units/Model/TaskModificationModelTest.php @@ -18,7 +18,8 @@ class TaskModificationModelTest extends Base $event_data = $event->getAll(); $this->assertNotEmpty($event_data); $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals('Task #1', $event_data['title']); + $this->assertEquals('After', $event_data['task']['title']); + $this->assertEquals('After', $event_data['changes']['title']); } public function onUpdate($event) @@ -28,7 +29,7 @@ class TaskModificationModelTest extends Base $event_data = $event->getAll(); $this->assertNotEmpty($event_data); $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals('Task #1', $event_data['title']); + $this->assertEquals('After', $event_data['task']['title']); } public function onAssigneeChange($event) @@ -38,7 +39,7 @@ class TaskModificationModelTest extends Base $event_data = $event->getAll(); $this->assertNotEmpty($event_data); $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals(1, $event_data['owner_id']); + $this->assertEquals(1, $event_data['changes']['owner_id']); } public function testThatNoEventAreFiredWhenNoChanges() @@ -66,19 +67,19 @@ class TaskModificationModelTest extends Base $taskFinderModel = new TaskFinderModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Before', 'project_id' => 1))); $this->container['dispatcher']->addListener(TaskModel::EVENT_CREATE_UPDATE, array($this, 'onCreateUpdate')); $this->container['dispatcher']->addListener(TaskModel::EVENT_UPDATE, array($this, 'onUpdate')); - $this->assertTrue($taskModificationModel->update(array('id' => 1, 'title' => 'Task #1'))); + $this->assertTrue($taskModificationModel->update(array('id' => 1, 'title' => 'After'))); $called = $this->container['dispatcher']->getCalledListeners(); $this->assertArrayHasKey(TaskModel::EVENT_CREATE_UPDATE.'.TaskModificationModelTest::onCreateUpdate', $called); $this->assertArrayHasKey(TaskModel::EVENT_UPDATE.'.TaskModificationModelTest::onUpdate', $called); $task = $taskFinderModel->getById(1); - $this->assertEquals('Task #1', $task['title']); + $this->assertEquals('After', $task['title']); } public function testChangeAssignee() diff --git a/tests/units/Model/TaskPositionModelTest.php b/tests/units/Model/TaskPositionModelTest.php new file mode 100644 index 00000000..03caf7ed --- /dev/null +++ b/tests/units/Model/TaskPositionModelTest.php @@ -0,0 +1,631 @@ +container); + $taskStatusModel = new TaskStatusModel($this->container); + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $columnModel = new ColumnModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(0, $taskModel->getProgress($taskFinderModel->getById(1), $columnModel->getList(1))); + + $this->assertTrue($taskPositionModel->movePosition(1, 1, 2, 1)); + $this->assertEquals(25, $taskModel->getProgress($taskFinderModel->getById(1), $columnModel->getList(1))); + + $this->assertTrue($taskPositionModel->movePosition(1, 1, 3, 1)); + $this->assertEquals(50, $taskModel->getProgress($taskFinderModel->getById(1), $columnModel->getList(1))); + + $this->assertTrue($taskPositionModel->movePosition(1, 1, 4, 1)); + $this->assertEquals(75, $taskModel->getProgress($taskFinderModel->getById(1), $columnModel->getList(1))); + + $this->assertTrue($taskStatusModel->close(1)); + $this->assertEquals(100, $taskModel->getProgress($taskFinderModel->getById(1), $columnModel->getList(1))); + } + + public function testMoveTaskToWrongPosition() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + + // We move the task 2 to the position 0 + $this->assertFalse($taskPositionModel->movePosition(1, 1, 3, 0)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + } + + public function testMoveTaskToGreaterPosition() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + + // We move the task 2 to the position 42 + $this->assertTrue($taskPositionModel->movePosition(1, 1, 1, 42)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + } + + public function testMoveTaskToEmptyColumn() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + + // We move the task 1 to the column 3 + $this->assertTrue($taskPositionModel->movePosition(1, 1, 3, 1)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(3, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + } + + public function testMoveTaskToAnotherColumn() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 2))); + $this->assertEquals(5, $taskCreationModel->create(array('title' => 'Task #5', 'project_id' => 1, 'column_id' => 2))); + $this->assertEquals(6, $taskCreationModel->create(array('title' => 'Task #6', 'project_id' => 1, 'column_id' => 2))); + $this->assertEquals(7, $taskCreationModel->create(array('title' => 'Task #7', 'project_id' => 1, 'column_id' => 3))); + $this->assertEquals(8, $taskCreationModel->create(array('title' => 'Task #8', 'project_id' => 1, 'column_id' => 1))); + + // We move the task 3 to the column 3 + $this->assertTrue($taskPositionModel->movePosition(1, 3, 3, 2)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(3, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(5); + $this->assertEquals(5, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(6); + $this->assertEquals(6, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(3, $task['position']); + + $task = $taskFinderModel->getById(7); + $this->assertEquals(7, $task['id']); + $this->assertEquals(3, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(8); + $this->assertEquals(8, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(3, $task['position']); + } + + public function testMoveTaskTop() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); + + // Move the last task to the top + $this->assertTrue($taskPositionModel->movePosition(1, 4, 1, 1)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(3, $task['position']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(4, $task['position']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + } + + public function testMoveTaskBottom() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); + + // Move the first task to the bottom + $this->assertTrue($taskPositionModel->movePosition(1, 1, 1, 4)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(4, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(3, $task['position']); + } + + public function testMovePosition() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $counter = 1; + $task_per_column = 5; + + foreach (array(1, 2, 3, 4) as $column_id) { + for ($i = 1; $i <= $task_per_column; $i++, $counter++) { + $task = array( + 'title' => 'Task #'.$i.'-'.$column_id, + 'project_id' => 1, + 'column_id' => $column_id, + 'owner_id' => 0, + ); + + $this->assertEquals($counter, $taskCreationModel->create($task)); + + $task = $taskFinderModel->getById($counter); + $this->assertNotEmpty($task); + $this->assertEquals($i, $task['position']); + } + } + + // We move task id #4, column 1, position 4 to the column 2, position 3 + $this->assertTrue($taskPositionModel->movePosition(1, 4, 2, 3)); + + // We check the new position of the task + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(3, $task['position']); + + // The tasks before have the correct position + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(3, $task['position']); + + $task = $taskFinderModel->getById(7); + $this->assertEquals(7, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + + // The tasks after have the correct position + $task = $taskFinderModel->getById(5); + $this->assertEquals(5, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(4, $task['position']); + + $task = $taskFinderModel->getById(8); + $this->assertEquals(8, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(4, $task['position']); + + // The number of tasks per column + $this->assertEquals($task_per_column - 1, $taskFinderModel->countByColumnId(1, 1)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 2)); + $this->assertEquals($task_per_column, $taskFinderModel->countByColumnId(1, 3)); + $this->assertEquals($task_per_column, $taskFinderModel->countByColumnId(1, 4)); + + // We move task id #1, column 1, position 1 to the column 4, position 6 (last position) + $this->assertTrue($taskPositionModel->movePosition(1, 1, 4, $task_per_column + 1)); + + // We check the new position of the task + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(4, $task['column_id']); + $this->assertEquals($task_per_column + 1, $task['position']); + + // The tasks before have the correct position + $task = $taskFinderModel->getById(20); + $this->assertEquals(20, $task['id']); + $this->assertEquals(4, $task['column_id']); + $this->assertEquals($task_per_column, $task['position']); + + // The tasks after have the correct position + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + + // The number of tasks per column + $this->assertEquals($task_per_column - 2, $taskFinderModel->countByColumnId(1, 1)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 2)); + $this->assertEquals($task_per_column, $taskFinderModel->countByColumnId(1, 3)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 4)); + + // Our previous moved task should stay at the same place + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(3, $task['position']); + + // Test wrong position number + $this->assertFalse($taskPositionModel->movePosition(1, 2, 3, 0)); + $this->assertFalse($taskPositionModel->movePosition(1, 2, 3, -2)); + + // Wrong column + $this->assertFalse($taskPositionModel->movePosition(1, 2, 22, 2)); + + // Test position greater than the last position + $this->assertTrue($taskPositionModel->movePosition(1, 11, 1, 22)); + + $task = $taskFinderModel->getById(11); + $this->assertEquals(11, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals($taskFinderModel->countByColumnId(1, 1), $task['position']); + + $task = $taskFinderModel->getById(5); + $this->assertEquals(5, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals($taskFinderModel->countByColumnId(1, 1) - 1, $task['position']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(3, $task['position']); + + $this->assertEquals($task_per_column - 1, $taskFinderModel->countByColumnId(1, 1)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 2)); + $this->assertEquals($task_per_column - 1, $taskFinderModel->countByColumnId(1, 3)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 4)); + + // Our previous moved task should stay at the same place + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(3, $task['position']); + + // Test moving task to position 1 + $this->assertTrue($taskPositionModel->movePosition(1, 14, 1, 1)); + + $task = $taskFinderModel->getById(14); + $this->assertEquals(14, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $this->assertEquals($task_per_column, $taskFinderModel->countByColumnId(1, 1)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 2)); + $this->assertEquals($task_per_column - 2, $taskFinderModel->countByColumnId(1, 3)); + $this->assertEquals($task_per_column + 1, $taskFinderModel->countByColumnId(1, 4)); + } + + public function testMoveTaskSwimlane() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $swimlaneModel->create(array('project_id' => 1, 'name' => 'test 1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(4, $taskCreationModel->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(5, $taskCreationModel->create(array('title' => 'Task #5', 'project_id' => 1, 'column_id' => 1))); + + // Move the task to the swimlane + $this->assertTrue($taskPositionModel->movePosition(1, 1, 2, 1, 1)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(3, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + // Move the task to the swimlane + $this->assertTrue($taskPositionModel->movePosition(1, 2, 2, 1, 1)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + // Move the task 5 to the last column + $this->assertTrue($taskPositionModel->movePosition(1, 5, 4, 1, 0)); + + // Check tasks position + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(3); + $this->assertEquals(3, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $task = $taskFinderModel->getById(4); + $this->assertEquals(4, $task['id']); + $this->assertEquals(1, $task['column_id']); + $this->assertEquals(2, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $task = $taskFinderModel->getById(5); + $this->assertEquals(5, $task['id']); + $this->assertEquals(4, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + } + + public function testEvents() + { + $taskPositionModel = new TaskPositionModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $swimlaneModel = new SwimlaneModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $swimlaneModel->create(array('project_id' => 1, 'name' => 'test 1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2))); + + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_COLUMN, array($this, 'onMoveColumn')); + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_POSITION, array($this, 'onMovePosition')); + $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_SWIMLANE, array($this, 'onMoveSwimlane')); + + // We move the task 1 to the column 2 + $this->assertTrue($taskPositionModel->movePosition(1, 1, 2, 1)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_COLUMN.'.TaskPositionModelTest::onMoveColumn', $called); + $this->assertEquals(1, count($called)); + + // We move the task 1 to the position 2 + $this->assertTrue($taskPositionModel->movePosition(1, 1, 2, 2)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(2, $task['position']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_POSITION.'.TaskPositionModelTest::onMovePosition', $called); + $this->assertEquals(2, count($called)); + + // Move to another swimlane + $this->assertTrue($taskPositionModel->movePosition(1, 1, 3, 1, 1)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['id']); + $this->assertEquals(3, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(1, $task['swimlane_id']); + + $task = $taskFinderModel->getById(2); + $this->assertEquals(2, $task['id']); + $this->assertEquals(2, $task['column_id']); + $this->assertEquals(1, $task['position']); + $this->assertEquals(0, $task['swimlane_id']); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskModel::EVENT_MOVE_SWIMLANE.'.TaskPositionModelTest::onMoveSwimlane', $called); + $this->assertEquals(3, count($called)); + } + + public function onMoveColumn($event) + { + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + + $event_data = $event->getAll(); + $this->assertNotEmpty($event_data); + $this->assertEquals(1, $event_data['task_id']); + $this->assertEquals(1, $event_data['position']); + $this->assertEquals(2, $event_data['column_id']); + $this->assertEquals(1, $event_data['project_id']); + } + + public function onMovePosition($event) + { + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + + $event_data = $event->getAll(); + $this->assertNotEmpty($event_data); + $this->assertEquals(1, $event_data['task_id']); + $this->assertEquals(2, $event_data['position']); + $this->assertEquals(2, $event_data['column_id']); + $this->assertEquals(1, $event_data['project_id']); + } + + public function onMoveSwimlane($event) + { + $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); + + $event_data = $event->getAll(); + $this->assertNotEmpty($event_data); + $this->assertEquals(1, $event_data['task_id']); + $this->assertEquals(1, $event_data['position']); + $this->assertEquals(3, $event_data['column_id']); + $this->assertEquals(1, $event_data['project_id']); + $this->assertEquals(1, $event_data['swimlane_id']); + } +} diff --git a/tests/units/Model/TaskPositionTest.php b/tests/units/Model/TaskPositionTest.php deleted file mode 100644 index 7ab6950e..00000000 --- a/tests/units/Model/TaskPositionTest.php +++ /dev/null @@ -1,631 +0,0 @@ -container); - $ts = new TaskStatusModel($this->container); - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $columnModel = new ColumnModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(0, $t->getProgress($tf->getById(1), $columnModel->getList(1))); - - $this->assertTrue($tp->movePosition(1, 1, 2, 1)); - $this->assertEquals(25, $t->getProgress($tf->getById(1), $columnModel->getList(1))); - - $this->assertTrue($tp->movePosition(1, 1, 3, 1)); - $this->assertEquals(50, $t->getProgress($tf->getById(1), $columnModel->getList(1))); - - $this->assertTrue($tp->movePosition(1, 1, 4, 1)); - $this->assertEquals(75, $t->getProgress($tf->getById(1), $columnModel->getList(1))); - - $this->assertTrue($ts->close(1)); - $this->assertEquals(100, $t->getProgress($tf->getById(1), $columnModel->getList(1))); - } - - public function testMoveTaskToWrongPosition() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - - // We move the task 2 to the position 0 - $this->assertFalse($tp->movePosition(1, 1, 3, 0)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - } - - public function testMoveTaskToGreaterPosition() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - - // We move the task 2 to the position 42 - $this->assertTrue($tp->movePosition(1, 1, 1, 42)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - } - - public function testMoveTaskToEmptyColumn() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - - // We move the task 1 to the column 3 - $this->assertTrue($tp->movePosition(1, 1, 3, 1)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - } - - public function testMoveTaskToAnotherColumn() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 2))); - $this->assertEquals(5, $tc->create(array('title' => 'Task #5', 'project_id' => 1, 'column_id' => 2))); - $this->assertEquals(6, $tc->create(array('title' => 'Task #6', 'project_id' => 1, 'column_id' => 2))); - $this->assertEquals(7, $tc->create(array('title' => 'Task #7', 'project_id' => 1, 'column_id' => 3))); - $this->assertEquals(8, $tc->create(array('title' => 'Task #8', 'project_id' => 1, 'column_id' => 1))); - - // We move the task 3 to the column 3 - $this->assertTrue($tp->movePosition(1, 3, 3, 2)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(5); - $this->assertEquals(5, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(6); - $this->assertEquals(6, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(3, $task['position']); - - $task = $tf->getById(7); - $this->assertEquals(7, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(8); - $this->assertEquals(8, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(3, $task['position']); - } - - public function testMoveTaskTop() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); - - // Move the last task to the top - $this->assertTrue($tp->movePosition(1, 4, 1, 1)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(3, $task['position']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(4, $task['position']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - } - - public function testMoveTaskBottom() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); - - // Move the first task to the bottom - $this->assertTrue($tp->movePosition(1, 1, 1, 4)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(4, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(3, $task['position']); - } - - public function testMovePosition() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $counter = 1; - $task_per_column = 5; - - foreach (array(1, 2, 3, 4) as $column_id) { - for ($i = 1; $i <= $task_per_column; $i++, $counter++) { - $task = array( - 'title' => 'Task #'.$i.'-'.$column_id, - 'project_id' => 1, - 'column_id' => $column_id, - 'owner_id' => 0, - ); - - $this->assertEquals($counter, $tc->create($task)); - - $task = $tf->getById($counter); - $this->assertNotEmpty($task); - $this->assertEquals($i, $task['position']); - } - } - - // We move task id #4, column 1, position 4 to the column 2, position 3 - $this->assertTrue($tp->movePosition(1, 4, 2, 3)); - - // We check the new position of the task - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(3, $task['position']); - - // The tasks before have the correct position - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(3, $task['position']); - - $task = $tf->getById(7); - $this->assertEquals(7, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - - // The tasks after have the correct position - $task = $tf->getById(5); - $this->assertEquals(5, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(4, $task['position']); - - $task = $tf->getById(8); - $this->assertEquals(8, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(4, $task['position']); - - // The number of tasks per column - $this->assertEquals($task_per_column - 1, $tf->countByColumnId(1, 1)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 2)); - $this->assertEquals($task_per_column, $tf->countByColumnId(1, 3)); - $this->assertEquals($task_per_column, $tf->countByColumnId(1, 4)); - - // We move task id #1, column 1, position 1 to the column 4, position 6 (last position) - $this->assertTrue($tp->movePosition(1, 1, 4, $task_per_column + 1)); - - // We check the new position of the task - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(4, $task['column_id']); - $this->assertEquals($task_per_column + 1, $task['position']); - - // The tasks before have the correct position - $task = $tf->getById(20); - $this->assertEquals(20, $task['id']); - $this->assertEquals(4, $task['column_id']); - $this->assertEquals($task_per_column, $task['position']); - - // The tasks after have the correct position - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - - // The number of tasks per column - $this->assertEquals($task_per_column - 2, $tf->countByColumnId(1, 1)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 2)); - $this->assertEquals($task_per_column, $tf->countByColumnId(1, 3)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 4)); - - // Our previous moved task should stay at the same place - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(3, $task['position']); - - // Test wrong position number - $this->assertFalse($tp->movePosition(1, 2, 3, 0)); - $this->assertFalse($tp->movePosition(1, 2, 3, -2)); - - // Wrong column - $this->assertFalse($tp->movePosition(1, 2, 22, 2)); - - // Test position greater than the last position - $this->assertTrue($tp->movePosition(1, 11, 1, 22)); - - $task = $tf->getById(11); - $this->assertEquals(11, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals($tf->countByColumnId(1, 1), $task['position']); - - $task = $tf->getById(5); - $this->assertEquals(5, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals($tf->countByColumnId(1, 1) - 1, $task['position']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(3, $task['position']); - - $this->assertEquals($task_per_column - 1, $tf->countByColumnId(1, 1)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 2)); - $this->assertEquals($task_per_column - 1, $tf->countByColumnId(1, 3)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 4)); - - // Our previous moved task should stay at the same place - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(3, $task['position']); - - // Test moving task to position 1 - $this->assertTrue($tp->movePosition(1, 14, 1, 1)); - - $task = $tf->getById(14); - $this->assertEquals(14, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $this->assertEquals($task_per_column, $tf->countByColumnId(1, 1)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 2)); - $this->assertEquals($task_per_column - 2, $tf->countByColumnId(1, 3)); - $this->assertEquals($task_per_column + 1, $tf->countByColumnId(1, 4)); - } - - public function testMoveTaskSwimlane() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $s = new SwimlaneModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'test 1'))); - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(3, $tc->create(array('title' => 'Task #3', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(4, $tc->create(array('title' => 'Task #4', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(5, $tc->create(array('title' => 'Task #5', 'project_id' => 1, 'column_id' => 1))); - - // Move the task to the swimlane - $this->assertTrue($tp->movePosition(1, 1, 2, 1, 1)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(3, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - // Move the task to the swimlane - $this->assertTrue($tp->movePosition(1, 2, 2, 1, 1)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - // Move the task 5 to the last column - $this->assertTrue($tp->movePosition(1, 5, 4, 1, 0)); - - // Check tasks position - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(3); - $this->assertEquals(3, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $task = $tf->getById(4); - $this->assertEquals(4, $task['id']); - $this->assertEquals(1, $task['column_id']); - $this->assertEquals(2, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $task = $tf->getById(5); - $this->assertEquals(5, $task['id']); - $this->assertEquals(4, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - } - - public function testEvents() - { - $tp = new TaskPositionModel($this->container); - $tc = new TaskCreationModel($this->container); - $tf = new TaskFinderModel($this->container); - $p = new ProjectModel($this->container); - $s = new SwimlaneModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'Project #1'))); - $this->assertEquals(1, $s->create(array('project_id' => 1, 'name' => 'test 1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'Task #1', 'project_id' => 1, 'column_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2))); - - $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_COLUMN, array($this, 'onMoveColumn')); - $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_POSITION, array($this, 'onMovePosition')); - $this->container['dispatcher']->addListener(TaskModel::EVENT_MOVE_SWIMLANE, array($this, 'onMoveSwimlane')); - - // We move the task 1 to the column 2 - $this->assertTrue($tp->movePosition(1, 1, 2, 1)); - - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $called = $this->container['dispatcher']->getCalledListeners(); - $this->assertArrayHasKey(TaskModel::EVENT_MOVE_COLUMN.'.TaskPositionTest::onMoveColumn', $called); - $this->assertEquals(1, count($called)); - - // We move the task 1 to the position 2 - $this->assertTrue($tp->movePosition(1, 1, 2, 2)); - - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(2, $task['position']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - - $called = $this->container['dispatcher']->getCalledListeners(); - $this->assertArrayHasKey(TaskModel::EVENT_MOVE_POSITION.'.TaskPositionTest::onMovePosition', $called); - $this->assertEquals(2, count($called)); - - // Move to another swimlane - $this->assertTrue($tp->movePosition(1, 1, 3, 1, 1)); - - $task = $tf->getById(1); - $this->assertEquals(1, $task['id']); - $this->assertEquals(3, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(1, $task['swimlane_id']); - - $task = $tf->getById(2); - $this->assertEquals(2, $task['id']); - $this->assertEquals(2, $task['column_id']); - $this->assertEquals(1, $task['position']); - $this->assertEquals(0, $task['swimlane_id']); - - $called = $this->container['dispatcher']->getCalledListeners(); - $this->assertArrayHasKey(TaskModel::EVENT_MOVE_SWIMLANE.'.TaskPositionTest::onMoveSwimlane', $called); - $this->assertEquals(3, count($called)); - } - - public function onMoveColumn($event) - { - $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); - - $event_data = $event->getAll(); - $this->assertNotEmpty($event_data); - $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals(1, $event_data['position']); - $this->assertEquals(2, $event_data['column_id']); - $this->assertEquals(1, $event_data['project_id']); - } - - public function onMovePosition($event) - { - $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); - - $event_data = $event->getAll(); - $this->assertNotEmpty($event_data); - $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals(2, $event_data['position']); - $this->assertEquals(2, $event_data['column_id']); - $this->assertEquals(1, $event_data['project_id']); - } - - public function onMoveSwimlane($event) - { - $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); - - $event_data = $event->getAll(); - $this->assertNotEmpty($event_data); - $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals(1, $event_data['position']); - $this->assertEquals(3, $event_data['column_id']); - $this->assertEquals(1, $event_data['project_id']); - $this->assertEquals(1, $event_data['swimlane_id']); - } -} diff --git a/tests/units/Model/TaskProjectMoveModelTest.php b/tests/units/Model/TaskProjectMoveModelTest.php index c4282638..52f61b28 100644 --- a/tests/units/Model/TaskProjectMoveModelTest.php +++ b/tests/units/Model/TaskProjectMoveModelTest.php @@ -24,7 +24,7 @@ class TaskProjectMoveModelTest extends Base $event_data = $event->getAll(); $this->assertNotEmpty($event_data); $this->assertEquals(1, $event_data['task_id']); - $this->assertEquals('test', $event_data['title']); + $this->assertEquals('test', $event_data['task']['title']); } public function testMoveAnotherProject() -- cgit v1.2.3 From 7b100cc57b58a1ef5c2c6cef28904e1ba79f0c49 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Wed, 20 Jul 2016 21:40:27 -0400 Subject: Move logic of PR #2496 to GroupModel --- app/Controller/ProjectPermissionController.php | 8 +-- app/Model/GroupModel.php | 19 +++++++ tests/units/Model/GroupModelTest.php | 69 ++++++++++++++++++++++++++ tests/units/Model/GroupTest.php | 60 ---------------------- 4 files changed, 89 insertions(+), 67 deletions(-) create mode 100644 tests/units/Model/GroupModelTest.php delete mode 100644 tests/units/Model/GroupTest.php diff --git a/app/Controller/ProjectPermissionController.php b/app/Controller/ProjectPermissionController.php index cbcdf77d..99f556e8 100644 --- a/app/Controller/ProjectPermissionController.php +++ b/app/Controller/ProjectPermissionController.php @@ -147,13 +147,7 @@ class ProjectPermissionController extends BaseController $values = $this->request->getValues(); if (empty($values['group_id']) && ! empty($values['external_id'])) { - $group = $this->groupModel->getByExternalId($values['external_id']); - if ($group) { - $values['group_id'] = $group['id']; - } - else { - $values['group_id'] = $this->groupModel->create($values['name'], $values['external_id']); - } + $values['group_id'] = $this->groupModel->getOrCreateExternalGroupId($values['name'], $values['external_id']); } if ($this->projectGroupRoleModel->addGroup($project['id'], $values['group_id'], $values['role'])) { diff --git a/app/Model/GroupModel.php b/app/Model/GroupModel.php index 0a975570..b43423b3 100644 --- a/app/Model/GroupModel.php +++ b/app/Model/GroupModel.php @@ -116,4 +116,23 @@ class GroupModel extends Base { return $this->db->table(self::TABLE)->eq('id', $values['id'])->update($values); } + + /** + * Get groupId from externalGroupId and create the group if not found + * + * @access public + * @param string $name + * @param string $external_id + * @return bool|integer + */ + public function getOrCreateExternalGroupId($name, $external_id) + { + $group_id = $this->db->table(self::TABLE)->eq('external_id', $external_id)->findOneColumn('id'); + + if (empty($group_id)) { + $group_id = $this->create($name, $external_id); + } + + return $group_id; + } } diff --git a/tests/units/Model/GroupModelTest.php b/tests/units/Model/GroupModelTest.php new file mode 100644 index 00000000..4ad0a167 --- /dev/null +++ b/tests/units/Model/GroupModelTest.php @@ -0,0 +1,69 @@ +container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertFalse($groupModel->create('Test')); + } + + public function testGetById() + { + $groupModel = new GroupModel($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + + $group = $groupModel->getById(1); + $this->assertEquals('Test', $group['name']); + $this->assertEquals('', $group['external_id']); + + $this->assertEmpty($groupModel->getById(2)); + } + + public function testGetAll() + { + $groupModel = new GroupModel($this->container); + $this->assertEquals(1, $groupModel->create('B')); + $this->assertEquals(2, $groupModel->create('A', 'uuid')); + + $groups = $groupModel->getAll(); + $this->assertCount(2, $groups); + $this->assertEquals('A', $groups[0]['name']); + $this->assertEquals('uuid', $groups[0]['external_id']); + $this->assertEquals('B', $groups[1]['name']); + $this->assertEquals('', $groups[1]['external_id']); + } + + public function testUpdate() + { + $groupModel = new GroupModel($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertTrue($groupModel->update(array('id' => 1, 'name' => 'My group', 'external_id' => 'test'))); + + $group = $groupModel->getById(1); + $this->assertEquals('My group', $group['name']); + $this->assertEquals('test', $group['external_id']); + } + + public function testRemove() + { + $groupModel = new GroupModel($this->container); + $this->assertEquals(1, $groupModel->create('Test')); + $this->assertTrue($groupModel->remove(1)); + $this->assertEmpty($groupModel->getById(1)); + } + + public function testGetOrCreateExternalGroupId() + { + $groupModel = new GroupModel($this->container); + $this->assertEquals(1, $groupModel->create('Group 1', 'ExternalId1')); + $this->assertEquals(1, $groupModel->getOrCreateExternalGroupId('Group 1', 'ExternalId1')); + $this->assertEquals(1, $groupModel->getOrCreateExternalGroupId('Group 2', 'ExternalId1')); + $this->assertEquals(2, $groupModel->getOrCreateExternalGroupId('Group 2', 'ExternalId2')); + } +} diff --git a/tests/units/Model/GroupTest.php b/tests/units/Model/GroupTest.php deleted file mode 100644 index 85c2c5d9..00000000 --- a/tests/units/Model/GroupTest.php +++ /dev/null @@ -1,60 +0,0 @@ -container); - $this->assertEquals(1, $groupModel->create('Test')); - $this->assertFalse($groupModel->create('Test')); - } - - public function testGetById() - { - $groupModel = new GroupModel($this->container); - $this->assertEquals(1, $groupModel->create('Test')); - - $group = $groupModel->getById(1); - $this->assertEquals('Test', $group['name']); - $this->assertEquals('', $group['external_id']); - - $this->assertEmpty($groupModel->getById(2)); - } - - public function testGetAll() - { - $groupModel = new GroupModel($this->container); - $this->assertEquals(1, $groupModel->create('B')); - $this->assertEquals(2, $groupModel->create('A', 'uuid')); - - $groups = $groupModel->getAll(); - $this->assertCount(2, $groups); - $this->assertEquals('A', $groups[0]['name']); - $this->assertEquals('uuid', $groups[0]['external_id']); - $this->assertEquals('B', $groups[1]['name']); - $this->assertEquals('', $groups[1]['external_id']); - } - - public function testUpdate() - { - $groupModel = new GroupModel($this->container); - $this->assertEquals(1, $groupModel->create('Test')); - $this->assertTrue($groupModel->update(array('id' => 1, 'name' => 'My group', 'external_id' => 'test'))); - - $group = $groupModel->getById(1); - $this->assertEquals('My group', $group['name']); - $this->assertEquals('test', $group['external_id']); - } - - public function testRemove() - { - $groupModel = new GroupModel($this->container); - $this->assertEquals(1, $groupModel->create('Test')); - $this->assertTrue($groupModel->remove(1)); - $this->assertEmpty($groupModel->getById(1)); - } -} -- cgit v1.2.3 From 3dd20c9c78e61028a4c7db232c7b2b30662a2987 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Wed, 20 Jul 2016 21:47:23 -0400 Subject: Rename CommentModel::EVENT_REMOVE to CommentModel::EVENT_DELETE --- app/Model/CommentModel.php | 4 ++-- app/Model/NotificationModel.php | 6 +++--- app/Subscriber/NotificationSubscriber.php | 2 +- app/Template/event/comment_delete.php | 11 +++++++++++ app/Template/event/comment_remove.php | 11 ----------- app/Template/notification/comment_delete.php | 7 +++++++ app/Template/notification/comment_remove.php | 7 ------- tests/units/Job/CommentEventJobTest.php | 4 ++-- 8 files changed, 26 insertions(+), 26 deletions(-) create mode 100644 app/Template/event/comment_delete.php delete mode 100644 app/Template/event/comment_remove.php create mode 100644 app/Template/notification/comment_delete.php delete mode 100644 app/Template/notification/comment_remove.php diff --git a/app/Model/CommentModel.php b/app/Model/CommentModel.php index 6b983858..a9e48bd3 100644 --- a/app/Model/CommentModel.php +++ b/app/Model/CommentModel.php @@ -26,7 +26,7 @@ class CommentModel extends Base */ const EVENT_UPDATE = 'comment.update'; const EVENT_CREATE = 'comment.create'; - const EVENT_REMOVE = 'comment.remove'; + const EVENT_DELETE = 'comment.delete'; const EVENT_USER_MENTION = 'comment.user.mention'; /** @@ -166,7 +166,7 @@ class CommentModel extends Base */ public function remove($comment_id) { - $this->commentEventJob->execute($comment_id, self::EVENT_REMOVE); + $this->commentEventJob->execute($comment_id, self::EVENT_DELETE); return $this->db->table(self::TABLE)->eq('id', $comment_id)->remove(); } } diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index ac8d9bae..925d646e 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -76,7 +76,7 @@ class NotificationModel extends Base return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); case CommentModel::EVENT_CREATE: return e('%s commented on the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_REMOVE: + case CommentModel::EVENT_DELETE: return e('%s removed a comment on the task #%d', $event_author, $event_data['task']['id']); case TaskFileModel::EVENT_CREATE: return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']); @@ -106,7 +106,7 @@ class NotificationModel extends Base return e('New comment on task #%d', $event_data['comment']['task_id']); case CommentModel::EVENT_UPDATE: return e('Comment updated on task #%d', $event_data['comment']['task_id']); - case CommentModel::EVENT_REMOVE: + case CommentModel::EVENT_DELETE: return e('Comment removed on task #%d', $event_data['comment']['task_id']); case SubtaskModel::EVENT_CREATE: return e('New subtask on task #%d', $event_data['subtask']['task_id']); @@ -157,7 +157,7 @@ class NotificationModel extends Base return $event_data['file']['task_id']; case CommentModel::EVENT_CREATE: case CommentModel::EVENT_UPDATE: - case CommentModel::EVENT_REMOVE: + case CommentModel::EVENT_DELETE: return $event_data['comment']['task_id']; case SubtaskModel::EVENT_CREATE: case SubtaskModel::EVENT_UPDATE: diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 104927a0..7de24e49 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -28,7 +28,7 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn SubtaskModel::EVENT_DELETE => 'handleEvent', CommentModel::EVENT_CREATE => 'handleEvent', CommentModel::EVENT_UPDATE => 'handleEvent', - CommentModel::EVENT_REMOVE => 'handleEvent', + CommentModel::EVENT_DELETE => 'handleEvent', CommentModel::EVENT_USER_MENTION => 'handleEvent', TaskFileModel::EVENT_CREATE => 'handleEvent', ); diff --git a/app/Template/event/comment_delete.php b/app/Template/event/comment_delete.php new file mode 100644 index 00000000..ead7d56a --- /dev/null +++ b/app/Template/event/comment_delete.php @@ -0,0 +1,11 @@ +

            + text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

            +
            +

            text->e($task['title']) ?>

            +
            text->markdown($comment['comment']) ?>
            +
            diff --git a/app/Template/event/comment_remove.php b/app/Template/event/comment_remove.php deleted file mode 100644 index ead7d56a..00000000 --- a/app/Template/event/comment_remove.php +++ /dev/null @@ -1,11 +0,0 @@ -

            - text->e($author), - $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) - ) ?> - dt->datetime($date_creation) ?> -

            -
            -

            text->e($task['title']) ?>

            -
            text->markdown($comment['comment']) ?>
            -
            diff --git a/app/Template/notification/comment_delete.php b/app/Template/notification/comment_delete.php new file mode 100644 index 00000000..928623ec --- /dev/null +++ b/app/Template/notification/comment_delete.php @@ -0,0 +1,7 @@ +

            text->e($task['title']) ?> (#)

            + +

            + +text->markdown($comment['comment']) ?> + +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/Template/notification/comment_remove.php b/app/Template/notification/comment_remove.php deleted file mode 100644 index 928623ec..00000000 --- a/app/Template/notification/comment_remove.php +++ /dev/null @@ -1,7 +0,0 @@ -

            text->e($task['title']) ?> (#)

            - -

            - -text->markdown($comment['comment']) ?> - -render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/tests/units/Job/CommentEventJobTest.php b/tests/units/Job/CommentEventJobTest.php index 88742feb..8571af8e 100644 --- a/tests/units/Job/CommentEventJobTest.php +++ b/tests/units/Job/CommentEventJobTest.php @@ -32,7 +32,7 @@ class CommentEventJobTest extends Base { $this->container['dispatcher']->addListener(CommentModel::EVENT_CREATE, function() {}); $this->container['dispatcher']->addListener(CommentModel::EVENT_UPDATE, function() {}); - $this->container['dispatcher']->addListener(CommentModel::EVENT_REMOVE, function() {}); + $this->container['dispatcher']->addListener(CommentModel::EVENT_DELETE, function() {}); $commentModel = new CommentModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); @@ -47,6 +47,6 @@ class CommentEventJobTest extends Base $called = $this->container['dispatcher']->getCalledListeners(); $this->assertArrayHasKey(CommentModel::EVENT_CREATE.'.closure', $called); $this->assertArrayHasKey(CommentModel::EVENT_UPDATE.'.closure', $called); - $this->assertArrayHasKey(CommentModel::EVENT_REMOVE.'.closure', $called); + $this->assertArrayHasKey(CommentModel::EVENT_DELETE.'.closure', $called); } } -- cgit v1.2.3 From df423ae4aff12e8143a52642a3025ee1c0dffcea Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 21 Jul 2016 17:46:17 -0400 Subject: Move repository to Kanboard organization --- Makefile | 2 +- README.md | 8 ++++---- app.json | 2 +- app/Template/config/about.php | 2 +- doc/docker.markdown | 2 +- doc/heroku.markdown | 4 ++-- doc/installation.markdown | 2 +- doc/plugin-authentication.markdown | 2 +- doc/plugin-group-provider.markdown | 2 +- doc/plugins.markdown | 2 +- doc/update.markdown | 2 +- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Makefile b/Makefile index 4595481d..a96846a1 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ static: archive: @ echo "Build archive: version=${version}, destination=${dst}" @ rm -rf ${BUILD_DIR}/kanboard ${BUILD_DIR}/kanboard-*.zip - @ cd ${BUILD_DIR} && git clone --depth 1 -q https://github.com/fguillot/kanboard.git + @ cd ${BUILD_DIR} && git clone --depth 1 -q https://github.com/kanboard/kanboard.git @ cd ${BUILD_DIR}/kanboard && composer --prefer-dist --no-dev --optimize-autoloader --quiet install @ rm -rf ${BUILD_DIR}/kanboard/data/*.sqlite @ rm -rf ${BUILD_DIR}/kanboard/data/*.log diff --git a/README.md b/README.md index 49735475..89dd6e25 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ Official website: - Open source and self-hosted - Super simple installation - Translated in many languages -- Distributed under [MIT License](https://github.com/fguillot/kanboard/blob/master/LICENSE) +- Distributed under [MIT License](https://github.com/kanboard/kanboard/blob/master/LICENSE) - The complete [list of features are available on the website](https://kanboard.net/features) -- [Change Log](https://github.com/fguillot/kanboard/blob/master/ChangeLog) -- [Documentation](https://github.com/fguillot/kanboard/blob/master/doc/index.markdown) +- [Change Log](https://github.com/kanboard/kanboard/blob/master/ChangeLog) +- [Documentation](https://github.com/kanboard/kanboard/blob/master/doc/index.markdown) Authors ------- - Main developer: [Frédéric Guillot](https://github.com/fguillot) -- [List of contributors](https://github.com/fguillot/kanboard/blob/master/CONTRIBUTORS.md) +- [List of contributors](https://github.com/kanboard/kanboard/blob/master/CONTRIBUTORS.md) Installation and Upgrade ------------------------ diff --git a/app.json b/app.json index 4d41737b..76d9b8e0 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "name": "Kanboard", "description": "Kanboard is a simple visual task board", - "repository": "https://github.com/fguillot/kanboard", + "repository": "https://github.com/kanboard/kanboard", "logo": "https://kanboard.net/assets/img/icon.svg", "keywords": ["kanboard", "kanban", "php", "agile"], "addons": ["heroku-postgresql:hobby-dev"] diff --git a/app/Template/config/about.php b/app/Template/config/about.php index 8e2d1325..8d5a575d 100644 --- a/app/Template/config/about.php +++ b/app/Template/config/about.php @@ -9,7 +9,7 @@
          • - Frédéric Guillot () + Frédéric Guillot ()
          • diff --git a/doc/docker.markdown b/doc/docker.markdown index e55130e5..5b77da76 100644 --- a/doc/docker.markdown +++ b/doc/docker.markdown @@ -93,4 +93,4 @@ References - [Official Kanboard images](https://registry.hub.docker.com/u/kanboard/kanboard/) - [Docker documentation](https://docs.docker.com/) - [Dockerfile stable version](https://github.com/kanboard/docker) -- [Dockerfile dev version](https://github.com/fguillot/kanboard/blob/master/Dockerfile) +- [Dockerfile dev version](https://github.com/kanboard/kanboard/blob/master/Dockerfile) diff --git a/doc/heroku.markdown b/doc/heroku.markdown index 43b15c72..1891efb0 100644 --- a/doc/heroku.markdown +++ b/doc/heroku.markdown @@ -4,7 +4,7 @@ Deploy Kanboard on Heroku You can try Kanboard for free on [Heroku](https://www.heroku.com/). You can use this one click install button or follow the manual instructions below: -[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/fguillot/kanboard) +[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/kanboard/kanboard) Requirements ------------ @@ -17,7 +17,7 @@ Manual instructions ```bash # Get the last development version -git clone https://github.com/fguillot/kanboard.git +git clone https://github.com/kanboard/kanboard.git cd kanboard # Push the code to Heroku (You can also use SSH if git over HTTP doesn't work) diff --git a/doc/installation.markdown b/doc/installation.markdown index 2ebe4d14..4955612f 100644 --- a/doc/installation.markdown +++ b/doc/installation.markdown @@ -28,7 +28,7 @@ From the repository (development version) You must install [composer](https://getcomposer.org/) to use this method. -1. `git clone https://github.com/fguillot/kanboard.git` +1. `git clone https://github.com/kanboard/kanboard.git` 2. `composer install --no-dev` 3. Go to the third step just above diff --git a/doc/plugin-authentication.markdown b/doc/plugin-authentication.markdown index 06fdfd8d..e1ca6f01 100644 --- a/doc/plugin-authentication.markdown +++ b/doc/plugin-authentication.markdown @@ -35,6 +35,6 @@ This object must implement the interface `Kanboard\Core\User\UserProviderInterfa Example of authentication plugins --------------------------------- -- [Authentication providers included in Kanboard](https://github.com/fguillot/kanboard/tree/master/app/Auth) +- [Authentication providers included in Kanboard](https://github.com/kanboard/kanboard/tree/master/app/Auth) - [Reverse-Proxy Authentication with LDAP support](https://github.com/kanboard/plugin-reverse-proxy-ldap) - [SMS Two-Factor Authentication](https://github.com/kanboard/plugin-sms-2fa) diff --git a/doc/plugin-group-provider.markdown b/doc/plugin-group-provider.markdown index 4d73b740..31c61aaf 100644 --- a/doc/plugin-group-provider.markdown +++ b/doc/plugin-group-provider.markdown @@ -52,4 +52,4 @@ $groupManager->register(new MyCustomLdapBackendGroupProvider($this->container)); Examples -------- -- [Group providers included in Kanboard (LDAP and Database)](https://github.com/fguillot/kanboard/tree/master/app/Group) +- [Group providers included in Kanboard (LDAP and Database)](https://github.com/kanboard/kanboard/tree/master/app/Group) diff --git a/doc/plugins.markdown b/doc/plugins.markdown index 475bc249..cff3eb6c 100644 --- a/doc/plugins.markdown +++ b/doc/plugins.markdown @@ -5,7 +5,7 @@ Note: The plugin API is **considered alpha** at the moment. Plugins are useful to extend the core functionalities of Kanboard, adding features, creating themes or changing the default behavior. -Plugin creators should specify explicitly the compatible versions of Kanboard. Internal code of Kanboard may change over time and your plugin must be tested with new versions. Always check the [ChangeLog](https://github.com/fguillot/kanboard/blob/master/ChangeLog) for breaking changes. +Plugin creators should specify explicitly the compatible versions of Kanboard. Internal code of Kanboard may change over time and your plugin must be tested with new versions. Always check the [ChangeLog](https://github.com/kanboard/kanboard/blob/master/ChangeLog) for breaking changes. - [Creating your plugin](plugin-registration.markdown) - [Using plugin hooks](plugin-hooks.markdown) diff --git a/doc/update.markdown b/doc/update.markdown index 44f81ff0..4aa59fff 100644 --- a/doc/update.markdown +++ b/doc/update.markdown @@ -10,7 +10,7 @@ Important things to do before updating - **Always make a backup of your data before upgrading** - Check that your backup is valid -- Always read the [change log](https://github.com/fguillot/kanboard/blob/master/ChangeLog) to check for breaking changes +- Always read the [change log](https://github.com/kanboard/kanboard/blob/master/ChangeLog) to check for breaking changes - Always close all user sessions (flush all sessions on the server) From the archive (stable version) -- cgit v1.2.3 From 68e2a3d052a6204c7a638c9335c2e23eed06ce54 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 21 Jul 2016 17:52:26 -0400 Subject: Update README badges --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 89dd6e25..d14982f0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ Kanboard ======== -[![Build Status](https://travis-ci.org/fguillot/kanboard.svg)](https://travis-ci.org/fguillot/kanboard) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/fguillot/kanboard/badges/quality-score.png?s=2b6490781608657cc8c43d02285bfafb4f489528)](https://scrutinizer-ci.com/g/fguillot/kanboard/) -[![SensioLabsInsight](https://insight.sensiolabs.com/projects/5e50750e-fc62-4a1f-b02a-71991123a2a7/mini.png)](https://insight.sensiolabs.com/projects/5e50750e-fc62-4a1f-b02a-71991123a2a7) -[![Gitter chat](https://badges.gitter.im/kanboard/gitter.png)](https://gitter.im/kanboard/kanboard) +[![Build Status](https://travis-ci.org/kanboard/kanboard.svg?branch=master)](https://travis-ci.org/kanboard/kanboard) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/kanboard/kanboard/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/kanboard/kanboard/?branch=master) Kanboard is a project management software that focus on the Kanban methodology. @@ -19,6 +17,7 @@ Official website: - The complete [list of features are available on the website](https://kanboard.net/features) - [Change Log](https://github.com/kanboard/kanboard/blob/master/ChangeLog) - [Documentation](https://github.com/kanboard/kanboard/blob/master/doc/index.markdown) +- IRC channel: [#kanboard](ircs://chat.freenode.net:6697/#kanboard) (Freenode) Authors ------- -- cgit v1.2.3 From 1dcaf6ad9fa631b12a54f4b2b5d564f7fc5f7f14 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Thu, 21 Jul 2016 18:36:44 -0400 Subject: Sync locales --- app/Locale/bs_BA/translations.php | 9 +++++++++ app/Locale/cs_CZ/translations.php | 9 +++++++++ app/Locale/da_DK/translations.php | 9 +++++++++ app/Locale/de_DE/translations.php | 9 +++++++++ app/Locale/el_GR/translations.php | 9 +++++++++ app/Locale/es_ES/translations.php | 9 +++++++++ app/Locale/fi_FI/translations.php | 9 +++++++++ app/Locale/fr_FR/translations.php | 11 +++++++++- app/Locale/hu_HU/translations.php | 9 +++++++++ app/Locale/id_ID/translations.php | 9 +++++++++ app/Locale/it_IT/translations.php | 9 +++++++++ app/Locale/ja_JP/translations.php | 9 +++++++++ app/Locale/ko_KR/translations.php | 37 ++++++++++++++++++++++++++++------ app/Locale/my_MY/translations.php | 9 +++++++++ app/Locale/nb_NO/translations.php | 9 +++++++++ app/Locale/nl_NL/translations.php | 11 +++++++++- app/Locale/pl_PL/translations.php | 9 +++++++++ app/Locale/pt_BR/translations.php | 9 +++++++++ app/Locale/pt_PT/translations.php | 11 +++++++++- app/Locale/ru_RU/translations.php | 9 +++++++++ app/Locale/sr_Latn_RS/translations.php | 9 +++++++++ app/Locale/sv_SE/translations.php | 9 +++++++++ app/Locale/th_TH/translations.php | 9 +++++++++ app/Locale/tr_TR/translations.php | 9 +++++++++ app/Locale/zh_CN/translations.php | 9 +++++++++ 25 files changed, 250 insertions(+), 9 deletions(-) diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php index 5f513347..6a062068 100644 --- a/app/Locale/bs_BA/translations.php +++ b/app/Locale/bs_BA/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index 1c28f4f9..b9a4de6e 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index abebd394..050a37d9 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index f569206b..d6c8bf60 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -1216,5 +1216,14 @@ return array( 'Global tags' => 'Globale Schlagwörter', 'There is no global tag at the moment.' => 'Es gibt zur Zeit kein globales Schlagwort', 'This field cannot be empty' => 'Dieses Feld kann nicht leer sein', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', 'Hide tasks in this column in the dashboard' => 'Aufgaben in dieser Spalte im Dashboard ausblenden', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php index c1d7c579..87ea68b0 100644 --- a/app/Locale/el_GR/translations.php +++ b/app/Locale/el_GR/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index 5699ce6f..1a4bae82 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 6fe4852c..5d37cb82 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 7663da0f..c8f7d343 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -1217,5 +1217,14 @@ return array( 'Global tags' => 'Libellés globaux', 'There is no global tag at the moment.' => 'Il n\'y a aucun libellé global pour le moment.', 'This field cannot be empty' => 'Ce champ ne peut être vide', - // 'Hide tasks in this column in the dashboard' => '', + 'Close a task when there is no activity in an specific column' => 'Fermer une tâche lorsqu\'il n\'y a aucune activité dans une colonne spécifique', + '%s removed a subtask for the task #%d' => '%s a supprimé une sous-tâche de la tâche n°%d', + '%s removed a comment on the task #%d' => '%s a supprimé un commentaire de la tâche n°%d', + 'Comment removed on task #%d' => 'Commentaire supprimé sur la tâche n°%d', + 'Subtask removed on task #%d' => 'Sous-tâche supprimée sur la tâche n°%d', + 'Hide tasks in this column in the dashboard' => 'Cacher les tâches de cette colonne dans le tableau de bord', + '%s removed a comment on the task %s' => '%s a supprimé un commentaire de la tâche %s', + '%s removed a subtask for the task %s' => '%s a supprimé une sous-tâche de la tâche %s', + 'Comment removed' => 'Commentaire supprimé', + 'Subtask removed' => 'Sous-tâche supprimée', ); diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index 96db72ef..febf8bc0 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 2d6e5aa3..18a7a72d 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index e10b61da..f6c63076 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -1216,5 +1216,14 @@ return array( 'Global tags' => 'Tag globali', 'There is no global tag at the moment.' => 'Non sono definiti tag globali al momento.', 'This field cannot be empty' => 'Questo campo non può essere vuoto', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index 2fe13ac9..dab731d2 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/ko_KR/translations.php b/app/Locale/ko_KR/translations.php index 4f062221..0b6007b1 100644 --- a/app/Locale/ko_KR/translations.php +++ b/app/Locale/ko_KR/translations.php @@ -154,8 +154,6 @@ return array( '%d closed tasks' => '%d개의 마친 할일', 'No task for this project' => '이 프로젝트에 할일이 없습니다', 'Public link' => '공개 접속 링크', - 'Change assignee' => '담당자 변경', - 'Change assignee for the task "%s"' => '할일 "%s"의 담당자를 변경', 'Timezone' => '시간대', 'Sorry, I didn\'t find this information in my database!' => '데이터베이스에서 정보가 발견되지 않았습니다!', 'Page not found' => '페이지가 발견되지 않는다', @@ -248,7 +246,6 @@ return array( 'Category' => '카테고리', 'Category:' => '카테고리:', 'Categories' => '카테고리', - 'Category not found.' => '카테고리가 발견되지 않습니다', 'Your category have been created successfully.' => '카테고리를 작성했습니다.', 'Unable to create your category.' => '카테고리의 작성에 실패했습니다.', 'Your category have been updated successfully.' => '카테고리를 갱신했습니다.', @@ -270,7 +267,6 @@ return array( 'Do you really want to remove this file: "%s"?' => '파일 "%s" 을 삭제할까요?', 'Attachments' => '첨부', 'Edit the task' => '할일 수정', - 'Edit the description' => '설명 수정', 'Add a comment' => '댓글 추가', 'Edit a comment' => '댓글 수정', 'Summary' => '개요', @@ -368,7 +364,6 @@ return array( 'No external authentication enabled.' => '외부 인증이 설정되어 있지 않습니다.', 'Password modified successfully.' => '패스워드를 변경했습니다.', 'Unable to change the password.' => '비밀 번호가 변경할 수 없었습니다.', - 'Change category for the task "%s"' => '할일 "%s"의 카테고리의 변경', 'Change category' => '카테고리 수정', '%s updated the task %s' => '%s이 할일 %s을 갱신 하였습니다', '%s opened the task %s' => '%s이 할일 %s을 시작시켰습니다', @@ -485,7 +480,6 @@ return array( 'Remove a swimlane' => '스윔레인의 삭제', 'Show default swimlane' => '기본 스윔레인의 표시', 'Swimlane modification for the project "%s"' => '"%s" 프로젝트의 스웜레인 수정', - 'Swimlane not found.' => '스윔레인이 발견되지 않습니다.', 'Swimlane removed successfully.' => '스윔레인을 삭제했습니다.', 'Swimlanes' => '스윔레인', 'Swimlane updated successfully.' => '스윔레인을 갱신했습니다.', @@ -1200,5 +1194,36 @@ return array( 'Email settings' => '이메일 설정', 'Email sender address' => '이메일 보낸이 주소', 'Email transport' => '이메일 전송', + // 'Webhook token' => '', 'Imports' => '가져오기', + // 'Project tags management' => '', + // 'Tag created successfully.' => '', + // 'Unable to create this tag.' => '', + // 'Tag updated successfully.' => '', + // 'Unable to update this tag.' => '', + // 'Tag removed successfully.' => '', + // 'Unable to remove this tag.' => '', + // 'Global tags management' => '', + // 'Tags' => '', + // 'Tags management' => '', + // 'Add new tag' => '', + // 'Edit a tag' => '', + // 'Project tags' => '', + // 'There is no specific tag for this project at the moment.' => '', + // 'Tag' => '', + // 'Remove a tag' => '', + // 'Do you really want to remove this tag: "%s"?' => '', + // 'Global tags' => '', + // 'There is no global tag at the moment.' => '', + // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', + // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php index ff8960aa..3d66b0bb 100644 --- a/app/Locale/my_MY/translations.php +++ b/app/Locale/my_MY/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index 8752a159..14e260cb 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index e07ea32c..8b47d514 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', - //' Hide tasks in this column in the dashboard' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', + // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index 896d2ed4..e72649e6 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 40f3bb4d..7b64f0e7 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 08375ad0..1810e2b5 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -1216,5 +1216,14 @@ return array( 'Global tags' => 'Etiquetas globais', 'There is no global tag at the moment.' => 'De momento não existe nenhuma etiqueta global.', // 'This field cannot be empty' => '', - //'Hide tasks in this column in the dashboard' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', + // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index c6285f6a..b3682f03 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -1216,5 +1216,14 @@ return array( 'Global tags' => 'Глобальные метка', 'There is no global tag at the moment.' => 'Нет глобальных меток.', 'This field cannot be empty' => 'Это поле не может быть пустым', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', 'Hide tasks in this column in the dashboard' => 'Не показывать задачи из этой колонки в кабинете', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 92ed3424..157d9e2d 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index eedcf0fc..e42a801d 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index a6de8bce..56adbdb8 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index 35e29649..4f4c84cd 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index 0ef01ef7..01eaff17 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -1216,5 +1216,14 @@ return array( // 'Global tags' => '', // 'There is no global tag at the moment.' => '', // 'This field cannot be empty' => '', + // 'Close a task when there is no activity in an specific column' => '', + // '%s removed a subtask for the task #%d' => '', + // '%s removed a comment on the task #%d' => '', + // 'Comment removed on task #%d' => '', + // 'Subtask removed on task #%d' => '', // 'Hide tasks in this column in the dashboard' => '', + // '%s removed a comment on the task %s' => '', + // '%s removed a subtask for the task %s' => '', + // 'Comment removed' => '', + // 'Subtask removed' => '', ); -- cgit v1.2.3 From be176fe36c28ca96c82baad858c9d5522b820709 Mon Sep 17 00:00:00 2001 From: Eskiso Date: Fri, 22 Jul 2016 09:28:00 +0100 Subject: Updated translation --- app/Locale/pt_PT/translations.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 1810e2b5..5267b03b 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -1215,15 +1215,15 @@ return array( 'Do you really want to remove this tag: "%s"?' => 'Tem a certeza que pretende remover esta etiqueta: "%s"?', 'Global tags' => 'Etiquetas globais', 'There is no global tag at the moment.' => 'De momento não existe nenhuma etiqueta global.', - // 'This field cannot be empty' => '', - // 'Close a task when there is no activity in an specific column' => '', - // '%s removed a subtask for the task #%d' => '', - // '%s removed a comment on the task #%d' => '', - // 'Comment removed on task #%d' => '', - // 'Subtask removed on task #%d' => '', - // 'Hide tasks in this column in the dashboard' => '', - // '%s removed a comment on the task %s' => '', - // '%s removed a subtask for the task %s' => '', - // 'Comment removed' => '', - // 'Subtask removed' => '', + 'This field cannot be empty' => 'Este campo não pode ficar vazio', + 'Close a task when there is no activity in an specific column' => 'Fechar tarefa quando não houver actividade numa coluna especifica', + '%s removed a subtask for the task #%d' => '%s removeu uma sub-tarefa da tarefa #%d', + '%s removed a comment on the task #%d' => '%s removeu um comentário da tarefa #%d ', + 'Comment removed on task #%d' => 'Comentário removido da tarefa #%d', + 'Subtask removed on task #%d' => 'Sub-tarefa removida da tarefa #%d', + 'Hide tasks in this column in the dashboard' => 'Esconder do meu painel tarefas nesta coluna', + '%s removed a comment on the task %s' => '%s removeu um comentário da tarefa %s', + '%s removed a subtask for the task %s' => '%s removeu uma sub-tarefa da tarefa %s', + 'Comment removed' => 'Comentário removido', + 'Subtask removed' => 'Sub-tarefa removida', ); -- cgit v1.2.3 From 3ea084fd31a9f820a0c9e15d240cced8e49d5965 Mon Sep 17 00:00:00 2001 From: Hairetdin Date: Sat, 23 Jul 2016 02:12:09 +0500 Subject: Russian documentation added (#2417) --- doc/ru_RU/2fa.markdown | 37 ++ doc/ru_RU/analytics-tasks.markdown | 37 ++ doc/ru_RU/analytics.markdown | 95 ++++ doc/ru_RU/api-json-rpc.markdown | 78 +++ doc/ru_RU/application-configuration.markdown | 54 +++ doc/ru_RU/assets.markdown | 53 ++ doc/ru_RU/automatic-actions.markdown | 128 +++++ doc/ru_RU/board-collapsed-expanded.markdown | 31 ++ doc/ru_RU/board-configuration.markdown | 39 ++ ...-horizontal-scrolling-and-compact-view.markdown | 19 + doc/ru_RU/board-show-hide-columns.markdown | 25 + doc/ru_RU/bruteforce-protection.markdown | 37 ++ doc/ru_RU/calendar-configuration.markdown | 59 +++ doc/ru_RU/calendar.markdown | 31 ++ doc/ru_RU/centos-installation.markdown | 127 +++++ doc/ru_RU/cli.markdown | 331 +++++++++++++ doc/ru_RU/closing-tasks.markdown | 30 ++ doc/ru_RU/cloudron.markdown | 45 ++ doc/ru_RU/coding-standards.markdown | 64 +++ doc/ru_RU/config.markdown | 523 ++++++++++++++++++++ doc/ru_RU/contributing.markdown | 96 ++++ doc/ru_RU/create-tasks-by-email.markdown | 61 +++ doc/ru_RU/creating-projects.markdown | 62 +++ doc/ru_RU/creating-tasks.markdown | 42 ++ doc/ru_RU/cronjob.markdown | 41 ++ doc/ru_RU/currency-rate.markdown | 43 ++ doc/ru_RU/custom-filters.markdown | 36 ++ doc/ru_RU/debian-installation.markdown | 104 ++++ doc/ru_RU/docker.markdown | 134 ++++++ doc/ru_RU/duplicate-move-tasks.markdown | 79 +++ doc/ru_RU/editing-projects.markdown | 25 + doc/ru_RU/email-configuration.markdown | 156 ++++++ doc/ru_RU/env.markdown | 21 + doc/ru_RU/ext-search.markdown | 235 +++++++++ doc/ru_RU/faq.markdown | 162 +++++++ doc/ru_RU/freebsd-installation.markdown | 187 +++++++ doc/ru_RU/gantt-chart-projects.markdown | 60 +++ doc/ru_RU/gantt-chart-tasks.markdown | 66 +++ doc/ru_RU/genindex.markdown | 15 + doc/ru_RU/groups.markdown | 35 ++ doc/ru_RU/heroku.markdown | 72 +++ doc/ru_RU/ical.markdown | 111 +++++ doc/ru_RU/index.markdown | 248 ++++++++++ doc/ru_RU/installation.markdown | 117 +++++ doc/ru_RU/kanban-vs-todo-and-scrum.markdown | 75 +++ doc/ru_RU/keyboard-shortcuts.markdown | 99 ++++ doc/ru_RU/ldap-authentication.markdown | 327 +++++++++++++ doc/ru_RU/ldap-configuration-examples.markdown | 438 +++++++++++++++++ doc/ru_RU/ldap-group-sync.markdown | 153 ++++++ doc/ru_RU/ldap-parameters.markdown | 49 ++ doc/ru_RU/ldap-profile-picture.markdown | 46 ++ doc/ru_RU/link-labels.markdown | 23 + doc/ru_RU/mysql-configuration.markdown | 128 +++++ doc/ru_RU/nice-urls.markdown | 233 +++++++++ doc/ru_RU/nitrous.markdown | 16 + doc/ru_RU/notifications.markdown | 111 +++++ doc/ru_RU/plugin-directory.markdown | 38 ++ doc/ru_RU/plugins.markdown | 167 +++++++ doc/ru_RU/postgresql-configuration.markdown | 92 ++++ doc/ru_RU/project-configuration.markdown | 105 ++++ doc/ru_RU/project-permissions.markdown | 55 +++ doc/ru_RU/project-types.markdown | 27 ++ doc/ru_RU/project-views.markdown | 154 ++++++ doc/ru_RU/recurring-tasks.markdown | 67 +++ doc/ru_RU/requirements.markdown | 137 ++++++ doc/ru_RU/reverse-proxy-authentication.markdown | 138 ++++++ doc/ru_RU/roles.markdown | 44 ++ doc/ru_RU/rss.markdown | 58 +++ doc/ru_RU/screenshots.markdown | 74 +++ doc/ru_RU/search.markdown | 24 + doc/ru_RU/sharing-projects.markdown | 82 ++++ doc/ru_RU/sqlite-database.markdown | 96 ++++ doc/ru_RU/subtasks.markdown | 111 +++++ doc/ru_RU/suse-installation.markdown | 36 ++ doc/ru_RU/swimlanes.markdown | 81 ++++ doc/ru_RU/syntax-guide.markdown | 246 ++++++++++ doc/ru_RU/task-links.markdown | 93 ++++ doc/ru_RU/tests.markdown | 262 ++++++++++ doc/ru_RU/time-tracking.markdown | 112 +++++ doc/ru_RU/transitions.markdown | 60 +++ doc/ru_RU/translations.markdown | 155 ++++++ doc/ru_RU/ubuntu-installation.markdown | 111 +++++ doc/ru_RU/update.markdown | 57 +++ doc/ru_RU/usage-examples.markdown | 193 ++++++++ doc/ru_RU/user-management.markdown | 89 ++++ doc/ru_RU/user-mentions.markdown | 49 ++ doc/ru_RU/user-types.markdown | 26 + doc/ru_RU/vagrant.markdown | 51 ++ doc/ru_RU/webhooks.markdown | 536 +++++++++++++++++++++ doc/ru_RU/what-is-kanban.markdown | 80 +++ doc/ru_RU/windows-apache-installation.markdown | 253 ++++++++++ doc/ru_RU/windows-iis-installation.markdown | 150 ++++++ doc/web.config | 22 + 93 files changed, 9880 insertions(+) create mode 100644 doc/ru_RU/2fa.markdown create mode 100644 doc/ru_RU/analytics-tasks.markdown create mode 100644 doc/ru_RU/analytics.markdown create mode 100644 doc/ru_RU/api-json-rpc.markdown create mode 100644 doc/ru_RU/application-configuration.markdown create mode 100644 doc/ru_RU/assets.markdown create mode 100644 doc/ru_RU/automatic-actions.markdown create mode 100644 doc/ru_RU/board-collapsed-expanded.markdown create mode 100644 doc/ru_RU/board-configuration.markdown create mode 100644 doc/ru_RU/board-horizontal-scrolling-and-compact-view.markdown create mode 100644 doc/ru_RU/board-show-hide-columns.markdown create mode 100644 doc/ru_RU/bruteforce-protection.markdown create mode 100644 doc/ru_RU/calendar-configuration.markdown create mode 100644 doc/ru_RU/calendar.markdown create mode 100644 doc/ru_RU/centos-installation.markdown create mode 100644 doc/ru_RU/cli.markdown create mode 100644 doc/ru_RU/closing-tasks.markdown create mode 100644 doc/ru_RU/cloudron.markdown create mode 100644 doc/ru_RU/coding-standards.markdown create mode 100644 doc/ru_RU/config.markdown create mode 100644 doc/ru_RU/contributing.markdown create mode 100644 doc/ru_RU/create-tasks-by-email.markdown create mode 100644 doc/ru_RU/creating-projects.markdown create mode 100644 doc/ru_RU/creating-tasks.markdown create mode 100644 doc/ru_RU/cronjob.markdown create mode 100644 doc/ru_RU/currency-rate.markdown create mode 100644 doc/ru_RU/custom-filters.markdown create mode 100644 doc/ru_RU/debian-installation.markdown create mode 100644 doc/ru_RU/docker.markdown create mode 100644 doc/ru_RU/duplicate-move-tasks.markdown create mode 100644 doc/ru_RU/editing-projects.markdown create mode 100644 doc/ru_RU/email-configuration.markdown create mode 100644 doc/ru_RU/env.markdown create mode 100644 doc/ru_RU/ext-search.markdown create mode 100644 doc/ru_RU/faq.markdown create mode 100644 doc/ru_RU/freebsd-installation.markdown create mode 100644 doc/ru_RU/gantt-chart-projects.markdown create mode 100644 doc/ru_RU/gantt-chart-tasks.markdown create mode 100644 doc/ru_RU/genindex.markdown create mode 100644 doc/ru_RU/groups.markdown create mode 100644 doc/ru_RU/heroku.markdown create mode 100644 doc/ru_RU/ical.markdown create mode 100644 doc/ru_RU/index.markdown create mode 100644 doc/ru_RU/installation.markdown create mode 100644 doc/ru_RU/kanban-vs-todo-and-scrum.markdown create mode 100644 doc/ru_RU/keyboard-shortcuts.markdown create mode 100644 doc/ru_RU/ldap-authentication.markdown create mode 100644 doc/ru_RU/ldap-configuration-examples.markdown create mode 100644 doc/ru_RU/ldap-group-sync.markdown create mode 100644 doc/ru_RU/ldap-parameters.markdown create mode 100644 doc/ru_RU/ldap-profile-picture.markdown create mode 100644 doc/ru_RU/link-labels.markdown create mode 100644 doc/ru_RU/mysql-configuration.markdown create mode 100644 doc/ru_RU/nice-urls.markdown create mode 100644 doc/ru_RU/nitrous.markdown create mode 100644 doc/ru_RU/notifications.markdown create mode 100644 doc/ru_RU/plugin-directory.markdown create mode 100644 doc/ru_RU/plugins.markdown create mode 100644 doc/ru_RU/postgresql-configuration.markdown create mode 100644 doc/ru_RU/project-configuration.markdown create mode 100644 doc/ru_RU/project-permissions.markdown create mode 100644 doc/ru_RU/project-types.markdown create mode 100644 doc/ru_RU/project-views.markdown create mode 100644 doc/ru_RU/recurring-tasks.markdown create mode 100644 doc/ru_RU/requirements.markdown create mode 100644 doc/ru_RU/reverse-proxy-authentication.markdown create mode 100644 doc/ru_RU/roles.markdown create mode 100644 doc/ru_RU/rss.markdown create mode 100644 doc/ru_RU/screenshots.markdown create mode 100644 doc/ru_RU/search.markdown create mode 100644 doc/ru_RU/sharing-projects.markdown create mode 100644 doc/ru_RU/sqlite-database.markdown create mode 100644 doc/ru_RU/subtasks.markdown create mode 100644 doc/ru_RU/suse-installation.markdown create mode 100644 doc/ru_RU/swimlanes.markdown create mode 100644 doc/ru_RU/syntax-guide.markdown create mode 100644 doc/ru_RU/task-links.markdown create mode 100644 doc/ru_RU/tests.markdown create mode 100644 doc/ru_RU/time-tracking.markdown create mode 100644 doc/ru_RU/transitions.markdown create mode 100644 doc/ru_RU/translations.markdown create mode 100644 doc/ru_RU/ubuntu-installation.markdown create mode 100644 doc/ru_RU/update.markdown create mode 100644 doc/ru_RU/usage-examples.markdown create mode 100644 doc/ru_RU/user-management.markdown create mode 100644 doc/ru_RU/user-mentions.markdown create mode 100644 doc/ru_RU/user-types.markdown create mode 100644 doc/ru_RU/vagrant.markdown create mode 100644 doc/ru_RU/webhooks.markdown create mode 100644 doc/ru_RU/what-is-kanban.markdown create mode 100644 doc/ru_RU/windows-apache-installation.markdown create mode 100644 doc/ru_RU/windows-iis-installation.markdown create mode 100644 doc/web.config diff --git a/doc/ru_RU/2fa.markdown b/doc/ru_RU/2fa.markdown new file mode 100644 index 00000000..0787c720 --- /dev/null +++ b/doc/ru_RU/2fa.markdown @@ -0,0 +1,37 @@ +Двух-уровневая аутентификация +============================= + +Любой пользователь может включить [двух-уровневую аутентификацию](http://en.wikipedia.org/wiki/Two_factor_authentication). После успешного входа, разовый код (6 знаков) запрашивается у пользователя для получения доступа в Канборд. + +Этот код присылается в программу на вашем смартфоне. + +Канборд использует [Time-based One-time Password Algorithm](http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm) основанный на [RFC 6238](http://tools.ietf.org/html/rfc6238). + +Имеется много программ совместимых со стандартной системой TOTP. Например, вы можете использовать эти приложения, бесплатные и с открытым исходным кодом: + +- [Google Authenticator](https://github.com/google/google-authenticator/) (Android, iOS, Blackberry) +- [FreeOTP](https://fedorahosted.org/freeotp/) (Android, iOS) +- [OATH Toolkit](http://www.nongnu.org/oath-toolkit/) (Command line utility on Unix/Linux) + +Эти системы могут работать офлайн и вам не нужно иметь мобильную связь. + +Настройка +--------- + +1. Перейдите в пользовательский профиль +2. Слева нажмите **Двухфакторная авторизация** и поставте галочку в чекбоке +3. Секретный ключ сгенерируется для вас + +![2FA](https://kanboard.net/screenshots/documentation/2fa.png) + +Рисунок. Двухуровневая аутентификация. + + +- Вы должны сохранить секретный ключ в вашей TOTP программе. Если вы используете сматрфон, то просто сосканируйте QR код с помощью FreeOTP или Google Authenticator. +- Каждый раз, когда вы будете входить в Канборд, будет запрашиваться новый код +- Не забудьте протестировать ваше устройство, перед тем как закрыть вашу сессию + +Новый секретный ключ генерируется каждый раз при включении/выключении этой возможности. + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/analytics-tasks.markdown b/doc/ru_RU/analytics-tasks.markdown new file mode 100644 index 00000000..176a4616 --- /dev/null +++ b/doc/ru_RU/analytics-tasks.markdown @@ -0,0 +1,37 @@ +Аналитика для задач +=================== + +На странице детального просмотра задачи, в левом боковом меню, для каждой задачи имеется раздел аналитики. + +Затраченное время и время цикла +------------------------------- + +![Lead and cycle time](https://kanboard.net/screenshots/documentation/task-lead-cycle-time.png) + +Рисунок. Затраченное время и время цикла + + +- Затраченное время - время между созданием задачи и датой завершения (закрытие задачи). +- Время цикла - время между началом испольнения задачи и датой завершения. +- Если задача не закрыта, то для расчета используется текущее время вместо даты завершения. +- Если дата начала выполнения задачи не указана, то время цикла не может быть расчитано. + + +**Заметка**: Вы можете настроить автоматическое создание даты начала выполения задачи, когда вы перемещаете задачу в определенную колонку. + + +Время затраченное в каждой колонке +---------------------------------- + +![Time spent into each column](https://kanboard.net/screenshots/documentation/time-into-each-column.png) + +Рисунок. Время затраченное в каждой колонке + + + +- Этот график показывает сколько времени задача находилась в каждой колонке. +- Затраченное время расчитывается до закрытия задачи. + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/analytics.markdown b/doc/ru_RU/analytics.markdown new file mode 100644 index 00000000..2af6de34 --- /dev/null +++ b/doc/ru_RU/analytics.markdown @@ -0,0 +1,95 @@ +Аналитика +========= + +Каждый проект имеет анлитический раздел. В зависимости от того как вы используете Канборд, вы можете видеть подобные отчеты: + +Перераспределение(загрузка) пользователей +----------------------------------------- + +![User repartition](https://kanboard.net/screenshots/documentation/user-repartition.png) + +Перераспределение(загрузка) пользователей + + +Круговая диаграмма, представленная выше, показыает количество открытых задач назначенных определенным пользователям. + + +Распределение задач +------------------- + +![Task distribution](https://kanboard.net/screenshots/documentation/task-distribution.png) + +Рисунок. Распределение задач + + + +На рисунке выше, представлена круговая диаграмма, которая показывает количество открытых задач в определенных колонках. + + + +Накопительная диаграмма +----------------------- + +![Cumulative flow diagram](https://kanboard.net/screenshots/documentation/cfd.png) + +Рисунок. Накопительная диаграмма + + +- Эта диаграмма отображает количество задач выполненных в каждой колонке в определенный промежуток времени. +- Счетчик задач записывается для каждой колонки каждый день. +- Если вы хотите исключить закрытые задачи, измените [глобальные настройки проекта](project-configuration.markdown). + + +Заметка: Для того чтобы увидеть этот график, вам нужно иметь, как минимум, данные за два дня. + + +Диаграмма сгорания +------------------ + +![Burndown chart](https://kanboard.net/screenshots/documentation/burndown-chart.png) + +Рисунок. Диаграмма сгорания + + + +[Диаграмма сгорания](https://ru.wikipedia.org/wiki/%D0%94%D0%B8%D0%B0%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B0_%D1%81%D0%B3%D0%BE%D1%80%D0%B0%D0%BD%D0%B8%D1%8F_%D0%B7%D0%B0%D0%B4%D0%B0%D1%87) доступна для каждого проекта. + + +- Эта диаграмма отображает время затраченное на выполнение работы. +- Канборд использует историю задач для генерации этой диаграммы. +- Сумма историй задач для каждой колонки пересчитывается каждый день. + +Среднее время затраченное в каждой колонке +------------------------------------------ + +![Average time spent into each column](https://kanboard.net/screenshots/documentation/average-time-spent-into-each-column.png) + +Рисунок. Среднее время затраченное в каждой колонке + + +Этот график показывает среднее время затраченное в каждой колонке для последних 1000 задач. + +- Канборд использует для подсчета данных переходы задач между колонками. +- Затраченное время подсчитывается до закрытия задачи. + +Среднее время выполнения и время цикла +-------------------------------------- + +![Average time spent into each column](https://kanboard.net/screenshots/documentation/average-lead-cycle-time.png) + +Рисунок. Среднее время затраченное в каждой колонке + +Эта диаграмма показывает Среднее время выполнения и цикла для последних 1000 задач. +- Время выполнения - время между созданием задачи и датой завершения. +- Время цикла - время между указанной датой начала выполнения задачи и датой завершения. +- Если задача не закрыта, текущая дата будет использована вместо даты завершения. + +Эти данные подсчитываются и записываются каждый день на протяжении жизни проекта. + +Заметка: Не забудьте выполнить [ежедневные cronjob](cronjob.markdown) для того чтобы иметь точную статистику. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/api-json-rpc.markdown b/doc/ru_RU/api-json-rpc.markdown new file mode 100644 index 00000000..257f83ec --- /dev/null +++ b/doc/ru_RU/api-json-rpc.markdown @@ -0,0 +1,78 @@ +Json-RPC API +============ + + +API пользователя и приложения +----------------------------- + + +Имеется два типа доступа к API: + +### API приложения[¶](#application-api "Ссылка на этот заголовок") + +- Доступ к API осуществляется с использованием пользователя “jsonrpc” и ключа, доступного в настройках +- Доступ ко всем процедурам +- Не проверяются права доступа +- Нет пользовательской сессии на сервере +- Этот доступ можно использовать для: утилит миграции/импорта данных, создания задач из других систем и т.д. + +### API пользователя[¶](#user-api "Ссылка на этот заголовок") + +- Доступ к API под пользовательскими учетными данными (имя пользователя и пароль) +- Доступ к ограниченному набору процедур +- Проверка прав доступа к проекту +- На сервере создается пользовательская сессия +- Этот доступ можно использовать для клиентов: мобильных/десктопных приложений, утилит коммандной строки и т.д. + +Безопасность +------------ + +- Всегда используйте протокол HTTPS с действительным сертификатом +- Если вы делаете мобильное приложение, позаботьтесь о безопасном хранении учетных данных пользователя на мобильном устройстве +- После 3 неправильных подключений к пользовательскому api, пользователь может разблокировать свою учетную запись только с использованием формы входа +- Двухуровневая аутентификация пока не доступна через API + + + +Протокол +-------- + + +Канборд использует протокол Json-RPC для взаимодействия с внешними программами. + +JSON-RPC - протокол удаленного вызова процедур в формате JSON. По сути своей, тот же XML-RPC, но использующий формат JSON. + +Мы используем [протокол версии 2](http://www.jsonrpc.org/specification). Вы можете вызывать API используя `POST`{.docutils .literal} HTTP запрос. + +Канборд поддерживает пакетные запросы, поэтому вы можете делать многократные API вызовы в одном HTTP запросе. Это, в частности, удобно для мобильных клиентов с высокой сетевой задержкой. + + +Использование +------------- + +- [Аутентификация](api-authentication.markdown) +- [Примеры](api-examples.markdown) +- [Приложение](api-application-procedures.markdown) +- [Проекты](api-project-procedures.markdown) +- [Права доступа к проекту](api-project-permission-procedures.markdown) +- [Доски](api-board-procedures.markdown) +- [Колонки](api-column-procedures.markdown) +- [Дорожки](api-swimlane-procedures.markdown) +- [Категории](api-category-procedures.markdown) +- [Автоматические дейсвия](api-action-procedures.markdown) +- [Задачи](api-task-procedures.markdown) +- [Подзадачи](api-subtask-procedures.markdown) +- [Файлы](api-file-procedures.markdown) +- [Ссылки](api-link-procedures.markdown) +- [Комментарии](api-comment-procedures.markdown) +- [Пользователи](api-user-procedures.markdown) +- [Группы](api-group-procedures.markdown) +- [Члены группы](api-group-member-procedures.markdown) +- [Специфичные запросы пользователя](api-me-procedures.markdown) + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/application-configuration.markdown b/doc/ru_RU/application-configuration.markdown new file mode 100644 index 00000000..d8b2661e --- /dev/null +++ b/doc/ru_RU/application-configuration.markdown @@ -0,0 +1,54 @@ +Настройки приложения +==================== + +Некоторые параметры для приложения могут быть изменены на странице настроек. Только администратор может сделать эти настройки. +Выберите в правом выпадающем меню **Настройки**, затем в слева выберите **Настройки приложения**. + +![Application settings](https://kanboard.net/screenshots/documentation/application-settings.png) + +Рисунок. Настройки приложения + + +URL приложения[¶](#application-url "Ссылка на этот заголовок") +-------------------------------------------------------------- + +Этот параметр используется для email уведомлений. В тексте сообщения будет содержаться ссылка на задачу в Канборде. + + +Язык[¶](#language "Ссылка на этот заголовок") +--------------------------------------------- + +Язык приложения может быть изменен в любое время. Язык устанавливается для всех пользователей Канборд. + + +Часовой пояс[¶](#time-zone "Ссылка на этот заголовок") +------------------------------------------------------ + +По умолчанию, Канборд использует часовой пояс UTC, но вы можете указать любой часовой пояс. Список содержит все поддерживаемые часовые пояса для вашего веб сервера. + + +Формат даты[¶](#date-format "Ссылка на этот заголовок") +------------------------------------------------------- + +Формать даты, который используется для полей дата. Например, дата завершения задачи. + +Канборд поддерживает 4 разных формата: + +- ДД/ММ/ГГГГ +- ММ/ДД/ГГГГ (по умолчанию) +- ГГГГ/ММ/ДД +- ММ.ДД.ГГГГ + +Формат [ISO 8601](http://en.wikipedia.org/wiki/ISO_8601) всегда принимается (YYYY-MM-DD or YYYY\_MM\_DD). + + +Пользовательский стиль CSS[¶](#custom-stylesheet "Ссылка на этот заголовок") +---------------------------------------------------------------------------- + +Вы можете сделать свой стиль CSS для Канборд или улучшить имеющийся стиль. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/assets.markdown b/doc/ru_RU/assets.markdown new file mode 100644 index 00000000..9a0124c5 --- /dev/null +++ b/doc/ru_RU/assets.markdown @@ -0,0 +1,53 @@ +Как создать asset (Javascript и CSS файлы) +========================================== + + +Файлы CSS стилей и Javascript объединены вместе и минимизированы. + +- Оригинальные файлы CSS хранятся в каталоге `assets/css/src/*.css`{.docutils .literal} +- Оригинальные файлы Javascript хранятся в каталоге `assets/js/src/*.js`{.docutils .literal} +- `assets/*/vendor.min.*`{.docutils .literal} - внешние зависимости объединены и минимизированы +- `assets/*/app.min.*`{.docutils .literal} - исходный код приложения объединены и минимизированы + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + +- [NodeJS](https://nodejs.org/) с `npm`{.docutils .literal} + + +Сборка файлов Javascript и CSS[¶](#building-javascript-and-css-files "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------ + + +Канборд использует [Gulp](http://gulpjs.com/) для сборки asset и [Bower](http://bower.io/) для управления зависимостями. Эти утилиты устанавлены в проекте как зависимости NodeJS. + + +### Запустить все[¶](#run-everything "Ссылка на этот заголовок") + + make static + +### Собрать `vendor.min.js`{.docutils .literal} и `vendor.min.css`{.docutils .literal}[¶](#build-vendor-min-js-and-vendor-min-css "Ссылка на этот заголовок") + + gulp vendor + +### Собрать `app.min.js`{.docutils .literal}[¶](#build-app-min-js "Ссылка на этот заголовок") + + gulp js + + +### Собрать `app.min.css`{.docutils .literal}[¶](#build-app-min-css "Ссылка на этот заголовок") + + gulp css + + +Примечание[¶](#notes "Ссылка на этот заголовок") +------------------------------------------------ + +Сборка asset невозможна из архива Kanboard, вы должны клонировать репозиторий. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/automatic-actions.markdown b/doc/ru_RU/automatic-actions.markdown new file mode 100644 index 00000000..1e0631c3 --- /dev/null +++ b/doc/ru_RU/automatic-actions.markdown @@ -0,0 +1,128 @@ +Автоматизация процессов +======================= + + +Для минимизации пользовательских действий, Kanboard поддерживает автоматизацию процессов. + +Каждый автоматизированный процесс представляет следующее: + +- Ожидание наступления события +- Выполняется действие при наступлении этого события +- В результате устанавливается определенный параметр + +Каждый проект может иметь свой набор автоматических процессов. Автоматические процессы доступны в панеле настроек (**Меню** -\> **Настройки**) **Автоматические действия**. + + +Добавление нового действия[¶](#add-a-new-action "Ссылка на этот заголовок") +--------------------------------------------------------------------------- + + +Нажмите на ссылку **Добавить новое действие**. + +![Automatique action](screenshots/automatic-action-creation.png) + +Рисунок. Автоматическое действие. + + +- Выберете действие +- Затем, выберете событие +- И в завершении, задайте параметр + + +Список доступных действий[¶](#list-of-available-actions "Ссылка на этот заголовок") +----------------------------------------------------------------------------------- + + +- Создать комментарий из внешнего источника +- Добавлять запись при перемещении задачи между колонками +- Автоматически назначать категорию по цвету +- Изменить категорию основываясь на внешнем ярлыке +- Автоматически назначать категории на основе ссылки +- Автоматически назначать цвет по категории +- Назначить цвет, когда задача перемещается в определенную колонку +- Изменение цвета задач при использовании ссылки на определенные задачи +- Назначить определенный цвет пользователю +- Назначить задачу тому кто выполнит действие +- Назначить задачу пользователю, который произвел изменение в колонке +- Назначить задачу определенному пользователю +- Изменить назначенного основываясь на внешнем имени пользователя +- Закрыть задачу +- Закрыть задачу в выбранной колонке +- Создать задачу из внешнего источника +- Создать дубликат задачи в другом проекте +- Отправить задачу по email +- Переместить задачу в другой проект +- Переместить задачу в другую колонку, когда она назначена пользователю +- Переносить задачи в другую колонку при изменении категории +- Переместить задачу в другую колонку, когда назначение снято +- Открыть задачу +- Автоматическое обновление даты начала + + +Примеры[¶](#examples "Ссылка на этот заголовок") +------------------------------------------------ + + +Здесь предствалены примеры использованные в реальной жизни: + +### Когда я перемещаю задачу в колонку “Выполнено”, автоматически закрывать эту задачу[¶](#when-i-move-a-task-to-the-column-done-automatically-close-this-task "Ссылка на этот заголовок") + +- Выберите действия: **Закрыть задачу в выбранной колонке** +- Выберите событие: **Переместить задачу в другую колонку** +- Установите параметр действия: **Колонка = Выполнено** (это колонка в которую будет перемещена задача) + +### Когда я перемещаю задачу в колонку “На утверждение”, назначить эту задачу определенному пользователю.[¶](#when-i-move-a-task-to-the-column-to-be-validated-assign-this-task-to-a-specific-user "Ссылка на этот заголовок") + +- Выберите действие: **Назначить задачу определенному пользователю** +- Выберите событие: **Переместить задачу в другую колонку** +- Установите параметр действия: **Колонка = На утверждение** и **Пользователь = Петр** (Петр - наш тестировщик) + +### Когда я перемещаю задачу в колонку “В работе”, назначить эту задачу определенному пользователю[¶](#when-i-move-a-task-to-the-column-work-in-progress-assign-this-task-to-the-current-user "Ссылка на этот заголовок") + +- Выберите действие: **Назначить задачу пользователю, который произвел изменение в колонке** +- Выберите событие: **Переместить задачу в другую колонку** +- Установите параметр действия: **Колонка = В работе** + + +### Когда задача выполнена, скопировать эту задачу в другой проект[¶](#when-a-task-is-completed-duplicate-this-task-to-another-project "Ссылка на этот заголовок") + +Предположим, мы имеем два проекта “Заказы покупателей” и “Производство”. Когда заказ в проекте “Заказы покупателей” утвержден, копируем этот заказ в проект “Производство”. + +- Выбираем действие: **Создать дубликат задачи в другом проекте** +- Выбираем событие: **Завершение задачи** +- Установите параметр действия: **Колонка = Утвержден** и **Проект = Производство** + + +### Когда задача перемещена в последнюю колонку, переместить эту задачу в другой проект[¶](#when-a-task-is-moved-to-the-last-column-move-the-exact-same-task-to-another-project "Ссылка на этот заголовок") + + +Предположим, мы имеем два проекта “Идеи” и “Разработка”, когда идея утверждена, перемещаем эту задачу в проект “Разработка”. + +- Выберите действие: **Переместить задачу в другой проект** +- Выберите событие: **Переместить задачу в другую колонку** +- Установите параметр действия: **Колонка = Утверждена** и **Проект = Разработка** + +### Я хочу назначать автоматически цвет для пользователя Петр[¶](#i-want-to-assign-automatically-a-color-to-the-user-bob "Ссылка на этот заголовок") + +- Выберите действие: **Назначить определенный цвет пользователю** +- Выберите событие: **Изменен назначенный** +- Установите параметр действия: **Цвет = Зеленый** и **Назначена = Петр** + + +### Я хочу назначить цвет автоматически для определенной категории “Важные запросы”[¶](#i-want-to-assign-a-color-automatically-to-the-defined-category-feature-request "Ссылка на этот заголовок") + +- Выберите действие: **Автоматически назначать цвет по категории** +- Выберите событие: **Создание или изменение задачи** +- Установите параметр действия: **Цвет = Голубой** и **Категория = Важные запросы** + + +### Я хочу устанавливать дату начала автоматически когда задача перемещена в колонку “В работе”[¶](#i-want-to-set-the-start-date-automatically-when-the-task-is-moved-to-the-column-work-in-progress "Ссылка на этот заголовок") + +- Выберите действие: **Автоматическое обновление даты начала** +- Выберите событие: **Переместить задачу в другую колонку** +- Установите параметр действия: **Колонка = В работе** + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/board-collapsed-expanded.markdown b/doc/ru_RU/board-collapsed-expanded.markdown new file mode 100644 index 00000000..a19981a5 --- /dev/null +++ b/doc/ru_RU/board-collapsed-expanded.markdown @@ -0,0 +1,31 @@ +Компактное и развернутое отображение задач +========================================== + +Задачи на Доске могут быть отображены в компактном или развернутом виде. Переключение между компактным и развернутым видом может быть выполнено с помощью горячей клавиши **“s”** или в раскрывающемся Меню (слева вверху) -\> Развернуть задачи или Свернуть задачи. + + +Компактный вид[¶](#collapsed-mode "Ссылка на этот заголовок") +------------------------------------------------------------- + + +![Tasks collapsed](screenshots/board-collapsed-mode.png) + +Рисунок. Задачи представлены в компактном виде + +- Если для задачи назначен исполнитель, то инициалы исполнителя показываются рядом с номером задачи; +- Если заголовок задачи слишком длинный, вы можете подвести курсор мышки над задачей и полный заголовок задачи отобразится во всплывающем окне. + + + +Развернутый вид[¶](#expanded-mode "Ссылка на этот заголовок") +------------------------------------------------------------- + + +![Tasks expanded](screenshots/board-expanded-mode.png) +Рисунок. Развернутый вид + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/board-configuration.markdown b/doc/ru_RU/board-configuration.markdown new file mode 100644 index 00000000..fb4fb58d --- /dev/null +++ b/doc/ru_RU/board-configuration.markdown @@ -0,0 +1,39 @@ +Настройка Доски +=============== + + +В правом верхнем выпадающем меню выберите **Настройки**, затем, слева, выберите **Настройки Доски**. + +![Board settings](https://kanboard.net/screenshots/documentation/board-settings.png) + +Рисунок. Настройка Доски + + +Подстветка задач[¶](#task-highlighting "Ссылка на этот заголовок") +------------------------------------------------------------------ + +Эта опция позволяет подсвечивать задачу, которая была перенесена недавно. + +Установите значение 0 для выключения подсветки. По умолчанию установлено значение 172800 секунд (2 дня) + +Перемещенные задачи будут подсвечиваться в течении двух дней. + + +Период обновления для публичных досок[¶](#refresh-interval-for-public-board "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------- + +Если вы создаете публичную доску, то страница, по умолчанию, будет обновляться каждые 60 секунд. + + +Период обновления для частных досок[¶](#refresh-interval-for-private-board "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------ + +Когда в вашем браузере открыта Доска, Канборд проверяет обновления изменение каждые 10 секунд. + +Процесс обновления реализован по технологии Ajax. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/board-horizontal-scrolling-and-compact-view.markdown b/doc/ru_RU/board-horizontal-scrolling-and-compact-view.markdown new file mode 100644 index 00000000..9eaa5c9e --- /dev/null +++ b/doc/ru_RU/board-horizontal-scrolling-and-compact-view.markdown @@ -0,0 +1,19 @@ +Горизонтальная прокрутка и компактный вид +========================================= + +Когда ширины экрана не хватает для отображения всех колонок, то внизу появляется горизонтальная прокрутка. + +Однако, можно переключится на компактный вид доски для отображения всех колонок на вашем экране. + + +![Switch to compact mode](screenshots/board-compact-mode.png) + +Рисунок. Переключение на компактное представление. + +Переключится между горизонтальной прокруткой и компактным видом можно с помощью горячей клавиши **“c”** или в левом верхнем раскрывающемся “Меню” -\> “Компактный вид” или “Широкий вид”. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/board-show-hide-columns.markdown b/doc/ru_RU/board-show-hide-columns.markdown new file mode 100644 index 00000000..5c333b5c --- /dev/null +++ b/doc/ru_RU/board-show-hide-columns.markdown @@ -0,0 +1,25 @@ +Показать и скрыть колонки на Доске +================================== + +Вы можете показать и скрыть колонки на Доске очень просто: + +![Hide a column](screenshots/hide-column.png) + +Рисунок. Спрятать колонку. + + +Чтобы скрыть (спрятать) колонку , откройте выпадающее меню колонки. + +![Show a column](screenshots/show-column.png) + +Рисунок.Показать колонку. + + +Для отображения скрытой колонки нажмите “иконку плюс” + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/bruteforce-protection.markdown b/doc/ru_RU/bruteforce-protection.markdown new file mode 100644 index 00000000..25e50880 --- /dev/null +++ b/doc/ru_RU/bruteforce-protection.markdown @@ -0,0 +1,37 @@ +Защита от Brute Force +===================== + +Защита от Brute Force (подбор пароля методом перебора) в Канборде работает на уровне учетной записи пользователя: + +- После 3 неправильных вводов пароля для одного и того же пользователя, на форме входа появляется капча для предотвращения дальнейшего подбора программой-роботом. +- После 6 неудачных вводов пароля, учетная запись пользователя блокируется на 15 минут. + +Эта возможность работает только для метода аутентификации с использованием формы входа на веб странице. + +Однако, **после трех ошибочных аутентификаций через пользовательский API, учетная запись может быть разблокирована с использованием формы входа на веб странице** + +В Канборде нет блокировок по IP адресу, потому что программы-роботы используют множество анонимных прокси. Однако, вы можете использовать внешнюю утилиту, например [fail2ban](http://www.fail2ban.org) , чтобы избежать массового сканирования. + +Настройки защиты от Brute Force могут быть изменены в следующих переменных: + + // Enable captcha after 3 authentication failure + + define('BRUTEFORCE_CAPTCHA', 3); + + + + // Lock the account after 6 authentication failure + + define('BRUTEFORCE_LOCKDOWN', 6); + + + + // Lock account duration in minutes + + define('BRUTEFORCE_LOCKDOWN_DURATION', 15); + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/calendar-configuration.markdown b/doc/ru_RU/calendar-configuration.markdown new file mode 100644 index 00000000..bd6d604e --- /dev/null +++ b/doc/ru_RU/calendar-configuration.markdown @@ -0,0 +1,59 @@ +Настройки календаря +=================== + +В правом верхнем выпадающем меню выберите **Настройки**, затем, слева, выберите **Настройки календаря**. + + +![Calendar settings](https://kanboard.net/screenshots/documentation/calendar-settings.png) + +Рисунок. Настройки календаря + + +В Канборде имеется два вида Календаря: + +- Календарь проекта +- Пользовательский календарь (доступен в левом меню Инфопанели) + + +Календарь проекта[¶](#project-calendar "Ссылка на этот заголовок") +------------------------------------------------------------------ + +Эти календари показывают задачи с указанной датой создания или датой начала и датой завершения. + +### Показать задачи в зависимости от даты создания[¶](#show-tasks-based-on-the-creation-date "Ссылка на этот заголовок") + +- Дата начала в календаре показывает дату создания задачи. +- Конечная дата показывает дату завершения. + + +### Показать задачи в зависимости от даты начала[¶](#show-tasks-based-on-the-start-date "Ссылка на этот заголовок") + +- Дата начала в календаре показывает дату начала задачи. +- Эта дата должна быть установлена вручную. +- Конечная дата показывает дату завершения. +- Если не указать дату начала, то задача не будет отображена в календаре. + + + +Пользовательский календарь[¶](#user-calendar "Ссылка на этот заголовок") +------------------------------------------------------------------------ + +Пользовательский календарь показывает только задачи назначенные пользователю и, опционально, информацию о подзадачах. + + +### Показать подзадачи, основанные на отслеживании времени[¶](#show-sub-tasks-based-on-the-time-tracking "Ссылка на этот заголовок") + +- Показывает подзадачи в календаре из записей таблицы отслеживания времени. +- Пересечения в пользовательской таблице времени также подсчитываются. + + +### Показывать оценку подзадач (прогнозирование будущих работ)[¶](#show-sub-task-estimates-forecast-of-future-work "Ссылка на этот заголовок") + +- Показывает оценку будущих работ для подзадач в статусе “для исполнения” и с указанным значением “оценка”. + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/calendar.markdown b/doc/ru_RU/calendar.markdown new file mode 100644 index 00000000..f0658c89 --- /dev/null +++ b/doc/ru_RU/calendar.markdown @@ -0,0 +1,31 @@ +Календарь +========= + + +Календарь может быть представлен в двух видах: + +- Представление в проекте с использование фильтров (доступно на Доске) +- Пользовательское представление (доступно в рабочей панели и в пользовательском разделе) + +В Календаре можно увидеть следующую информацию: + +- Задачи с “датой испольнения”, отображаются наверху. **Дата испольнения может быть изменена перемещением задачи на другой день**. +- Задачи с датой создания или датой начала. **Эти события не могут быть изменены в календаре**. +- Отслеживание времени подзадачи. Все записанные временные диапазоны будут отображены в Календаре. +- Подсчеты, прогнозы затрачиваемого время на подзадачу. + +![Calendar](https://kanboard.net/screenshots/documentation/calendar.png) + +Рисунок. Календарь + + +Настроки Календаря могут быть изменены на странице **Настройки** + +Заметка: Дата исполения не содержит информацию о времени. + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/centos-installation.markdown b/doc/ru_RU/centos-installation.markdown new file mode 100644 index 00000000..95808586 --- /dev/null +++ b/doc/ru_RU/centos-installation.markdown @@ -0,0 +1,127 @@ +Инсталяция Канборд на Centos +============================ + + +**Внимание**: Некоторые возможности Канборда требуют [запуск ежедневных фоновых задач](cronjob.markdown). + + +Centos 7[¶](#centos-7 "Ссылка на этот заголовок") +------------------------------------------------- + +Установите PHP и Apache: + + + yum install -y php php-mbstring php-pdo php-gd unzip wget + + +По умолчанию, Centos 7 использует PHP 5.4.16 и Apache 2.4.6. + + + +Перезапустите Apache: + + + + systemctl restart httpd.service + + + +Установите Канборд: + + + + cd /var/www/html + + wget https://kanboard.net/kanboard-latest.zip + + unzip kanboard-latest.zip + + chown -R apache:apache kanboard/data + + rm kanboard-latest.zip + + + +Если включен SELinux, убедитесь что пользователь веб сервера Apache имеет права на запись в директорию data: + + + + chcon -R -t httpd_sys_content_rw_t /var/www/html/kanboard/data + + + +Убедитесь, что Канборд может посылать email сообщения и делать внешние сетевые запросы, например с SELinux: + + + + setsebool -P httpd_can_network_connect=1 + + + +Позволяет делать внешние подключения если используется LDAP, SMTP, Web hooks или другая интеграция. + + + +Centos 6.x[¶](#centos-6-x "Ссылка на этот заголовок") +----------------------------------------------------- + + + +Установите PHP и Apache: + + + + yum install -y php php-mbstring php-pdo php-gd unzip wget + + + +По умолчанию, Centos 6.5 использует PHP 5.3.3 и Apache 2.2.15. + + + +Включите короткие теги: + + + +- Отредактируйте файл `/etc/php.ini`{.docutils .literal} + + + +- Измените строку `short_open_tag = On`{.docutils .literal} (вместо `short_open_tag = Off`{.docutils .literal}) + + + +Перезапустите Apache: + + + + service httpd restart + + + +Установите Канборд: + + + + cd /var/www/html + + wget https://kanboard.net/kanboard-latest.zip + + unzip kanboard-latest.zip + + chown -R apache:apache kanboard/data + + rm kanboard-latest.zip + + + +Готово. Можете работать с Канборд. Откройте в браузере `http://ваш_сервер/kanboard/`{.docutils .literal}. + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/cli.markdown b/doc/ru_RU/cli.markdown new file mode 100644 index 00000000..9c7b56a7 --- /dev/null +++ b/doc/ru_RU/cli.markdown @@ -0,0 +1,331 @@ +Интерфейс командной строки +========================== + + + +Канборд обеспечивает простой интерфейс командной строки, которым можно воспользоваться только из Unix терминала. Эта возможность доступна только с локальной машины. + + + +Интерфейс командной строки полезен для выполнения команд вне процессов веб сервера. + + + +Использование[¶](#usage "Ссылка на этот заголовок") +--------------------------------------------------- + + + +- Откройте терминал и перейдите в директорию Канборд (например: `cd /var/www/kanboard`) + + + +- Выполните команду `./kanboard` + + + + + + + + Kanboard version master + + + + Usage: + + command [options] [arguments] + + + + Options: + + -h, --help Display this help message + + -q, --quiet Do not output any message + + -V, --version Display this application version + + --ansi Force ANSI output + + --no-ansi Disable ANSI output + + -n, --no-interaction Do not ask any interactive question + + -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug + + + + Available commands: + + cronjob Execute daily cronjob + + help Displays help for a command + + list Lists commands + + export + + export:daily-project-column-stats Daily project column stats CSV export (number of tasks per column and per day) + + export:subtasks Subtasks CSV export + + export:tasks Tasks CSV export + + export:transitions Task transitions CSV export + + locale + + locale:compare Compare application translations with the fr_FR locale + + locale:sync Synchronize all translations based on the fr_FR locale + + notification + + notification:overdue-tasks Send notifications for overdue tasks + + plugin + + plugin:install Install a plugin from a remote Zip archive + + plugin:uninstall Remove a plugin + + plugin:upgrade Update all installed plugins + + projects + + projects:daily-stats Calculate daily statistics for all projects + + trigger + + trigger:tasks Trigger scheduler event for all tasks + + user + + user:reset-2fa Remove two-factor authentication for a user + + user:reset-password Change user password + + + +Доступные команды[¶](#available-commands "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +### Экспорт задач в формате CSV[¶](#tasks-csv-export "Ссылка на этот заголовок") + + + +Применение: + + + + ./kanboard export:tasks + + + +Пример: + + + + ./kanboard export:tasks 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv + + + +Данные CSV передаются в `stdout`. + + + +### Экспорт подзадач в формате CSV[¶](#subtasks-csv-export "Ссылка на этот заголовок") + + + +Применение: + + + + ./kanboard export:subtasks + + + +Пример: + + + + ./kanboard export:subtasks 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv + + + +### Экспорт перемещения задач в формате CSV[¶](#task-transitions-csv-export "Ссылка на этот заголовок") + + + +Применение: + + + + ./kanboard export:transitions + + + +Пример: + + + + ./kanboard export:transitions 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv + + + +### Экспорт ежедневных сведений в формате CSV[¶](#export-daily-summaries-data-in-csv "Ссылка на этот заголовок") + + + +Экспортированные данные будут выведены в стандартный вывод: + + + + ./kanboard export:daily-project-column-stats + + + +Пример: + + + + ./kanboard export:daily-project-column-stats 1 2014-10-01 2014-11-30 > /tmp/my_custom_export.csv + + + +### Отправка уведомлений для просроченных задач[¶](#send-notifications-for-overdue-tasks "Ссылка на этот заголовок") + + + +Email сообщения будут отправлены всем пользователям, у которых включено оповещение. + + + + ./kanboard notification:overdue-tasks + + + +Необязательные параметры: + + + +- `--show`: Показывать отправку уведомлений + + + +- `--group`: Группировать все просроченные задачи для одного пользователя (со всех проектов) на один email + + + +- `--manager`: Посылать все просроченные задачи менеджеру (менеджерам) проекта в одном email сообщении + + + +Вы можете просмотреть просроченные задачи с помощью параметра `--show`: + + + +```bash +./kanboard notification:overdue-tasks --show ++-----+---------+------------+------------+--------------+----------+ +| Id | Title | Due date | Project Id | Project name | Assignee | ++-----+---------+------------+------------+--------------+----------+ +| 201 | Test | 2014-10-26 | 1 | Project #0 | admin | +| 202 | My task | 2014-10-28 | 1 | Project #0 | | ++-----+---------+------------+------------+--------------+----------+ +``` + + +### Запуск ежедневной калькуляции статистики[¶](#run-daily-project-stats-calculation "Ссылка на этот заголовок") + + + +Эта команда считает статистику для каждого проекта: + + + + ./kanboard projects:daily-stats + + Run calculation for Project #0 + + Run calculation for Project #1 + + Run calculation for Project #10 + + + +### Триггеры для задач[¶](#trigger-for-tasks) + + + +Эта команда посылает “событие для ежедневных фоновых заданий” для всех открытых задач в каждом проекте. + + + + ./kanboard trigger:tasks + + Trigger task event: project_id=2, nb_tasks=1 + + + +### Сброс пароля пользователя[¶](#reset-user-password "Ссылка на этот заголовок") + + + + ./kanboard user:reset-password my_user + + + +Будет запрошен пароль и подтверждение. Символы не отображаются на экране. + + + +### Удаление двухуровневой аутентификации для пользователя[¶](#remove-two-factor-authentication-for-a-user "Ссылка на этот заголовок") + + + + ./kanboard user:reset-2fa my_user + + + +### Установка плагина[¶](#install-a-plugin "Ссылка на этот заголовок") + + + + ./kanboard plugin:install https://github.com/kanboard/plugin-github-auth/releases/download/v1.0.1/GithubAuth-1.0.1.zip + + + +Заметка: Установленные файлы будут иметь теже права, что и у текущего пользователя + + + +### Удаление плагина[¶](#remove-a-plugin "Ссылка на этот заголовок") + + + + ./kanboard plugin:uninstall Budget + + + +### Обновление всех плагинов[¶](#upgrade-all-plugins "Ссылка на этот заголовок") + + + + ./kanboard plugin:upgrade + + * Updating plugin: Budget Planning + + * Plugin up to date: Github Authentication + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/closing-tasks.markdown b/doc/ru_RU/closing-tasks.markdown new file mode 100644 index 00000000..ae91757d --- /dev/null +++ b/doc/ru_RU/closing-tasks.markdown @@ -0,0 +1,30 @@ +Закрытие задач +============== + +Когда задача закрыта, то она скрывается на Доске. + +Не смотря на это, вы можете в любой момент зайти в список закрытых задач используя запрос **status:closed** в любой форме поиска или просто выбрать фильтр “Закрытые задачи” в выпадающем меню. + +Имеется два пути для закрытия задачи: - На Доске выбрать задачу и выпадающем меню выбрать **Закрыть задачу** + +![Close a task from drop-down menu](https://kanboard.net/screenshots/documentation/menu-close-task.png) + +Рисунок. Закрытие задачи, используя выпадающее меню. + + +или - Используя детальное представление задачи, выбрать **Закрыть задачу** в меню боковой панели (слева) + + +![Close task](https://kanboard.net/screenshots/documentation/closing-tasks.png) + +Рисунок. Закрытие задачи. + + + +**Заметка**: Когда вы закрываете задачу, у всех не выполненных подзадач будет изменен статус на “Выполнено” + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/cloudron.markdown b/doc/ru_RU/cloudron.markdown new file mode 100644 index 00000000..2e41d0d0 --- /dev/null +++ b/doc/ru_RU/cloudron.markdown @@ -0,0 +1,45 @@ +Как запустить Канборд на Cloudron +================================= + + +[Cloudron](https://cloudron.io) приватный смартсервер, на котором вы можете установить веб приложения, такие как Канборд. Вы можете установить Канборд в определенном домене, при этом каждой инсталяции создавается резервная копия и поддерживается новая версия Канборда автоматически. + + + +[![Install](https://cloudron.io/img/button.svg)](https://cloudron.io/button.html?app=net.kanboard.cloudronapp) + + + +Учетные записи[¶](#accounts "Ссылка на этот заголовок") +------------------------------------------------------- + + +Приложение плотно интегрируется с системой Управления пользователями Cloudron (через LDAP). Только пользователи Cloudron могут войти в Канборд. Плюс, любой администратор Cloudron становится администратором Канборда автоматически. + + +Установка плагинов[¶](#installing-plugins "Ссылка на этот заголовок") +--------------------------------------------------------------------- + + + +Плагины могут быть установлены и настроены с помощью утилиты [Cloudron CLI](https://cloudron.io/references/cli.html). Для подробной информации смотрите [описание приложения](https://cloudron.io/appstore.html?app=net.kanboard.cloudronapp). + + + +Исходный код приложения[¶](#application-source-code "Ссылка на этот заголовок") +------------------------------------------------------------------------------- + + + +Исходный код приложения Cloudron находится [здесь](https://github.com/cloudron-io/kanboard-app). + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/coding-standards.markdown b/doc/ru_RU/coding-standards.markdown new file mode 100644 index 00000000..b6100375 --- /dev/null +++ b/doc/ru_RU/coding-standards.markdown @@ -0,0 +1,64 @@ +Стандарты используемые при написании кода +========================================= + + + +Код PHP[¶](#php-code "Ссылка на этот заголовок") +------------------------------------------------ + + + +- Отступ: 4 пробела + + + +- Перевод строки: Unix =\> `\n`{.docutils .literal} + + + +- Кодировка: UTF-8 + + + +- Используйте только открытые теги ` `\n`{.docutils .literal} + + + +Код CSS[¶](#css-code "Ссылка на этот заголовок") +------------------------------------------------ + + + +- Отступ: 4 пробела + + + +- Перевод строки: Unix =\> `\n`{.docutils .literal} + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/config.markdown b/doc/ru_RU/config.markdown new file mode 100644 index 00000000..b0419966 --- /dev/null +++ b/doc/ru_RU/config.markdown @@ -0,0 +1,523 @@ +Конфигурационный файл +===================== + + + +Вы можете изменить базовые настройки Канборда добавив файл `config.php` в корень проекта или в каталог `data`. Вы, также, можете переименовать файл `config.default.php` в `config.php` и установить желаемые значения. + + +Включение/выключение режима отладки[¶](#enable-disable-debug-mode "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------- + + + + define('DEBUG', true); + + define('LOG_DRIVER', 'file'); // Other drivers are: syslog, stdout, stderr or file + + + +Обработчик логов может быть определен если вы включите режим отладки. Режим отладки фиксирует все SQL запросы и время затрачиваемое на генерацию страниц. + + + +Плагины[¶](#plugins "Ссылка на этот заголовок") +----------------------------------------------- + + + +Каталог плагинов: + + + + define('PLUGINS_DIR', 'data/plugins'); + + + +Включение/выключение установки плагинов через интерфейс пользователя: + + + + define('PLUGIN_INSTALLER', true); // Default is true + + + +Каталог для загружаемых файлов[¶](#folder-for-uploaded-files "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------- + + + + define('FILES_DIR', 'data/files'); + + + +Включение/выключение переопределения url адресов[¶](#enable-disable-url-rewrite "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------------- + + + + define('ENABLE_URL_REWRITE', false); + + + +Настройка email[¶](#email-configuration "Ссылка на этот заголовок") +------------------------------------------------------------------- + + + + // E-mail address for the "From" header (notifications) + + define('MAIL_FROM', 'notifications@kanboard.local'); + + + + // Mail transport to use: "smtp", "sendmail" or "mail" (PHP mail function) + + define('MAIL_TRANSPORT', 'mail'); + + + + // SMTP configuration to use when the "smtp" transport is chosen + + define('MAIL_SMTP_HOSTNAME', ''); + + define('MAIL_SMTP_PORT', 25); + + define('MAIL_SMTP_USERNAME', ''); + + define('MAIL_SMTP_PASSWORD', ''); + + define('MAIL_SMTP_ENCRYPTION', null); // Valid values are "null", "ssl" or "tls" + + + + // Sendmail command to use when the transport is "sendmail" + + define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); + + + +Настройки базы данных[¶](#database-settings "Ссылка на этот заголовок") +----------------------------------------------------------------------- + + + + // Database driver: sqlite, mysql or postgres (sqlite by default) + + define('DB_DRIVER', 'sqlite'); + + + + // Mysql/Postgres username + + define('DB_USERNAME', 'root'); + + + + // Mysql/Postgres password + + define('DB_PASSWORD', ''); + + + + // Mysql/Postgres hostname + + define('DB_HOSTNAME', 'localhost'); + + + + // Mysql/Postgres database name + + define('DB_NAME', 'kanboard'); + + + + // Mysql/Postgres custom port (null = default port) + + define('DB_PORT', null); + + + + // Mysql SSL key + + define('DB_SSL_KEY', null); + + + + // Mysql SSL certificate + + define('DB_SSL_CERT', null); + + + + // Mysql SSL CA + + define('DB_SSL_CA', null); + + + +Настройки LDAP[¶](#ldap-settings "Ссылка на этот заголовок") +------------------------------------------------------------ + + + + // Enable LDAP authentication (false by default) + + define('LDAP_AUTH', false); + + + + // LDAP server hostname + + define('LDAP_SERVER', ''); + + + + // LDAP server port (389 by default) + + define('LDAP_PORT', 389); + + + + // By default, require certificate to be verified for ldaps:// style URL. Set to false to skip the verification + + define('LDAP_SSL_VERIFY', true); + + + + // Enable LDAP START_TLS + + define('LDAP_START_TLS', false); + + + + // By default Kanboard lowercase the ldap username to avoid duplicate users (the database is case sensitive) + + // Set to true if you want to preserve the case + + define('LDAP_USERNAME_CASE_SENSITIVE', false); + + + + // LDAP bind type: "anonymous", "user" or "proxy" + + define('LDAP_BIND_TYPE', 'anonymous'); + + + + // LDAP username to use with proxy mode + + // LDAP username pattern to use with user mode + + define('LDAP_USERNAME', null); + + + + // LDAP password to use for proxy mode + + define('LDAP_PASSWORD', null); + + + + // LDAP DN for users + + // Example for ActiveDirectory: CN=Users,DC=kanboard,DC=local + + // Example for OpenLDAP: ou=People,dc=example,dc=com + + define('LDAP_USER_BASE_DN', ''); + + + + // LDAP pattern to use when searching for a user account + + // Example for ActiveDirectory: '(&(objectClass=user)(sAMAccountName=%s))' + + // Example for OpenLDAP: 'uid=%s' + + define('LDAP_USER_FILTER', ''); + + + + // LDAP attribute for username + + // Example for ActiveDirectory: 'samaccountname' + + // Example for OpenLDAP: 'uid' + + define('LDAP_USER_ATTRIBUTE_USERNAME', 'uid'); + + + + // LDAP attribute for user full name + + // Example for ActiveDirectory: 'displayname' + + // Example for OpenLDAP: 'cn' + + define('LDAP_USER_ATTRIBUTE_FULLNAME', 'cn'); + + + + // LDAP attribute for user email + + define('LDAP_USER_ATTRIBUTE_EMAIL', 'mail'); + + + + // LDAP attribute to find groups in user profile + + define('LDAP_USER_ATTRIBUTE_GROUPS', 'memberof'); + + + + // LDAP attribute for user avatar image: thumbnailPhoto or jpegPhoto + + define('LDAP_USER_ATTRIBUTE_PHOTO', ''); + + + + // LDAP attribute for user language, example: 'preferredlanguage' + + // Put an empty string to disable language sync + + define('LDAP_USER_ATTRIBUTE_LANGUAGE', ''); + + + + // Allow automatic LDAP user creation + + define('LDAP_USER_CREATION', true); + + + + // LDAP DN for administrators + + // Example: CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local + + define('LDAP_GROUP_ADMIN_DN', ''); + + + + // LDAP DN for managers + + // Example: CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local + + define('LDAP_GROUP_MANAGER_DN', ''); + + + + // Enable LDAP group provider for project permissions + + // The end-user will be able to browse LDAP groups from the user interface and allow access to specified projects + + define('LDAP_GROUP_PROVIDER', false); + + + + // LDAP Base DN for groups + + define('LDAP_GROUP_BASE_DN', ''); + + + + // LDAP group filter + + // Example for ActiveDirectory: (&(objectClass=group)(sAMAccountName=%s*)) + + define('LDAP_GROUP_FILTER', ''); + + + + // LDAP user group filter + + // If this filter is configured, Kanboard will search user groups in LDAP_GROUP_BASE_DN + + // Example for OpenLDAP: (&(objectClass=posixGroup)(memberUid=%s)) + + define('LDAP_GROUP_USER_FILTER', ''); + + + + // LDAP attribute for the group name + + define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn'); + + + +Настройки аутентификации Reverse-Proxy[¶](#reverse-proxy-authentication-settings "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------ + + + + // Enable/disable the reverse proxy authentication + + define('REVERSE_PROXY_AUTH', false); + + + + // Header name to use for the username + + define('REVERSE_PROXY_USER_HEADER', 'REMOTE_USER'); + + + + // Username of the admin, by default blank + + define('REVERSE_PROXY_DEFAULT_ADMIN', ''); + + + + // Default domain to use for setting the email address + + define('REVERSE_PROXY_DEFAULT_DOMAIN', ''); + + + +Настройки аутентификации RememberMe[¶](#rememberme-authentication-settings "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------ + + + + // Enable/disable remember me authentication + + define('REMEMBER_ME_AUTH', true); + + + +Настройки Secure HTTP headers[¶](#secure-http-headers-settings "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------ + + + + // Enable or disable "Strict-Transport-Security" HTTP header + + define('ENABLE_HSTS', true); + + + + // Enable or disable "X-Frame-Options: DENY" HTTP header + + define('ENABLE_XFRAME', true); + + + +Запись событий[¶](#logging "Ссылка на этот заголовок") +------------------------------------------------------ + + + +По умолчанию, Канборд записывает не все события. Если вы хотите включить запись событий, вы должны установить обработчик логов. + + + + // Available log drivers: syslog, stderr, stdout or file + + define('LOG_DRIVER', ''); + + + + // Log filename if the log driver is "file" + + define('LOG_FILE', __DIR__.DIRECTORY_SEPARATOR.'data'.DIRECTORY_SEPARATOR.'debug.log'); + + + +Защита от Brute-force[¶](#brute-force-protection "Ссылка на этот заголовок") +---------------------------------------------------------------------------- + + + + // Enable captcha after 3 authentication failure + + define('BRUTEFORCE_CAPTCHA', 3); + + + + // Lock the account after 6 authentication failure + + define('BRUTEFORCE_LOCKDOWN', 6); + + + + // Lock account duration in minute + + define('BRUTEFORCE_LOCKDOWN_DURATION', 15); + + + +Сессии[¶](#session "Ссылка на этот заголовок") +---------------------------------------------- + + + + // Session duration in second (0 = until the browser is closed) + + // See http://php.net/manual/en/session.configuration.php#ini.session.cookie-lifetime + + define('SESSION_DURATION', 0); + + + +Проксирование клиентских HTTP[¶](#http-client-proxy "Ссылка на этот заголовок") +------------------------------------------------------------------------------- + + + +Если внешние запросы HTTP необходимо пробрасывать через прокси: + + + + define('HTTP_PROXY_HOSTNAME', ''); + + define('HTTP_PROXY_PORT', '3128'); + + define('HTTP_PROXY_USERNAME', ''); + + define('HTTP_PROXY_PASSWORD', ''); + + + +Другие настройки[¶](#various-settings "Ссылка на этот заголовок") +----------------------------------------------------------------- + + + + // Escape html inside markdown text + + define('MARKDOWN_ESCAPE_HTML', true); + + + + // API alternative authentication header, the default is HTTP Basic Authentication defined in RFC2617 + + define('API_AUTHENTICATION_HEADER', ''); + + + + // Hide login form, useful if all your users use Google/Github/ReverseProxy authentication + + define('HIDE_LOGIN_FORM', false); + + + + // Disabling logout (for external SSO authentication) + + define('DISABLE_LOGOUT', false); + + + + // Override API token stored in the database, useful for automated tests + + define('API_AUTHENTICATION_TOKEN', 'My unique API Token'); + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/contributing.markdown b/doc/ru_RU/contributing.markdown new file mode 100644 index 00000000..54917067 --- /dev/null +++ b/doc/ru_RU/contributing.markdown @@ -0,0 +1,96 @@ +Руководство для участников проекта +================================== + + + +Как я могу помочь проекту?[¶](#how-can-i-help "Ссылка на этот заголовок") +------------------------------------------------------------------------- + + + +Канборд пока не идеален, поэтому есть несколько вариантов помочь проекту: + + + +- Присылать отзывы +- Сообщать об ошибках +- Добавлять или обновлять переводы +- Улучшать документацию +- Писать код +- Рассказать друзьям, что Канборд отличная программа :) + + + +Перед тем как начать большое дело, создайте новое “обсуждение вопроса” (issue) на [https://github.com/fguillot/kanboard/issues](https://github.com/fguillot/kanboard/issues) и объясните ваше предложение. + + + +Я хочу внести предложения по проекту[¶](#i-want-to-give-feedback "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------- + + + +- У вас есть идея по улучшению (пользовательский интерфейс или другие возможности) +- Посмотрите в обсуждениях (issue), может ваша идея уже предложена кем-то +- Откройте новое обсуждение (issue) +- Опишите вашу идею +- Вы можете проголосовать +1 за имеющиеся предложения + + +Я хочу сообщить об ошибке[¶](#i-want-to-report-a-bug "Ссылка на этот заголовок") +-------------------------------------------------------------------------------- + +- Убедитесь, что обсуждение вопроса (issue) ранее не публиковалось +- Откройте новую заявку (ticket) +- Опишите, что именно не работает +- Опишите, как воспроизвести ошибку (последовательность, как вы вышли на данную ошибку) +- Опишите ваше окружение (версию Канборда, какая ОС, веб сервер, версию PHP, база данных и версия, хостинг провайдер) + + +Я хочу перевести Канборд на другой язык[¶](#i-want-to-translate-kanboard "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------- + +Канборд уже переведен на несколько языков. Вы можете улучшить эти переводы. Некоторые переводы еще не завершены. Для того, чтобы сделать перевод, ознакомтесь с [руководством по переводу на другой язык](translations.markdown). + + +Я хочу улучшить документацию[¶](#i-want-to-improve-the-documentation "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------ + +- Вы считаете, что что-то недостаточно хорошо описано, имеются грамматические или орфографические ошибки, что-то еще. +- Документация написана в формате Markdown и хранится в каталоге `docs`{.docutils .literal}. +- Редактируйте файлы и присылайте pull-request. +- Документация на официальном вебсайте синхронизируется с репозиторием. + + +Я хочу внести свой вклад в код[¶](#i-want-to-contribute-to-the-code "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------- + +Pull-requests всегда приветствуются, однако, чтобы они были приняты, вы должны следовать следующим указаниям: + +- **Перед тем как внести большое изменение или переделать дизайн, откройте новую заявку (ticket) для обсуждения.** +- Если вы хотите добавить новую возможность, уважайте филосовию Канборда: **Мы фокусируемся на простоте**, мы не хотим иметь раздутую программу. +- Это же относится и к пользовательскому интерфейсу: **простота и производительность** +- Присылайте только по одному pull-request для новой возможности или исправления ошибки. +- Небольшие pull-request легче просмотреть и быстрее влить в проект. +- Убедитесь, что [модульные тесты выполняются успешно](tests.markdown). +- Уважайте [стандарты кодирования](coding-standards.markdown). +- Пишите код, который могут поддерживать другие, избегайте дублирования, используйте лучше практики PHP. + +В любом случае, если вы не уверены в чем-то - открывайте новую заявку (ticket) + + +Рассказать друзьям, что Канборд отличная программа :)[¶](#tell-your-friends-that-kanboard-is-awesome "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------------------------------- + +Если вы используете Канборд, покажите его и окружающим. Расскажите всем о прелестях бесплатного и опенсурсного программного обеспечения. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/create-tasks-by-email.markdown b/doc/ru_RU/create-tasks-by-email.markdown new file mode 100644 index 00000000..baddc682 --- /dev/null +++ b/doc/ru_RU/create-tasks-by-email.markdown @@ -0,0 +1,61 @@ +Создание задач через email +========================== + + +Вы можете создавать задачи отправляя email (сообщения через электронную почту). Эта возможность доступна при использовании плагинов. + +В настоящий момент, Канборд поддерживает три внешних плагина: + + +- [Mailgun](https://github.com/kanboard/plugin-mailgun) +- [Sendgrid](https://github.com/kanboard/plugin-sendgrid) +- [Postmark](https://github.com/kanboard/plugin-postmark) + +Эти плагины позволяют обрабатывать входящие электронные сообщения (email) без дополнительной настройки SMTP сервера. + +При получении плагином email сообщения, плагин передает это сообщение в веб транслятор Канборда. + + +Обработка входящих email сообщений[¶](#incoming-emails-workflow "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------- + + +1. Вы отправляете email сообщение на определенный адрес, например **something+myproject@inbound.mydomain.tld** +2. Email сообщение перенаправляется на SMTP сервер +3. SMTP провайдер передает в веб сервис Канборда email сообщение в JSON формате или в формате multipart/form-data +4. Канборд обрабатывает полученное email сообщение и создает задачу в указанном проекте + +**Заметка**: Новые задачи автоматически создаются в первой колонке. + + +Формат email сообщения[¶](#email-format "Ссылка на этот заголовок") +------------------------------------------------------------------- + +- Email адрес до знака **@** должен содержать разделитель **плюс**, например **kanboard+project123** +- Строка следующая после знака плюс означает **Идентификатор проекта**, например, проект **Проект 123** может иметь идентификатор проекта **project123**. Идентификатор проекта можно задать в свойствах проекта **Меню** -\> **Настройки** -\> **Изменить проект** -\> **Идентификатор**. **Идентификатор** должен быть из цифр и латинских букв. +- Тема из email сообщения становится названием задачи +- Текст email сообщения становится описанием задачи (в формате Markdown) + +Email сообщения могут быть написаны в текстовом или HTML формате. **Канборд сам переконвертирует формат сообщения в Markdown** + + +Безопастность и требования[¶](#security-and-requirements "Ссылка на этот заголовок") +------------------------------------------------------------------------------------ + +- Веб транслятор Канборд защищен случайным ключом +- Email адрес отправителя должен быть такой же как и у пользователя Канборд +- Проект в Канборде должен иметь уникальный идентификатор +- Отправитель email сообщения должен быть участником проекта + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/creating-projects.markdown b/doc/ru_RU/creating-projects.markdown new file mode 100644 index 00000000..b878a538 --- /dev/null +++ b/doc/ru_RU/creating-projects.markdown @@ -0,0 +1,62 @@ +Создание проектов +================= + + +Kanboard может содержать одновременно несколько проектов. Проекты могут быть следующих типов: + +- Командный проект +- Приватный проект для одного пользователя + +Создание проекта для нескольких пользователей[¶](#creating-projects-for-multiple-users "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------ + +- Только пользователи с ролью администратор и менеджер могут создавать такие проекты +- Можно добавлять к проекту пользователей и группы + +На рабочей панели нажмите ссылку **Новый проект**: + +![Project creation form](screenshots/new-project.png) + +Рисунок. Форма создания проекта. + + +Теперь надо только добавить название для проекта! Легко, не правда ли? + + +Создание приватного проекта[¶](#creating-a-private-project "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------- + +- Любой пользователь Kanboard может создать приватный проект +- **Нет** возможности добавлять участников к приватному проекту +- Только владелец приватного проекта и администратор могут получить доступ к проекту + + +На рабочей панели нажмите **Новый проект с ограниченным доступом**. + + + +Создание проекта из другого проекта[¶](#creating-projects-from-another-project "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------------- + +При создании нового проекта у вас есть возможность использовать данные другого (ранее созданного) проекта: + +- Разрешения +- Действия +- Дорожки +- Категории +- Задачи + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/creating-tasks.markdown b/doc/ru_RU/creating-tasks.markdown new file mode 100644 index 00000000..ec2922a8 --- /dev/null +++ b/doc/ru_RU/creating-tasks.markdown @@ -0,0 +1,42 @@ +Создание задач +============== + + +На Доске нажмите значок плюс рядом с названием колонки: + + +![Task creation from the board](https://kanboard.net/screenshots/documentation/task-creation-board.png) + +Рисунок. Создание задачи на Доске + + +Далее появится форма создания задачи: + +![Task creation form](https://kanboard.net/screenshots/documentation/task-creation-form.png) + +Рисунок. Форма создания задачи. + + +Только поле **Название** является обязательным полем для заполнения. + + +Описание полей: + +- **Название**: Название вашей задачи, которое будет отображаться на доске. +- **Описание**: Позволяет вам добавить больше информации о задаче, содержимое может содержать синтаксис [Markdown](syntax-guide.markdown). +- **Создать другую задачу**: Отметьте этот чекбокс если вы хотите создать похожую задачу (некоторые поля будут заполнены). +- **Назначена**: Пользователь, которому будет назначена для выполнения эта задача. +- **Категория**: Только одна категория может быть назначена задаче. +- **Колонка**: Колонка в которой задача будет создана, ваша задача будет помещена вниз. +- **Цвет**: Выберите цвет для карточки. +- **Сложность**: используется в быстрых управлениях проектами (Scrum); сложность - это число, которое говорит команде проекта насколько тяжело выполнить задачу. Обычно пользователи используют шкалу Фибоначи. +- **Запланировано часов**: Планирование времени, которое будет затрачено на выполнение задачи. Измеряется в часах. +- **Сделать до**: Просроченные задачи будут иметь дату завершения красного цвета, а предстоящие задачи будут иметь дату завершения черного цвета. + +**-** + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/cronjob.markdown b/doc/ru_RU/cronjob.markdown new file mode 100644 index 00000000..c3bb5f6d --- /dev/null +++ b/doc/ru_RU/cronjob.markdown @@ -0,0 +1,41 @@ +Ежедневные фоновые задачи +========================= + + +Для корректной работы, Канборд должен запускать ежедневные фоновые задачи. На Unix платформах этот процесс выполнятся в `cron`. + +Фоновые задачи необходимы для следующих возможностей: + +- Отчеты и аналитика (подсчет ежедневной статистики для каждого проекта) +- Рассылка оповещений для просроченных задач +- Выполнение автоматических действий подключенных к событиям “Ежедневные фоновые процессы для задач” + + +Настройка на Unix и Linux платформах[¶](#configuration-on-unix-and-linux-platforms "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------------- + +Для создания фоновых задач под операционной системой Unix/Linux используются разные решения. Здесь приведен пример для Ubuntu 14.04. Для других систем процедура похожа. + + +Отредактируйте crontab под пользователем вашего веб сервера: + + + sudo crontab -u www-data -e + + +Пример запуска ежедневной фоновой задачи в 8 утра: + + + 0 8 * * * cd /path/to/kanboard && ./kanboard cronjob >/dev/null 2>&1 + + +Примечание: процес выполнения фоновых задач должен иметь права доступа к вашей базе данных в случае если вы используете Sqlite. Обычно, достаточно запускать фоновую задачу под пользователем веб сервера. + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/currency-rate.markdown b/doc/ru_RU/currency-rate.markdown new file mode 100644 index 00000000..6d7dbc3e --- /dev/null +++ b/doc/ru_RU/currency-rate.markdown @@ -0,0 +1,43 @@ +Курсы валют +=========== + + +Каждый пользователь может иметь предопределенный ежечасный курс для разных валют. Если вы хотите вручную занести курсы валют, то вы можете указать ставку в соответсвии с курсом. + +Эта опция используются для расчета бюджета проекта. + +![Currency Rate](https://kanboard.net/screenshots/documentation/currency-rate.png) + +Рисунок. Курсы валют + + +Для настроек курса валют выберите, справа вверху в выпадающем меню, **Настройки** -\> затем, слева, **Курсы валют**. + + + + + + + + + + + + + + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/custom-filters.markdown b/doc/ru_RU/custom-filters.markdown new file mode 100644 index 00000000..60630860 --- /dev/null +++ b/doc/ru_RU/custom-filters.markdown @@ -0,0 +1,36 @@ +Пользовательские фильтры +======================== + +Пользовательские фильтры позволяют вам сохранять любые поисковые запросы. Таким образом, вы можете легко расширить стандартные фильтры и сохранить часто используемые поисковые запросы. + +- Пользовательские фильтры сохраняются в проекте и имеют привязку к создателю. +- Если создатель фильтра является менеджером проекта, то он может предоставить этот фильтр всем участникам проекта. + + +Создание фильтра[¶](#filter-creation "Ссылка на этот заголовок") +---------------------------------------------------------------- + + +Перейдите в **Меню** -\> **Пользовательские фильтры** или **Меню** -\> **Настройки** -\> **Пользовательские фильтры** + +![Custom Filter Creation](https://kanboard.net/screenshots/documentation/custom-filter-creation.png) + +Рисунок. Создание пользовательского фильтра. + + + +Созданый фильтр появится на Доске рядом со стандартными фильтрами + +![Custom Filter Dropdown](https://kanboard.net/screenshots/documentation/custom-filter-dropdown.png) + +Рисунок. Выпадающий список - Пользовательский фильтр. + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/debian-installation.markdown b/doc/ru_RU/debian-installation.markdown new file mode 100644 index 00000000..2c33465e --- /dev/null +++ b/doc/ru_RU/debian-installation.markdown @@ -0,0 +1,104 @@ +Как установить Канборд на Debian? +================================= + +Некоторые возможности Канборда требуют [запуск ежедневных фоновых задач](cronjob.markdown). + + +Debian 8 (Jessie)[¶](#debian-8-jessie "Ссылка на этот заголовок") +----------------------------------------------------------------- + + +Установите Apache и PHP: + + + apt-get update + + apt-get install -y php5 php5-sqlite php5-gd unzip + + service apache2 restart + + + +Установите Канборд: + + + cd /var/www/html + + wget https://kanboard.net/kanboard-latest.zip + + unzip kanboard-latest.zip + + chown -R www-data:www-data kanboard/data + + rm kanboard-latest.zip + + + +Debian 7 (Wheezy)[¶](#debian-7-wheezy "Ссылка на этот заголовок") +----------------------------------------------------------------- + + + +Установите Apache и PHP: + + + + apt-get update + + apt-get install -y php5 php5-sqlite php5-gd unzip + + + +Установите Канборд: + + + + cd /var/www + + wget https://kanboard.net/kanboard-latest.zip + + unzip kanboard-latest.zip + + chown -R www-data:www-data kanboard/data + + rm kanboard-latest.zip + + + +Debian 6 (Squeeze)[¶](#debian-6-squeeze "Ссылка на этот заголовок") +------------------------------------------------------------------- + + + +Установите Apache и PHP: + + + + apt-get update + + apt-get install -y libapache2-mod-php5 php5-sqlite php5-gd unzip + + + +Установите Канборд: + + + + cd /var/www + + wget https://kanboard.net/kanboard-latest.zip + + unzip kanboard-latest.zip + + chown -R www-data:www-data kanboard/data + + rm kanboard-latest.zip + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/docker.markdown b/doc/ru_RU/docker.markdown new file mode 100644 index 00000000..358ade73 --- /dev/null +++ b/doc/ru_RU/docker.markdown @@ -0,0 +1,134 @@ +Как запустить Канборд с Docker? +=============================== + + +Канборд можно легко запустить с [Docker](https://www.docker.com). + + +Размер образа, приблизительно, **50MB** содержит: + +- [Alpine Linux](http://alpinelinux.org/) +- The [process manager S6](http://skarnet.org/software/s6/) +- Nginx +- PHP-FPM + + +Канборд запускает фоновые задачи каждый день в полночь. Переписывание URL (URL rewriting) включено в базовой конфигурации. + +Когда контейнер запущен, использование памяти около **20MB**. + + +Использование стабильной версии[¶](#use-the-stable-version "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------- + + +Для получения последней стабильной версии Канборда используйте тег **stable**: + + + + docker pull kanboard/kanboard + + docker run -d --name kanboard -p 80:80 -t kanboard/kanboard:stable + + + +Использование разрабатываемой версии (автоматической сборки)[¶](#use-the-development-version-automated-build "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------------------------------------------- + + + +Каждый новый коммит в репозитории вызывает новую сборку в [Docker Hub](https://registry.hub.docker.com/u/kanboard/kanboard/). + + + + docker pull kanboard/kanboard + + docker run -d --name kanboard -p 80:80 -t kanboard/kanboard:latest + + + +Используя **разрабатываемую версию** Канборда с тегом **latest**, вы принимаете на себя все риски нестабильной версии. + + + +Создание своего образа Docker[¶](#build-your-own-docker-image "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------- + +Для сборки своего образа, в репозитории Канборда имеется `Dockerfile`{.docutils .literal}. Склонируйте репозиторий Канборда и выполните следующую команду: + + + + docker build -t youruser/kanboard:master . + + + +или + + + + make docker-image + + + +Для запуска вашего контейнера в фоновом режиме на порту 80: + + + + docker run -d --name kanboard -p 80:80 -t youruser/kanboard:master + + + +Тома[¶](#volumes "Ссылка на этот заголовок") +-------------------------------------------- + + +Вы можете прикрепить 2 тома к вашему контейнеру: + +- Каталог с данными: `/var/www/kanboard/data` +- Каталог с плагинами: `/var/www/kanboard/plugins` + + + +Используйте опцию `-v` для монтирования тома на удаленной машине как описано в [официальной документации Docker](https://docs.docker.com/engine/userguide/containers/dockervolumes/). + + + +Обновление вашего контейнера[¶](#upgrade-your-container "Ссылка на этот заголовок") +----------------------------------------------------------------------------------- + +- Загрузите новый образ +- Удалите старый контейнер +- Перезапустите новый контейнер с теми же томами + + +Переменные окружения[¶](#environment-variables "Ссылка на этот заголовок") +-------------------------------------------------------------------------- + + +Список переменных окружения доступен на [этой странице](env.markdown). + + + +Файлы конфигурации[¶](#config-files "Ссылка на этот заголовок") +--------------------------------------------------------------- + +- Контейнер уже содержит конфигурационный файл расположенный в `/var/www/kanboard/config.php`. +- Вы можете сохранить свой конфиг файл в томе с данными: `/var/www/kanboard/data/config.php`. + + + +Ссылки[¶](#references "Ссылка на этот заголовок") +------------------------------------------------- + +- [Официальные образы Канборд](https://registry.hub.docker.com/u/kanboard/kanboard/) +- [Документация Docker](https://docs.docker.com/) +- [Стабильная версия Dockerfile](https://github.com/kanboard/docker) +- [Разрабатываемая версия Dockerfile](https://github.com/fguillot/kanboard/blob/master/Dockerfile) + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/duplicate-move-tasks.markdown b/doc/ru_RU/duplicate-move-tasks.markdown new file mode 100644 index 00000000..48cec06c --- /dev/null +++ b/doc/ru_RU/duplicate-move-tasks.markdown @@ -0,0 +1,79 @@ +Дублирование и перенос задач +============================ + + +Создание копии задачи в том же проекте[¶](#duplicate-a-task-into-the-same-project "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------- + + +Перейдите в детальное представление задачи и выберите в боковой панели (слева) **Клонировать**. + +![Task Duplication](https://kanboard.net/screenshots/documentation/task-duplication.png) + +Рисунок. Создание копии задачи. + + +Новая задача будет создана с теми же свойствами как и у оригинальной задачи. + + +Создание копии задачи в другой проект[¶](#duplicate-a-task-to-another-project "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------- + + +Перейдите в детальное представление задачи и выберите в боковом меню (слева) **Клонировать в другой проект**. + +![Task Duplication Another Project](https://kanboard.net/screenshots/documentation/task-duplication-another-project.png) + +Рисунок. Создание копии задачи в другой проект. + + +При выборе проекта в выпадающем списке, показываются только те проекты в которых вы являетесь участниками. + +Перед тем как скопировать задачу, Канборд просит вас указать свойства проекта (куда будет копироваться), потому что проекты могуг иметь разные столбцы, дорожки и т.д. + +Вам нужно указать: + +- Дорожку, в которую скопируется задача +- Колонку +- Категорию +- Испольнителя + +Перемещение задачи в другой проект[¶](#move-a-task-to-another-project "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------- + +Перейдите в детальное представление задачи и выберите в боковом меню **Переместить в другой проект** + +Процедура перемещения задачи в другой проект такая же как и при копировании, вы должны указать новые свойства для задачи. + + +Список копируемых полей[¶](#list-of-fields-duplicated "Ссылка на этот заголовок") +--------------------------------------------------------------------------------- + + +Ниже приведен список полей (свойств), которые будут скопированы: + +- заголовок +- описание +- дата\_исполнение +- цвет\_id +- проект\_id +- колонка\_id +- владелец\_id +- оценка +- категория\_id +- время\_запланировано +- дорожка\_id +- повторение\_статус +- повторение\_триггер +- повторение\_фактор +- повторение\_timeframe +- повторение\_basedate + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/editing-projects.markdown b/doc/ru_RU/editing-projects.markdown new file mode 100644 index 00000000..5ff81f90 --- /dev/null +++ b/doc/ru_RU/editing-projects.markdown @@ -0,0 +1,25 @@ +Редактирование проектов +======================= + + +Проект может быть переименован и выключен в любое время + +Для переименования проекта нажмите на ссылку **“Изменить проект”** (для перехода выберите **Меню** -\> **Настройки**) + + +![Project edition](screenshots/project-edition.png) + +Рисунок. Изменение проекта. + +- Дата начала и дата завершения используются при генерации диаграммы Ганта +- Описание отображается как подсказка на Доске и на странице со списком проектов +- Администраторы и менеджеры проекта могут сделать приватный проект доступным для других пользователей установив галочку в чекбоксе **“Приватный проект”** +- Вы можете сделать публичный проект приватным. + +Внимание: Когда вы делаете приватный проект из публичного, все пользователи ранее присоединенные к проекту будут иметь доступ. Ограничьте список пользователей для вашего приватного проекта. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/email-configuration.markdown b/doc/ru_RU/email-configuration.markdown new file mode 100644 index 00000000..e04aca7b --- /dev/null +++ b/doc/ru_RU/email-configuration.markdown @@ -0,0 +1,156 @@ +Настройка email +=============== + + +Настройки пользователя[¶](#user-settings "Ссылка на этот заголовок") +-------------------------------------------------------------------- + +Для получение уведомлений на email, пользователи Канборда должны иметь: + +- Включенные уведомления, должны быть включены в профиле пользователя +- Должен быть прописан правильный email адрес в профиле пользователя +- Быть участником проекта, который отсылает уведомления + + +Примечание: Пользователь, выполнивший вход в Канборд и выполняющий действие, не будет получать уведомления. Уведомления будут получать только другие участники проекта. + + + +Email шлюзы[¶](#email-transports "Ссылка на этот заголовок") +------------------------------------------------------------ + +В Канборд доступны несколько шлюзов для email: + +- SMTP +- Sendmail +- Встроенная mail функция PHP +- Другие методы могут предоставить внешние плагины: Postmark, Sendgrid and Mailgun + + +Настройки сервера[¶](#server-settings "Ссылка на этот заголовок") +----------------------------------------------------------------- + +По умолчанию, Канборд использует встроенную в PHP функцию для передачи email сообщений. Обычно не требуется дополнительных настроек, если ваш сервер уже может отправлять email сообщения. + +Если вы захотите использовать другие методы: SMTP протокол и Sendmail, то ниже приведены инструкции по настройке. + +### Настройка SMTP[¶](#smtp-configuration "Ссылка на этот заголовок") + +Переименуйте файл `config.default.php`{.docutils .literal} в `config.php`{.docutils .literal} и измените следующие значения: + + + // We choose "smtp" as mail transport + + define('MAIL_TRANSPORT', 'smtp'); + + + + // We define our server settings + + define('MAIL_SMTP_HOSTNAME', 'mail.example.com'); + + define('MAIL_SMTP_PORT', 25); + + + + // Credentials for authentication on the SMTP server (not mandatory) + + define('MAIL_SMTP_USERNAME', 'username'); + + define('MAIL_SMTP_PASSWORD', 'super password'); + + + +Возможно понадобится использовать шифрованное подключение TLS или SSL: + + + define('MAIL_SMTP_ENCRYPTION', 'ssl'); // Valid values are "null", "ssl" or "tls" + + +### Настройка Sendmail[¶](#sendmail-configuration "Ссылка на этот заголовок") + +По умолчанию команда отправки сообщений выглядит так `/usr/sbin/sendmail -bs`{.docutils .literal}, но вы можете изменить ее в файле конфигурации. + +Например: + + + + // We choose "sendmail" as mail transport + + define('MAIL_TRANSPORT', 'sendmail'); + + + + // If you need to change the sendmail command, replace the value + + define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'); + + + +### Встроенная mail функция PHP[¶](#php-native-mail-function "Ссылка на этот заголовок") + +Это конфигурация по умолчанию: + + + + define('MAIL_TRANSPORT', 'mail'); + + + +### Email адрес отправителя[¶](#the-sender-email-address "Ссылка на этот заголовок") + +По умолчанию, сообщения отправляются с адресом отправителя `notifications@kanboard.local`{.docutils .literal}. На этот адрес нельзя ответить. + +Вы можете настроить этот адрес изменив значение константы `MAIL_FROM`{.docutils .literal} в вашем конфигурационном файле. + + + define('MAIL_FROM', 'kanboard@mydomain.tld'); + + +Это может быть полезным, если ваш SMTP сервер не принимает неправильные адреса. + + +### Как отобразить ссылку на задачу в уведомлении?[¶](#how-to-display-a-link-to-the-task-in-notifications "Ссылка на этот заголовок") + +Чтобы сделать это, вы должны указать URL вашего установленного Канборда в [Настройках приложения](application-configuration.markdown). + +Например: + + + +- [http://demo.kanboard.ru/](http://demo.kanboard.ru/) + + + +- /имясервера/kanboard/ + + + +- [http://kanboard.mydomain.com/](http://kanboard.mydomain.com/) + + + +Не забудьте добавить в конце слеш `/`{.docutils .literal}. + + + +Вы должны сделать это вручную, потому что Канборд не может угадать URL из скрипта командной строки и некоторые конфигурации веб серверов очень специфичны. + + +Решение проблем[¶](#troubleshooting "Ссылка на этот заголовок") +--------------------------------------------------------------- + +Если email сообщения не отправляются и вы уверены, что все настроили правильно: + +- Проверьте папку Спам +- Включите режим отладки и посмотрите отладочный файл `data/debug.log`{.docutils .literal}, вы можете увидеть конкретную ошибку +- Убедитесь, что ваш сервер или ваш хостинг провайдер позволяет вам отсылать email сообщения +- Если вы используете SeLinux, разрешите PHP отсылать email сообщения. + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/env.markdown b/doc/ru_RU/env.markdown new file mode 100644 index 00000000..3764e98e --- /dev/null +++ b/doc/ru_RU/env.markdown @@ -0,0 +1,21 @@ +Переменные окружения +==================== + +Переменные окружения могут пригодится когда Канборд развертывается как контейнер (Docker). + + +| Переменная | Описание | +|---------|------------------------------------------------------------------| +| DATABASE\_URL | `[database type]://[username]:[password]@[host]:[port]/[database name]`, например: `postgres://foo:foo@myserver:5432/kanboard` | +| DEBUG | Включение/выключение режима отладки: “true” или “false” | +| LOG\_DRIVER | Logging driver: stdout, stderr, file or syslog | + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ext-search.markdown b/doc/ru_RU/ext-search.markdown new file mode 100644 index 00000000..1d6e7fe1 --- /dev/null +++ b/doc/ru_RU/ext-search.markdown @@ -0,0 +1,235 @@ +Синтаксис расширенного поиска +============================= + + +В Канборде используется простой язык запросов для расширенного поиска. Вы можете искать задачи, комментарии, подзадачи, ссылки, но только активные. + + +Пример запроса[¶](#example-of-query "Ссылка на этот заголовок") +--------------------------------------------------------------- + + + +В этом примере показываются как отобразить задачи назначенные мне с датой окончания завтра и название содержит “my title”: + + + + assigne:me due:tomorrow my title + + + +Глобальный поиск[¶](#global-search "Ссылка на этот заголовок") +-------------------------------------------------------------- + + + +### Поиск по id задачи или названию задачи[¶](#search-by-task-id-or-title "Ссылка на этот заголовок") + +- Поиск задачи по id: `#123` +- Поиск по id задачи и названию задачи: `123` +- Поиск по названию задачи: `любые слова и цифры`, но не должны содержать атрибуты поиска + + +### Поиск по статусу[¶](#search-by-status "Ссылка на этот заголовок") + +Атрибут: **status** + +- Запрос на поиск открытых задач: `status:open` +- Запрос на поиск закрытых задач: `status:closed` + + + +### Поиск по испольнителю[¶](#search-by-assignee "Ссылка на этот заголовок") + + +Атрибут: **assignee** + +- Поиск по полному имени испольнителя: `assignee:"Петр Иванов"` +- Поиск исполнителя по имени пользователя: `assignee:pivanov` +- Отбор нескольких испольнителей: `assignee:tsemenov assignee:"Петр Иванов"` +- Поиск задач без исполнителя: `assignee:nobody` +- Поиск задач назначенных мне: `assignee:me` + + +### Поиск по создателю задач[¶](#search-by-task-creator "Ссылка на этот заголовок") + + +Атрибут: **creator** + +- Отбор задач созданных мной: `creator:me` +- Отбор задач которые создал Петр Иванов: `creator:"Петр Иванов"` +- Отбор задач созданных пользователем с id \#1: `creator:1` + + +### Поиск по исполнителю подзадач[¶](#search-by-subtask-assignee "Ссылка на этот заголовок") + +Атрибут: **subtask:assignee** + +- Например: `subtask:assignee:"Петр Иванов"` + + +### Поиск по цвету[¶](#search-by-color "Ссылка на этот заголовок") + +Атрибут: **color** + +- Отбор по цвету с id blue: `color:blue` +- Отбор по названию цвета: `color:"Deep Orange"` + + +### Отбор по “Сделать до”[¶](#search-by-the-due-date "Ссылка на этот заголовок") + + +Атрибут: **due** + +- Поиск задач со сроком испольнения до сегодня: `due:today` +- Поиск задач со сроком исполнения завтра: `due:tomorrow` +- Поиск задач со сроком исполнения вчера: `due:yesterday` +- Поиск задач с конкретной датой исполнения: `due:2016-06-29` + +Дата должна быть в формате ISO 8601: **YYYY-MM-DD**. + +Все строковые форматы поддерживаемые функцией `strtotime()` допустимы. Например, `next Thursday`, `-2 days`{.docutils .literal}, `+2 months`, `tomorrow` и т.д. + + +Операторы сравнения с датой: + +- Старше чем: **due:\>2015-06-29** +- Моложе чем: **due:\<2015-06-29** +- Старше чем или равно: **due:\>=2015-06-29** +- Моложе чем или равно: **due:\<=2015-06-29** + + +### Поиск по дате изменения[¶](#search-by-modification-date "Ссылка на этот заголовок") + +Атрибут: **modified** или **updated** + +Формат даты такой же как и у “Сделать до” + +Отфильтровать недавно измененные задачи: `modified:recently`. + +Этот запрос использует тоже значение что и в настройках Доски - “Время подсвечивания задачи”. + + +### Поиск по дате создания[¶](#search-by-creation-date "Ссылка на этот заголовок") + +Атрибут: **created** + +Работает также как и поиск по дате изменения. + + +### Поиск по описанию[¶](#search-by-description "Ссылка на этот заголовок") + +Атрибут: **description** or **desc** + +Например: `description:"здесь пишем тескт для поиска"` + + +### Поиск по внешним ссылкам[¶](#search-by-external-reference "Ссылка на этот заголовок") + +Например: нужно найти задачу, которая содержит ссылку на id или название другой задачи. + +- `ref:1234` или `reference:TICKET-1234` + + +### Поиск по категории[¶](#search-by-category "Ссылка на этот заголовок") + +Атрибут: **category** + +- Найти задачи с указанной категорией: `category:"Важные запросы"` +- Найти задачи, которые содержать указанные категории: `category:"Ошибки" category:"Изменения"` +- Найти задачи без категорий: `category:none` + + +### Поиск проектов[¶](#search-by-project "Ссылка на этот заголовок") + +Атрибут: **project** + +- Поиск задач по имени проекта: `project:"Какой-то проект"` +- Поиск задач по id проекта: `project:23` +- Поиск задач в нескольких проектах: `project:"Проект A" project:"Проект B"` + + +### Поиск в колонках[¶](#search-by-columns "Ссылка на этот заголовок") + +Атрибут: **column** + +- Поиск задач в указанной колонке: `column:"В работе"` +- Поиск задач в нескольких колонках: `column:"Невыполненные заказы" column:ready` + + +### Поиск в Дорожках[¶](#search-by-swim-lane "Ссылка на этот заголовок") + +Атрибут: **swimlane** + +- Поиск задач в указанной Дорожке: `swimlane:"Версия 42"` +- Поиск задач в базовой Дорожке: `swimlane:default` +- Поиск задач в нескольких Дорожках: `swimlane:"Версия 1.2" swimlane:"Версия 1.3"` + + +### Поиск ссылки на задачу[¶](#search-by-task-link "Ссылка на этот заголовок") + +Атрибут: **link** + +- Поиск задач содержащих ссылку: `link:"это веха задачи "` +- Поиск задач по нескольким ссылкам: `link:"веха задачи " link:"относится к"` + + +### Поиск по комментарию[¶](#search-by-comment "Ссылка на этот заголовок") + +Атрибут: **comment** + +- Найти комментарии, которые содержат указанное название: `comment:"Какое-то название"` + + +Поиск активности задач[¶](#activity-stream-search "Ссылка на этот заголовок") +----------------------------------------------------------------------------- + + + +### Поиск событий по названию задачи[¶](#search-events-by-task-title "Ссылка на этот заголовок") + + + +Атрибут: **title** или без ничего (по умолчанию) + +- Например: `title:"My task"` +- Поиск задачи по id: `#123` + + +### Поиск событий по статусу задачи[¶](#search-events-by-task-status "Ссылка на этот заголовок") + +Атрибут: **status** + + + +### Поиск событий по создателю[¶](#search-by-event-creator "Ссылка на этот заголовок") + +Атрибут: **creator** + + + +### Поиск событий по дате создания[¶](#search-by-event-creation-date "Ссылка на этот заголовок") + +Атрибут: **created** + + + +### Поиск событий по проекту[¶](#search-events-by-project "Ссылка на этот заголовок") + +Атрибут: **project** + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/faq.markdown b/doc/ru_RU/faq.markdown new file mode 100644 index 00000000..0730f2c8 --- /dev/null +++ b/doc/ru_RU/faq.markdown @@ -0,0 +1,162 @@ +Часто задаваемые вопросы +======================== + + +Вы можете порекомендовать веб хостинг провайдера для Канборд?[¶](#can-you-recommend-a-web-hosting-provider-for-kanboard "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------------------------- + +Работу Канборд поддерживают несколько крупных провайдеров VPS, такие как [Digital Ocean](https://www.digitalocean.com/?refcode=4b541f47aae4), [Linode](https://www.linode.com/?r=4e381ac8a61116f40c60dc7438acc719610d8b11) или [Gandi](https://www.gandi.net/). + +Для получения большей производительности, выбирайте провайдера с быстрыми дисками чтения/записи, потому что Канборд использует по умолчанию Sqlite. Избегайте провайдеров которые используют подключения NFS. + + +У меня выводится пустая страница после установки или обновления Канборд[¶](#i-get-a-blank-page-after-installing-or-upgrading-kanboard "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------------------------------------------------------------------- + +- Проверьте, установили ли вы всё на сервер, что было указано в требованиях +- Посмотрите ошибки в PHP и Apache логах +- Проверьте права доступа к файлам +- Если вы используете кеширование OPcode, перезапустите ваш веб сервер или php-fpm + + +У меня выводится ошибка “There is no suitable CSPRNG installed on your system”[¶](#i-have-the-error-there-is-no-suitable-csprng-installed-on-your-system "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ + +Если вы используете PHP \< 7.0, то вам нужно включить расширение openssl или доступ из приложения к `/dev/urandom`, если имеются ограничения от `open_basedir`. + + +Страница не найдена и URL выглядит криво (&)[¶](#page-not-found-and-the-url-seems-wrong-amp "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------- + +- UTL выглядит как `/?controller=auth&action=login&redirect_query=` вместо `?controller=auth&action=login&redirect_query=` +- Канборд выдает ошибку “Страница не найдена” + + +Эта ошибка исходит из настроек конфигурации вашего PHP, значение `arg_separator.output` отсутствует в базовой настройке. Есть разные пути решения этой проблемы: + +Измените значение прямо в вашем `php.ini`: + + + arg_separator.output = "&" + + +Переделайте значение с помощью `.htaccess`: + + + php_value arg_separator.output "&" + + +Иначе Канборд будет брать значение напрямую из PHP. + + + +Ошибка аутентификации в API и Apache + PHP-FPM[¶](#authentication-failure-with-the-api-and-apache-php-fpm "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------------------------- + +По умолчанию, php-cgi под Apache не передает HTTP Basic user/pass в PHP. Чтобы это окружение заработало, добавьте эти строки в ваш файл `.htaccess`: + + + + RewriteCond %{HTTP:Authorization} ^(.+)$ + + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + + +Проблемы с eAccelerator[¶](#known-issues-with-eaccelerator "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------- + +Канборд не очень хорошо работает с [eAccelerator](http://eaccelerator.net). Проблема в том, что выдается чистая страница или падает Apache: + + + [Wed Mar 05 21:36:56 2014] [notice] child pid 22630 exit signal Segmentation fault (11) + + +Лучшее решение, чтобы избежать этой проблемы, выключить eAccelerator или прописать в конфиге какие файлы вы хотите кешировать (параметр `eaccelerator.filter`). + + + +Проект [eAccelerator выглядит мертвым и не обновляется с 2012](https://github.com/eaccelerator/eaccelerator/commits/master). Мы рекомендуем перейти на последнюю версию PHP, потому что в него включен [OPcache](http://php.net/manual/en/intro.opcache.php). + + +Почему минимальная рекомендуемая версия PHP 5.3.3?[¶](#why-the-minimum-requirement-is-php-5-3-3 "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------- + +Канборд использует функцию `password_hash()` для шифрования пароля, а эта функция доступна только для PHP \>= 5.5. + +Однако, имеется back-port для [более ранних версий PHP](https://github.com/ircmaxell/password_compat#requirements). Эта библиотека требует минимум PHP 5.3.7 для корректной работы. + +По всей видимости, патчи безопасности back-port имеются в Centos и Debian, поэтому PHP 5.3.3 подходит для работы Канборд. + +Канборд v1.0.10 и v1.0.11 требует минимум PHP 5.3.7, но эти изменения возвращены на PHP 5.3.3 в Канборде \>= v1.0.12 + + + +Как проверить работу Канборда со встроенным веб-сервером PHP?[¶](#how-to-test-kanboard-with-the-php-built-in-web-server "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------------------------- + +Если вы не хотите устанавливать веб сервер типа Apache, то вы можете протестировать работу Канборда на [встроенном в PHP веб сервере](http://www.php.net/manual/en/features.commandline.webserver.php): + + + unzip kanboard-VERSION.zip + + cd kanboard + + php -S localhost:8000 + + open http://localhost:8000/ + + + +Как установить Канборд на Yunohost?[¶](#how-to-install-kanboard-on-yunohost "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------- + +[YunoHost](https://yunohost.org/) это серверная операционная система, цель которой предоставить хостинг для всех. + +Отсюда можно [загрузить инсталяционный пакет Kanboard для Yunohost](https://github.com/mbugeia/kanboard_ynh). + + +Где я могу найти список связанных с Канборд проектов?[¶](#where-can-i-find-a-list-of-related-projects "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------- + +- [Kanboard API python client by @freekoder]([https://github.com/freekoder/kanboard-py](https://github.com/freekoder/kanboard-py)) + +- [Kanboard Presenter by David Eberlein](https://github.com/davideberlein/kanboard-presenter) + +- [CSV2Kanboard by @ashbike]([https://github.com/ashbike/csv2kanboard](https://github.com/ashbike/csv2kanboard)) + +- [Kanboard for Yunohost by @mbugeia]([https://github.com/mbugeia/kanboard\_ynh](https://github.com/mbugeia/kanboard_ynh)) + +- [Trello import script by @matueranet]([https://github.com/matueranet/kanboard-import-trello](https://github.com/matueranet/kanboard-import-trello)) + +- [Chrome extension by Timo](https://chrome.google.com/webstore/detail/kanboard-quickmenu/akjbeplnnihghabpgcfmfhfmifjljneh?utm_source=chrome-ntp-icon), [Source code](https://github.com/BlueTeck/kanboard_chrome_extension) + +- [Python client script by @dzudek]([https://gist.github.com/fguillot/84c70d4928eb1e0cb374](https://gist.github.com/fguillot/84c70d4928eb1e0cb374)) + +- [Shell script for SQLite to MySQL/MariaDB migration by @oliviermaridat]([https://github.com/oliviermaridat/kanboard-sqlite2mysql](https://github.com/oliviermaridat/kanboard-sqlite2mysql)) + +- [Git hooks for integration with Kanboard by Gene Pavlovsky](https://github.com/gene-pavlovsky/kanboard-git-hooks) + + + +Имеются ли руководства по Канборду на других языках?[¶](#are-there-some-tutorials-about-kanboard-in-other-languages "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------------------------------------------------- + +- [Серия статей про Kanboard на немецком языке](http://demaya.de/wp/2014/07/kanboard-eine-jira-alternative-im-detail-installation/) . +- [Русская документация по Канборд](http://kanboard.ru/doc/). + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/freebsd-installation.markdown b/doc/ru_RU/freebsd-installation.markdown new file mode 100644 index 00000000..b014e354 --- /dev/null +++ b/doc/ru_RU/freebsd-installation.markdown @@ -0,0 +1,187 @@ +Инсталяция на FreeBSD 10 +======================== + + +Инсталяция из пакетов[¶](#install-from-packages "Ссылка на этот заголовок") +--------------------------------------------------------------------------- + + $ pkg update + + $ pkg upgrade + + $ pkg install apache24 mod_php56 kanboard + + + +Включите Apache в `/etc/rc.conf`{.docutils .literal}: + + + + $ echo apache24_enable="YES" >> /etc/rc.conf + + + +Установите PHP для Apache: + + + + $ echo "AddType application/x-httpd-php .php" >> /usr/local/etc/apache24/Includes/php.conf + + $ echo "DirectoryIndex index.php index.html" >> /usr/local/etc/apache24/Includes/php.conf + + + +Затем, запустите Apache: + + + + $ service apache24 start + + + +Создайте символическую ссылку на каталог Kanboard в корне Apache: + + + + cd /usr/local/www/apache24/data + + ln -s /usr/local/www/kanboard + + + +Готово. Можете перейти в /вашвебсервер/kanboard и начинать работать! + + + +*Примечание*: Если вы хотите добавить дополнительные возможности, типа интеграции LDAP, то нужно установить соответствующий PHP модуль. Также, вам необходимо настроить соответсвующие права на каталог data. + + + +Установка из портов[¶](#installing-from-ports "Ссылка на этот заголовок") +------------------------------------------------------------------------- + + +Нужно установить 3 основных элемента: + + + +- Apache + +- mod\_php for Apache + +- Kanboard + + + +Загрузите и распакуйте порты: + + + + $ portsnap fetch + + $ portsnap extract + + + +или обновите имеющиеся: + + + + $ portsnap fetch + + $ portsnap update + + + +Дополнительную информацию о дереве портов вы можете посмотреть на [FreeBSD Handbook](https://www.freebsd.org/doc/handbook/ports-using.html). + + + +Установка Apache: + + + + $ cd /usr/ports/www/apache24 + + $ make install clean + + + +Включите Apache в `/etc/rc.conf`{.docutils .literal}: + + + + $ echo apache24_enable="YES" >> /etc/rc.conf + + + +Установите mod\_php для Apache: + + + + $ cd /usr/ports/www/mod_php5 + + $ make install clean + + + +Установите Kanboard из портов: + + + + $ cd /usr/ports/www/kanboard + + $ make install clean + + + +Установите PHP для Apache: + + + + $ echo "AddType application/x-httpd-php .php" >> /usr/local/etc/apache24/Includes/php.conf + + $ echo "DirectoryIndex index.php index.html" >> /usr/local/etc/apache24/Includes/php.conf + + + +Затем, запустите Apache: + + + + $ service apache24 start + + + +Готово. Можете перейти в /вашвебсервер/kanboard и начинать работать! + + + +*Примечание*: Если вы хотите использовать дополнительные возможности, типа интеграции LDAP, то нужно установить PHP модуль из `lang/php5-extensions`{.docutils .literal}. + + + +Установка из архива[¶](#manual-installation "Ссылка на этот заголовок") +----------------------------------------------------------------------- + +Начина с версии 1.0.16 Kanboard имеется в портах FreeBSD, поэтому нет необходимости устанавливать вручную. + + + +Обратите внимание[¶](#please-note "Ссылка на этот заголовок") +------------------------------------------------------------- + +- Порт расположен на хостинге [bitbucket](https://bitbucket.org/if0/freebsd-kanboard/). Делайте комментарии, ответвления и предлагайте обновления! +- Некоторые возможности Канборд требуют [запуск ежедневных фоновых задач](cronjob.markdown). + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/gantt-chart-projects.markdown b/doc/ru_RU/gantt-chart-projects.markdown new file mode 100644 index 00000000..d440a85d --- /dev/null +++ b/doc/ru_RU/gantt-chart-projects.markdown @@ -0,0 +1,60 @@ +Диаграмма Ганта для всех проектов +================================= + + + +Цель диаграммы Ганта для проектов - показать прогресс проектов основанный на дате начала и дате завершения. + + + +- Диаграмма Ганта для проектов доступна из раздела **Управление проектами** + + + +- Только менеджеры проекта и администраторы имеют доступ в этот раздел + + + +- Менеджеры проекта могут видеть только те проекты, в которых они являются участниками + + + +- Приватные проекты не показывают этот график + + + +![Gantt Chart for all projects](https://kanboard.net/screenshots/documentation/gantt-chart-all-projects.png) + +Рисунок. Диаграмма Ганта для всех проектов + + + +- **Дата начала** и **дата завершения** проекта используются для рисования графика + + + +- Горизонтальные полосы (столбики) могут быть расширены (сжаты) и перемещены горизонтально с помощью мыши + + + +- Перемещение по вертикали невозможно + + + +- Полосы (столбики) проекта отображаются черным, когда проект не имеет дату начала и завершения + + + +- Информационная подсказка показывает список менеджеров и участников проекта + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/gantt-chart-tasks.markdown b/doc/ru_RU/gantt-chart-tasks.markdown new file mode 100644 index 00000000..1b8c4a2c --- /dev/null +++ b/doc/ru_RU/gantt-chart-tasks.markdown @@ -0,0 +1,66 @@ +Диаграмма Ганта для задач +========================= + + + +Цель диаграммы Ганта - показать время отведенное на задачу в заданном проекте. + + + +- Диаграмма Ганта доступна в рабочем окружении проекта + + + +- Только менеджеры проектов могут иметь доступ в этот раздел + + + +![Gantt Chart](https://kanboard.net/screenshots/documentation/gantt-chart-project.png) + +Рисунок. Диаграмма Ганта. + + + +- Дата начала и дата завершения задач используется для рисования диаграммы + + + +- Задача может быть расширена и перемещена горизонтально с помощью мыши + + + +- Перемещение по вертикали невозможно + + + +- Полоса (горизонтальный столбик) на диаграмме имеет такой же цвет как и задача + + + +- Каждая полоса отображает статус прогресса в процентах. Проценты подсчитываются с учетом позиции задачи в колонке на Доске. + + + +- Для соответсвия модели Kanban, задачи могут быть отсортированы в соответствии с позициями на доске или по дате начала + + + +- Новые задачи созданные через диаграмму Ганта будут показаны на Доске в первой колонке на первой позиции + + + +- Задачи отображаются черным цветом, если не указана дата начала или дата исполнения + + + +![Task not defined](https://kanboard.net/screenshots/documentation/gantt-chart-not-defined.png) + +Рисунок. Задача без указанных дат начала или завершения + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/genindex.markdown b/doc/ru_RU/genindex.markdown new file mode 100644 index 00000000..ceb48d17 --- /dev/null +++ b/doc/ru_RU/genindex.markdown @@ -0,0 +1,15 @@ +Алфавитный указатель +==================== + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/groups.markdown b/doc/ru_RU/groups.markdown new file mode 100644 index 00000000..5ab043d4 --- /dev/null +++ b/doc/ru_RU/groups.markdown @@ -0,0 +1,35 @@ +Управление группами +=================== + + + +В Канборде каждый пользователь может быть членом одной или нескольких групп. Группа - это что-то вроде команды или организации. + + + +Только администраторы могут создавать новую группу и добавлять туда пользователей. + + + +Настройка групп доступна через **Управление пользователями** (выпадающее меню справа вверху) -\> **Просмотр всех пользователей**. Здесь вы можете создавать новые группы и добавлять пользователей в группы. + + + +![Group Management](screenshots/groups-management.png) + +Рисунок. Управление группами. + + + +Менеджеры проектов могут предоставлять доступ группам к проектам на [странице Разрешения проекта](project-permissions.markdown). + + + +Внешние id в основном используются для предоставления доступа внешним группам. Канборд поддерживает группы из LDAP посредством [автоматической синхронизации групп из LDAP сервера](ldap-group-sync.markdown). + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/heroku.markdown b/doc/ru_RU/heroku.markdown new file mode 100644 index 00000000..6e2bd945 --- /dev/null +++ b/doc/ru_RU/heroku.markdown @@ -0,0 +1,72 @@ +Развертывание Канборд на Heroku +=============================== + +Вы можете бесплатно испытать работу Kanboard на [Heroku](https://www.heroku.com/). Вам нужно нажать кнопку **Deploy to Heroku** и следовать руководству приведенному ниже: + +[![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/fguillot/kanboard) + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Учетная запись на Heroku. Вы можете зарегистрироваться бесплатно. +- Установленная утилита командной строки Heroku + + + +Руководство по установке[¶](#manual-instructions "Ссылка на этот заголовок") +---------------------------------------------------------------------------- + + + # Get the last development version + + git clone https://github.com/fguillot/kanboard.git + + cd kanboard + + + + # Push the code to Heroku (You can also use SSH if git over HTTP doesn't work) + + heroku create + + git push heroku master + + + + # Start a new dyno with a Postgresql database + + heroku ps:scale web=1 + + heroku addons:add heroku-postgresql:hobby-dev + + + + # Open your browser + + heroku open + + + +Ограничения[¶](#limitations "Ссылка на этот заголовок") +------------------------------------------------------- + +- Хранилище на Heroku эфимерное. Это означает, что файлы, загружаемые через Канборд, будут отсутствовать в системе после перезагрузки. Вы можете установить плагин для хранения файлов в облаке, например [Amazon S3](https://github.com/kanboard/plugin-s3). +- Некоторые возможности Канборда требуют, чтобы вы выполняли [запуск ежедневных фоновых задач](cronjob.markdown). + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ical.markdown b/doc/ru_RU/ical.markdown new file mode 100644 index 00000000..77b6340e --- /dev/null +++ b/doc/ru_RU/ical.markdown @@ -0,0 +1,111 @@ +Синхронизация вашего календаря +============================== + + +Канборд поддерживает iCal транслятор для проектов и пользователей. Эта возможность позволяет вам импортировать задачи из Канборд в любую программу календарь (например, Microsoft Outlook, Apple Calendar, Mozilla Thunderbird и Google Calendar). + +Подписки на календарь возможны только на **чтение**, т.е. вы не можете создавать задачи во внешнем календаре. Данные из Календаря экспортируются в стандарте iCal. + +Заметка: Только задачи в промежутке от -2 месяцев до +6 месяцев (прошедшие два месяца и предстоящие 6 месяцев) экспортируются в iCalendar транслятор. + + +Календарь проекта[¶](#project-calendars "Ссылка на этот заголовок") +------------------------------------------------------------------- + +- Каждый проект имеет свой календарь. +- Ссылка на подписку уникальна для каждого проекта. Ссылка становится активной, когда вы включаете общий доступ к вашему проекту: **Меню** -\> **Настройки** -\> **Общий доступ** +- Этот календарь показывает только задачи для выбранного проекта. + + +Календарь пользователя[¶](#user-calendars "Ссылка на этот заголовок") +--------------------------------------------------------------------- + +- Каждый пользователь имеет свой собственный календарь. +- Ссылка на подписку уникальная для каждого пользователя. Ссылка становится активной, когда вы включите общий доступ для пользователя: в правом верхнем выпадающем меню - **Мой профиль** -\> в левом меню - **Общий доступ**. +- Этот календарь показывает задачи назначенные пользователю во всех проектах. + + +Добавление Канборд календаря в календарь Apple[¶](#adding-your-kanboard-calendar-to-apple-calendar "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------------------ + +- Откройте календарь +- Выберите **Файл** -\> **Новая подписка на календарь** +- Скопируйте в Канборд URL ссылку на iCal транслятор и вставьте + +![Add iCal subscription](https://kanboard.net/screenshots/documentation/apple-calendar-add-subscription.png) + +Рисунок. Добавление подписки на календарь. + + +- Вы можете выбрать синхронизацию календаря с iCloud, чтобы иметь доступ к календарю с любых ваших устройств +- Не забудьте указать частоту синхронизации + + +![Edit iCal subscription](https://kanboard.net/screenshots/documentation/apple-calendar-edit-subscription.png) + +Рисунок. Редактирование подписки на календарь. + + +Добавление вашего календаря из Канборд в Microsoft Outlook[¶](#adding-your-kanboard-calendar-to-microsoft-outlook "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------------------- + +![Outlook Add Internet Calendar](https://kanboard.net/screenshots/documentation/outlook-add-subscription.png) + +Рисунок. Добавление в Outlook календаря из интернет + +- Откройте Outlook +- Выберите **Открыть календарь** -\> **Из интернета** +- Скопируйте в Канборд URL ссылку на iCal транслятор и вставьте + + +![Outlook Edit Internet Calendar](https://kanboard.net/screenshots/documentation/outlook-edit-subscription.png) + +Рисунок. Настройка интернет календаря в Outlook. + + +Добавление вашего календаря из Канборд в Mozilla Thunderbird[¶](#adding-your-kanboard-calendar-to-mozilla-thunderbird "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------------------------------------- + + +- Установите в Thunderbird Дополнение **Lightning** +- Выберите **Файл** -\> **Новый календарь** +- В диалоговом окне, выберите **Из сети** + +![Thunderbird Step 1](https://kanboard.net/screenshots/documentation/thunderbird-new-calendar-step1.png) + +Рисунок. Создание календаря в Thunderbird, шаг 1. + + + +- Выберите формат iCalendar +- Скопируйте в Канборд URL ссылку на iCal транслятор и вставьте + +![Thunderbird Step 2](https://kanboard.net/screenshots/documentation/thunderbird-new-calendar-step2.png) + +Рисунок. Создание календаря в Thunderbird, шаг 2. + +- Выберите цвета и другие настройки и в завершении нажмите **Сохранить**. + + +Добавление вашего календаря Канборд в календарь Google[¶](#adding-your-kanboard-calendar-to-google-calendar "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------------- + +- Нажмите иконку “треугольник” рядом с **Другие календари** (слева). +- Вставьте ссылку на календарь из Канборд в поле “Добавить календарь друга” +- Скопируйте в Канборд URL ссылку на iCal транслятор и вставьте + + +![Google Calendar](https://kanboard.net/screenshots/documentation/google-calendar-add-subscription.png) + +Рисунок. Календарь Google. + +Ваш календарь из Канборд будет доступен на планшетах и смартфонах, нужно только сделать синхронизацию. + + +[Справка по настройке календаря Google](https://support.google.com/calendar/?hl=ru#topic=3417969). + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/index.markdown b/doc/ru_RU/index.markdown new file mode 100644 index 00000000..c4a12d52 --- /dev/null +++ b/doc/ru_RU/index.markdown @@ -0,0 +1,248 @@ +Документация +============ + + +Как работать в Kanboard[¶](#using-kanboard "Ссылка на этот заголовок") +---------------------------------------------------------------------- + + +### Введение[¶](#introduction "Ссылка на этот заголовок") + + +- [Что такое Kanban?](what-is-kanban.markdown) + +- [Kanban против Todo списков и Scrum](kanban-vs-todo-and-scrum.markdown) + +- [Где можно использовать Kanboard](usage-examples.markdown) + + +### Использование доски[¶](#using-the-board "Ссылка на этот заголовок") + + +- [Доска, Календарь, Список и Гант представления](project-views.markdown) + +- [Компактное или развернутое отображение задач](board-collapsed-expanded.markdown) + +- [Горизонтальная прокрутка и компактный вид](board-horizontal-scrolling-and-compact-view.markdown) + +- [Отображение и скрытие колонок](board-show-hide-columns.markdown) + + +### Работа с проектами[¶](#working-with-projects "Ссылка на этот заголовок") + +- [Типы проектов](project-types.markdown) + +- [Создание проектов](creating-projects.markdown) + +- [Редактирование проектов](editing-projects.markdown) + +- [Публичные доски и задачи](sharing-projects.markdown) + +- [Автоматизация процессов](automatic-actions.markdown) + +- [Права доступа к проекту](project-permissions.markdown) + +- [Дорожки](swimlanes.markdown) + +- [Календарь](calendar.markdown) + +- [Аналитика](analytics.markdown) + +- [Диаграмма Ганта для задач](gantt-chart-tasks.markdown) + +- [Диаграмма Ганта для проектов](gantt-chart-projects.markdown) + +- [Пользовательские фильтры](custom-filters.markdown) + + + +### Работа с задачами[¶](#working-with-tasks "Ссылка на этот заголовок") + +- [Создание задач](creating-tasks.markdown) + +- [Закрытие задач](closing-tasks.markdown) + +- [Дублирование и перенос задач](duplicate-move-tasks.markdown) + +- [Добавление снимка экрана](screenshots.markdown) + +- [Ссылки на задачу](task-links.markdown) + +- [Перемещения](transitions.markdown) + +- [Отслеживание времени](time-tracking.markdown) + +- [Повторяющиеся задачи](recurring-tasks.markdown) + +- [Создание задач через email](create-tasks-by-email.markdown) + +- [Подзадачи](subtasks.markdown) + +- [Аналитика для задач](analytics-tasks.markdown) + +- [Ссылка на пользователя](user-mentions.markdown) + + + +### Работа с пользователями и группами[¶](#working-with-users-and-groups "Ссылка на этот заголовок") + +- [Роли](roles.markdown) + +- [Типы пользователей](user-types.markdown) + +- [Управление группами](groups.markdown) + +- [Управление пользователями](user-management.markdown) + +- [Уведомления](notifications.markdown) + +- [Двухуровневая аутентификация](2fa.markdown) + + + +### Настройки[¶](#settings "Ссылка на этот заголовок") + +- [Горячие клавиши](keyboard-shortcuts.markdown) + +- [Настройки приложения](application-configuration.markdown) + +- [Настройки проекта](project-configuration.markdown) + +- [Настройка Доски](board-configuration.markdown) + +- [Настройки календаря](calendar-configuration.markdown) + +- [Настройка ссылок](link-labels.markdown) + +- [Курсы валют](currency-rate.markdown) + + +### Встроенные возможности[¶](#integrations "Ссылка на этот заголовок") + +- [iCalendar подписки](ical.markdown) + +- [RSS/Atom подписки](rss.markdown) + +- [Json-RPC API](api-json-rpc.markdown) + +- [Webhooks](webhooks.markdown) + +- [Плагины](plugins.markdown) + + +### Дополнительно[¶](#more "Ссылка на этот заголовок") + +- [Синтаксис расширенного поиска](ext-search.markdown) + +- [Интерфейс командной строки](cli.markdown) + +- [Руководство по синтаксису](syntax-guide.markdown) + +- [Защита от Brute force](bruteforce-protection.markdown) + +- [Часто задаваемые вопросы](faq.markdown) + + + +Технические детали[¶](#technical-details "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + +### Инсталяция[¶](#installation "Ссылка на этот заголовок") + +- [Требования](requirements.markdown) + +- [Инструкция по инсталяции](installation.markdown) + +- [Обновление Kanboard до новой версии](update.markdown) + +- [Инсталяция на Ubuntu](ubuntu-installation.markdown) + +- [Инсталяция на Debian](debian-installation.markdown) + +- [Инсталяция на Centos](centos-installation.markdown) + +- [Инсталяция на OpenSuse](suse-installation.markdown) + +- [Инсталяция на FreeBSD](freebsd-installation.markdown) + +- [Инсталяция на Windows Server и IIS](windows-iis-installation.markdown) + +- [Инсталяция на Windows Server и Apache](windows-apache-installation.markdown) + +- [Инсталяция на Heroku](heroku.markdown) + +- [Запуск Kanboard под Docker](docker.markdown) + +- [Запуск Kanboard под Vagrant](vagrant.markdown) + +- [Запуск Kanboard на Cloudron](cloudron.markdown) + +- [Запуск Kanboard на Nitrous](nitrous.markdown) + + +### Настройка[¶](#configuration "Ссылка на этот заголовок") + +- [Ежедневные фоновые задачи](cronjob.markdown) + +- [Конфигурационный файл](config.markdown) + +- [Переменные окружения](env.markdown) + +- [Настройка email](email-configuration.markdown) + +- [Переопределение URL](nice-urls.markdown) + +- [Директория плагинов](plugin-directory.markdown) + + + +### База данных[¶](#database "Ссылка на этот заголовок") + +- [База данных Sqlite](sqlite-database.markdown) + +- [Как использовать Mysql](mysql-configuration.markdown) + +- [Как использовать Postgresql](postgresql-configuration.markdown) + + +### Аутентификация[¶](#authentication "Ссылка на этот заголовок") + +- [LDAP аутентификация](ldap-authentication.markdown) + +- [Синхронизация групп LDAP](ldap-group-sync.markdown) + +- [Изображения из профиля LDAP](ldap-profile-picture.markdown) + +- [Параметры LDAP](ldap-parameters.markdown) + +- [Пример конфигурации LDAP](ldap-configuration-examples.markdown) + +- [Аутентификация Reverse proxy](reverse-proxy-authentication.markdown) + + +### Участие в проекте[¶](#contributors "Ссылка на этот заголовок") + +- [Руководство для участников проекта](contributing.markdown) + +- [Переводы на другие языки](translations.markdown) + +- [Стандарты при написании кода](coding-standards.markdown) + +- [Выполнение тестов](tests.markdown) + +- [Создание assets](assets.markdown) + + +[Документация](https://github.com/fguillot/kanboard/tree/master/doc) написана в формате [Markdown](https://ru.wikipedia.org/wiki/Markdown). Если вы желаете улучшить документацию - пошлите pull-request. + + + +* [Проект перевода документации Канборд на русский язык](https://github.com/hairetdin/kanboard-doc-ru). [Русская документация Канборд в формате html](http://kanboard.ru/doc/). + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/installation.markdown b/doc/ru_RU/installation.markdown new file mode 100644 index 00000000..e59e43d2 --- /dev/null +++ b/doc/ru_RU/installation.markdown @@ -0,0 +1,117 @@ +Инсталяция +========== + + + +В первую очередь, ознакомтесь с [требованиями](requirements.markdown). + + + +Инсталяция из архива (стабильная версия)[¶](#from-the-archive-stable-version "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------- + + + +1. У вас должен быть установлен веб сервер с PHP + +2. Скачайте исходный код и скопируйте директорию `kanboard` в каталог веб сервера + +3. Проверьте, чтобы директория `data` была доступна на запись + +4. В вашем браузере перейдите по ссылке /вашвебсервер/kanboard + +5. Логин и пароль по умолчанию - **admin/admin** + +6. Все, теперь вы можете работать в Канборд + +7. Не забудьте сменить пароль! + + + +Место хранения данных: + + +- База данных Sqlite: `db.sqlite` + +- Файл отладки: `debug.log` (если включена отладка) + +- Загруженные файлы: `files/*` + +- Изображения: `files/thumbnails/*` + + + +Те, кто использует удаленную базу данных (Mysql/Postgresql) и удаленное файловое хранилище (Aws S3 или подобное), могут не назначать права доступа к локальным данным. + + +Инсталяция из репозитория (разрабатываемая версия)[¶](#from-the-repository-development-version "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------------------------- + + + +Вы можете установить [composer](https://getcomposer.org/) для этого метода инсталяции. + + +1. `git clone https://github.com/fguillot/kanboard.git` + +2. `composer install --no-dev` + +3. Далее, перейдите к третьему шагу [Инсталяция из архива](installation.html#from-the-archive-stable-version) + + + +**Внимание**: Инсталируя **текущую разрабатываемую версию**, вы должны понимать, что это нестабильная версия и берете все риски по работе Канборд на себя. + + + +Инсталяция в другой каталог[¶](#installation-outside-of-the-document-root "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------- + + + +Если вы хотите инсталировать Канборд в другую директорию, вне корневого каталога веб сервера, вам нужно создать, как минимум, следующие символьные ссылки: + + . + + ├── assets -> ../kanboard/assets + ├── doc -> ../kanboard/doc + ├── favicon.ico -> ../kanboard/favicon.ico + ├── index.php -> ../kanboard/index.php + ├── jsonrpc.php -> ../kanboard/jsonrpc.php + └── robots.txt -> ../kanboard/robots.txt + + + +`.htaccess` необязательно, потому что его содержимое может быть включена прямо в конфигурацию Apache. + + +Вы можете указать текущее месторасположение директорий плагинов и файлов изменив [конфигурационный файл](config.markdown). + + + +Безопасность[¶](#security "Ссылка на этот заголовок") +----------------------------------------------------- + +- Не забудьте изменить логин и пароль пользователя admin, назначенный по умолчанию + +- Не предоставляйте всем права на директорию `data` через URL. + + + +Примечание[¶](#notes "Ссылка на этот заголовок") +------------------------------------------------ + + +- Некоторые возможности Канборда требуют, чтобы [ежедневно выполнялись фоновые задачи](cronjob.markdown). + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/kanban-vs-todo-and-scrum.markdown b/doc/ru_RU/kanban-vs-todo-and-scrum.markdown new file mode 100644 index 00000000..7c1b205b --- /dev/null +++ b/doc/ru_RU/kanban-vs-todo-and-scrum.markdown @@ -0,0 +1,75 @@ +Сравнение Kanban, Todo lists и Scrum +==================================== + + +Сравнение Kanban и Todo lists[¶](#kanban-vs-todo-lists "Ссылка на этот заголовок") +---------------------------------------------------------------------------------- + + +### Todo lists (списки для исполнения):[¶](#todo-lists "Ссылка на этот заголовок") + +- Имеют одну фазу (только список пунктов) + +- Возможна многозадачность (не эффективна) + + + +### Kanban:[¶](#kanban "Ссылка на этот заголовок") + + + +- Имеет много фаз, каждая колонка представлена как шаг процесса + +- Концентрация внимания и исключение многозадачности, потому что вы можете установить этап процесса заданной колонкой + + + +Сравнение Kanban и Scrum[¶](#kanban-vs-scrum "Ссылка на этот заголовок") +------------------------------------------------------------------------ + + +### [Scrum:](https://ru.wikipedia.org/wiki/Scrum)[¶](#scrum "Ссылка на этот заголовок") + + +- Спринты жестко фиксированные временем, обычно 2 или 4 недели + +- Не позволяет вносить изменения в течении итерации + +- Обязательна предварительная оценка + +- Используется скорость как единица измерения по умолчанию + +- Доска Scrum очищается между спринтами + +- Scrum имеет преопределенные роли, такие как, мастер, владелец продукта и команда + +- Множество встреч: планирование, беклог груминг (причесывание), ежедневные совещания, ретроспектива + + + +### Kanban:[¶](#id1 "Ссылка на этот заголовок") + +- Непрерывный поток + +- Гибкость - изменения могут быть сделаны в любое время + +- Предварительная оценка опциональна + +- Используется время выполнения (lead time) и время цикла (cycle time) для измерения производительности + +- Доска Kanboar постоянна + +- Kanban не навязывает строгих ограничений или встреч, процессы более гибкие + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/keyboard-shortcuts.markdown b/doc/ru_RU/keyboard-shortcuts.markdown new file mode 100644 index 00000000..a09c92bc --- /dev/null +++ b/doc/ru_RU/keyboard-shortcuts.markdown @@ -0,0 +1,99 @@ +Горячие клавиши +=============== + + +Горячие клавиши доступны в зависимости от страницы на которой вы находитесь. + + + +В проекте (Доска, Календарь, Список, Гант)[¶](#project-views-board-calendar-list-gantt "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------ + +- Переключиться на Обзор проектов = **v o** (переключите клавиатуру в английскую раскладку и нажмите клавиши **v** и **o** ) + + + +- Переключиться на Доску = **v b** + + + +- Переключиться на Календарь = **v c** + + + +- Переключиться на список = **v l** + + + +- Переключиться на диаграмму Ганта = **v g** + + + +На Доске[¶](#board-view "Ссылка на этот заголовок") +--------------------------------------------------- + +- Новая задача = **n** + + + +- Свернуть/развернуть задачи = **s** + + + +- Компактный/широкий вид = **c** + + + +В Задаче[¶](#task-view "Ссылка на этот заголовок") +-------------------------------------------------- + +- Редактировать задачу = **e** + + + +- Новая подзадача = **s** + + + +- Новый комментарий = **c** + + + +- Новая внутренняя ссылка = **l** + + + +В приложении (главное окно Канборд)[¶](#application "Ссылка на этот заголовок") +------------------------------------------------------------------------------- + + +- Показать список горячих клавиш = **?** + + + +- Открыть переключатель проектов = **b** + + + +- Переход в окно поиска = **f** + + + +- Очистить окно поиска = **r** + + + +- Закрыть окно диалога = **ESC** + + + +- Расширенный поиск = **CTRL+ENTER** or **⌘+ENTER** + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ldap-authentication.markdown b/doc/ru_RU/ldap-authentication.markdown new file mode 100644 index 00000000..a94d8f89 --- /dev/null +++ b/doc/ru_RU/ldap-authentication.markdown @@ -0,0 +1,327 @@ +Аутентификация LDAP +=================== + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Включенное в PHP раширение LDAP + + + +- Сервер LDAP: + + + + - OpenLDAP + + - Microsoft Active Directory + + - Novell eDirectory + + + +Рабочий процесс[¶](#workflow "Ссылка на этот заголовок") +-------------------------------------------------------- + + + +Когда активирована аутентификация LDAP, процесс входа выглядит следующим образом: + + + +1. Выполняется попытка аутентификации пользователя в базе данных Канборда + +2. Если пользователь не найден в базе Канборда, выполняется аутентификация LDAP + +3. Если аутентификация LDAP выполнена успешно, по умолчанию, локальный пользователь (в Канборде) создается автоматически без пароля и помечается как пользователь LDAP. + + + +Полное имя и email адрес автоматически подгружаются из сервера LDAP. + + + +Типы аутентификации[¶](#authentication-types "Ссылка на этот заголовок") +------------------------------------------------------------------------ + + +| Тип | Описание | +|--------------|-------------------------------------------------------------| +| Proxy User | Использовать специального пользователя для просмотра директории LDAP | +| User | Использовать учетные данные конечного пользователя для просмотра директории LDAP | +| Anonymous | Не надо выполнять аутентификацию для доступа к каталогу LDAP | + + +**Рекомендуемый метод аутентификации - “Proxy”**. + + + +### Анонимный (Anonymous) метод[¶](#anonymous-mode "Ссылка на этот заголовок") + + + + define('LDAP_BIND_TYPE', 'anonymous'); + + define('LDAP_USERNAME', null); + + define('LDAP_PASSWORD', null); + + + +Этот метод используется по умолчанию, но некоторые сервера LDAP не поддерживают доступ анонимам, из соображений безопасности. + + + +### Proxy метод[¶](#proxy-mode "Ссылка на этот заголовок") + + + +Специальный пользователь используется для доступа к директории LDAP: + + + + define('LDAP_BIND_TYPE', 'proxy'); + + define('LDAP_USERNAME', 'my proxy user'); + + define('LDAP_PASSWORD', 'my proxy password'); + + + +### Пользовательский метод (user)[¶](#user-mode "Ссылка на этот заголовок") + + + +Этот метод используется для доступа под учетной записью конечного пользователя. + + + +Например, Microsoft Active Directory не разрешает подключение под анонимным пользователем и если вы не хотите использовать пользователя proxy, то используйте этот метод. + + + + define('LDAP_BIND_TYPE', 'user'); + + define('LDAP_USERNAME', '%s@kanboard.local'); + + define('LDAP_PASSWORD', null); + + + +В этом методе, константа `LDAP_USERNAME` использутся как шаблон для пользователя ldap, например: + + + +- `%s@kanboard.local` будет заменен `my_user@kanboard.local` + + + +- `KANBOARD\\%s` будет заменен на `KANBOARD\my_user` + + + +Фильтр пользователей LDAP[¶](#user-ldap-filter "Ссылка на этот заголовок") +-------------------------------------------------------------------------- + + +Параметр конфигурации `LDAP_USER_FILTER` используется для поиска пользователей по директории LDAP. + + + +Например: + + + +- `(&(objectClass=user)(sAMAccountName=%s))` будет заменено на `(&(objectClass=user)(sAMAccountName=указанный_пользователь))` + + +- `uid=%s` is replaced by `uid=указанный_пользователь` + + + +Другие примеры [фильтров для Active Directory](http://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx) + + + +Пример фильра доступа в Канборд: + + + +`(&(objectClass=user)(sAMAccountName=%s)(memberOf=CN=Kanboard Users,CN=Users,DC=kanboard,DC=local))` + + + +Этот пример разрешает подключатся к Канборду только пользователям участникам группы “Kanboard Users” + + + +Пример для Microsoft Active Directory[¶](#example-for-microsoft-active-directory "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------ + + + +Предположим, что мы имеем домен `KANBOARD` (kanboard.local) и контролер домена `myserver.kanboard.local`. + + + +Первый пример для метода прокси (proxy): + + + + + + + + define('LDAP_AUTH', true); + + + + define('LDAP_SERVER', 'my-ldap-server'); + + define('LDAP_PORT', 389); + + + + define('LDAP_BIND_TYPE', 'proxy'); + + define('LDAP_USERNAME', 'administrator@kanboard.local'); + + define('LDAP_PASSWORD', 'secret'); + + + + define('LDAP_USER_BASE_DN', 'CN=Users,DC=kanboard,DC=local'); + + define('LDAP_USER_FILTER', '(&(objectClass=user)(sAMAccountName=%s))'); + + + + define('LDAP_USER_ATTRIBUTE_USERNAME', 'samaccountname'); + + define('LDAP_USER_ATTRIBUTE_FULLNAME', 'displayname'); + + define('LDAP_USER_ATTRIBUTE_PHOTO', 'jpegPhoto'); + + define('LDAP_USER_ATTRIBUTE_LANGUAGE', 'preferredLanguage'); + + + + define('LDAP_GROUP_ADMIN_DN', 'CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local'); + + define('LDAP_GROUP_MANAGER_DN', 'CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local'); + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'CN=Users,DC=kanboard,DC=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=group)(sAMAccountName=%s*))'); + + define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn'); + + + +OpenLDAP с memberOf overlay[¶](#openldap-with-memberof-overlay "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------ + +Пример LDIF пользователя: + + + dn: uid=manager,ou=Users,dc=kanboard,dc=local + + objectClass: top + + objectClass: person + + objectClass: organizationalPerson + + objectClass: inetOrgPerson + + uid: manager + + sn: Lastname + + givenName: Firstname + + cn: Kanboard Manager + + displayName: Kanboard Manager + + mail: manager@kanboard.local + + userPassword: password + + memberOf: cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local + + + +Пример LDIF группы: + + + + dn: cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local + + objectClass: top + + objectClass: groupOfNames + + cn: Kanboard Managers + + member: uid=manager,ou=Users,dc=kanboard,dc=local + + + +Конфигурация Канборд: + + +- Аутентификация пользователя + + + +- Роли в Канборд сопоставляются с группами LDAP + + + +- Поставщики групп LDAP включены + + + + + + + + define('LDAP_AUTH', true); + + + + define('LDAP_SERVER', 'my-ldap-server'); + + define('LDAP_PORT', 389); + + + + define('LDAP_BIND_TYPE', 'proxy'); + + define('LDAP_USERNAME', 'cn=admin,DC=kanboard,DC=local'); + + define('LDAP_PASSWORD', 'password'); + + + + define('LDAP_USER_BASE_DN', 'OU=Users,DC=kanboard,DC=local'); + + define('LDAP_USER_FILTER', 'uid=%s'); + + + + define('LDAP_GROUP_ADMIN_DN', 'cn=Kanboard Admins,ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_MANAGER_DN', 'cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local'); + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=groupOfNames)(cn=%s*))'); + + define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn'); + + + +OpenLDAP с Posix groups (memberUid)[¶](#openldap-with-posix-groups-memberuid "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------- + + +Пример LDIF пользователя: + + + + dn: uid=manager,ou=Users,dc=kanboard,dc=local + + objectClass: inetOrgPerson + + objectClass: posixAccount + + objectClass: shadowAccount + + uid: manager + + sn: Lastname + + givenName: Firstname + + cn: Kanboard Manager + + displayName: Kanboard Manager + + uidNumber: 10001 + + gidNumber: 8000 + + userPassword: password + + homeDirectory: /home/manager + + mail: manager@kanboard.local + + + +Пример LDIF группы: + + + + dn: cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local + + objectClass: posixGroup + + cn: Kanboard Managers + + gidNumber: 5001 + + memberUid: manager + + + +Конфигурация Канборд: + + + +- Аутентификация пользователя + + + +- Роли в Канборд сопоставляются с группами LDAP + + + +- Поставщики групп LDAP включены + + + + + + + + define('LDAP_AUTH', true); + + + + define('LDAP_SERVER', 'my-ldap-server'); + + define('LDAP_PORT', 389); + + + + define('LDAP_BIND_TYPE', 'proxy'); + + define('LDAP_USERNAME', 'cn=admin,DC=kanboard,DC=local'); + + define('LDAP_PASSWORD', 'password'); + + + + define('LDAP_USER_BASE_DN', 'OU=Users,DC=kanboard,DC=local'); + + define('LDAP_USER_FILTER', 'uid=%s'); + + + + define('LDAP_GROUP_ADMIN_DN', 'cn=Kanboard Admins,ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_MANAGER_DN', 'cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local'); + + + + // This filter is used to find the groups of our user + + define('LDAP_GROUP_USER_FILTER', '(&(objectClass=posixGroup)(memberUid=%s))'); + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=posixGroup)(cn=%s*))'); + + define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn'); + + + +OpenLDAP с groupOfNames[¶](#openldap-with-groupofnames "Ссылка на этот заголовок") +---------------------------------------------------------------------------------- + + +Пример LDIF пользователя: + + + + dn: uid=manager,ou=Users,dc=kanboard,dc=local + + objectClass: top + + objectClass: person + + objectClass: organizationalPerson + + objectClass: inetOrgPerson + + uid: manager + + sn: Lastname + + givenName: Firstname + + cn: Kanboard Manager + + displayName: Kanboard Manager + + mail: manager@kanboard.local + + userPassword: password + + + +Пример LDIF группы: + + + + dn: cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local + + objectClass: top + + objectClass: groupOfNames + + cn: Kanboard Managers + + member: uid=manager,ou=Users,dc=kanboard,dc=local + + + +Конфигурация Канборд: + + + +- Аутентификация пользователя + + + +- Роли в Канборд сопоставляются с группами LDAP + + + +- Поставщики групп LDAP включены + + + + + + + + define('LDAP_AUTH', true); + + + + define('LDAP_SERVER', 'my-ldap-server'); + + define('LDAP_PORT', 389); + + + + define('LDAP_BIND_TYPE', 'proxy'); + + define('LDAP_USERNAME', 'cn=admin,DC=kanboard,DC=local'); + + define('LDAP_PASSWORD', 'password'); + + + + define('LDAP_USER_BASE_DN', 'OU=Users,DC=kanboard,DC=local'); + + define('LDAP_USER_FILTER', 'uid=%s'); + + + + define('LDAP_GROUP_ADMIN_DN', 'cn=Kanboard Admins,ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_MANAGER_DN', 'cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local'); + + + + // This filter is used to find the groups of our user + + define('LDAP_GROUP_USER_FILTER', '(&(objectClass=groupOfNames)(member=uid=%s,ou=Users,dc=kanboard,dc=local))'); + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=groupOfNames)(cn=%s*))'); + + define('LDAP_GROUP_ATTRIBUTE_NAME', 'cn'); + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ldap-group-sync.markdown b/doc/ru_RU/ldap-group-sync.markdown new file mode 100644 index 00000000..87d9d1cc --- /dev/null +++ b/doc/ru_RU/ldap-group-sync.markdown @@ -0,0 +1,153 @@ +Синхронизация групп LDAP +======================== + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Правильно настроенную аутентификацию LDAP + + + +- Используется сервер LDAP, который поддерживает `memberOf` или `memberUid` (PosixGroups) + + + +Автоматическое определение ролей пользователей на основании LDAP групп[¶](#define-automatically-user-roles-based-on-ldap-groups "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------------------------------------------------------------- + + + +Используйте следующие константы в вашем конфигурационном файле: + + + +- `LDAP_GROUP_ADMIN_DN`: Уникальные имена (Distinguished Names) для администраторов приложения + + + +- `LDAP_GROUP_MANAGER_DN`: Уникальные имена (Distinguished Names) для менеджеров приложения + + + +### Пример для Active Directory:[¶](#example-for-active-directory "Ссылка на этот заголовок") + + + + define('LDAP_GROUP_ADMIN_DN', 'CN=Kanboard Admins,CN=Users,DC=kanboard,DC=local'); + + define('LDAP_GROUP_MANAGER_DN', 'CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local'); + + + +- Участники группы “Kanboard Admins” будут иметь роль “Администратор” + + + +- Участники группы “Kanboard Managers” будут иметь роль “Менеджер” + + + +- Все, кто не попадает под предыдущие определения, будут иметь роль “Пользователь” + + + +### Пример OpenLDAP с Posix Groups:[¶](#example-for-openldap-with-posix-groups "Ссылка на этот заголовок") + + + + define('LDAP_GROUP_BASE_DN', 'ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_USER_FILTER', '(&(objectClass=posixGroup)(memberUid=%s))'); + + define('LDAP_GROUP_ADMIN_DN', 'cn=Kanboard Admins,ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_MANAGER_DN', 'cn=Kanboard Managers,ou=Groups,dc=kanboard,dc=local'); + + + +Вы **должны определить параметр** `LDAP_GROUP_USER_FILTER`, если ваше сервер LDAP использует `memberUid` вместо `memberOf`. Все параметры в этом примере обязательные. + + + +Автоматическая загрузка групп LDAP для Канборд проекта[¶](#automatically-load-ldap-groups-for-project-permissions "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------------------- + + + +Эта возможность позволяет вам синхронизировать автоматически группы LDAP с группами Канборд. Каждая группа может иметь разные роли в проектах. + + + +В проекте на странице *Разрешения*, можно ввести имя группы (имеется автодополнение) и Канборд будет искать группу во всех подключенных поставщиках. + + + +Если группа не найдена в локальной базе данных, то она будет автоматически синхронизированна. + + + +- `LDAP_GROUP_PROVIDER`: Включение поставщика группы LDAP + + + +- `LDAP_GROUP_BASE_DN`: Уникальное имя (Distinguished Names) для поиска группы в LDAP директории + + + +- `LDAP_GROUP_FILTER`: фильтр LDAP используемый для выполнения запроса + + + +- `LDAP_GROUP_ATTRIBUTE_NAME`: атрибут LDAP используемый для получения имени группы + + + +### Пример для Active Directory:[¶](#id1 "Ссылка на этот заголовок") + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'CN=Groups,DC=kanboard,DC=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=group)(sAMAccountName=%s*))'); + + + +С помощью фильтра, в примере выше, Канборд будет искать группы соответсвующие запросу. Если пользователь введет текст “Мои группы” в автозаполняемое поле, Канборд вернет все группы которые соответсвуют шаблону: `(&(objectClass=group)(sAMAccountName=Мои группы*))`. + + + +- Примечание 1: Спец символ `*` очень важен, в противном случает **будет выбрано только точное совпадение** + + + +- Примечание 2: Эта функция возможна только с аутентификацией LDAP настроенной на метод “proxy” или “anonymous” + + + +[Больше примеров фильтров LDAP для Active Directory](http://social.technet.microsoft.com/wiki/contents/articles/5392.active-directory-ldap-syntax-filters.aspx) + + + +### Пример OpenLDAP с Posix Groups:[¶](#id2 "Ссылка на этот заголовок") + + + + define('LDAP_GROUP_PROVIDER', true); + + define('LDAP_GROUP_BASE_DN', 'ou=Groups,dc=kanboard,dc=local'); + + define('LDAP_GROUP_FILTER', '(&(objectClass=posixGroup)(cn=%s*))'); + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ldap-parameters.markdown b/doc/ru_RU/ldap-parameters.markdown new file mode 100644 index 00000000..5d00913d --- /dev/null +++ b/doc/ru_RU/ldap-parameters.markdown @@ -0,0 +1,49 @@ +Параметры LDAP для конфигурации +=============================== + + + +Список доступных параметров LDAP: + + +| Параметр | Значение по умолчанию |Описание | +|---------------------------|------------------------------|-----------------------------| +| `LDAP_AUTH` | false | Включить аутентификацию LDAP | +| `LDAP_SERVER` | Нет значения | Имя сервера LDAP | +| `LDAP_PORT` | 389 | Порт сервера LDAP | +| `LDAP_SSL_VERIFY` | true | Проверка сертификата для URL `ldaps://` | +| `LDAP_START_TLS` | false | Включение LDAP start TLS | +| `LDAP_USERNAME_CASE_SENSITIVE` | false | Включение/выключение нижнего и верхнего регистра букв в Канборд для пользователей ldap для исключения дублирования пользователей (база данных чувствительна к регистру) | +| `LDAP_BIND_TYPE` | anonymous | Тип подключения: “anonymous”, “user” or “proxy” | +| `LDAP_USERNAME` | null | Имя пользователя LDAP для использования в методе proxy или шаблон имени пользователя для использования в методе user | +| `LDAP_PASSWORD` | null | Пароль LDAP при использовании метода proxy | +| `LDAP_USER_BASE_DN`| Нет значения | Уникальное имя (DN) LDAP для пользователей (Пример: “CN=Users,DC=kanboard,DC=local”) | +| `LDAP_USER_FILTER` | Нет значения | Шаблон LDAP, который используется для поиска пользователей (Пример: “(&(objectClass=user)(sAMAccountName=%s))”) | +| `LDAP_USER_ATTRIBUTE_USERNAME` | uid | Атрибут LDAP для имени пользователя (Например: “samaccountname”) | +| `LDAP_USER_ATTRIBUTE_FULLNAME` | cn | Атрибут LDAP полного имени пользователя (Например: “displayname”) | +| `LDAP_USER_ATTRIBUTE_EMAIL` | mail | Атрибут LDAP для email пользователя | +| `LDAP_USER_ATTRIBUTE_GROUPS` | memberof | Атрибут LDAP для поиска групп в профиле пользователя | +| `LDAP_USER_ATTRIBUTE_PHOTO` | Нет значения | Атрибут LDAP для поиска фотографии пользователя (jpegPhoto или thumbnailPhoto) | +| `LDAP_USER_ATTRIBUTE_LANGUAGE` | Нет значения | Атрибут LDAP для языка пользователя (preferredlanguage), применимый формат языка - “ru-RU” | +| `LDAP_USER_CREATION` | true | Включение автоматического создания пользователя из LDAP | +| `LDAP_GROUP_ADMIN_DN` | Нет значения | Уникальное имя (DN) LDAP для администраторов (Например: “CN=Kanboard-Admins,CN=Users,DC=kanboard,DC=local”) | +| `LDAP_GROUP_MANAGER_DN` | Нет значения | Уникальное имя (DN) LDAP для менеджеров (Например: “CN=Kanboard Managers,CN=Users,DC=kanboard,DC=local”) | +| `LDAP_GROUP_PROVIDER` | false | Включение поставщика групп LDAP для “Разрешения” в проектах | +| `LDAP_GROUP_BASE_DN` | Нет значения | Уникальное имя (Base DN) LDAP для групп | +| `LDAP_GROUP_FILTER` | Нет значения | Фильтр групп LDAP (Например: “(&(objectClass=group)(sAMAccountName=%s\*))”) | +| `LDAP_GROUP_USER_FILTER` | Empty | Если определено, то Канборд будет искать группы пользователей в LDAP\_GROUP\_BASE\_DN с помощью этого фильтра, это удобно только для posixGroups (Например: `(&(objectClass=posixGroup)(memberUid=%s))`| +| `LDAP_GROUP_ATTRIBUTE_NAME` | cn | атрибут LDAP для имени группы | + + +Примечание + + + +- Атрибуты LDAP должны быть в нижнем регистре + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ldap-profile-picture.markdown b/doc/ru_RU/ldap-profile-picture.markdown new file mode 100644 index 00000000..9d6bb543 --- /dev/null +++ b/doc/ru_RU/ldap-profile-picture.markdown @@ -0,0 +1,46 @@ +Фотография пользователя из профиля LDAP +======================================= + + + +Канборд может автоматически загружать фотографию пользователя из сервера LDAP. + + + +Эта функция возможна только если активирована аутентификация LDAP и указан параметр `LDAP_USER_ATTRIBUTE_PHOTO`. + + + +Настройка[¶](#configuration "Ссылка на этот заголовок") +------------------------------------------------------- + + + +В вашем `config.php`, вы должны установить атрибут LDAP, используемый для хранения изображения. + + + + define('LDAP_USER_ATTRIBUTE_PHOTO', 'jpegPhoto'); + + + +Обычно используются атрибуты `jpegPhoto` или `thumbnailPhoto`. Изображения могут хранится в формате JPEG или PNG. + + + +Для загрузки изображения в пользовательски профиль, администраторы Active Directory могут использовать программу [AD Photo Edit](http://www.cjwdev.co.uk/Software/ADPhotoEdit/Info.html). + + + +Примечание[¶](#notes "Ссылка на этот заголовок") +------------------------------------------------ + +Изображение из профиля **загружается при входе, только если изображение не было загружено ранее**. + +Для смены изображения, нужно вручную удалить ранее загруженное изображение из профиля пользователя. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/link-labels.markdown b/doc/ru_RU/link-labels.markdown new file mode 100644 index 00000000..d091a33c --- /dev/null +++ b/doc/ru_RU/link-labels.markdown @@ -0,0 +1,23 @@ +Настройки ссылки +================ + + +Связи в задачах могут быть изменены в настройках приложения (**Настройки** -\> **Настройки ссылки**) + +![Link Labels](https://kanboard.net/screenshots/documentation/link-labels.png) + +Рисунок. Метки для ссылок. + + +Каждая метка может иметь противоположное опеределение. Если нет противоположного значения, метка считается двунаправленная. + +![Link Label Creation](https://kanboard.net/screenshots/documentation/link-label-creation.png) + +Рисунок. Создание ссылки. + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/mysql-configuration.markdown b/doc/ru_RU/mysql-configuration.markdown new file mode 100644 index 00000000..82c02b37 --- /dev/null +++ b/doc/ru_RU/mysql-configuration.markdown @@ -0,0 +1,128 @@ +Настройка Mysql/MariaDB +======================= + + + +По умолчанию Канборд использует для хранения данных Sqlite. Вместо Sqlite возможно использовать Mysql или MariaDB. + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Сервер Mysql + + + +- Установленное расширение PHP - `pdo_mysql` + + + +Примечание: работа Канборда протестирована с **Mysql \>= 5.5 и MariaDB \>= 10.0** + + + +Настройка Mysql[¶](#mysql-configuration "Ссылка на этот заголовок") +------------------------------------------------------------------- + + + +### Создание базы данных[¶](#create-a-database "Ссылка на этот заголовок") + + + +Первым шагом надо создать базу данных на вашем сервере Mysql. Например, вы можете создать базу в командной строке клиента mysql: + + + + CREATE DATABASE kanboard; + + + +### Создание файла конфигурации[¶](#create-a-config-file "Ссылка на этот заголовок") + + + +Файл `config.php` должен содержать следующие значения: + + + + + + AllowOverride FileInfo Options=All,MultiViews AuthConfig + + + + + +URL ярлыки[¶](#url-shortcuts "Ссылка на этот заголовок") +-------------------------------------------------------- + + + +- Перейти к задаче \#123: **/t/123** + + + +- Перейти на доску в проект \#2: **/b/2** + + + +- Перейти в календарь проекта \#5: **/c/5** + + + +- Перейти к просмотру списком проекта \#8: **/l/8** + + + +- Перейти к настройкам проекта для проекта id \#42: **/p/42** + + + +Настройка[¶](#configuration "Ссылка на этот заголовок") +------------------------------------------------------- + + + +По умолчанию, Канборд проверяет включен ли в Apache mode rewrite. + + + +Для исключения автоматической проверки переопределения URL на веб сервере, вы должны включить эту опцию в вашем конфигурационном фале: + + + + define('ENABLE_URL_REWRITE', true); + + + +Когда константа имеет значение `true`: + + + +- Сгенерированные из утилиты командной строки URL будут также преобразованы + + + +- Если вы используете другой веб сервер вместо Apache, например Nginx или Microsoft IIS, вы можете сами настроить переопределение URL + + + +Примечание: Канборд всегда использует URL по “старинке”, если данная константа не настроена. Эта настройка опциональна. + + + +Пример настройки Nginx[¶](#nginx-configuration-example "Ссылка на этот заголовок") +---------------------------------------------------------------------------------- + + + +В разделе `server`, вашего конфигурационного файла Nginx, вы можете использовать этот пример: + + + + index index.php; + + + + location / { + + try_files $uri $uri/ /index.php$is_args$args; + + + + # If Kanboard is under a subfolder + + # try_files $uri $uri/ /kanboard/index.php; + + } + + + + location ~ \.php$ { + + try_files $uri =404; + + fastcgi_split_path_info ^(.+\.php)(/.+)$; + + fastcgi_pass unix:/var/run/php5-fpm.sock; + + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + + fastcgi_index index.php; + + include fastcgi_params; + + } + + + + # Deny access to the directory data + + location ~* /data { + + deny all; + + return 404; + + } + + + + # Deny access to .htaccess + + location ~ /\.ht { + + deny all; + + return 404; + + } + + + +В конфигурационном файле Канборда `config.php`: + + + + define('ENABLE_URL_REWRITE', true); + + + +Адаптируйте пример приведенный выше к вашей конфигурации. + + + +Пример настройки IIS[¶](#iis-configuration-example "Ссылка на этот заголовок") +------------------------------------------------------------------------------ + + + +Создайте web.config в каталоге где установлен Канборд: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +В конфигурационном файле Канборда `config.php`: + + + + define('ENABLE_URL_REWRITE', true); + + + +Адаптируйте пример приведенный выше к вашей конфигурации. + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/nitrous.markdown b/doc/ru_RU/nitrous.markdown new file mode 100644 index 00000000..8b975b0d --- /dev/null +++ b/doc/ru_RU/nitrous.markdown @@ -0,0 +1,16 @@ +Nitrous быстрый старт +===================== + + +Создайте свободное окружение разработки для проекта Kanboard в облаке на [Nitrous.io](https://www.nitrous.io). + +Зайдите на ваш сайт через ссылку в IDE `Preview > 3000`{.docutils .literal}. + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/notifications.markdown b/doc/ru_RU/notifications.markdown new file mode 100644 index 00000000..8fc37876 --- /dev/null +++ b/doc/ru_RU/notifications.markdown @@ -0,0 +1,111 @@ +Уведомления +=========== + + + +Канборд имеет возможность отправлять сообщения по нескольким каналам: + + + +- Email + +- Веб (уведомления в Канборд) + + + +Внешние плагины позволяют вам посылать уведомления в Slack, Hipchat, Jabber или другие чат системы. + + + +Настройка[¶](#configuration "Ссылка на этот заголовок") +------------------------------------------------------- + + + +Любой пользователь может включить уведоления в своем профиле: в правом верхнем углу выберите во всплывающем меню **Мой профиль** -\> **Уведомления**. Уведомления по умолчанию выключены. + + + +Для получения уведомлений по email вам надо иметь электронную почту (email), которая должна быть указана в вашем профиле, и Канборд должен быть настроен на отправку электронной почты. + + + +![Notifications](https://kanboard.net/screenshots/documentation/notifications.png) + +Рисунок. Уведомления + + + +Вы можете выбрать, удобный для вас, способ получения уведомлений: + + + +- Email + + + +- Веб (смотрите ниже) + + + +Для каждого проекта в котором вы являетесь участником, вы можете выбрать получение уведомления для: + + + +- Всех задач + + + +- Только для задач назначеных вам + + + +- Только для задач, которые создали вы + + + +- Только для задач, созданных вами и назначенных вам + + + +Также, вы можете выбрать проекты из которых хотите получать уведомления. По умолчанию - все проекты, в которых вы являетесь участником. + + + +Веб уведомления[¶](#web-notifications "Ссылка на этот заголовок") +----------------------------------------------------------------- + + + +Веб уведомления доступны на рабочей панели **Мои уведомления** или вверху в виде иконки: + + + +![Web Notifications Icon](https://kanboard.net/screenshots/documentation/web-notifications-icon.png) + +Рисунок. Иконка веб уведомления. + + + +Уведомления отображаются списком. Вы можете выбрать действие **Пометить как прочитанное** для каждого сообщения или отметить сразу все. + + + +![Web Notifications](https://kanboard.net/screenshots/documentation/web-notifications.png) + +Рисунок. Веб уведомления. + + + +Таким образом, вы можете получать веб уведомления без использования электронной почты. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/plugin-directory.markdown b/doc/ru_RU/plugin-directory.markdown new file mode 100644 index 00000000..1920c91d --- /dev/null +++ b/doc/ru_RU/plugin-directory.markdown @@ -0,0 +1,38 @@ +Настройка директории плагинов +============================= + + + +Для установки, обновления и удаления плагинов в интерфейсе пользователя, вам необходимо выполнить следующие пункты: + + + +- Директория плагинов должна быть доступна на запись от пользователя веб сервера + + + +- Расширение zip должно быть доступно на вашем сервере + + + +- Параметр в конфигурации `PLUGIN_INSTALLER` должен быть установлен в `true` + + + +Для отключения этой возможности, измените значение в конфигурационном файле `PLUGIN_INSTALLER` на `false`. Также, вы должны изменить права доступа на директорию плагинов. + + + +Только администраторы могут устанавливать плагины через пользовательский интерфейс. + + + +По умолчанию, доступны только плагины из списка на веб сайте Канборда. + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/plugins.markdown b/doc/ru_RU/plugins.markdown new file mode 100644 index 00000000..e5ec2719 --- /dev/null +++ b/doc/ru_RU/plugins.markdown @@ -0,0 +1,167 @@ +Разработка плагина +================== + + + +**Внимание: API плагинов на данный момент в состоянии альфа.** + + + +Плагины удобны для расширения базового функционала Канборда: добавление возможностей, создание тем или изменения базового поведения. + + + +Создатели плагина должны указать точную версию Канборда, под которую написан плагин. Внутренний код Канборда может изменяться и ваш плагин должен тестироваться на совместимость с новой версией. Всегда следите за [ChangeLog](https://github.com/fguillot/kanboard/blob/master/ChangeLog) для внесения изменений. + + + +- [Создание вашего плагина](plugin-registration.markdown) + + + +- [Использование plugin hooks](plugin-hooks.markdown) + + + +- [События](plugin-events.markdown) + + + +- [Изменение базового поведения приложений](plugin-overrides.markdown) + + + +- [Добавление миграции схемы для плагинов](plugin-schema-migrations.markdown) + + + +- [Пользовательские маршруты](plugin-routes.markdown) + + + +- [Добавление обработчиков](plugin-helpers.markdown) + + + +- [Добавление почтовых трансляторов](plugin-mail-transports.markdown) + + + +- [Добавление типов оповещений](plugin-notifications.markdown) + + + +- [Добавление автоматических действий](plugin-automatic-actions.markdown) + + + +- [Расширение данных пользователей, задач и проектов](plugin-metadata.markdown) + + + +- [Архитектура аутентификации](plugin-authentication-architecture.markdown) + + + +- [Регистрация плагина аутентификации](plugin-authentication.markdown) + + + +- [Архитектура авторизации](plugin-authorization-architecture.markdown) + + + +- [Провайдер пользовательской группы](plugin-group-provider.markdown) + + + +- [Провайдер внешней ссылки](plugin-external-link.markdown) + + + +- [Добавление провайдера аватара](plugin-avatar-provider.markdown) + + + +- [Клиент LDAP](plugin-ldap-client.markdown) + + + +Примеры плагинов[¶](#examples-of-plugins "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +- [Двухуровневая аутентификация SMS](https://github.com/kanboard/plugin-sms-2fa) + + + +- [Аутентификация Reverse-Proxy с поддержкой LDAP](https://github.com/kanboard/plugin-reverse-proxy-ldap) + + + +- [Slack](https://github.com/kanboard/plugin-slack) + + + +- [Hipchat](https://github.com/kanboard/plugin-hipchat) + + + +- [Jabber](https://github.com/kanboard/plugin-jabber) + + + +- [Sendgrid](https://github.com/kanboard/plugin-sendgrid) + + + +- [Mailgun](https://github.com/kanboard/plugin-mailgun) + + + +- [Postmark](https://github.com/kanboard/plugin-postmark) + + + +- [Amazon S3](https://github.com/kanboard/plugin-s3) + + + +- [Планирование бюджета](https://github.com/kanboard/plugin-budget) + + + +- [Расписание пользователя](https://github.com/kanboard/plugin-timetable) + + + +- [Прогнозирование подзадач](https://github.com/kanboard/plugin-subtask-forecast) + + + +- [Пример автоматических действий](https://github.com/kanboard/plugin-example-automatic-action) + + + +- [Пример плагина темы](https://github.com/kanboard/plugin-example-theme) + + + +- [Пример плагина CSS](https://github.com/kanboard/plugin-example-css) + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/postgresql-configuration.markdown b/doc/ru_RU/postgresql-configuration.markdown new file mode 100644 index 00000000..9407ce59 --- /dev/null +++ b/doc/ru_RU/postgresql-configuration.markdown @@ -0,0 +1,92 @@ +Настройка Postgresql +==================== + + + +По умолчанию, Канборд использует для хранения данных Sqlite, но возможно использовать и Postgresql. + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Установленный и настроенный сервер Postgresql + + + +- Установленное PHP расширение - `pdo_pgsql` (Debian/Ubuntu: `apt-get install php5-pgsql`) + + + +Примечание: работа Канборда протестирована с **Postgresql 9.3 и 9.4** + + + +Настройка[¶](#configuration "Ссылка на этот заголовок") +------------------------------------------------------- + + + +### Создайте пустую базу данных выполнив команду `pgsql`:[¶](#create-an-empty-database-with-the-command-pgsql "Ссылка на этот заголовок") + + + + CREATE DATABASE kanboard; + + + +### Создание конфигурационного файла[¶](#create-a-config-file "Ссылка на этот заголовок") + + + +Файл `config.php` должен содержать следующие значения: + +```php + Настройки -\> Разрешения** + + + +![Project Permissions](screenshots/project-permissions.png) + +Рисунок. Права доступа к проекту + + + +Если вы выберите **Разрешить любому**, то все пользователи Канборд будут считаться участниками Проекта. В таком случае, нет необходимости назначать роли. Потому что, разрешения, назначенные пользователям и группам, на доступ к Проекту не будут работать. + + + +Приватный проект не позволяет устанавливать разрешения. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/project-types.markdown b/doc/ru_RU/project-types.markdown new file mode 100644 index 00000000..d1169241 --- /dev/null +++ b/doc/ru_RU/project-types.markdown @@ -0,0 +1,27 @@ +Типы проектов +============= + + + +Проекты могут быть двух типов: + + + +| Тип | Описание | +|-----------------|----------------------------------------------------------| +| Командный проект| В проекте могут принимать участие пользователи и группы | +| Приватный проект| Проект принадлежит только одному пользователю, к проекту нельзя присоединить участников| + + + +- Командный проект могут создавать только пользователи с ролью Администратор и Менеджер. +- Приватный проект могут создавать все пользователи. + + +[Читать документацию про роли в Kanboard](roles.markdown) + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/project-views.markdown b/doc/ru_RU/project-views.markdown new file mode 100644 index 00000000..1d1f1117 --- /dev/null +++ b/doc/ru_RU/project-views.markdown @@ -0,0 +1,154 @@ +Представления Доска, Календарь, Список и Гант +============================================= + + + +В каждом проекте задачи могут быть отображены в разных представлениях: **Доска, Календарь, Список и Гант**. Для отображения представлений используется фильтр в верхней части рабочей панели. Для поиска используется [расширенный синтаксис](ext-search.markdown). + + + +Представление - Доска[¶](#board-view "Ссылка на этот заголовок") +---------------------------------------------------------------- + + + +![Board view](screenshots/board-view.png) + +Рисунок. Представление зачад в виде доски + + + +- В этом представлении вы можете мышкой перемещать задачи между колонками. + + + +- Также, для перемещения задач на доске, можно использовать горячие клавиши **“v b”**. + + + +- Затемнения вокруг задачи показывает активную или измененную задачу. + + + +![Board Task Limit](screenshots/board-task-limit.png) + +Рисунок. Лимит задач на Доске + + + +Когда лимит задач для колонки достигнут, тогда фон колонки становится красный. Это означает, что слишком много задач выполняются одновременно. + + + +[Ознакомится с настройками Доски](board-configuration.markdown) + + + +Представление - Календарь[¶](#calendar-view "Ссылка на этот заголовок") +----------------------------------------------------------------------- + + + +![Calendar view](screenshots/calendar-view.png) + +Рисунок. Представление в виде календаря + + + +- В этом представлении вы можете видеть задачи на конкретные даты. + + + +- Вы можете сделать настройки, которые позволят вам видеть задачи в работе. + + + +- Вы можете использовать горячие клавиши **“v c”** для перехода на представление Календарь. + + + +- [Ознакомится с настройками Календаря](calendar-configuration.markdown) + + + +Представление - Список[¶](#list-view "Ссылка на этот заголовок") +---------------------------------------------------------------- + + + +![List view](https://kanboard.net/screenshots/documentation/list-view.png) + +Рисунок. Представление списком. + + + +- С помощью этого представления все результаты отображаются в виде таблицы. + + + +- Для быстрого перехода на представление Список вы можете использовать горячие клавиши **“v l”**. + + + +Представление - Гант.[¶](#gantt-view "Ссылка на этот заголовок") +---------------------------------------------------------------- + + + +![Gantt view](screenshots/gantt-view.png) + +Рисунок. Представление диаграммой Ганта. + + + +- Представление Гант отображает задачи горизонтальными графиками + + + +- Для построения графика используется дата начала и срок выполнения + + + +- Для быстрого перехода к представлению Гант используйте горячие клавиши : **“v g”** + + + +Обзор Проекта[¶](#project-overview "Ссылка на этот заголовок") +-------------------------------------------------------------- + + + +![Project overview](screenshots/project-view.png) + +Рисунок. Представления проекта + + + +- Отображает описание проекта + + + +- Показывает прикрепленные и загруженные документы проекта + + + +- Показывает список участников проекта + + + +- Показывает последнюю активность в проекте + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/recurring-tasks.markdown b/doc/ru_RU/recurring-tasks.markdown new file mode 100644 index 00000000..a6572f2c --- /dev/null +++ b/doc/ru_RU/recurring-tasks.markdown @@ -0,0 +1,67 @@ +Повторяющиеся задачи +==================== + + + +Для соответсвия методологии Канбан, повторяющиеся задачи не имеют в качестве основы дату, а запускаются при наступлении событий на Доске. + + + +- Повторяющиеся задачи копируются (появляются вновь) в первой колонке Доски когда наступает определенное событие + + + +- Дата завершения (срок выполнения задачи) пересчитывается автоматически + + + +- Each task records the task id of the parent task that created it and the child task created + + + +Настройка[¶](#configuration "Ссылка на этот заголовок") +------------------------------------------------------- + + + +Перейдите на страницу детального представления задачи или используйте выпадающее меню на доске, выберите **Редактировать повторы**. + + + +![Recurring task](https://kanboard.net/screenshots/documentation/recurring-tasks.png) + +Рисунок. Редактировать повторы. + + + +В редактировании повторов имеется выбор 3 триггеров для генерации периодической задачи: + + + +- Когда задача перемещается из первой колонки + + + +- Когда задача перемещается в последнюю колонку + + + +- Когда задача закрывается + + + +Дата завершения, если установлена для текущей задачи, может быть пересчитана с учетом **Коэффициента для расчета новой даты** и **Период для рассчета новой даты завершения** (например, 7 дней, 6 месяцев, 1 год). Базовой датой вычисления новой даты завершения может быть и имеющаяся дата завершения, или дата действия. + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/requirements.markdown b/doc/ru_RU/requirements.markdown new file mode 100644 index 00000000..aa6933b9 --- /dev/null +++ b/doc/ru_RU/requirements.markdown @@ -0,0 +1,137 @@ +Системные требования +==================== + + + +На сервере[¶](#server-side "Ссылка на этот заголовок") +------------------------------------------------------ + + + +### Поддерживаемые операционные системы[¶](#compatible-operating-systems "Ссылка на этот заголовок") + +|Операционная система| +|-----------------------------------| +|Linux Ubuntu Xenial Xerus 16.04 LTS| +| Linux Ubuntu Trusty 14.04 LTS| +| Linux Centos 6.x| +| Linux Centos 7.x| +| Linux Redhat 6.x| +|Linux Redhat 7.x| +| Linux Debian 8| +| FreeBSD 10.x| +| Microsoft Windows 2012 R2| +| Microsoft Windows 2008| + + + +### Поддерживаемые базы данных[¶](#compatible-databases "Ссылка на этот заголовок") + + +|База данных | +|----------------------| +|Sqlite 3.x | +|Mysql \>= 5.5 | +|MariaDB \>= 10 | +| Postgresql \>= 9.3 | + + + +Какую базу данных выбрать? + + +| Тип | Когда использовать | +|--------------------|--------------------------------------------------------| +| Sqlite | Один пользователь или небольшая команда | +| Mysql/Postgres | Большая команда, конфигурация высокой доступности | + + + + +Не используйте Sqlite на смонтированном NFS. Используйте Sqlite только на дисках с высокой скоростью чтение/запись. + + + +### Совместимые веб сервера[¶](#compatible-web-servers "Ссылка на этот заголовок") + +Apache HTTP Server, Nginx , Microsoft IIS + +Канборд изначально сконфигурирован для работы с Apache (URL rewriting). + + + +### Версии PHP[¶](#php-versions "Ссылка на этот заголовок") + + +PHP \>= 5.3.3, PHP 5.4, PHP 5.5, PHP 5.6, PHP 7.x + + + +### Требуемые расширения для PHP[¶](#php-extensions-required "Ссылка на этот заголовок") + + +| Требуемые расширения для PHP | Примечание | +|----------------------------------|-----------------------------------------| +| pdo\_sqlite | Только при использовании Sqlite | +| pdo\_mysql | Только при использоании Mysql/MariaDB | +| pdo\_pgsql | Только при использовании Postgres | +| gd |   | +| mbstring |   | +| openssl |   | +| json |   | +| hash |   | +| ctype |   | +| session |   | +| ldap | Только для аутентификации LDAP | +| Zend OPcache | Рекомендуется | + + +### Рекомендуется[¶](#recommendations "Ссылка на этот заголовок") + + + +- Современная Linux или Unix операционная система. + + + +- Высокая производительность достигается с последней версией PHP со включенным кешированием OPcode. + + + +На клиенте[¶](#client-side "Ссылка на этот заголовок") +------------------------------------------------------ + + + +### Браузеры[¶](#browsers "Ссылка на этот заголовок") + + + +Используйте современные браузеры, обновленные до последней версии: + +|Браузер | +|-----------------| +| Safari | +| Google Chrome | +| Mozilla Firefox | +| Microsoft Internet Explorer \>= 11| +| Microsoft Edge | + + + +### Устройства[¶](#devices "Ссылка на этот заголовок") + + +| Устройство | Разрешение экрана | +|--------------------------------------|--------------------------------------| +| Персональный компьютер или ноутбук | \>= 1366 x 768 | +| Планшет | \>= 1024 x 768 | + + +Канборд, пока, не оптимизирован для работы на смартфонах. Конечно, он работает, но пользовательский интерфейс не совсем удобный для использования. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/reverse-proxy-authentication.markdown b/doc/ru_RU/reverse-proxy-authentication.markdown new file mode 100644 index 00000000..2d97a6e4 --- /dev/null +++ b/doc/ru_RU/reverse-proxy-authentication.markdown @@ -0,0 +1,138 @@ +Аутентификация Reverse Proxy +============================ + + + +Этот метод аутентификации часто используется для [SSO](https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%85%D0%BD%D0%BE%D0%BB%D0%BE%D0%B3%D0%B8%D1%8F_%D0%B5%D0%B4%D0%B8%D0%BD%D0%BE%D0%B3%D0%BE_%D0%B2%D1%85%D0%BE%D0%B4%D0%B0) (Технология единого входа), особенно удобно в больших организациях. + + + +Аутентификация выполняется с помощью другой системы, поэтому Канборд не знает вашего пароля и допускает вас к приложению, так как вы уже прошли аутентификацию. + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Правильно сконфигурированный reverse proxy + + + +или + + + +- Apache Auth на том же сервере + + + +Как это работает?[¶](#how-does-this-work "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +1. Ваш reverse proxy аутентифицирует пользователя и посылает имя пользователя через заголовок HTTP. + + + +2. Канборд извлекает имя пользователя из запроса + + + + - Пользователь создается в Канборд автоматически (опция настраивается) + + + + - Открывается новая сессия Канборд (дополнительная аутентификация в Канборд не нужна) + + + +Инструкция по установке[¶](#installation-instructions "Ссылка на этот заголовок") +--------------------------------------------------------------------------------- + + + +### Настройка вашего reverse proxy[¶](#setting-up-your-reverse-proxy "Ссылка на этот заголовок") + + + +В рамках данной документации не рассматривается установка и настройка reverse proxy. Вы должны убедится, что логин пользователя отправляется с reverse proxy в заголовке HTTP. + + + +### Настройки Канборда[¶](#setting-up-kanboard "Ссылка на этот заголовок") + + + +Создайте свой файл конфигурации `config.php` или скопируйте конфигурацию из файла `config.default.php`: + + + + \`\_\_ имя заголовка будет `REMOTE_USER`. Например, Apache добавляет `REMOTE_USER` по умолчанию, если установлено `Require valid-user`. + + + +- Если Apache служит reverse proxy для другого Apache выполняющего Канборд, то заголовок `REMOTE_USER` не установлен (это же относится к IIS и Nginx). + + + +- Если у вас имеется действующий reverse proxy, то [проект HTTP ICAP](http://tools.ietf.org/html/draft-stecher-icap-subid-00#section-3.4) предполагает, что заголовок должен быть `X-Authenticated-User`. Этот стандарт де-факто был принят разными инструментами. + + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/roles.markdown b/doc/ru_RU/roles.markdown new file mode 100644 index 00000000..5af8a937 --- /dev/null +++ b/doc/ru_RU/roles.markdown @@ -0,0 +1,44 @@ +Пользовательские роли +===================== + + + +Роли в приложениях[¶](#application-roles "Ссылка на этот заголовок") + +-------------------------------------------------------------------- + + + +Каждый пользователь системы Канборд имеет одну из этих ролей + + + +| Роль | Описание | +|----------------|-----------------------------------------------------------| +| Администратор | Имеет доступ ко всему | +| Менеджер | Может создавать командные проекты, но не может изменять настройки приложения | +| Пользователь | Может создавать только приватные проекты | + + + + +Роли в проектах[¶](#project-roles "Ссылка на этот заголовок") + +------------------------------------------------------------- + + + +В каждом командном проекте могут быть назначены разные роли для пользователей и групп: + + +| Роль | Описание | +|-----------------|----------------------------------------------------------| +| Менеджер проекта| Может изменять настройки проекта, имеет доступ к диаграмме Ганта и отчетам | +| Участник проекта| Может создавать задачи и пользоваться доской | +| Наблюдатель проекта | Имеет доступ к доске и задачам только на просмотр (чтение) | + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/rss.markdown b/doc/ru_RU/rss.markdown new file mode 100644 index 00000000..c4718880 --- /dev/null +++ b/doc/ru_RU/rss.markdown @@ -0,0 +1,58 @@ +RSS/Atom подписки +================= + + + +Канборд поддерживает RSS ленты для проектов и пользователей. + + + +- RSS/Atom лента для проекта - содержит только активность в проекте + + + +- RSS/Atom лента пользователя - содержит поток активности пользователя во всех проектах, в которых пользователь является участником + + + +Эти подписки доступны только при включенном общем доступе в пользовательском профиле или в настройках проекта. + + + +Включение/выключение RSS ленты проекта[¶](#enable-disable-project-rss-feeds "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------- + + + +Перейдите в **Настройки проекта** -\> **Общий доступ** + + + +![Disable public access](https://kanboard.net/screenshots/documentation/project-disable-sharing.png) + +Рисунок. Выключение общего доступа. + + + +Включение/выключение RSS ленты пользователя[¶](#enable-disable-user-rss-feeds "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------- + + + +Перейдите в **Мой профиль** -\> **Общий доступ** + + + +Ссылка на RSS ленту защищена случайным ключом, только пользователи, которые знают URL ссылку, могут иметь доступ к ленте. + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/screenshots.markdown b/doc/ru_RU/screenshots.markdown new file mode 100644 index 00000000..2260f258 --- /dev/null +++ b/doc/ru_RU/screenshots.markdown @@ -0,0 +1,74 @@ +Добавление снимков экрана (скриншота) +===================================== + + + +Для экономии времени вы можете копировать и вставлять изображения прямо в Канборде. Загруженные изображения прикрепляются к задаче. + + + +Например, очень удобно для решения проблемы прикрепить снимок экрана. + + + +Вы можете добавить снимок экрана прямо из Доски нажав на выпадающее меню задачи и выбрав **Прикрепить картинку** или на странице детального просмотра задачи. + + + +![Drop-down screenshot menu](https://kanboard.net/screenshots/documentation/dropdown-screenshot.png) + + + +Рисунок. Выпадающее меню задачи - **Прикрепить картинку**. + + + +Для добавления нового снимка экрана (скриншота), сделайте снимок экрана (нажмите клавиши Ctrl+PrtScn) и вставьте его используя сочетания клавиш CTRL+V или Command+V + + + +![Screenshot page](https://kanboard.net/screenshots/documentation/task-screenshot.png) + +Рисунок. Прикрепить картинку. + + + +В Mac OS X вы можете использовать следующие горячие клавиши для создания снимка экрана: + + + +- Command-Control-Shift-3: Делает снимок экрана и сохраняет его в буфер обмена + + + +- Command-Control-Shift-4 и выделите необходимую область на экране: Делает снимок экрана для области экрана и сохраняет ее в буфер обмена + + + +- Command-Control-Shift-4, затем пробел, затем нажать на окно: Делает снимок окна и сохраняет его в буфер обмена + + + +Имеется много разных других программ для создания снимков с экрана с примечаниями и разными формами. + + + +**Заметка**: Эта возможность работает не во всех браузерах. Например, не работает в Safari из-за этой ошибки: [https://bugs.webkit.org/show\_bug.cgi?id=49141](https://bugs.webkit.org/show_bug.cgi?id=49141) + + + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/search.markdown b/doc/ru_RU/search.markdown new file mode 100644 index 00000000..14c3f5b1 --- /dev/null +++ b/doc/ru_RU/search.markdown @@ -0,0 +1,24 @@ +Поиск + +===== + + + +Для работы поиска включите JavaScript в браузере. + + + +Здесь можно делать поиск по всем разделам этой документации. Введите ключевые слова в текстовое поле и нажмите кнопку «искать». Внимание: будут найдены только те страницы, в которых есть все указанные слова. Страницы, где есть только часть этих слов, отобраны не будут. + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/sharing-projects.markdown b/doc/ru_RU/sharing-projects.markdown new file mode 100644 index 00000000..e8448189 --- /dev/null +++ b/doc/ru_RU/sharing-projects.markdown @@ -0,0 +1,82 @@ +Публичные доски и задачи +======================== + + + +По умолчанию, Доска имеет приватный доступ, но имеется возможность сделать Доску публичной. + + + +Публичная доска **не может быть изменена (имеется только доступ на чтение)**. Доступ к доске защищен случайно сгенерированным ключом, только пользователи знающие правильный URL могут увидеть публичную Доску. + + + +Публичная Доска автоматически обновляется каждые 60 секунд. Детали задач, также, доступны только для чтения. + + + +Пример использования: + + + +- Публикация вашей Доски для кого-либо снаружи (работник из другой организации) + + + +- Отображение Доски на большом экране в вашем офисе + + + +Включение общего доступа[¶](#enable-public-access "Ссылка на этот заголовок") +----------------------------------------------------------------------------- + + + +Выберете ваш проект, затем нажмите на ссылку **“Общий доступ”** и в завершении нажмите на кнопку **“Включить общий доступ”** + + + +![Enable public access](screenshots/project-enable-sharing.png) + +Рисунок. Включение общего доступа + + + +Когда общий доступ к проекту включен, сгенерируется несколько ссылок: + + + +- Ссылка для просмотра + + + +- RSS лента + + + +- iCalendar данные + + + +![Disable public access](screenshots/project-disable-sharing.png) + +Рисунок. Отключить общий доступ. + + + +Вы можете выключить общий доступ к проекту в любой момент. + + + +Каждый раз, когда вы включаете или выключаете общий доступ, генерируется новый ключ. **Доступ по предыдущей ссылке будет невозможен**. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/sqlite-database.markdown b/doc/ru_RU/sqlite-database.markdown new file mode 100644 index 00000000..202452cb --- /dev/null +++ b/doc/ru_RU/sqlite-database.markdown @@ -0,0 +1,96 @@ +Настройка базы данных Sqlite +============================ + + + +Канборд использует для хранения данных Sqlite по умолчанию. Все задачи, проекты и учетные записи пользователей храняться в этой базе данных. + + + +База данных Sqlite хранит данные в файле `db.sqlite` в директории `data`. + + + +Экспорт/Резервное копирование[¶](#export-backup "Ссылка на этот заголовок") +--------------------------------------------------------------------------- + + + +### Командная строка[¶](#command-line "Ссылка на этот заголовок") + + + +Создание резервных копий выполняется просто, надо скопировать файл `data/db.sqlite` туда, где у вас будут хранится резервные копии. + + + +### Пользовательский интерфейс[¶](#user-interface "Ссылка на этот заголовок") + + + +Также, в любое время, вы можете скачать базу данных прямо через меню **Настройки**. + + + +Выгружаемая база данных упакована с помощью Gzip и имя базы выглядитит как `db.sqlite.gz`. + + + +Импорт/Восстановление[¶](#import-restoration "Ссылка на этот заголовок") +------------------------------------------------------------------------ + + + +Загрузить базу данных через пользовательский интерфейс невозможно. Восстановление должно быть выполнено вручную, когда никто не работает с программой. + + + +- Для восстановления резервной копии, достаточно заменить рабочий файл `data/db.sqlite`. + + + +- Для разархивирования базы данных упакованной с помощью gzip, выполните следующую команду в терминале: `gunzip db.sqlite.gz`. + + + +Оптимизация[¶](#optimization "Ссылка на этот заголовок") +-------------------------------------------------------- + + + +Время от времени, рекомендуется оптимизировать базу данных выполнив команду `VACUUM`. Эта команда пересоздает всю базу данных и используется в следующих случаях: + + + +- Для уменьшения размера файла базы данных. В процессе работы пользователей, после удаления записей, в базе данных остается пустое пространство и, соответственно, размер файла базы данных остается прежним. + + + +- Дефрагментация, база данных фрагментирована выполнением частыми вставками или обновлениями. + + + +### Выполнение оптимизации в командной строке[¶](#from-the-command-line "Ссылка на этот заголовок") + + + + sqlite3 data/db.sqlite 'VACUUM' + + + +### Выполнение оптимизации через пользовательский интерфейс[¶](#from-the-user-interface "Ссылка на этот заголовок") + + + +Перейдите в правое выпадающее меню **Настройки** и нажмите на ссылку **Оптимизировать базу данных** + + + +Для дополнительной информации, изучите [документацию Sqlite](https://sqlite.org/lang_vacuum.html). + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/subtasks.markdown b/doc/ru_RU/subtasks.markdown new file mode 100644 index 00000000..c78aee73 --- /dev/null +++ b/doc/ru_RU/subtasks.markdown @@ -0,0 +1,111 @@ +Подзадачи +========= + + +Подзадачи - это прекрасная возможность разделить основную задачу на части. + + + +Каждая подзадача: + + + +- Может быть назначена участнику проекта + + + +- Имеет 3 разных статуса: **Для исполнения**, **В работе**, **Выполнено** + + + +- Имеет информацию по отслеживанию времени: **затраченное время** и **запланированное время** + + + +- Может быть перемещена в списке, для изменения порядка выполнения + + + +Создание подзадачи[¶](#creating-subtasks "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +В детальном представлении задачи, в левой боковой панели нажмите **Добавить подзадачу**: + + + +![Add a subtask](https://kanboard.net/screenshots/documentation/add-subtask.png) + +Рисунок. Добавление подзадачи. + + + +Вы, также, можете быстро добавить подзадачу нажав на заголовок: + + + +![Add a subtask from the task view](https://kanboard.net/screenshots/documentation/add-subtask-shortcut.png) + +Рисунок. Добавление подзадачи на странице детального просмотра задачи. + + + +Изменение статуса подзадачи[¶](#change-subtask-status "Ссылка на этот заголовок") +--------------------------------------------------------------------------------- + + + +Когда вы нажимаете на заголовок подзадачи стату меняется: + + + +![Subtask in progress](https://kanboard.net/screenshots/documentation/subtask-status-inprogress.png) + +Рисунок. Выполнение подзадачи. + + + +Иконка перед названием подзадачи обновляется в соответсвии со статусом. + + + +![Subtask done](https://kanboard.net/screenshots/documentation/subtask-status-done.png) + +Рисунок. Подзадача выполнена. + + + +**Заметка**: Когда задача закрыта, то все подзадачи меняют статус на **Выполнена**. + + + +Таймер подзадачи[¶](#subtask-timer "Ссылка на этот заголовок") +-------------------------------------------------------------- + + + +- Когда подзадача выполняется, таймер должен быт запущен. Таймер можно запустить и остановить в любое время. + + + +- Время таймера записывается автоматически в затраченное время. Так же, вы можете изменить вручную значение **затраченного времени** при редактировании подзадачи. + + + +- Подсчитываемое время округляется до 15 минут. Эта информация записывается в отдельную таблицу. + + + +- Время, затраченное на выполнение задачи, и запланированнное время обновляется автоматически, в соответсвии с суммой всех подзадач. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/suse-installation.markdown b/doc/ru_RU/suse-installation.markdown new file mode 100644 index 00000000..6d508708 --- /dev/null +++ b/doc/ru_RU/suse-installation.markdown @@ -0,0 +1,36 @@ +Инсталяция на OpenSuse +====================== + + + +OpenSuse Leap 42.1[¶](#opensuse-leap-42-1 "Ссылка на этот заголовок") +--------------------------------------------------------------------- + + + + sudo zypper install php5 php5-sqlite php5-gd php5-json php5-mcrypt php5-mbstring php5-openssl + + cd /srv/www/htdocs + + sudo wget https://kanboard.net/kanboard-latest.zip + + sudo unzip kanboard-latest.zip + + sudo chmod -R 777 kanboard + + sudo rm kanboard-latest.zip + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/swimlanes.markdown b/doc/ru_RU/swimlanes.markdown new file mode 100644 index 00000000..d6e36fdd --- /dev/null +++ b/doc/ru_RU/swimlanes.markdown @@ -0,0 +1,81 @@ +Дорожки +======= + + + +Дорожки - это горизонтальное разделение вашей Доски. Например, очень удобно разделять релизы программ, разделить ваши задачи для разных продуктов, команд или чего-то еще. + + + +Доска с дорожками[¶](#board-with-swimlanes "Ссылка на этот заголовок") +---------------------------------------------------------------------- + + + +![Swimlanes](screenshots/swimlanes.png) + +Рисунок. Дорожки + + + +- Вы можете свернуть дорожку нажав на иконку слева + + + +- “Стандатная дорожка” всегда расположена сверху + + + +Управление дорожками[¶](#managing-swimlanes "Ссылка на этот заголовок") +----------------------------------------------------------------------- + + + +- Все проекты имеют дорожку по умолчанию - **Стандартная дорожка** + + + +- Если имеется больше одной дорожки, то на Доске будут показаны все имеющиеся дорожки. + + + +- Вы можете перемещать мышкой задачи между дорожками. + + + +Для настройки дорожек перейдите на страницу **настройки проекта** (Меню -\> Настройки) и нажмите **Дорожки** (слева). + + + +![Swimlanes Configuration](screenshots/swimlane-configuration.png) + +Рисунок. Настройка Дорожек. + + + +Теперь вы можете добавить новую дорожку или переименовать стандартную дорожку. Также, вы можете выключить дорожку или изменить расположение любой дорожки. + + + +- Стандартная дорожка всегда расположена сверху, но вы можете ее выключить и она не будет отображаться на Доске. + + + +- Выключенные дорожки не отображаются на Доске. + + + +- **Удаление дорожки не влечет за собой удаление расположенных на этой дорожке задач**, эти задачи будут перемещены в “Стандартную дорожку”. + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/syntax-guide.markdown b/doc/ru_RU/syntax-guide.markdown new file mode 100644 index 00000000..9d7414a8 --- /dev/null +++ b/doc/ru_RU/syntax-guide.markdown @@ -0,0 +1,246 @@ +Руководство по синтаксису +========================= + + + +Канборд использует [Markdown синтаксис](https://ru.wikipedia.org/wiki/Markdown) для комментариев или описания задач. Далее приведены примеры: + + + +Жирный и курсив[¶](#bold-and-italic "Ссылка на этот заголовок") +--------------------------------------------------------------- + +- Жирный текст: Используйте 2 звездочки или 2 подчеркивания вокруг слов(а) + + + +- Курсив: Используйте 1 звездочку или 1 подчеркивание вокруг слов(а) + + + +### Пример написания (источник)[¶](#source "Ссылка на этот заголовок") + + + + This **word** is very __important__. + + + + And here, an *italic* word with one _underscore_. + + + +### Результат[¶](#result "Ссылка на этот заголовок") + + + +This **word** is very **important**. + + + +And here, an *italic* word with one *underscore*. + + + +Неупорядоченные списки[¶](#unordered-lists "Ссылка на этот заголовок") +---------------------------------------------------------------------- + + + +Неупорядоченный список использует звездочки, минусы или плюсы вначале абзаца + + + +### Пример написания (источник)[¶](#id1 "Ссылка на этот заголовок") + + + + - Item 1 + + - Item 2 + + - Item 3 + + + + or + + + + * Item 1 + + * Item 2 + + * Item 3 + + + +### Результат[¶](#id2 "Ссылка на этот заголовок") + + + +- Item 1 + +- Item 2 + +- Item 3 + + + +Упорядоченные списки[¶](#ordered-lists "Ссылка на этот заголовок") +------------------------------------------------------------------ + + + +Упорядоченные списки префиксом имеют цифру: + + + +### Пример написания (источник)[¶](#id3 "Ссылка на этот заголовок") + + + + 1. Do that first + + 2. Do this + + 3. And that + + + +### Результат[¶](#id4 "Ссылка на этот заголовок") + + + +1. Do that first + +2. Do this + +3. And that + + + +Ссылки[¶](#links "Ссылка на этот заголовок") +-------------------------------------------- + + + +### Пример написания (источник)[¶](#id5 "Ссылка на этот заголовок") + + + + [My link title](https://kanboard.net/) + + + + + + + +### Результат[¶](#id6 "Ссылка на этот заголовок") + + + +[My link title](https://kanboard.net/) + + + +[https://kanboard.net](https://kanboard.net) + + + +Исходный код[¶](#source-code "Ссылка на этот заголовок") +-------------------------------------------------------- + + + +### Код встраиваемый в текст[¶](#inline-code "Ссылка на этот заголовок") + + + +Используйте обратные кавычки (переключитесь на анлийскую раскладку и нажмите ё) + + + + Execute this command: `tail -f /var/log/messages`. + + + +### Результат[¶](#id7 "Ссылка на этот заголовок") + + + +Execute this command: `tail -f /var/log/messages`{.docutils .literal}. + + + +### Блоки кода[¶](#code-blocks "Ссылка на этот заголовок") + + + +Используйте 3 обратных кавычки с указанием языка программирования + + + + ```php + + + + ``` + + + +### Результат[¶](#id8 "Ссылка на этот заголовок") + + + + + + + +Заголовки[¶](#titles "Ссылка на этот заголовок") +------------------------------------------------ + + + +### Пример написания (источник)[¶](#id9 "Ссылка на этот заголовок") + + + + # Title level 1 + + + + ## Title level 2 + + + + ### Title level 3 + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/task-links.markdown b/doc/ru_RU/task-links.markdown new file mode 100644 index 00000000..2912f91b --- /dev/null +++ b/doc/ru_RU/task-links.markdown @@ -0,0 +1,93 @@ +Ссылки на задачи +================ + + + +Задачи могут быть созданы вместе с предопределенными связями: + + + +![Task Links](https://kanboard.net/screenshots/documentation/task-links.png) + +Рисунок. Ссылки на задачи + + + +Связи по умолчанию: + + + +- **относится к** + + + +- **блокирована**| блокирует + + + +- **блокирует** | блокирована + + + +- **дублирована** | дублирует + + + +- **дублирует** | дублирована + + + +- **является продолжением** | является началом для + + + +- **является началом для** | является продолжением + + + +- **часть вехи** | является вехой для + + + +- **является вехой для** | часть вехи + + + +- **исправлено** | исправляет + + + +- **исправляет** | исправлено + + + +Эти названия могут быть быть изменены в настройках приложения. + + + + + + + + + + + + + + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/tests.markdown b/doc/ru_RU/tests.markdown new file mode 100644 index 00000000..2373d030 --- /dev/null +++ b/doc/ru_RU/tests.markdown @@ -0,0 +1,262 @@ +Автоматизированные тесты +======================== + + + +[PHPUnit](https://phpunit.de/) используется для запуска автоматизированных тестов в Канборд. + + + +Вы можете запускать тесты для разных баз данных (Sqlite, Mysql and Postgresql), чтобы убедится, что результаты будут одинаковые. + + + +Требования[¶](#requirements "Ссылка на этот заголовок") +------------------------------------------------------- + + + +- Компьютер Linux/Unix + + + +- PHP cli + + + +- Установленный PHPUnit + + + +- Mysql и Postgresql (опционально) + + + +Unit тесты[¶](#unit-tests "Ссылка на этот заголовок") +----------------------------------------------------- + + + +### Тестирование с Sqlite[¶](#test-with-sqlite "Ссылка на этот заголовок") + + + +Sqlite тестирование использует базу данных в памяти, без использования записи на файловую систему. + + + +Конфигурационный файл PHPUnit - `tests/units.sqlite.xml`. Из директории Kanboard запустите команду `phpunit -c tests/units.sqlite.xml`. + + + +Пример: + + + + phpunit -c tests/units.sqlite.xml + + + + PHPUnit 5.0.0 by Sebastian Bergmann and contributors. + + + + ............................................................... 63 / 649 ( 9%) + + ............................................................... 126 / 649 ( 19%) + + ............................................................... 189 / 649 ( 29%) + + ............................................................... 252 / 649 ( 38%) + + ............................................................... 315 / 649 ( 48%) + + ............................................................... 378 / 649 ( 58%) + + ............................................................... 441 / 649 ( 67%) + + ............................................................... 504 / 649 ( 77%) + + ............................................................... 567 / 649 ( 87%) + + ............................................................... 630 / 649 ( 97%) + + ................... 649 / 649 (100%) + + + + Time: 1.22 minutes, Memory: 151.25Mb + + + + OK (649 tests, 43595 assertions) + + + +### Тестирование с Mysql[¶](#test-with-mysql "Ссылка на этот заголовок") + + + +У вас должна быть локально установлена база данных Mysql или MariaDb. + + + +По умолчанию, используются следующие учетные данные: + + + +- Hostname: **localhost** + +- Username: **root** + +- Password: none + +- Database: **kanboard\_unit\_test** + + + +При каждом выполнении база данных удаляется и создается снова. + + + +Конфигурационный файл HPUnit - `tests/units.mysql.xml`. Из директории Kanboard запустите команду `phpunit -c tests/units.mysql.xml`. + + + +### Тестирование с Postgresql[¶](#test-with-postgresql "Ссылка на этот заголовок") + + + +У вас должен быть локально установлен Postgresql. + + + +По умолчанию, используются следующие учетные данные: + + + +- Hostname: **localhost** + +- Username: **postgres** + +- Password: none + +- Database: **kanboard\_unit\_test** + + + +Убедитесь, что пользователь `postgres` может создавать и удалять базу данных. База данных пересоздается при каждом выполнении теста. + + + +Конфигурационных файл PHPUnit - `tests/units.postgres.xml`. Из директории Kanboard, запустите команду `phpunit -c tests/units.postgres.xml`. + + + +Тесты интеграции[¶](#integration-tests "Ссылка на этот заголовок") +------------------------------------------------------------------ + + + +Фактически тестируются только вызовы API. + + + +Реальные HTTP calls выполняются с этими тестами. Поэтому, необходим локальный экземпляр Канборда, который слушает на `http://localhost:8000/`. + + + +Все данные будут удалены/изменены при тестировании. Более того скрипт будет сброшен и установлен новый ключ API. + + + +1. Запустите локольный экземпляр Канборда: `php -S 127.0.0.1:8000` + + + +2. Запустите тест в другом терминале + + + +Этот же метод используется для запуска тестов для разных баз данных: + + + +- Sqlite: `phpunit -c tests/integration.sqlite.xml` + +- Mysql: `phpunit -c tests/integration.mysql.xml` + +- Postgresql: `phpunit -c tests/integration.postgres.xml` + + + +Пример: + + + + phpunit -c tests/integration.sqlite.xml + + + + PHPUnit 5.0.0 by Sebastian Bergmann and contributors. + + + + ............................................................... 63 / 135 ( 46%) + + ............................................................... 126 / 135 ( 93%) + + ......... 135 / 135 (100%) + + + + Time: 1.18 minutes, Memory: 14.75Mb + + + + OK (135 tests, 526 assertions) + + + +Непрерывная интеграция с Travis-CI[¶](#continuous-integration-with-travis-ci "Ссылка на этот заголовок") + +-------------------------------------------------------------------------------------------------------- + + + +После каждого commit влитого в мой репозиторий, юнит тесты выполняются для 5 различных версий PHP: + + + +- PHP 7.0 + +- PHP 5.6 + +- PHP 5.5 + +- PHP 5.4 + +- PHP 5.3 + + + +При тестировании каждой версии PHP используются 3 поддерживаемые базы данных: Sqlite, Mysql and Postgresql. + + + +Конфигурационный файл Travis - `.travis.yml` - находится в корневой директории Kanboard. + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/time-tracking.markdown b/doc/ru_RU/time-tracking.markdown new file mode 100644 index 00000000..98364d38 --- /dev/null +++ b/doc/ru_RU/time-tracking.markdown @@ -0,0 +1,112 @@ +Отслеживание времени +==================== + + + +Отслеживание времени (контроль времени) может быть использовано для уровня задач или для уровня подзадач. + + + +Отслеживание времени испольнения задач[¶](#task-time-tracking "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------- + + + +![Task time tracking](https://kanboard.net/screenshots/documentation/task-time-tracking.png) + +Рисунок. Отслеживание времени испольнения задач + + + +Задачи имеют два поля: + + + +- Запланировано времени + + + +- Затрачено времени + + + +Эти значения показывают время работы и могут быть установлены вручную + + + +Отслеживание времени подзадач[¶](#subtask-time-tracking "Ссылка на этот заголовок") +----------------------------------------------------------------------------------- + + + +![Subtask time tracking](https://kanboard.net/screenshots/documentation/subtask-time-tracking.png) + +Рисунок. Отслеживание времени подзадач + + + +Подзадачи тоже имеют поля “Запланировано” и “Затрачено” время. + + + +Когда вы меняете значения в этих полях, **отслеживание времени задачи обновляется автоматически и формируется суммарное время всех подзадач** + + + +Канборд записывает время между изменениями статуса каждой подзадачи в отдельную таблицу. + + + +- При изменении статуса подзадачи с **“Для испольнения”** на **“В работе”**, записывается время начала + + + +- При изменении статуса подзадачи с **“В работе”** на **“Выполнено”**, записывается как время окончания и, при этом, обновляется **затраченное время** в подзадаче и в задаче. + + + +Анализ всех записей можно увидеть на странице детального просмотра задачи: + + + +![Task timesheet](https://kanboard.net/screenshots/documentation/task-timesheet.png) + +Рисунок. Таблица учета времени. + + + +Для каждой подзадачи, таймер может быть остановлен и запущен в любое время: + + + +![Subtask timer](https://kanboard.net/screenshots/documentation/subtask-timer.png) + +Рисунок. Таймер подзадач. + + + +- Таймер не зависит от статуса подзадачи + + + +- Вы можете запустить таймер для новой записи, созданной в таблице отслеживания задач, в любое время + + + +- Вы можете остановить учет времени даты завершения в таблице отслеживания задач, в любое время + + + +- Подсчет затраченного времени округляется до четверти часа + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/transitions.markdown b/doc/ru_RU/transitions.markdown new file mode 100644 index 00000000..efb95c50 --- /dev/null +++ b/doc/ru_RU/transitions.markdown @@ -0,0 +1,60 @@ +Перемещения задач +================= + + + +Запись о перемещении отражает каждое движение задачи между колонками. + + + +![Transitions](https://kanboard.net/screenshots/documentation/transitions.png) + +Рисунок. Перемещения. + + + +Перемещение доступно в боковом меню в детальном представлении задачи (**Перемещения**). Вы можете увидеть следующую информацию: + + + +- Дата, когда было выполенено перемещение + + + +- Исходная колонка - колонка, из которой было сделано перемещение + + + +- Колонка назначения - колонка, в которую была перемещена задача + + + +- Исполнитель (пользователь, который переместил задачу) + + + +- Время проведенное в колонке (сколько времени было затрачено на выполнение задачи в указанной колонке) + + + +Данные о перемещении задач, также, могут быть экспортированы со страницы настроек проекта (**Меню** -\> **Экспорт**). + + + +![Transitions Export](https://kanboard.net/screenshots/documentation/transitions-export.png) + +Рисунок. Экспорт перемещений задач. + + + +Для указанного промежутка времени вы можете сформировать CSV файл, который вы можете импортировать в любое программное обеспечение с электронными таблицами (например, Excell). + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/translations.markdown b/doc/ru_RU/translations.markdown new file mode 100644 index 00000000..f4bcafc0 --- /dev/null +++ b/doc/ru_RU/translations.markdown @@ -0,0 +1,155 @@ +Переводы на другие языки (локализация) +====================================== + + + +Как перевести Канборд на новый язык?[¶](#how-to-translate-kanboard-to-a-new-language "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------------------- + + + +- Переводы хранятся в директории `app/Locale` + + + +- В этой директории есть поддиректории для разных языков, например, для русского имеется `ru_RU`, для французского - `fr_FR` и т.д. + + + +- Переводы находятся в PHP файле, который возвращает массив с парой ключ-значение + + + +- Ключ - оригинальный текст на английском и значение - перевод на соответсвующем языке + + + +- **Французские переводы всегда в актуальном состоянии** + + + +- Всегда используйте последнюю версию (branch master) + + + +### Создание нового перевода[¶](#create-a-new-translation "Ссылка на этот заголовок") + + + +1. Создайте новую директорию: `app/Locale/xx_XX`, например `app/Locale/fr_CA` для канадского фрацузского + + + +2. Создайте новый файл для перевода: `app/Locale/xx_XX/translations.php` + + + +3. Используйте как образец содержимое французского перевода (локализации) и замените значения + + + +4. Внесите изменения в файл `app/Model/Language.php` + + + +5. Проверьте добавленный язык на локальной версии Канборда + + + +6. Пошлите [pull-request на Github](https://help.github.com/articles/using-pull-requests/) + + + +Как обновить имеющийся перевод?[¶](#how-to-update-an-existing-translation "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------- + + + +1. Откройте файл перевода `app/Locale/xx_XX/translations.php` + + + +2. Отсутсвующие переводы закоментированы - `//` и значения пустые, нужно заполнить значения и удалить коментарий + + + +3. Проверьте внесенные изменения на локальной версии Канборда и пошлите [pull-request](https://help.github.com/articles/using-pull-requests/) + + + +Как добавить новый текст перевода в приложение?[¶](#how-to-add-new-translated-text-in-the-application "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------------------- + + + +Переводы отображаются с помощью функций в исходном коде: + + + +- `t()`: показывает текст с HTML escaping + + + +- `e()`: показывает текст без HTML escaping + + + +Всегда используйте английскую версию исходного кода. + + + +Текстовые строки используют функцию `sprintf()` для замены элементов: + + + +- `%s` используется для замены строки + + + +- `%d` используется для замены цифры + + + +Ознакомится с доступными форматами вы можете в [документации PHP](http://php.net/sprintf). + + + +Как найти отсутствующие переводы в приложении?[¶](#how-to-find-missing-translations-in-the-applications "Ссылка на этот заголовок") +----------------------------------------------------------------------------------------------------------------------------------- + + + +Из терминала запустите следующую команду: + + + + ./kanboard locale:compare + + + +Все отсутствующие и неиспользуемые переводы будут показаны на экране. Добавьте их во французскую локализацию и синхронизируйте с другими локализациями (смотрите ниже) + + + +Как синхронизировать файлы переводов?[¶](#how-to-synchronize-translation-files "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------------- + + + +В оболочке Unix запустите следующую команду: + + + + ./kanboard locale:sync + + + +Французский перевод используется для ссылки на другие локализации. + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/ubuntu-installation.markdown b/doc/ru_RU/ubuntu-installation.markdown new file mode 100644 index 00000000..ac3cb565 --- /dev/null +++ b/doc/ru_RU/ubuntu-installation.markdown @@ -0,0 +1,111 @@ +Как инсталировать Канборд на Ubuntu? +==================================== + + + +Ubuntu Xenial 16.04 LTS[¶](#ubuntu-xenial-16-04-lts "Ссылка на этот заголовок") +------------------------------------------------------------------------------- + + + +Установите Apache и PHP: + + + + sudo apt-get update + + sudo apt-get install -y apache2 libapache2-mod-php7.0 php7.0-cli php7.0-mbstring php7.0-sqlite3 \ + + php7.0-opcache php7.0-json php7.0-mysql php7.0-pgsql php7.0-ldap php7.0-gd + + + +Установите Канборд: + + + + cd /var/www/html + + sudo wget https://kanboard.net/kanboard-latest.zip + + sudo unzip kanboard-latest.zip + + sudo chown -R www-data:www-data kanboard/data + + sudo rm kanboard-latest.zip + + + +Ubuntu Trusty 14.04 LTS[¶](#ubuntu-trusty-14-04-lts "Ссылка на этот заголовок") +------------------------------------------------------------------------------- + + + +Установите Apache и PHP: + + + + sudo apt-get update + + sudo apt-get install -y php5 php5-sqlite php5-mysql php5-pgsql php5-ldap php5-gd php5-json php5-mcrypt unzip + + + +Установите Канборд: + + + + cd /var/www/html + + sudo wget https://kanboard.net/kanboard-latest.zip + + sudo unzip kanboard-latest.zip + + sudo chown -R www-data:www-data kanboard/data + + sudo rm kanboard-latest.zip + + + +Ubuntu Precise 12.04 LTS[¶](#ubuntu-precise-12-04-lts "Ссылка на этот заголовок") +--------------------------------------------------------------------------------- + + + +Установите Apache и PHP: + + + + sudo apt-get update + + sudo apt-get install -y php5 php5-sqlite php5-mysql php5-pgsql php5-ldap php5-gd php5-json php5-mcrypt unzip + + + +Установите Канборд: + + + + cd /var/www + + sudo wget https://kanboard.net/kanboard-latest.zip + + sudo unzip kanboard-latest.zip + + sudo chown -R www-data:www-data kanboard/data + + sudo rm kanboard-latest.zip + + + +Некоторые возможности Канборда требуют [запуска ежедневных фоновых задач](cronjob.markdown). + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/update.markdown b/doc/ru_RU/update.markdown new file mode 100644 index 00000000..7cfabdb0 --- /dev/null +++ b/doc/ru_RU/update.markdown @@ -0,0 +1,57 @@ +Обновление Канборд до новой версии +================================== + + +Обновление Канборда до новой версии бесшовное. Процесс сводится к тому, что надо просто скопировать каталог с данными из старой версии в новый Канборд. Канборд запустит миграцию баз данных автоматически. + + + +Важные замечания перед обновлением[¶](#important-things-to-do-before-updating "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------- + +- Перед обновлением, обязательно сделайте копию ваших данных со старой версии Канборда + +- Всегда следите за [историей изменений](https://github.com/fguillot/kanboard/blob/master/ChangeLog) для отслеживания критических изменений + +- Всегда закрывайте все пользовательские сессии (очищайте все сессии на сервере) + + +Обновление из архива (стабильная версия)[¶](#from-the-archive-stable-version "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------- + + + +1. Скачайте и распакуйте архив с новой версией + +2. Скопируйте содержимое каталога с данными старой версии во вновь распакованный каталог + +3. Скопируйте из старой версии Канборда `config.php`, если вы его создавали + +4. Скопируйте плагины, если есть + +5. Убедитесь, что директория `data` имеет права на запись от пользователя веб сервера + +6. Проверьте работу новой версии + +7. Удалите старую версию Канборда + + +Обновление из репозитория (разрабатываемая версия)[¶](#from-the-repository-development-version "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------------------------- + + + +1. `git pull` + +2. `composer install --no-dev` + +3. Выполните вход и проверьте, что все работает корректно + + +**Внимание**: Выполняя обновление из разрабатываемой версии, вы должны понимать, что это нестабильная версия и берете все риски по работе Канборд на себя. + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/usage-examples.markdown b/doc/ru_RU/usage-examples.markdown new file mode 100644 index 00000000..d0d580e8 --- /dev/null +++ b/doc/ru_RU/usage-examples.markdown @@ -0,0 +1,193 @@ +Примеры использования +===================== + + + +Вы можете настроить вашу доску в соответсвии с вашими бизнес-процессами + + + +Разработка программного обеспечения[¶](#software-development "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------- + + + +- Заказ + + + +- Готов + + + +- В работе + + + +- Требуется утверждение + + + +- Утверждено + + + +- Развернуто в продакшн + + + +Отслеживание ошибок[¶](#bug-tracking "Ссылка на этот заголовок") +---------------------------------------------------------------- + + + +- Сообщение + + + +- Подтверждено + + + +- В работе + + + +- Проверено + + + +- Исправлено + + + +Продажи[¶](#sales "Ссылка на этот заголовок") +--------------------------------------------- + + + +- Клиенты + + + +- Встречи + + + +- Предложения + + + +- Приобретение + + + +Эффективное управление бизнесом[¶](#lean-business-management "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------- + + + +- Идеи + + + +- События + + + +- Мероприятия + + + +- Анализы + + + +- Исполненно + + + +Подбор персонала[¶](#recruiting-process "Ссылка на этот заголовок") +------------------------------------------------------------------- + + + +- Предложения о работе + + + +- Кандидаты + + + +- Телефонный отбор + + + +- Собеседование + + + +- Наем + + + +Онлайн магазин[¶](#online-shops "Ссылка на этот заголовок") +----------------------------------------------------------- + + + +- Заказы + + + +- Упаковка + + + +- Готов к отправке + + + +- Отправлен + + + +Производство[¶](#manufactory "Ссылка на этот заголовок") +-------------------------------------------------------- + + + +- Заказы покупателей + + + +- Сборка + + + +- Проверка + + + +- Упаковка + + + +- Готово к отгрузке + + + +- Отправлен + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/user-management.markdown b/doc/ru_RU/user-management.markdown new file mode 100644 index 00000000..ce74b7f8 --- /dev/null +++ b/doc/ru_RU/user-management.markdown @@ -0,0 +1,89 @@ +Управление пользователями +========================= + + + +Создание нового пользователя[¶](#add-a-new-user "Ссылка на этот заголовок") +--------------------------------------------------------------------------- + + + +Только администратор может создавать нового пользователя. + + + +1. В выпадающем меню, в правом верхнем углу, выберите **Управление пользователями** + + + +2. Вверху имеются ссылки - **Новый локальный пользователь** и **Новый удаленный пользователь** + + + +3. При создании пользователя нужно заполнить форму и сохранить + + + +![New user](screenshots/new-user.png) + +Рисунок. Форма создания нового пользователя. + + + +При создании **Локального пользователя** вы должны, как минимум, заполнить следующие поля: + + + +- **Имя пользователя**: это поле является уникальным идентификатором вашего пользователя (логин) + + + +- **Пароль**: Пароль пользователя должен иметь минимум 6 символов + + + +Для **удаленных пользователей** обязательно только **Имя пользователя**. + + + +Редактирование пользователей[¶](#edit-users "Ссылка на этот заголовок") +----------------------------------------------------------------------- + + + +После перехода в **Управление пользователями**, вам будет доступен список пользователей. Кликните на пользователя в столбце **Имя пользователя**. Далее, вам будет доступно редактирование настроек и профиля пользователя. + + + +- Если вы имеете права пользователя, то вы сможете только изменить ваш профиль + + + +- Для редактирования любого пользователя вам должны быть назначены права администратора + + + +Удаеление пользователей[¶](#remove-users "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +В списке пользователей выберите в колонке **Действия** в выпадающем меню **Удалить**. Эта ссылка доступна только для администраторов. + + + +Если вы удалите пользователя, то все задачи назначенные пользователю перестанут быть назначенными. + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/user-mentions.markdown b/doc/ru_RU/user-mentions.markdown new file mode 100644 index 00000000..766103e3 --- /dev/null +++ b/doc/ru_RU/user-mentions.markdown @@ -0,0 +1,49 @@ +Ссылка на пользователя +====================== + + + +В Канборде есть возможность посылать уведомления пользователю, если кто-то ссылается на него в тексте. + + + +Если вы хотите заострить внимание о ком-либо в комментарии или в задаче, то вы можете использовать символ @ и следом указать имя пользователя. Канборд автоматически предлагает список пользователей: + + + +![User Mention](screenshots/mention-autocomplete.png) + +Рисунок. Ссылка на пользователя. + + + +- В данный момент, добавлять ссылку на пользователя можно только в описании задачи и тексте комментария. + + + +- Ссылка на пользователя работает только в задачах и при создании комментария. + + + +- Для получения уведомления, пользователь, на которого ссылаются, должен быть участником проекта, в котором создается ссылка. + + + +- Если была создана ссылка на пользователя, то этот пользователь получит уведомление. + + + +- @username - выглядит как ссылка на публичный профиль пользователя. + + + +Уведомление посылаются пользователю в соответсвии с пользовательскими настройками: это может быть email, уведомление на веб странице или даже сообщение в Slack/Hipchat/Jabber, если вы установили соответсвующие плагины. + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/user-types.markdown b/doc/ru_RU/user-types.markdown new file mode 100644 index 00000000..9afb58b8 --- /dev/null +++ b/doc/ru_RU/user-types.markdown @@ -0,0 +1,26 @@ +Типы пользователей +================== + + + +В Канборде могут быть два типа пользователей: + + + +| Тип | Описание | +|--------------|-------------------------------------------------------------| +| Локальный пользователь | Пароль пользователя хранится в базе данных Канборда| +| Удаленный пользователь | Учетные данные пользователя управляются (контролируются) другой системой (например, LDAP сервер). Другими словами, аутентификация пользователя происходит во внешней системе, не в Канборде.| + + + +Примеры удаленных пользователей: + +- LDAP пользователь + +- Аутентификация пользователя через реверс-прокси + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/vagrant.markdown b/doc/ru_RU/vagrant.markdown new file mode 100644 index 00000000..59e920cc --- /dev/null +++ b/doc/ru_RU/vagrant.markdown @@ -0,0 +1,51 @@ +Запуск Канборда с Vagrant +========================= + + + +Вы можете легко развернуть Канборд с Vagrant: + + + +- Склонируйте проект с репозитория git + + + +- Выполните `vagrant up` + + + +- Для входа в приложение используйте URL `http://localhost:8001/` + + + +Виртуальная машина построена на Ubuntu 14.04 с PHP 5.5. + + + + + + + + + + + + + + + + + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/webhooks.markdown b/doc/ru_RU/webhooks.markdown new file mode 100644 index 00000000..c598abf9 --- /dev/null +++ b/doc/ru_RU/webhooks.markdown @@ -0,0 +1,536 @@ +Web Hooks +========= + + + +Webhooks служат для взаимодействия с внешними приложениями. Webhook посылает уведомление стороннему приложению о событиях, которые произошли в Канборд. + + + +- Webhooks могут быть использованы для создания задач вызовом простого URL (Вы можете сделать это и при помощи API) + + + +- Обращение к внешнему приложению может происходить автоматически, когда наступает какое-либо событие в Канборд (создана задача, обновлен комментарий и т.д.) + + + +Как написать webhook приемник во внешнем приложении?[¶](#how-to-write-a-web-hook-receiver "Ссылка на этот заголовок") +--------------------------------------------------------------------------------------------------------------------- + + + +Все внутренние события в Канборде могут быть посланы во внешний URL. + + + +- Webhook URL (url приемник внешнего приложения) может быть задан в **Настройки** -\> **Webhooks** -\> **Webhook URL** + + + +- Когда в Канборде происходит событие, Канборд обращается к указанному URL автоматически + + + +- Данные конвертируются в формат JSON и передаются с помощью POST HTTP запроса + + + +- Webhook ключ передается в составе запроса в виде строкового параметра. Таким образом, вы можете проверить, что запрос на самом деле пришел из Канборда. + + + +- **Созданный вами URL должен среагировать в течении 1 секунды**. Это желательно сделать потому, что запросы являются синхронными (ограничения языка PHP) и возможны тормоза в пользовательском интерфейсе, если скрипт будет слишком медленный! + + + +### Список поддерживаемых событий[¶](#list-of-supported-events "Ссылка на этот заголовок") + + + +- comment.create (комментарий.создать) + + + +- comment.update (комментарий.обновить) + + + +- file.create (файл.создать) + + + +- task.move.project (задача.переместить.проект) + + + +- task.move.column (задача.переместить.колонка) + + + +- task.move.position (задача.переместить.место) + + + +- task.move.swimlane (задача.переместить.дорожка) + + + +- task.update (задача.обновить) + + + +- task.create (задача.создать) + + + +- task.close (задача.закрыть) + + + +- task.open (задача.открыть) + + + +- task.assignee\_change (задача.назначить\_изменить) + + + +- subtask.update (подзадача.обновить) + + + +- subtask.create (подзадача.создать) + + + +### Пример HTTP запроса[¶](#example-of-http-request "Ссылка на этот заголовок") + + + + POST https://your_webhook_url/?token=WEBHOOK_TOKEN_HERE + + User-Agent: Kanboard Webhook + + Content-Type: application/json + + Connection: close + + + + { + + "event_name": "task.move.column", + + "event_data": { + + "task_id": "1", + + "project_id": "1", + + "position": 1, + + "column_id": "1", + + "swimlane_id": "0", + + "src_column_id": "2", + + "dst_column_id": "1", + + "date_moved": "1431991532", + + "recurrence_status": "0", + + "recurrence_trigger": "0" + + } + + } + + + +Функциональная часть всех событий имеет следующий формат: + + + + { + + "event_name": "model.event_name", + + "event_data": { + + "key1": "value1", + + "key2": "value2", + + ... + + } + + } + + + +Значения `event_data`{.docutils .literal} могут быть неупорядочены в событиях. + + + +### Пример функциональной части события[¶](#examples-of-event-payloads "Ссылка на этот заголовок") + + + +Создание задачи: + + + + { + + "event_name": "task.create", + + "event_data": { + + "title": "Demo", + + "description": "", + + "project_id": "1", + + "owner_id": "1", + + "category_id": 0, + + "swimlane_id": 0, + + "column_id": "2", + + "color_id": "yellow", + + "score": 0, + + "time_estimated": 0, + + "date_due": 0, + + "creator_id": 1, + + "date_creation": 1431991532, + + "date_modification": 1431991532, + + "date_moved": 1431991532, + + "position": 1, + + "task_id": 1 + + } + + } + + + +Изменение задачи: + + + + { + + "event_name": "task.update", + + "event_data": { + + "id": "1", + + "title": "Demo", + + "description": "", + + "date_creation": "1431991532", + + "color_id": "yellow", + + "project_id": "1", + + "column_id": "1", + + "owner_id": "1", + + "position": "1", + + "is_active": "1", + + "date_completed": null, + + "score": "0", + + "date_due": "0", + + "category_id": "2", + + "creator_id": "1", + + "date_modification": 1431991603, + + "reference": "", + + "date_started": 1431993600, + + "time_spent": 0, + + "time_estimated": 0, + + "swimlane_id": "0", + + "date_moved": "1431991572", + + "recurrence_status": "0", + + "recurrence_trigger": "0", + + "recurrence_factor": "0", + + "recurrence_timeframe": "0", + + "recurrence_basedate": "0", + + "recurrence_parent": null, + + "recurrence_child": null, + + "task_id": "1", + + "changes": { + + "category_id": "2" + + } + + } + + } + + + +События изменеия задачи имеют поле `changes`{.docutils .literal}, которое содержит обновленные значения. + + + +Перемещение задачи в другую колонку: + + + + { + + "event_name": "task.move.column", + + "event_data": { + + "task_id": "1", + + "project_id": "1", + + "position": 1, + + "column_id": "1", + + "swimlane_id": "0", + + "src_column_id": "2", + + "dst_column_id": "1", + + "date_moved": "1431991532", + + "recurrence_status": "0", + + "recurrence_trigger": "0" + + } + + } + + + +Перемещение задачи в другое место: + + + + { + + "event_name": "task.move.position", + + "event_data": { + + "task_id": "2", + + "project_id": "1", + + "position": 1, + + "column_id": "1", + + "swimlane_id": "0", + + "src_column_id": "1", + + "dst_column_id": "1", + + "date_moved": "1431996905", + + "recurrence_status": "0", + + "recurrence_trigger": "0" + + } + + } + + + +Создание комментария: + + + + { + + "event_name": "comment.create", + + "event_data": { + + "id": 1, + + "task_id": "1", + + "user_id": "1", + + "comment": "test", + + "date_creation": 1431991615 + + } + + } + + + +Изменение комментария: + + + + { + + "event_name": "comment.update", + + "event_data": { + + "id": "1", + + "task_id": "1", + + "user_id": "1", + + "comment": "test edit" + + } + + } + + + +Создание подзадачи: + + + + { + + "event_name": "subtask.create", + + "event_data": { + + "id": 3, + + "task_id": "1", + + "title": "Test", + + "user_id": "1", + + "time_estimated": "2", + + "position": 3 + + } + + } + + + +Изменение подзадачи: + + + + { + + "event_name": "subtask.update", + + "event_data": { + + "id": "1", + + "status": 1, + + "task_id": "1" + + } + + } + + + +Загрузка файла: + + + + { + + "event_name": "file.create", + + "event_data": { + + "task_id": "1", + + "name": "test.png" + + } + + } + + + +Создан снимок экрана: + + + + { + + "event_name": "file.create", + + "event_data": { + + "task_id": "2", + + "name": "Screenshot taken May 19, 2015 at 10:56 AM" + + } + + } + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/what-is-kanban.markdown b/doc/ru_RU/what-is-kanban.markdown new file mode 100644 index 00000000..46196bbb --- /dev/null +++ b/doc/ru_RU/what-is-kanban.markdown @@ -0,0 +1,80 @@ +Что такое Kanban? +================= + + + +Kanban - методология, которая первоначально применила компания Toyota для увеличения производительности. Описание в википедии - [Канбан доска](https://ru.wikipedia.org/wiki/%D0%9A%D0%B0%D0%BD%D0%B1%D0%B0%D0%BD-%D0%B4%D0%BE%D1%81%D0%BA%D0%B0) + + + +Смысл Kanban заключается в следующем: + + + +- Визуализация рабочих процессов + + + +- Уменьшение времени для достижения цели + + + +Визуализация рабочих процессов[¶](#visualize-your-workflow "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------- + + + +- Ваш рабочий процесс отображается на доске и вы ясно видете картину вашего проекта + + + +- Каждая колонка представляет шаг вашего рабочего процесса + + + +Сосредоточте внимание и избегайте многозадачности[¶](#bring-focus-and-avoid-multitasking "Ссылка на этот заголовок") +-------------------------------------------------------------------------------------------------------------------- + + + +- Каждая фаза может иметь работу ограниченную временем + + + +- Уменьшайте объем для определения узких мест + + + +- Ограничте количество одновременно выполняемых задач + + + +Подсчет производительности и улучшений[¶](#measure-performance-and-improvement "Ссылка на этот заголовок") +---------------------------------------------------------------------------------------------------------- + + + +Kanban использует время выполнения (lead time) и время цикла (cycle time) для подсчета производительности: + + + +- **Время выполнения**: Время между созданием задачи и ее завершением + + + +- **Время цикла**: Время между началом выполнения задачи и ее завершением + + + +Например, вам заложено время выполнения - 100 дней, а затратили на выполнение задачи (время цикла) всего 1 час. + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/windows-apache-installation.markdown b/doc/ru_RU/windows-apache-installation.markdown new file mode 100644 index 00000000..7f181e10 --- /dev/null +++ b/doc/ru_RU/windows-apache-installation.markdown @@ -0,0 +1,253 @@ +Установка Канборд на Windows Server и Apache +============================================ + + + +Это руководство поможет вам шаг за шагом установить Канборд на Windows Server с Apache и PHP + + + +**Внимание**: Если у вас 64 разрядная платформа, то вам нужно выбрать “x64”, и выберите “x86” для 32 разрядной операционной системы. + + + +Установка распространяемого пакета Visual C++[¶](#visual-c-redistributable-installation "Ссылка на этот заголовок") +------------------------------------------------------------------------------------------------------------------- + + + +PHP и Apache скомпилированы с Visual Studio, поэтому вам нужно установить эту библиотеку, если вы не сделали это ранее. + + + +1. Скачайте библиотеку с [официального вебсайта Microsoft](http://www.microsoft.com/en-us/download/details.aspx?id=30679) + + + +2. Запустите установку `vcredist_x64.exe` или `vcredist_x86.exe`, в соответствии с вашей платформой + + + +Установка Apache[¶](#apache-installation "Ссылка на этот заголовок") +-------------------------------------------------------------------- + + + +1. Скачайте исходники Apache с [Apache Lounge](http://www.apachelounge.com/download/) + + + +2. Разархивируйте Apache24 в каталог `C:\Apache24` + + + +### Назначение имени сервера[¶](#define-the-server-name "Ссылка на этот заголовок") + + + +Откройте файл `C:\Apache24\conf\httpd.conf` и добавьте директиву: + + + + ServerName localhost + + + +### Установка сервиса Apache[¶](#install-the-apache-service "Ссылка на этот заголовок") + + + +Откройте консоль (`cmd.exe`), перейдите в каталог `C:\Apache24\bin` и установите сервис Apache: + + + + cd C:\Apache24\bin + + + + # Install the windows service + + httpd.exe -k install + + + +### Установка ApacheMonitor[¶](#install-apachemonitor "Ссылка на этот заголовок") + + + +- Выполните `C:\Apache24\bin\ApacheMonitor.exe` и добавьте его в автозагрузку. + + + +- Теперь во всплывающем меню, при нажатии правой кнопки мыши на иконке, нажмите запустить Apache + + + +### Проверка работы Apache[¶](#check-the-apache-installation "Ссылка на этот заголовок") + + + +В браузере откройте . Вы должны увидеть пустую страницу и текст “It works!”. + + + +Установка PHP[¶](#php-installation "Ссылка на этот заголовок") +-------------------------------------------------------------- + + + +1. Скачайте последнюю стабильную версию PHP с [официального сайта PHP](http://windows.php.net/download/), выберите версию **Thread Safe** и используйте соответствующую разрядность: x86 or x64. + + + +2. Разархивируйте файлы в `C:\php` + + + +3. Перейдите в каталог PHP (`C:\php`) и переименуйе файл `php.ini-production` в `php.ini` + + + +Отредактируйте `php.ini`: + + + +Раскоментируйте директорию расширений: + + + + extension_dir = "C:/php/ext" + + + +Раскоментируйте следующие модули PHP: + + + + extension=php_gd2.dll + + extension=php_ldap.dll + + extension=php_mbstring.dll + + extension=php_openssl.dll + + extension=php_pdo_sqlite.dll + + + +Установите часовой пояс: + + + + date.timezone = America/Montreal + + + +Список всех поддерживаемых часовых поясов можно посмотреть в [документации PHP](http://php.net/manual/en/timezones.america.php). + + + +Загрузка модулей PHP для Apache: + + + +Добавьте следующие строки конфигурации в файл `C:\Apache24\conf\httpd.conf`: + + + + LoadModule php5_module "c:/php/php5apache2_4.dll" + + AddHandler application/x-httpd-php .php + + + + # configure the path to php.ini + + PHPIniDir "C:/php" + + + + # change this directive + + DirectoryIndex index.php index.html + + + +Перезапустите Apache. + + + +Проверка работы PHP: + + + +Создайте файл `phpinfo.php` в каталоге `C:\Apache24\htdocs`: + + + + + + + +Откройте в браузере [http://localhost/phpinfo.php](http://localhost/phpinfo.php) и вы должны увидеть информацию о PHP. + + + +Устновка Канборд[¶](#kanboard-installation "Ссылка на этот заголовок") +---------------------------------------------------------------------- + + + +- [Скачайте zip файл](https://kanboard.net/downloads) + + + +- Разархивируйте архив в `C:\Apache24\htdocs\kanboard` + + + +- Откройте в браузере . Ура. Теперь вы можете работать в Канборд. Все легко и просто. + + + +- Учетная запись и пароль по умолчанию - **admin/admin** + + + +Протестировано на[¶](#tested-configuration "Ссылка на этот заголовок") +---------------------------------------------------------------------- + + + +- Windows 2008 R2 / Apache 2.4.12 / PHP 5.6.8 + + + +Примечание[¶](#notes "Ссылка на этот заголовок") +------------------------------------------------ + + + +- Некоторые функции Канборда требуют выполнять [запуск ежедневных фоновых задач](cronjob.markdown). + + + + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/ru_RU/windows-iis-installation.markdown b/doc/ru_RU/windows-iis-installation.markdown new file mode 100644 index 00000000..0aabca6a --- /dev/null +++ b/doc/ru_RU/windows-iis-installation.markdown @@ -0,0 +1,150 @@ +Инсталяция Kanboard на Windows 2008/2012 с IIS +============================================== + + + +Это пошаговое руководство поможет вам установить Канборд на Windows Server с IIS и PHP. + + + +Установка PHP[¶](#php-installation "Ссылка на этот заголовок") +-------------------------------------------------------------- + + + +- Установите IIS на ваш Windows сервер (Добавьте новую роль и не забудьте включить CGI/FastCGI) + + + +- При инсталяции PHP можете использовать следующую официальную документацию: + + + + - [Microsoft IIS 5.1 and IIS 6.0](http://php.net/manual/en/install.windows.iis6.php) + + - [Microsoft IIS 7.0 and later](http://php.net/manual/en/install.windows.iis7.php) + + - [PHP for Windows is available here](http://windows.php.net/download/) + + + +Отредактируйте `php.ini`, раскоментируйте эти PHP модули: + + + + extension=php_gd2.dll + + extension=php_ldap.dll + + extension=php_mbstring.dll + + extension=php_openssl.dll + + extension=php_pdo_sqlite.dll + + + +Установите часовой пояс + + + + date.timezone = America/Montreal + + + +Список поддерживаемых часовых поясов можно посмотреть в [документации PHP](http://php.net/manual/en/timezones.america.php). + + + +Проверьте, что PHP работает корректно: + + + +Перейдите в корневой каталог IIS `C:\inetpub\wwwroot` и создайте файл `phpinfo.php`, со следующим содержимым: + + + + + + + +В браузере откройте страницу `http://localhost/phpinfo.php` и вы должны увидеть текущие настройки PHP. Если вы видите ошибку 500, значит что-то сделано неправильно при установке. + + + +Примечание: + + + +- Если вы используете PHP \< 5.4, то необходимо включить короткие теги (short tags) в php.ini + + + +- Не забудьте включить необходимые php расширения, упомянутые выше + + + +- Если вы наблюдаете ошибку “the library MSVCP110.dll is missing”, то возможно вам нужно скачать распространяемый пакет Visual C++ для Visual Studio с сайта Microsoft. + + + +Установка Канборд[¶](#kanboard-installation "Ссылка на этот заголовок") +----------------------------------------------------------------------- + + + +- Скачайте zip файл + + + +- Распакуйте архив в `C:\inetpub\wwwroot\kanboard` (например) + + + +- Убедитесь, что у пользователя вебсервера IIS имеется доступ на запись на директорию `data` + + + +- Откройте веб браузер и используйте Kanboard + + + +- Пользователь и пароль по умолчанию - **admin/admin** + + + +Работа Канборд тестировалось на[¶](#tested-configurations "Ссылка на этот заголовок") +------------------------------------------------------------------------------------- + + + +- Windows 2008 R2 Standard Edition / IIS 7.5 / PHP 5.5.16 + +- Windows 2012 Standard Edition / IIS 8.5 / PHP 5.3.29 + + + +Примечание[¶](#notes "Ссылка на этот заголовок") +------------------------------------------------ + + + +- Некоторые возможности Канборда требуют [запуск выполнения ежедневных фоновых задач](cronjob.markdown). + + + + + + + + +[Русская документация Kanboard](http://kanboard.ru/doc/) + diff --git a/doc/web.config b/doc/web.config new file mode 100644 index 00000000..1461fe2d --- /dev/null +++ b/doc/web.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + -- cgit v1.2.3 From 5fe81ae6ef59ee73e7d6f34fb333d3d19a08a266 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Fri, 22 Jul 2016 17:58:39 -0400 Subject: Add new template hooks --- app/Template/board/table_column.php | 1 + app/Template/header.php | 1 + doc/plugin-hooks.markdown | 4 +++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/Template/board/table_column.php b/app/Template/board/table_column.php index 6336234a..75a6eb4c 100644 --- a/app/Template/board/table_column.php +++ b/app/Template/board/table_column.php @@ -47,6 +47,7 @@
          • + hook->render('template:board:column:dropdown', array('swimlane' => $swimlane, 'column' => $column)) ?> diff --git a/app/Template/header.php b/app/Template/header.php index 13521ae7..f99f1031 100644 --- a/app/Template/header.php +++ b/app/Template/header.php @@ -59,6 +59,7 @@ url->link(t('New private project'), 'ProjectCreationController', 'createPrivate', array(), false, 'popover') ?> + hook->render('template:header:creation-dropdown') ?> diff --git a/doc/plugin-hooks.markdown b/doc/plugin-hooks.markdown index 1f90bdbc..787c62df 100644 --- a/doc/plugin-hooks.markdown +++ b/doc/plugin-hooks.markdown @@ -155,6 +155,7 @@ List of template hooks: | `template:board:public:task:after-title` | Task in public board: after title | | `template:board:task:footer` | Task in board: footer | | `template:board:task:icons` | Task in board: tooltip icon | +| `template:board:column:dropdown` | Dropdown menu in board columns | | `template:config:sidebar` | Sidebar on settings page | | `template:config:application ` | Application settings form | | `template:config:email` | Email settings page | @@ -162,7 +163,8 @@ List of template hooks: | `template:dashboard:sidebar` | Sidebar on dashboard page | | `template:export:sidebar` | Sidebar on export pages | | `template:import:sidebar` | Sidebar on import pages | -| `template:header:dropdown` | Dropdown on header | +| `template:header:dropdown` | Page header dropdown menu (user avatar icon) | +| `template:header:creation-dropdown` | Page header dropdown menu (plus icon) | | `template:layout:head` | Page layout `` tag | | `template:layout:top` | Page layout top header | | `template:layout:bottom` | Page layout footer | -- cgit v1.2.3 From b6119e7dee84869a619dedccd9c80df4422a4f5b Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:05:15 -0400 Subject: Added internal task links to activity stream --- ChangeLog | 1 + app/Action/TaskAssignCategoryLink.php | 13 +- app/Action/TaskAssignColorLink.php | 10 +- app/Core/Base.php | 1 + app/EventBuilder/TaskLinkEventBuilder.php | 89 +++++++++++ app/Helper/HookHelper.php | 2 +- app/Job/TaskLinkEventJob.php | 45 ++++++ app/Model/NotificationModel.php | 39 ++--- app/Model/TaskLinkModel.php | 173 ++++++++++++--------- app/ServiceProvider/JobProvider.php | 5 + app/Subscriber/NotificationSubscriber.php | 3 + .../event/task_internal_link_create_update.php | 16 ++ app/Template/event/task_internal_link_delete.php | 16 ++ app/Template/notification/task_file_create.php | 2 +- .../task_internal_link_create_update.php | 11 ++ .../notification/task_internal_link_delete.php | 11 ++ tests/units/Action/TaskAssignCategoryLinkTest.php | 51 +++--- tests/units/Action/TaskAssignColorLinkTest.php | 45 ++++-- .../EventBuilder/TaskLinkEventBuilderTest.php | 70 +++++++++ tests/units/Job/TaskLinkEventJobTest.php | 65 ++++++++ tests/units/Model/NotificationModelTest.php | 39 ++--- tests/units/Model/TaskLinkModelTest.php | 28 ++++ tests/units/Notification/MailNotificationTest.php | 117 ++++++++++++++ tests/units/Notification/MailTest.php | 117 -------------- .../units/Notification/WebhookNotificationTest.php | 29 ++++ tests/units/Notification/WebhookTest.php | 29 ---- 26 files changed, 705 insertions(+), 322 deletions(-) create mode 100644 app/EventBuilder/TaskLinkEventBuilder.php create mode 100644 app/Job/TaskLinkEventJob.php create mode 100644 app/Template/event/task_internal_link_create_update.php create mode 100644 app/Template/event/task_internal_link_delete.php create mode 100644 app/Template/notification/task_internal_link_create_update.php create mode 100644 app/Template/notification/task_internal_link_delete.php create mode 100644 tests/units/EventBuilder/TaskLinkEventBuilderTest.php create mode 100644 tests/units/Job/TaskLinkEventJobTest.php create mode 100644 tests/units/Notification/MailNotificationTest.php delete mode 100644 tests/units/Notification/MailTest.php create mode 100644 tests/units/Notification/WebhookNotificationTest.php delete mode 100644 tests/units/Notification/WebhookTest.php diff --git a/ChangeLog b/ChangeLog index a1e39436..ee57c86c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,7 @@ Version 1.0.32 (unreleased) New features: * New automated action to close tasks without activity in a specific column +* Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority * Added the possibility to hide tasks in dashboard for a specific column diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php index 6937edd1..d4a4c0ec 100644 --- a/app/Action/TaskAssignCategoryLink.php +++ b/app/Action/TaskAssignCategoryLink.php @@ -60,8 +60,10 @@ class TaskAssignCategoryLink extends Base public function getEventRequiredParameters() { return array( - 'task_id', - 'link_id', + 'task_link' => array( + 'task_id', + 'link_id', + ) ); } @@ -75,7 +77,7 @@ class TaskAssignCategoryLink extends Base public function doAction(array $data) { $values = array( - 'id' => $data['task_id'], + 'id' => $data['task_link']['task_id'], 'category_id' => $this->getParam('category_id'), ); @@ -91,9 +93,8 @@ class TaskAssignCategoryLink extends Base */ public function hasRequiredCondition(array $data) { - if ($data['link_id'] == $this->getParam('link_id')) { - $task = $this->taskFinderModel->getById($data['task_id']); - return empty($task['category_id']); + if ($data['task_link']['link_id'] == $this->getParam('link_id')) { + return empty($data['task']['category_id']); } return false; diff --git a/app/Action/TaskAssignColorLink.php b/app/Action/TaskAssignColorLink.php index 9ab5458b..9759f622 100644 --- a/app/Action/TaskAssignColorLink.php +++ b/app/Action/TaskAssignColorLink.php @@ -59,8 +59,10 @@ class TaskAssignColorLink extends Base public function getEventRequiredParameters() { return array( - 'task_id', - 'link_id', + 'task_link' => array( + 'task_id', + 'link_id', + ) ); } @@ -74,7 +76,7 @@ class TaskAssignColorLink extends Base public function doAction(array $data) { $values = array( - 'id' => $data['task_id'], + 'id' => $data['task_link']['task_id'], 'color_id' => $this->getParam('color_id'), ); @@ -90,6 +92,6 @@ class TaskAssignColorLink extends Base */ public function hasRequiredCondition(array $data) { - return $data['link_id'] == $this->getParam('link_id'); + return $data['task_link']['link_id'] == $this->getParam('link_id'); } } diff --git a/app/Core/Base.php b/app/Core/Base.php index 098bd880..20a2d391 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -154,6 +154,7 @@ use Pimple\Container; * @property \Kanboard\Job\SubtaskEventJob $subtaskEventJob * @property \Kanboard\Job\TaskEventJob $taskEventJob * @property \Kanboard\Job\TaskFileEventJob $taskFileEventJob + * @property \Kanboard\Job\TaskLinkEventJob $taskLinkEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob * @property \Psr\Log\LoggerInterface $logger diff --git a/app/EventBuilder/TaskLinkEventBuilder.php b/app/EventBuilder/TaskLinkEventBuilder.php new file mode 100644 index 00000000..8be5299f --- /dev/null +++ b/app/EventBuilder/TaskLinkEventBuilder.php @@ -0,0 +1,89 @@ +taskLinkId = $taskLinkId; + return $this; + } + + /** + * Build event data + * + * @access public + * @return TaskLinkEvent|null + */ + public function build() + { + $taskLink = $this->taskLinkModel->getById($this->taskLinkId); + + if (empty($taskLink)) { + $this->logger->debug(__METHOD__.': TaskLink not found'); + return null; + } + + return new TaskLinkEvent(array( + 'task_link' => $taskLink, + 'task' => $this->taskFinderModel->getDetails($taskLink['task_id']), + )); + } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + if ($eventName === TaskLinkModel::EVENT_CREATE_UPDATE) { + return e('%s set a new internal link for the task #%d', $author, $eventData['task']['id']); + } elseif ($eventName === TaskLinkModel::EVENT_DELETE) { + return e('%s removed an internal link for the task #%d', $author, $eventData['task']['id']); + } + + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + if ($eventName === TaskLinkModel::EVENT_CREATE_UPDATE) { + return e('A new internal link for the task #%d have been defined', $eventData['task']['id']); + } elseif ($eventName === TaskLinkModel::EVENT_DELETE) { + return e('Internal link removed for the task #%d', $eventData['task']['id']); + } + + return ''; + } +} diff --git a/app/Helper/HookHelper.php b/app/Helper/HookHelper.php index 2d13ebcc..cb4dc1ef 100644 --- a/app/Helper/HookHelper.php +++ b/app/Helper/HookHelper.php @@ -56,7 +56,7 @@ class HookHelper extends Base * @access public * @param string $hook * @param string $template - * @return \Kanboard\Helper\Hook + * @return $this */ public function attach($hook, $template) { diff --git a/app/Job/TaskLinkEventJob.php b/app/Job/TaskLinkEventJob.php new file mode 100644 index 00000000..669608ad --- /dev/null +++ b/app/Job/TaskLinkEventJob.php @@ -0,0 +1,45 @@ +jobParams = array($taskLinkId, $eventName); + return $this; + } + + /** + * Execute job + * + * @param int $taskLinkId + * @param string $eventName + * @return $this + */ + public function execute($taskLinkId, $eventName) + { + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId($taskLinkId) + ->build(); + + if ($event !== null) { + $this->dispatcher->dispatch($eventName, $event); + } + } +} diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index 925d646e..39c1f581 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -3,6 +3,7 @@ namespace Kanboard\Model; use Kanboard\Core\Base; +use Kanboard\EventBuilder\TaskLinkEventBuilder; /** * Notification @@ -85,7 +86,9 @@ class NotificationModel extends Base case CommentModel::EVENT_USER_MENTION: return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']); default: - return e('Notification'); + return TaskLinkEventBuilder::getInstance($this->container) + ->buildTitleWithAuthor($event_author, $event_name, $event_data) ?: + e('Notification'); } } @@ -138,7 +141,9 @@ class NotificationModel extends Base case CommentModel::EVENT_USER_MENTION: return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']); default: - return e('Notification'); + return TaskLinkEventBuilder::getInstance($this->container) + ->buildTitleWithoutAuthor($event_name, $event_data) ?: + e('Notification'); } } @@ -152,32 +157,10 @@ class NotificationModel extends Base */ public function getTaskIdFromEvent($event_name, array $event_data) { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - return $event_data['file']['task_id']; - case CommentModel::EVENT_CREATE: - case CommentModel::EVENT_UPDATE: - case CommentModel::EVENT_DELETE: - return $event_data['comment']['task_id']; - case SubtaskModel::EVENT_CREATE: - case SubtaskModel::EVENT_UPDATE: - case SubtaskModel::EVENT_DELETE: - return $event_data['subtask']['task_id']; - case TaskModel::EVENT_CREATE: - case TaskModel::EVENT_UPDATE: - case TaskModel::EVENT_CLOSE: - case TaskModel::EVENT_OPEN: - case TaskModel::EVENT_MOVE_COLUMN: - case TaskModel::EVENT_MOVE_POSITION: - case TaskModel::EVENT_MOVE_SWIMLANE: - case TaskModel::EVENT_ASSIGNEE_CHANGE: - case CommentModel::EVENT_USER_MENTION: - case TaskModel::EVENT_USER_MENTION: - return $event_data['task']['id']; - case TaskModel::EVENT_OVERDUE: - return $event_data['tasks'][0]['id']; - default: - return 0; + if ($event_name === TaskModel::EVENT_OVERDUE) { + return $event_data['tasks'][0]['id']; } + + return isset($event_data['task']['id']) ? $event_data['task']['id'] : 0; } } diff --git a/app/Model/TaskLinkModel.php b/app/Model/TaskLinkModel.php index 09978eae..e8d3c5df 100644 --- a/app/Model/TaskLinkModel.php +++ b/app/Model/TaskLinkModel.php @@ -3,7 +3,6 @@ namespace Kanboard\Model; use Kanboard\Core\Base; -use Kanboard\Event\TaskLinkEvent; /** * TaskLink model @@ -26,7 +25,8 @@ class TaskLinkModel extends Base * * @var string */ - const EVENT_CREATE_UPDATE = 'tasklink.create_update'; + const EVENT_CREATE_UPDATE = 'task_internal_link.create_update'; + const EVENT_DELETE = 'task_internal_link.delete'; /** * Get projectId from $task_link_id @@ -53,7 +53,19 @@ class TaskLinkModel extends Base */ public function getById($task_link_id) { - return $this->db->table(self::TABLE)->eq('id', $task_link_id)->findOne(); + return $this->db + ->table(self::TABLE) + ->columns( + self::TABLE.'.id', + self::TABLE.'.opposite_task_id', + self::TABLE.'.task_id', + self::TABLE.'.link_id', + LinkModel::TABLE.'.label', + LinkModel::TABLE.'.opposite_id AS opposite_link_id' + ) + ->eq(self::TABLE.'.id', $task_link_id) + ->join(LinkModel::TABLE, 'id', 'link_id') + ->findOne(); } /** @@ -139,20 +151,6 @@ class TaskLinkModel extends Base return $result; } - /** - * Publish events - * - * @access private - * @param array $events - */ - private function fireEvents(array $events) - { - foreach ($events as $event) { - $event['project_id'] = $this->taskFinderModel->getProjectId($event['task_id']); - $this->container['dispatcher']->dispatch(self::EVENT_CREATE_UPDATE, new TaskLinkEvent($event)); - } - } - /** * Create a new link * @@ -160,42 +158,25 @@ class TaskLinkModel extends Base * @param integer $task_id Task id * @param integer $opposite_task_id Opposite task id * @param integer $link_id Link id - * @return integer Task link id + * @return integer|boolean */ public function create($task_id, $opposite_task_id, $link_id) { - $events = array(); $this->db->startTransaction(); - // Get opposite link $opposite_link_id = $this->linkModel->getOppositeLinkId($link_id); + $task_link_id1 = $this->createTaskLink($task_id, $opposite_task_id, $link_id); + $task_link_id2 = $this->createTaskLink($opposite_task_id, $task_id, $opposite_link_id); - $values = array( - 'task_id' => $task_id, - 'opposite_task_id' => $opposite_task_id, - 'link_id' => $link_id, - ); - - // Create the original task link - $this->db->table(self::TABLE)->insert($values); - $task_link_id = $this->db->getLastId(); - $events[] = $values; - - // Create the opposite task link - $values = array( - 'task_id' => $opposite_task_id, - 'opposite_task_id' => $task_id, - 'link_id' => $opposite_link_id, - ); - - $this->db->table(self::TABLE)->insert($values); - $events[] = $values; + if ($task_link_id1 === false || $task_link_id2 === false) { + $this->db->cancelTransaction(); + return false; + } $this->db->closeTransaction(); + $this->fireEvents(array($task_link_id1, $task_link_id2), self::EVENT_CREATE_UPDATE); - $this->fireEvents($events); - - return (int) $task_link_id; + return $task_link_id1; } /** @@ -210,46 +191,24 @@ class TaskLinkModel extends Base */ public function update($task_link_id, $task_id, $opposite_task_id, $link_id) { - $events = array(); $this->db->startTransaction(); - // Get original task link $task_link = $this->getById($task_link_id); - - // Find opposite task link $opposite_task_link = $this->getOppositeTaskLink($task_link); - - // Get opposite link $opposite_link_id = $this->linkModel->getOppositeLinkId($link_id); - // Update the original task link - $values = array( - 'task_id' => $task_id, - 'opposite_task_id' => $opposite_task_id, - 'link_id' => $link_id, - ); - - $rs1 = $this->db->table(self::TABLE)->eq('id', $task_link_id)->update($values); - $events[] = $values; + $result1 = $this->updateTaskLink($task_link_id, $task_id, $opposite_task_id, $link_id); + $result2 = $this->updateTaskLink($opposite_task_link['id'], $opposite_task_id, $task_id, $opposite_link_id); - // Update the opposite link - $values = array( - 'task_id' => $opposite_task_id, - 'opposite_task_id' => $task_id, - 'link_id' => $opposite_link_id, - ); - - $rs2 = $this->db->table(self::TABLE)->eq('id', $opposite_task_link['id'])->update($values); - $events[] = $values; + if ($result1 === false || $result2 === false) { + $this->db->cancelTransaction(); + return false; + } $this->db->closeTransaction(); + $this->fireEvents(array($task_link_id, $opposite_task_link['id']), self::EVENT_CREATE_UPDATE); - if ($rs1 && $rs2) { - $this->fireEvents($events); - return true; - } - - return false; + return true; } /** @@ -261,21 +220,83 @@ class TaskLinkModel extends Base */ public function remove($task_link_id) { + $this->taskLinkEventJob->execute($task_link_id, self::EVENT_DELETE); + $this->db->startTransaction(); $link = $this->getById($task_link_id); $link_id = $this->linkModel->getOppositeLinkId($link['link_id']); - $this->db->table(self::TABLE)->eq('id', $task_link_id)->remove(); + $result1 = $this->db + ->table(self::TABLE) + ->eq('id', $task_link_id) + ->remove(); - $this->db + $result2 = $this->db ->table(self::TABLE) ->eq('opposite_task_id', $link['task_id']) ->eq('task_id', $link['opposite_task_id']) - ->eq('link_id', $link_id)->remove(); + ->eq('link_id', $link_id) + ->remove(); + + if ($result1 === false || $result2 === false) { + $this->db->cancelTransaction(); + return false; + } $this->db->closeTransaction(); return true; } + + /** + * Publish events + * + * @access protected + * @param integer[] $task_link_ids + * @param string $eventName + */ + protected function fireEvents(array $task_link_ids, $eventName) + { + foreach ($task_link_ids as $task_link_id) { + $this->queueManager->push($this->taskLinkEventJob->withParams($task_link_id, $eventName)); + } + } + + /** + * Create task link + * + * @access protected + * @param integer $task_id + * @param integer $opposite_task_id + * @param integer $link_id + * @return integer|boolean + */ + protected function createTaskLink($task_id, $opposite_task_id, $link_id) + { + return $this->db->table(self::TABLE)->persist(array( + 'task_id' => $task_id, + 'opposite_task_id' => $opposite_task_id, + 'link_id' => $link_id, + )); + } + + /** + * Update task link + * + * @access protected + * @param integer $task_link_id + * @param integer $task_id + * @param integer $opposite_task_id + * @param integer $link_id + * @return boolean + */ + protected function updateTaskLink($task_link_id, $task_id, $opposite_task_id, $link_id) + { + return $this->db->table(self::TABLE)->eq('id', $task_link_id)->update(array( + 'task_id' => $task_id, + 'opposite_task_id' => $opposite_task_id, + 'link_id' => $link_id, + )); + } } diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php index c7f323f1..5b42794b 100644 --- a/app/ServiceProvider/JobProvider.php +++ b/app/ServiceProvider/JobProvider.php @@ -8,6 +8,7 @@ use Kanboard\Job\ProjectFileEventJob; use Kanboard\Job\SubtaskEventJob; use Kanboard\Job\TaskEventJob; use Kanboard\Job\TaskFileEventJob; +use Kanboard\Job\TaskLinkEventJob; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -44,6 +45,10 @@ class JobProvider implements ServiceProviderInterface return new TaskFileEventJob($c); }); + $container['taskLinkEventJob'] = $container->factory(function ($c) { + return new TaskLinkEventJob($c); + }); + $container['projectFileEventJob'] = $container->factory(function ($c) { return new ProjectFileEventJob($c); }); diff --git a/app/Subscriber/NotificationSubscriber.php b/app/Subscriber/NotificationSubscriber.php index 7de24e49..7cc68b26 100644 --- a/app/Subscriber/NotificationSubscriber.php +++ b/app/Subscriber/NotificationSubscriber.php @@ -3,6 +3,7 @@ namespace Kanboard\Subscriber; use Kanboard\Event\GenericEvent; +use Kanboard\Model\TaskLinkModel; use Kanboard\Model\TaskModel; use Kanboard\Model\CommentModel; use Kanboard\Model\SubtaskModel; @@ -31,6 +32,8 @@ class NotificationSubscriber extends BaseSubscriber implements EventSubscriberIn CommentModel::EVENT_DELETE => 'handleEvent', CommentModel::EVENT_USER_MENTION => 'handleEvent', TaskFileModel::EVENT_CREATE => 'handleEvent', + TaskLinkModel::EVENT_CREATE_UPDATE => 'handleEvent', + TaskLinkModel::EVENT_DELETE => 'handleEvent', ); } diff --git a/app/Template/event/task_internal_link_create_update.php b/app/Template/event/task_internal_link_create_update.php new file mode 100644 index 00000000..de257977 --- /dev/null +++ b/app/Template/event/task_internal_link_create_update.php @@ -0,0 +1,16 @@ +

            + text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

            +
            +

            + url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])), + $this->text->e($task_link['label']) + ) ?> +

            +
            diff --git a/app/Template/event/task_internal_link_delete.php b/app/Template/event/task_internal_link_delete.php new file mode 100644 index 00000000..e537bf81 --- /dev/null +++ b/app/Template/event/task_internal_link_delete.php @@ -0,0 +1,16 @@ +

            + text->e($author), + $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id'])) + ) ?> + dt->datetime($date_creation) ?> +

            +
            +

            + text->e($task_link['label']), + $this->url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])) + ) ?> +

            +
            diff --git a/app/Template/notification/task_file_create.php b/app/Template/notification/task_file_create.php index feab8dd2..c19f7279 100644 --- a/app/Template/notification/task_file_create.php +++ b/app/Template/notification/task_file_create.php @@ -2,4 +2,4 @@

            -render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> \ No newline at end of file +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/Template/notification/task_internal_link_create_update.php b/app/Template/notification/task_internal_link_create_update.php new file mode 100644 index 00000000..73cad84d --- /dev/null +++ b/app/Template/notification/task_internal_link_create_update.php @@ -0,0 +1,11 @@ +

            text->e($task['title']) ?> (#)

            + +

            + url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])), + $this->text->e($task_link['label']) + ) ?> +

            + +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/app/Template/notification/task_internal_link_delete.php b/app/Template/notification/task_internal_link_delete.php new file mode 100644 index 00000000..bb54e0a7 --- /dev/null +++ b/app/Template/notification/task_internal_link_delete.php @@ -0,0 +1,11 @@ +

            text->e($task['title']) ?> (#)

            + +

            + text->e($task_link['label']), + $this->url->link(t('#%d', $task_link['opposite_task_id']), 'TaskViewController', 'show', array('task_id' => $task_link['opposite_task_id'])) + ) ?> +

            + +render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?> diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php index d7e68f72..b9d7e9d9 100644 --- a/tests/units/Action/TaskAssignCategoryLinkTest.php +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -2,12 +2,12 @@ require_once __DIR__.'/../Base.php'; +use Kanboard\EventBuilder\TaskLinkEventBuilder; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; use Kanboard\Model\TaskLinkModel; use Kanboard\Model\CategoryModel; -use Kanboard\Event\TaskLinkEvent; use Kanboard\Action\TaskAssignCategoryLink; class TaskAssignCategoryLinkTest extends Base @@ -18,6 +18,7 @@ class TaskAssignCategoryLinkTest extends Base $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); @@ -27,13 +28,12 @@ class TaskAssignCategoryLinkTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -44,51 +44,58 @@ class TaskAssignCategoryLinkTest extends Base public function testWhenLinkDontMatch() { $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); $action->setParam('category_id', 1); - $action->setParam('link_id', 1); + $action->setParam('link_id', 2); $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(0, $task['category_id']); } public function testThatExistingCategoryWillNotChange() { $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); $projectModel = new ProjectModel($this->container); $categoryModel = new CategoryModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignCategoryLink($this->container); $action->setProjectId(1); - $action->setParam('category_id', 2); + $action->setParam('category_id', 1); $action->setParam('link_id', 2); $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); $this->assertEquals(1, $categoryModel->create(array('name' => 'C1', 'project_id' => 1))); - $this->assertEquals(2, $categoryModel->create(array('name' => 'C2', 'project_id' => 1))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1, 'category_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); - $event = new TaskLinkEvent(array( - 'project_id' => 1, - 'task_id' => 1, - 'opposite_task_id' => 2, - 'link_id' => 2, - )); + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(1, $task['category_id']); } } diff --git a/tests/units/Action/TaskAssignColorLinkTest.php b/tests/units/Action/TaskAssignColorLinkTest.php index 07d0969b..27364bc9 100644 --- a/tests/units/Action/TaskAssignColorLinkTest.php +++ b/tests/units/Action/TaskAssignColorLinkTest.php @@ -2,7 +2,7 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; +use Kanboard\EventBuilder\TaskLinkEventBuilder; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\ProjectModel; @@ -13,42 +13,55 @@ class TaskAssignColorLinkTest extends Base { public function testChangeColor() { - $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); $taskFinderModel = new TaskFinderModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 1)); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignColorLink($this->container); $action->setProjectId(1); + $action->setParam('link_id', 2); $action->setParam('color_id', 'red'); - $action->setParam('link_id', 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 2)); + + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); $task = $taskFinderModel->getById(1); - $this->assertNotEmpty($task); $this->assertEquals('red', $task['color_id']); } public function testWithWrongLink() { - $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); - - $event = new GenericEvent(array('project_id' => 1, 'task_id' => 1, 'link_id' => 2)); + $taskFinderModel = new TaskFinderModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $action = new TaskAssignColorLink($this->container); $action->setProjectId(1); + $action->setParam('link_id', 2); $action->setParam('color_id', 'red'); - $action->setParam('link_id', 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'P1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'T1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'T2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $event = TaskLinkEventBuilder::getInstance($this->container) + ->withTaskLinkId(1) + ->build(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); + + $task = $taskFinderModel->getById(1); + $this->assertEquals('yellow', $task['color_id']); } } diff --git a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php new file mode 100644 index 00000000..7364d651 --- /dev/null +++ b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php @@ -0,0 +1,70 @@ +container); + $taskLinkEventBuilder->withTaskLinkId(42); + $this->assertNull($taskLinkEventBuilder->build()); + } + + public function testBuild() + { + $taskLinkModel = new TaskLinkModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $event = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + + $this->assertInstanceOf('Kanboard\Event\TaskLinkEvent', $event); + $this->assertNotEmpty($event['task_link']); + $this->assertNotEmpty($event['task']); + } + + public function testBuildTitle() + { + $taskLinkModel = new TaskLinkModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); + $this->assertEquals('Foobar set a new internal link for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_DELETE, $eventData->getAll()); + $this->assertEquals('Foobar removed an internal link for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', 'not found', $eventData->getAll()); + $this->assertSame('', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor(TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); + $this->assertEquals('A new internal link for the task #1 have been defined', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor(TaskLinkModel::EVENT_DELETE, $eventData->getAll()); + $this->assertEquals('Internal link removed for the task #1', $title); + + $title = $taskLinkEventBuilder->buildTitleWithoutAuthor('not found', $eventData->getAll()); + $this->assertSame('', $title); + } +} diff --git a/tests/units/Job/TaskLinkEventJobTest.php b/tests/units/Job/TaskLinkEventJobTest.php new file mode 100644 index 00000000..1949316a --- /dev/null +++ b/tests/units/Job/TaskLinkEventJobTest.php @@ -0,0 +1,65 @@ +container); + $taskLinkEventJob->withParams(123, 'foobar'); + + $this->assertSame(array(123, 'foobar'), $taskLinkEventJob->getJobParams()); + } + + public function testWithMissingLink() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_CREATE_UPDATE, function() {}); + + $taskLinkEventJob = new TaskLinkEventJob($this->container); + $taskLinkEventJob->execute(42, TaskLinkModel::EVENT_CREATE_UPDATE); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertEmpty($called); + } + + public function testTriggerCreationEvents() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_CREATE_UPDATE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskLinkModel::EVENT_CREATE_UPDATE.'.closure', $called); + } + + public function testTriggerDeleteEvents() + { + $this->container['dispatcher']->addListener(TaskLinkModel::EVENT_DELETE, function() {}); + + $taskCreationModel = new TaskCreationModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'task 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + $this->assertTrue($taskLinkModel->remove(1)); + + $called = $this->container['dispatcher']->getCalledListeners(); + $this->assertArrayHasKey(TaskLinkModel::EVENT_DELETE.'.closure', $called); + } +} diff --git a/tests/units/Model/NotificationModelTest.php b/tests/units/Model/NotificationModelTest.php index 889f3349..0bd9db6e 100644 --- a/tests/units/Model/NotificationModelTest.php +++ b/tests/units/Model/NotificationModelTest.php @@ -7,6 +7,7 @@ use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\CommentModel; use Kanboard\Model\TaskFileModel; +use Kanboard\Model\TaskLinkModel; use Kanboard\Model\TaskModel; use Kanboard\Model\ProjectModel; use Kanboard\Model\NotificationModel; @@ -23,47 +24,38 @@ class NotificationModelTest extends Base $subtaskModel = new SubtaskModel($this->container); $commentModel = new CommentModel($this->container); $taskFileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'test', 'task_id' => 1))); $this->assertEquals(1, $commentModel->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); $this->assertEquals(1, $taskFileModel->create(1, 'test', 'blah', 123)); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); $task = $taskFinderModel->getDetails(1); $subtask = $subtaskModel->getById(1, true); $comment = $commentModel->getById(1); $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); - $this->assertNotEmpty($task); - $this->assertNotEmpty($subtask); - $this->assertNotEmpty($comment); - $this->assertNotEmpty($file); - - foreach (NotificationSubscriber::getSubscribedEvents() as $event_name => $values) { - $title = $notificationModel->getTitleWithoutAuthor($event_name, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array() - )); - - $this->assertNotEmpty($title); - - $title = $notificationModel->getTitleWithAuthor('foobar', $event_name, array( + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $eventData = array( 'task' => $task, 'comment' => $comment, 'subtask' => $subtask, 'file' => $file, + 'task_link' => $tasklink, 'changes' => array() - )); + ); - $this->assertNotEmpty($title); + $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor($eventName, $eventData)); + $this->assertNotEmpty($notificationModel->getTitleWithAuthor('Foobar', $eventName, $eventData)); } $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor(TaskModel::EVENT_OVERDUE, array('tasks' => array(array('id' => 1))))); - $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor('unkown', array())); + $this->assertNotEmpty($notificationModel->getTitleWithoutAuthor('unknown', array())); } public function testGetTaskIdFromEvent() @@ -75,6 +67,7 @@ class NotificationModelTest extends Base $subtaskModel = new SubtaskModel($this->container); $commentModel = new CommentModel($this->container); $taskFileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); @@ -86,18 +79,20 @@ class NotificationModelTest extends Base $subtask = $subtaskModel->getById(1, true); $comment = $commentModel->getById(1); $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); $this->assertNotEmpty($task); $this->assertNotEmpty($subtask); $this->assertNotEmpty($comment); $this->assertNotEmpty($file); - foreach (NotificationSubscriber::getSubscribedEvents() as $event_name => $values) { - $task_id = $notificationModel->getTaskIdFromEvent($event_name, array( + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $task_id = $notificationModel->getTaskIdFromEvent($eventName, array( 'task' => $task, 'comment' => $comment, 'subtask' => $subtask, 'file' => $file, + 'task_link' => $tasklink, 'changes' => array() )); diff --git a/tests/units/Model/TaskLinkModelTest.php b/tests/units/Model/TaskLinkModelTest.php index 78590891..01a7888b 100644 --- a/tests/units/Model/TaskLinkModelTest.php +++ b/tests/units/Model/TaskLinkModelTest.php @@ -9,6 +9,34 @@ use Kanboard\Model\ProjectModel; class TaskLinkModelTest extends Base { + public function testGeyById() + { + $taskLinkModel = new TaskLinkModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'A'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'B'))); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 6)); + + $taskLink = $taskLinkModel->getById(1); + $this->assertEquals(1, $taskLink['id']); + $this->assertEquals(1, $taskLink['task_id']); + $this->assertEquals(2, $taskLink['opposite_task_id']); + $this->assertEquals(6, $taskLink['link_id']); + $this->assertEquals(7, $taskLink['opposite_link_id']); + $this->assertEquals('is a child of', $taskLink['label']); + + $taskLink = $taskLinkModel->getById(2); + $this->assertEquals(2, $taskLink['id']); + $this->assertEquals(2, $taskLink['task_id']); + $this->assertEquals(1, $taskLink['opposite_task_id']); + $this->assertEquals(7, $taskLink['link_id']); + $this->assertEquals(6, $taskLink['opposite_link_id']); + $this->assertEquals('is a parent of', $taskLink['label']); + } + // Check postgres issue: "Cardinality violation: 7 ERROR: more than one row returned by a subquery used as an expression" public function testGetTaskWithMultipleMilestoneLink() { diff --git a/tests/units/Notification/MailNotificationTest.php b/tests/units/Notification/MailNotificationTest.php new file mode 100644 index 00000000..6579d9bc --- /dev/null +++ b/tests/units/Notification/MailNotificationTest.php @@ -0,0 +1,117 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $commentModel = new CommentModel($this->container); + $fileModel = new TaskFileModel($this->container); + $taskLinkModel = new TaskLinkModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'test', 'task_id' => 1))); + $this->assertEquals(1, $commentModel->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); + $this->assertEquals(1, $fileModel->create(1, 'test', 'blah', 123)); + $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); + + $task = $taskFinderModel->getDetails(1); + $subtask = $subtaskModel->getById(1, true); + $comment = $commentModel->getById(1); + $file = $commentModel->getById(1); + $tasklink = $taskLinkModel->getById(1); + + $this->assertNotEmpty($task); + $this->assertNotEmpty($subtask); + $this->assertNotEmpty($comment); + $this->assertNotEmpty($file); + + foreach (NotificationSubscriber::getSubscribedEvents() as $eventName => $values) { + $eventData = array( + 'task' => $task, + 'comment' => $comment, + 'subtask' => $subtask, + 'file' => $file, + 'task_link' => $tasklink, + 'changes' => array() + ); + $this->assertNotEmpty($mailNotification->getMailContent($eventName, $eventData)); + $this->assertNotEmpty($mailNotification->getMailSubject($eventName, $eventData)); + } + } + + public function testSendWithEmailAddress() + { + $mailNotification = new MailNotification($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $userModel = new UserModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + $this->assertTrue($userModel->update(array('id' => 1, 'email' => 'test@localhost'))); + + $this->container['emailClient'] = $this + ->getMockBuilder('\Kanboard\Core\Mail\Client') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('send')) + ->getMock(); + + $this->container['emailClient'] + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('test@localhost'), + $this->equalTo('admin'), + $this->equalTo('[test][New task] test (#1)'), + $this->stringContains('test') + ); + + $mailNotification->notifyUser($userModel->getById(1), TaskModel::EVENT_CREATE, array('task' => $taskFinderModel->getDetails(1))); + } + + public function testSendWithoutEmailAddress() + { + $mailNotification = new MailNotification($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $userModel = new UserModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $this->container['emailClient'] = $this + ->getMockBuilder('\Kanboard\Core\Mail\Client') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('send')) + ->getMock(); + + $this->container['emailClient'] + ->expects($this->never()) + ->method('send'); + + $mailNotification->notifyUser($userModel->getById(1), TaskModel::EVENT_CREATE, array('task' => $taskFinderModel->getDetails(1))); + } +} diff --git a/tests/units/Notification/MailTest.php b/tests/units/Notification/MailTest.php deleted file mode 100644 index 9f077ac8..00000000 --- a/tests/units/Notification/MailTest.php +++ /dev/null @@ -1,117 +0,0 @@ -container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $c = new CommentModel($this->container); - $f = new TaskFileModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'test', 'task_id' => 1))); - $this->assertEquals(1, $c->create(array('comment' => 'test', 'task_id' => 1, 'user_id' => 1))); - $this->assertEquals(1, $f->create(1, 'test', 'blah', 123)); - - $task = $tf->getDetails(1); - $subtask = $s->getById(1, true); - $comment = $c->getById(1); - $file = $c->getById(1); - - $this->assertNotEmpty($task); - $this->assertNotEmpty($subtask); - $this->assertNotEmpty($comment); - $this->assertNotEmpty($file); - - foreach (NotificationSubscriber::getSubscribedEvents() as $event => $values) { - $this->assertNotEmpty($en->getMailContent($event, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array()) - )); - - $this->assertNotEmpty($en->getMailSubject($event, array( - 'task' => $task, - 'comment' => $comment, - 'subtask' => $subtask, - 'file' => $file, - 'changes' => array()) - )); - } - } - - public function testSendWithEmailAddress() - { - $en = new MailNotification($this->container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $u = new UserModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - $this->assertTrue($u->update(array('id' => 1, 'email' => 'test@localhost'))); - - $this->container['emailClient'] = $this - ->getMockBuilder('\Kanboard\Core\Mail\Client') - ->setConstructorArgs(array($this->container)) - ->setMethods(array('send')) - ->getMock(); - - $this->container['emailClient'] - ->expects($this->once()) - ->method('send') - ->with( - $this->equalTo('test@localhost'), - $this->equalTo('admin'), - $this->equalTo('[test][New task] test (#1)'), - $this->stringContains('test') - ); - - $en->notifyUser($u->getById(1), TaskModel::EVENT_CREATE, array('task' => $tf->getDetails(1))); - } - - public function testSendWithoutEmailAddress() - { - $en = new MailNotification($this->container); - $p = new ProjectModel($this->container); - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $u = new UserModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('title' => 'test', 'project_id' => 1))); - - $this->container['emailClient'] = $this - ->getMockBuilder('\Kanboard\Core\Mail\Client') - ->setConstructorArgs(array($this->container)) - ->setMethods(array('send')) - ->getMock(); - - $this->container['emailClient'] - ->expects($this->never()) - ->method('send'); - - $en->notifyUser($u->getById(1), TaskModel::EVENT_CREATE, array('task' => $tf->getDetails(1))); - } -} diff --git a/tests/units/Notification/WebhookNotificationTest.php b/tests/units/Notification/WebhookNotificationTest.php new file mode 100644 index 00000000..6fbc349c --- /dev/null +++ b/tests/units/Notification/WebhookNotificationTest.php @@ -0,0 +1,29 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $this->container['dispatcher']->addSubscriber(new NotificationSubscriber($this->container)); + + $configModel->save(array('webhook_url' => 'http://localhost/?task-creation')); + + $this->container['httpClient'] + ->expects($this->once()) + ->method('postJson') + ->with($this->stringContains('http://localhost/?task-creation&token='), $this->anything()); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + } +} diff --git a/tests/units/Notification/WebhookTest.php b/tests/units/Notification/WebhookTest.php deleted file mode 100644 index 5a9eb1c7..00000000 --- a/tests/units/Notification/WebhookTest.php +++ /dev/null @@ -1,29 +0,0 @@ -container); - $p = new ProjectModel($this->container); - $tc = new TaskCreationModel($this->container); - $this->container['dispatcher']->addSubscriber(new NotificationSubscriber($this->container)); - - $c->save(array('webhook_url' => 'http://localhost/?task-creation')); - - $this->container['httpClient'] - ->expects($this->once()) - ->method('postJson') - ->with($this->stringContains('http://localhost/?task-creation&token='), $this->anything()); - - $this->assertEquals(1, $p->create(array('name' => 'test'))); - $this->assertEquals(1, $tc->create(array('project_id' => 1, 'title' => 'test'))); - } -} -- cgit v1.2.3 From a823cc1d08535539f850711c0b9edb5b648f1960 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:50:59 -0400 Subject: NotificationModel refactoring --- app/EventBuilder/BaseEventBuilder.php | 23 ++- app/EventBuilder/CommentEventBuilder.php | 52 +++++- app/EventBuilder/EventIteratorBuilder.php | 48 ++++++ app/EventBuilder/ProjectFileEventBuilder.php | 29 +++- app/EventBuilder/SubtaskEventBuilder.php | 48 +++++- app/EventBuilder/TaskEventBuilder.php | 102 +++++++++++- app/EventBuilder/TaskFileEventBuilder.php | 38 ++++- app/EventBuilder/TaskLinkEventBuilder.php | 2 +- app/Job/CommentEventJob.php | 2 +- app/Job/ProjectFileEventJob.php | 2 +- app/Job/SubtaskEventJob.php | 2 +- app/Job/TaskEventJob.php | 2 +- app/Job/TaskFileEventJob.php | 2 +- app/Job/TaskLinkEventJob.php | 2 +- app/Locale/bs_BA/translations.php | 6 +- app/Locale/cs_CZ/translations.php | 6 +- app/Locale/da_DK/translations.php | 6 +- app/Locale/de_DE/translations.php | 6 +- app/Locale/el_GR/translations.php | 6 +- app/Locale/es_ES/translations.php | 6 +- app/Locale/fi_FI/translations.php | 6 +- app/Locale/fr_FR/translations.php | 6 +- app/Locale/hu_HU/translations.php | 6 +- app/Locale/id_ID/translations.php | 6 +- app/Locale/it_IT/translations.php | 6 +- app/Locale/ja_JP/translations.php | 6 +- app/Locale/ko_KR/translations.php | 6 +- app/Locale/my_MY/translations.php | 6 +- app/Locale/nb_NO/translations.php | 6 +- app/Locale/nl_NL/translations.php | 6 +- app/Locale/pl_PL/translations.php | 6 +- app/Locale/pt_BR/translations.php | 6 +- app/Locale/pt_PT/translations.php | 6 +- app/Locale/ru_RU/translations.php | 6 +- app/Locale/sr_Latn_RS/translations.php | 6 +- app/Locale/sv_SE/translations.php | 6 +- app/Locale/th_TH/translations.php | 6 +- app/Locale/tr_TR/translations.php | 6 +- app/Locale/zh_CN/translations.php | 6 +- app/Model/NotificationModel.php | 176 +++++++-------------- app/Template/event/task_assignee_change.php | 2 +- tests/units/Action/TaskAssignCategoryLinkTest.php | 6 +- tests/units/Action/TaskAssignColorLinkTest.php | 4 +- .../units/EventBuilder/CommentEventBuilderTest.php | 4 +- .../EventBuilder/ProjectFileEventBuilderTest.php | 4 +- .../units/EventBuilder/SubtaskEventBuilderTest.php | 6 +- tests/units/EventBuilder/TaskEventBuilderTest.php | 10 +- .../EventBuilder/TaskFileEventBuilderTest.php | 4 +- .../EventBuilder/TaskLinkEventBuilderTest.php | 6 +- 49 files changed, 494 insertions(+), 232 deletions(-) create mode 100644 app/EventBuilder/EventIteratorBuilder.php diff --git a/app/EventBuilder/BaseEventBuilder.php b/app/EventBuilder/BaseEventBuilder.php index c677563e..5aa777a0 100644 --- a/app/EventBuilder/BaseEventBuilder.php +++ b/app/EventBuilder/BaseEventBuilder.php @@ -19,5 +19,26 @@ abstract class BaseEventBuilder extends Base * @access public * @return GenericEvent|null */ - abstract public function build(); + abstract public function buildEvent(); + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + abstract public function buildTitleWithAuthor($author, $eventName, array $eventData); + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + abstract public function buildTitleWithoutAuthor($eventName, array $eventData); } diff --git a/app/EventBuilder/CommentEventBuilder.php b/app/EventBuilder/CommentEventBuilder.php index 7b4060e4..ba5842a4 100644 --- a/app/EventBuilder/CommentEventBuilder.php +++ b/app/EventBuilder/CommentEventBuilder.php @@ -3,6 +3,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\CommentEvent; +use Kanboard\Model\CommentModel; /** * Class CommentEventBuilder @@ -32,7 +33,7 @@ class CommentEventBuilder extends BaseEventBuilder * @access public * @return CommentEvent|null */ - public function build() + public function buildEvent() { $comment = $this->commentModel->getById($this->commentId); @@ -45,4 +46,53 @@ class CommentEventBuilder extends BaseEventBuilder 'task' => $this->taskFinderModel->getDetails($comment['task_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case CommentModel::EVENT_UPDATE: + return e('%s updated a comment on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_CREATE: + return e('%s commented on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_DELETE: + return e('%s removed a comment on the task #%d', $author, $eventData['task']['id']); + case CommentModel::EVENT_USER_MENTION: + return e('%s mentioned you in a comment on the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case CommentModel::EVENT_CREATE: + return e('New comment on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_UPDATE: + return e('Comment updated on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_DELETE: + return e('Comment removed on task #%d', $eventData['comment']['task_id']); + case CommentModel::EVENT_USER_MENTION: + return e('You were mentioned in a comment on the task #%d', $eventData['task']['id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/EventIteratorBuilder.php b/app/EventBuilder/EventIteratorBuilder.php new file mode 100644 index 00000000..afa146b6 --- /dev/null +++ b/app/EventBuilder/EventIteratorBuilder.php @@ -0,0 +1,48 @@ +builders[] = $builder; + return $this; + } + + public function rewind() { + $this->position = 0; + } + + /** + * @return BaseEventBuilder + */ + public function current() { + return $this->builders[$this->position]; + } + + public function key() { + return $this->position; + } + + public function next() { + ++$this->position; + } + + public function valid() { + return isset($this->builders[$this->position]); + } +} diff --git a/app/EventBuilder/ProjectFileEventBuilder.php b/app/EventBuilder/ProjectFileEventBuilder.php index 70514a99..6698f78a 100644 --- a/app/EventBuilder/ProjectFileEventBuilder.php +++ b/app/EventBuilder/ProjectFileEventBuilder.php @@ -33,7 +33,7 @@ class ProjectFileEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $file = $this->projectFileModel->getById($this->fileId); @@ -47,4 +47,31 @@ class ProjectFileEventBuilder extends BaseEventBuilder 'project' => $this->projectModel->getById($file['project_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + return ''; + } } diff --git a/app/EventBuilder/SubtaskEventBuilder.php b/app/EventBuilder/SubtaskEventBuilder.php index f0271257..5f7e831d 100644 --- a/app/EventBuilder/SubtaskEventBuilder.php +++ b/app/EventBuilder/SubtaskEventBuilder.php @@ -4,6 +4,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\SubtaskEvent; use Kanboard\Event\GenericEvent; +use Kanboard\Model\SubtaskModel; /** * Class SubtaskEventBuilder @@ -59,7 +60,7 @@ class SubtaskEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $eventData = array(); $eventData['subtask'] = $this->subtaskModel->getById($this->subtaskId, true); @@ -76,4 +77,49 @@ class SubtaskEventBuilder extends BaseEventBuilder $eventData['task'] = $this->taskFinderModel->getDetails($eventData['subtask']['task_id']); return new SubtaskEvent($eventData); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case SubtaskModel::EVENT_UPDATE: + return e('%s updated a subtask for the task #%d', $author, $eventData['task']['id']); + case SubtaskModel::EVENT_CREATE: + return e('%s created a subtask for the task #%d', $author, $eventData['task']['id']); + case SubtaskModel::EVENT_DELETE: + return e('%s removed a subtask for the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case SubtaskModel::EVENT_CREATE: + return e('New subtask on task #%d', $eventData['subtask']['task_id']); + case SubtaskModel::EVENT_UPDATE: + return e('Subtask updated on task #%d', $eventData['subtask']['task_id']); + case SubtaskModel::EVENT_DELETE: + return e('Subtask removed on task #%d', $eventData['subtask']['task_id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/TaskEventBuilder.php b/app/EventBuilder/TaskEventBuilder.php index e7a5653d..aa897632 100644 --- a/app/EventBuilder/TaskEventBuilder.php +++ b/app/EventBuilder/TaskEventBuilder.php @@ -3,6 +3,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\TaskEvent; +use Kanboard\Model\TaskModel; /** * Class TaskEventBuilder @@ -98,7 +99,7 @@ class TaskEventBuilder extends BaseEventBuilder * @access public * @return TaskEvent|null */ - public function build() + public function buildEvent() { $eventData = array(); $eventData['task_id'] = $this->taskId; @@ -120,4 +121,103 @@ class TaskEventBuilder extends BaseEventBuilder return new TaskEvent(array_merge($eventData, $this->values)); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + switch ($eventName) { + case TaskModel::EVENT_ASSIGNEE_CHANGE: + $assignee = $eventData['task']['assignee_name'] ?: $eventData['task']['assignee_username']; + + if (! empty($assignee)) { + return e('%s changed the assignee of the task #%d to %s', $author, $eventData['task']['id'], $assignee); + } + + return e('%s removed the assignee of the task %s', $author, e('#%d', $eventData['task']['id'])); + case TaskModel::EVENT_UPDATE: + return e('%s updated the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_CREATE: + return e('%s created the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_CLOSE: + return e('%s closed the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_OPEN: + return e('%s opened the task #%d', $author, $eventData['task']['id']); + case TaskModel::EVENT_MOVE_COLUMN: + return e( + '%s moved the task #%d to the column "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['column_title'] + ); + case TaskModel::EVENT_MOVE_POSITION: + return e( + '%s moved the task #%d to the position %d in the column "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['position'], + $eventData['task']['column_title'] + ); + case TaskModel::EVENT_MOVE_SWIMLANE: + if ($eventData['task']['swimlane_id'] == 0) { + return e('%s moved the task #%d to the first swimlane', $author, $eventData['task']['id']); + } + + return e( + '%s moved the task #%d to the swimlane "%s"', + $author, + $eventData['task']['id'], + $eventData['task']['swimlane_name'] + ); + + case TaskModel::EVENT_USER_MENTION: + return e('%s mentioned you in the task #%d', $author, $eventData['task']['id']); + default: + return ''; + } + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + switch ($eventName) { + case TaskModel::EVENT_CREATE: + return e('New task #%d: %s', $eventData['task']['id'], $eventData['task']['title']); + case TaskModel::EVENT_UPDATE: + return e('Task updated #%d', $eventData['task']['id']); + case TaskModel::EVENT_CLOSE: + return e('Task #%d closed', $eventData['task']['id']); + case TaskModel::EVENT_OPEN: + return e('Task #%d opened', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_COLUMN: + return e('Column changed for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_POSITION: + return e('New position for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_MOVE_SWIMLANE: + return e('Swimlane changed for task #%d', $eventData['task']['id']); + case TaskModel::EVENT_ASSIGNEE_CHANGE: + return e('Assignee changed on task #%d', $eventData['task']['id']); + case TaskModel::EVENT_OVERDUE: + $nb = count($eventData['tasks']); + return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $eventData['tasks'][0]['id']); + case TaskModel::EVENT_USER_MENTION: + return e('You were mentioned in the task #%d', $eventData['task']['id']); + default: + return ''; + } + } } diff --git a/app/EventBuilder/TaskFileEventBuilder.php b/app/EventBuilder/TaskFileEventBuilder.php index 7f1ce3b3..8c985cc0 100644 --- a/app/EventBuilder/TaskFileEventBuilder.php +++ b/app/EventBuilder/TaskFileEventBuilder.php @@ -4,6 +4,7 @@ namespace Kanboard\EventBuilder; use Kanboard\Event\TaskFileEvent; use Kanboard\Event\GenericEvent; +use Kanboard\Model\TaskFileModel; /** * Class TaskFileEventBuilder @@ -33,7 +34,7 @@ class TaskFileEventBuilder extends BaseEventBuilder * @access public * @return GenericEvent|null */ - public function build() + public function buildEvent() { $file = $this->taskFileModel->getById($this->fileId); @@ -47,4 +48,39 @@ class TaskFileEventBuilder extends BaseEventBuilder 'task' => $this->taskFinderModel->getDetails($file['task_id']), )); } + + /** + * Get event title with author + * + * @access public + * @param string $author + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithAuthor($author, $eventName, array $eventData) + { + if ($eventName === TaskFileModel::EVENT_CREATE) { + return e('%s attached a file to the task #%d', $author, $eventData['task']['id']); + } + + return ''; + } + + /** + * Get event title without author + * + * @access public + * @param string $eventName + * @param array $eventData + * @return string + */ + public function buildTitleWithoutAuthor($eventName, array $eventData) + { + if ($eventName === TaskFileModel::EVENT_CREATE) { + return e('New attachment on task #%d: %s', $eventData['file']['task_id'], $eventData['file']['name']); + } + + return ''; + } } diff --git a/app/EventBuilder/TaskLinkEventBuilder.php b/app/EventBuilder/TaskLinkEventBuilder.php index 8be5299f..f1a3fba2 100644 --- a/app/EventBuilder/TaskLinkEventBuilder.php +++ b/app/EventBuilder/TaskLinkEventBuilder.php @@ -33,7 +33,7 @@ class TaskLinkEventBuilder extends BaseEventBuilder * @access public * @return TaskLinkEvent|null */ - public function build() + public function buildEvent() { $taskLink = $this->taskLinkModel->getById($this->taskLinkId); diff --git a/app/Job/CommentEventJob.php b/app/Job/CommentEventJob.php index c89350ed..47cf8020 100644 --- a/app/Job/CommentEventJob.php +++ b/app/Job/CommentEventJob.php @@ -37,7 +37,7 @@ class CommentEventJob extends BaseJob { $event = CommentEventBuilder::getInstance($this->container) ->withCommentId($commentId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/ProjectFileEventJob.php b/app/Job/ProjectFileEventJob.php index d68949c5..45e6ece3 100644 --- a/app/Job/ProjectFileEventJob.php +++ b/app/Job/ProjectFileEventJob.php @@ -36,7 +36,7 @@ class ProjectFileEventJob extends BaseJob { $event = ProjectFileEventBuilder::getInstance($this->container) ->withFileId($fileId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/SubtaskEventJob.php b/app/Job/SubtaskEventJob.php index 1dc243ef..85c4d73e 100644 --- a/app/Job/SubtaskEventJob.php +++ b/app/Job/SubtaskEventJob.php @@ -39,7 +39,7 @@ class SubtaskEventJob extends BaseJob $event = SubtaskEventBuilder::getInstance($this->container) ->withSubtaskId($subtaskId) ->withValues($values) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/TaskEventJob.php b/app/Job/TaskEventJob.php index 46f7a16c..7d026a68 100644 --- a/app/Job/TaskEventJob.php +++ b/app/Job/TaskEventJob.php @@ -47,7 +47,7 @@ class TaskEventJob extends BaseJob ->withChanges($changes) ->withValues($values) ->withTask($task) - ->build(); + ->buildEvent(); if ($event !== null) { foreach ($eventNames as $eventName) { diff --git a/app/Job/TaskFileEventJob.php b/app/Job/TaskFileEventJob.php index de2c40db..293dbf27 100644 --- a/app/Job/TaskFileEventJob.php +++ b/app/Job/TaskFileEventJob.php @@ -36,7 +36,7 @@ class TaskFileEventJob extends BaseJob { $event = TaskFileEventBuilder::getInstance($this->container) ->withFileId($fileId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Job/TaskLinkEventJob.php b/app/Job/TaskLinkEventJob.php index 669608ad..31f62f07 100644 --- a/app/Job/TaskLinkEventJob.php +++ b/app/Job/TaskLinkEventJob.php @@ -36,7 +36,7 @@ class TaskLinkEventJob extends BaseJob { $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId($taskLinkId) - ->build(); + ->buildEvent(); if ($event !== null) { $this->dispatcher->dispatch($eventName, $event); diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php index 6a062068..f1529e02 100644 --- a/app/Locale/bs_BA/translations.php +++ b/app/Locale/bs_BA/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ažurirao zadatak #%d', '%s created the task #%d' => '%s kreirao zadatak #%d', '%s closed the task #%d' => '%s zatvorio zadatak #%d', - '%s open the task #%d' => '%s otvorio zadatak #%d', + '%s opened the task #%d' => '%s otvorio zadatak #%d', '%s moved the task #%d to the column "%s"' => '%s premjestio zadatak #%d u kolonu "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s premjestio zadatak #%d na poziciju %d u koloni "%s"', 'Activity' => 'Aktivnosti', 'Default values are "%s"' => 'Podrazumijevane vrijednosti su: "%s"', 'Default columns for new projects (Comma-separated)' => 'Podrazumijevane kolone za novi projekat (Odvojene zarezom)', 'Task assignee change' => 'Promijena izvršioca zadatka', - '%s change the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s', + '%s changed the assignee of the task #%d to %s' => '%s zamijeni izvršioca za zadatak #%d u %s', '%s changed the assignee of the task %s to %s' => '%s promijenio izvršioca za zadatak %s u %s', 'New password for the user "%s"' => 'Nova šifra korisnika "%s"', 'Choose an event' => 'Izaberi događaj', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Stopa valute je uspješno dodana.', 'Unable to add this currency rate.' => 'Nemoguće dodati stopu valute.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s', + '%s removed the assignee of the task %s' => '%s je uklonio izvršioca zadatka %s', 'Enable Gravatar images' => 'Omogući Gravatar slike', 'Information' => 'Informacije', 'Check two factor authentication code' => 'Provjera faktor-dva autentifikacionog koda', diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index b9a4de6e..c7e6e536 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s aktualizoval úkol #%d ', '%s created the task #%d' => '%s vytvořil úkol #%d ', '%s closed the task #%d' => '%s uzavřel úkol #%d ', - '%s open the task #%d' => '%s znovu otevřel úkol #%d ', + '%s opened the task #%d' => '%s znovu otevřel úkol #%d ', '%s moved the task #%d to the column "%s"' => '%s přesunul úkol #%d do sloupce "%s" ', '%s moved the task #%d to the position %d in the column "%s"' => '%s přesunul úkol #%d na pozici %d ve sloupci "%s" ', 'Activity' => 'Aktivity', 'Default values are "%s"' => 'Standardní hodnoty jsou: "%s"', 'Default columns for new projects (Comma-separated)' => 'Výchozí sloupce pro nové projekty (odděleny čárkou)', 'Task assignee change' => 'Změna přiřazení uživatelů', - '%s change the assignee of the task #%d to %s' => '%s změnil přidělení úkolu #%d na uživatele %s', + '%s changed the assignee of the task #%d to %s' => '%s změnil přidělení úkolu #%d na uživatele %s', '%s changed the assignee of the task %s to %s' => '%s změnil přidělení úkolu %s na uživatele %s', 'New password for the user "%s"' => 'Nové heslo pro uživatele "%s"', 'Choose an event' => 'Vybrat událost', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Směnný kurz byl úspěšně přidán.', 'Unable to add this currency rate.' => 'Nelze přidat tento směnný kurz', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s odstranil přiřazení úkolu %s ', + '%s removed the assignee of the task %s' => '%s odstranil přiřazení úkolu %s ', 'Enable Gravatar images' => 'Aktiviere Gravatar Bilder', 'Information' => 'Informace', 'Check two factor authentication code' => 'Zkontrolujte dvouúrovňový autentifikační klíč', diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index 050a37d9..6cecfaec 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s opdaterede opgaven #%d', '%s created the task #%d' => '%s oprettede opgaven #%d', '%s closed the task #%d' => '%s lukkede opgaven #%d', - '%s open the task #%d' => '%s åbnede opgaven #%d', + '%s opened the task #%d' => '%s åbnede opgaven #%d', '%s moved the task #%d to the column "%s"' => '%s flyttede opgaven #%d til kolonnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttede opgaven #%d til position %d i kolonnen "%s"', 'Activity' => 'Aktivitet', 'Default values are "%s"' => 'Standard værdier er "%s"', 'Default columns for new projects (Comma-separated)' => 'Standard kolonne for nye projekter (kommasepareret)', 'Task assignee change' => 'Opgaven ansvarlig ændring', - '%s change the assignee of the task #%d to %s' => '%s skrift ansvarlig for opgaven #%d til %s', + '%s changed the assignee of the task #%d to %s' => '%s skrift ansvarlig for opgaven #%d til %s', '%s changed the assignee of the task %s to %s' => '%s skift ansvarlig for opgaven %s til %s', 'New password for the user "%s"' => 'Ny adgangskode for brugeren "%s"', 'Choose an event' => 'Vælg et event', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index d6c8bf60..d25e7e8a 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s hat die Aufgabe #%d aktualisiert', '%s created the task #%d' => '%s hat die Aufgabe #%d angelegt', '%s closed the task #%d' => '%s hat die Aufgabe #%d geschlossen', - '%s open the task #%d' => '%s hat die Aufgabe #%d geöffnet', + '%s opened the task #%d' => '%s hat die Aufgabe #%d geöffnet', '%s moved the task #%d to the column "%s"' => '%s hat die Aufgabe #%d in die Spalte "%s" verschoben', '%s moved the task #%d to the position %d in the column "%s"' => '%s hat die Aufgabe #%d an die Position %d in der Spalte "%s" verschoben', 'Activity' => 'Aktivität', 'Default values are "%s"' => 'Die Standardwerte sind "%s"', 'Default columns for new projects (Comma-separated)' => 'Standardspalten für neue Projekte (komma-getrennt)', 'Task assignee change' => 'Zuständigkeit geändert', - '%s change the assignee of the task #%d to %s' => '%s hat die Zusständigkeit der Aufgabe #%d geändert um %s', + '%s changed the assignee of the task #%d to %s' => '%s hat die Zusständigkeit der Aufgabe #%d geändert um %s', '%s changed the assignee of the task %s to %s' => '%s hat die Zuständigkeit der Aufgabe %s geändert um %s', 'New password for the user "%s"' => 'Neues Passwort des Benutzers "%s"', 'Choose an event' => 'Aktion wählen', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Der Währungskurs wurde erfolgreich hinzugefügt.', 'Unable to add this currency rate.' => 'Währungskurs konnte nicht hinzugefügt werden', 'Webhook URL' => 'Webhook-URL', - '%s remove the assignee of the task %s' => '%s Zuordnung für die Aufgabe %s entfernen', + '%s removed the assignee of the task %s' => '%s Zuordnung für die Aufgabe %s entfernen', 'Enable Gravatar images' => 'Aktiviere Gravatar-Bilder', 'Information' => 'Information', 'Check two factor authentication code' => 'Prüfe Zwei-Faktor-Authentifizierungscode', diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php index 87ea68b0..b02207d5 100644 --- a/app/Locale/el_GR/translations.php +++ b/app/Locale/el_GR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ενημέρωσε την εργασία n°%d', '%s created the task #%d' => '%s δημιούργησε την εργασία n°%d', '%s closed the task #%d' => '%s έκλεισε την εργασία n°%d', - '%s open the task #%d' => '%s άνοιξε την εργασία n°%d', + '%s opened the task #%d' => '%s άνοιξε την εργασία n°%d', '%s moved the task #%d to the column "%s"' => '%s μετακίνησε την εργασία n°%d στη στήλη « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s μετακίνησε την εργασία n°%d στη θέση n°%d της στήλης « %s »', 'Activity' => 'Δραστηριότητα', 'Default values are "%s"' => 'Οι προεπιλεγμένες τιμές είναι « %s »', 'Default columns for new projects (Comma-separated)' => 'Προεπιλεγμένες στήλες για νέα έργα (Comma-separated)', 'Task assignee change' => 'Αλλαγή εκδοχέα εργασίας', - '%s change the assignee of the task #%d to %s' => '%s άλλαξε τον εκδοχέα της εργασίας n˚%d σε %s', + '%s changed the assignee of the task #%d to %s' => '%s άλλαξε τον εκδοχέα της εργασίας n˚%d σε %s', '%s changed the assignee of the task %s to %s' => '%s ενημέρωσε τον εκδοχέα της εργασίας %s σε %s', 'New password for the user "%s"' => 'Νέο password του χρήστη « %s »', 'Choose an event' => 'Επιλογή event', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Η ισοτιμία προστέθηκε με επιτυχία.', 'Unable to add this currency rate.' => 'Αδύνατο να προστεθεί αυτή η ισοτιμία.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s αφαίρεσε τον εκδοχέα της εργασίας %s', + '%s removed the assignee of the task %s' => '%s αφαίρεσε τον εκδοχέα της εργασίας %s', 'Enable Gravatar images' => 'Ενεργοποίηση εικόνων Gravatar', 'Information' => 'Πληροφορίες', 'Check two factor authentication code' => 'Ελέγξτε δύο παράγοντες ελέγχου ταυτότητας κωδικού', diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index 1a4bae82..fa59ca07 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s actualizó la tarea #%d', '%s created the task #%d' => '%s creó la tarea #%d', '%s closed the task #%d' => '%s cerró la tarea #%d', - '%s open the task #%d' => '%s abrió la tarea #%d', + '%s opened the task #%d' => '%s abrió la tarea #%d', '%s moved the task #%d to the column "%s"' => '%s movió la tarea #%d a la columna «%s»', '%s moved the task #%d to the position %d in the column "%s"' => '%s movió la tarea #%d a la posición %d de la columna «%s»', 'Activity' => 'Actividad', 'Default values are "%s"' => 'Los valores por defecto son «%s»', 'Default columns for new projects (Comma-separated)' => 'Columnas por defecto para los nuevos proyectos (separadas mediante comas)', 'Task assignee change' => 'Cambiar responsable de la tarea', - '%s change the assignee of the task #%d to %s' => '%s cambió el responsable de la tarea #%d por %s', + '%s changed the assignee of the task #%d to %s' => '%s cambió el responsable de la tarea #%d por %s', '%s changed the assignee of the task %s to %s' => '%s cambió el responsable de la tarea %s por %s', 'New password for the user "%s"' => 'Nueva contraseña para el usuario «%s»', 'Choose an event' => 'Seleccione un evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'El cambio de moneda se ha añadido correctamente.', 'Unable to add this currency rate.' => 'No se puede añadir este cambio de moneda.', 'Webhook URL' => 'URL del disparador web (webhook)', - '%s remove the assignee of the task %s' => '%s quita el responsable de la tarea %s', + '%s removed the assignee of the task %s' => '%s quita el responsable de la tarea %s', 'Enable Gravatar images' => 'Activar imágenes Gravatar', 'Information' => 'Información', 'Check two factor authentication code' => 'Revisar código de autenticación en dos pasos', diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 5d37cb82..200a9cde 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s päivitti tehtävää #%d', '%s created the task #%d' => '%s loi tehtävän #%d', '%s closed the task #%d' => '%s sulki tehtävän #%d', - '%s open the task #%d' => '%s avasi tehtävän #%d', + '%s opened the task #%d' => '%s avasi tehtävän #%d', '%s moved the task #%d to the column "%s"' => '%s siirsi tehtävän #%d sarakkeeseen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s siirsi tehtävän #%d %d. sarakkeessa %s', 'Activity' => 'Toiminta', 'Default values are "%s"' => 'Oletusarvot ovat "%s"', 'Default columns for new projects (Comma-separated)' => 'Oletussarakkeet uusille projekteille', 'Task assignee change' => 'Tehtävän saajan vaihto', - '%s change the assignee of the task #%d to %s' => '%s vaihtoi tehtävän #%d saajaksi %s', + '%s changed the assignee of the task #%d to %s' => '%s vaihtoi tehtävän #%d saajaksi %s', '%s changed the assignee of the task %s to %s' => '%s vaihtoi tehtävän %s saajaksi %s', 'New password for the user "%s"' => 'Uusi salasana käyttäjälle "%s"', 'Choose an event' => 'Valitse toiminta', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index c8f7d343..9f6cf971 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s a mis à jour la tâche n°%d', '%s created the task #%d' => '%s a créé la tâche n°%d', '%s closed the task #%d' => '%s a fermé la tâche n°%d', - '%s open the task #%d' => '%s a ouvert la tâche n°%d', + '%s opened the task #%d' => '%s a ouvert la tâche n°%d', '%s moved the task #%d to the column "%s"' => '%s a déplacé la tâche n°%d dans la colonne « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s a déplacé la tâche n°%d à la position n°%d dans la colonne « %s »', 'Activity' => 'Activité', 'Default values are "%s"' => 'Les valeurs par défaut sont « %s »', 'Default columns for new projects (Comma-separated)' => 'Colonnes par défaut pour les nouveaux projets (séparation par des virgules)', 'Task assignee change' => 'Modification de la personne assignée à une tâche', - '%s change the assignee of the task #%d to %s' => '%s a changé la personne assignée à la tâche n˚%d pour %s', + '%s changed the assignee of the task #%d to %s' => '%s a changé la personne assignée à la tâche n˚%d pour %s', '%s changed the assignee of the task %s to %s' => '%s a changé la personne assignée à la tâche %s pour %s', 'New password for the user "%s"' => 'Nouveau mot de passe pour l\'utilisateur « %s »', 'Choose an event' => 'Choisir un événement', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Le taux de change a été ajouté avec succès.', 'Unable to add this currency rate.' => 'Impossible d\'ajouter ce taux de change', 'Webhook URL' => 'URL du webhook', - '%s remove the assignee of the task %s' => '%s a enlevé la personne assignée à la tâche %s', + '%s removed the assignee of the task %s' => '%s a enlevé la personne assignée à la tâche %s', 'Enable Gravatar images' => 'Activer les images Gravatar', 'Information' => 'Informations', 'Check two factor authentication code' => 'Vérification du code pour l\'authentification à deux-facteurs', diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index febf8bc0..781a0423 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s frissítette a feladatot #%d', '%s created the task #%d' => '%s létrehozta a feladatot #%d', '%s closed the task #%d' => '%s lezárta a feladatot #%d', - '%s open the task #%d' => '%s megnyitotta a feladatot #%d', + '%s opened the task #%d' => '%s megnyitotta a feladatot #%d', '%s moved the task #%d to the column "%s"' => '%s átmozgatta a feladatot #%d a "%s" oszlopba', '%s moved the task #%d to the position %d in the column "%s"' => '%s átmozgatta a feladatot #%d a %d pozícióba a "%s" oszlopban', 'Activity' => 'Tevékenységek', 'Default values are "%s"' => 'Az alapértelmezett értékek: %s', 'Default columns for new projects (Comma-separated)' => 'Alapértelmezett oszlopok az új projektekben (vesszővel elválasztva)', 'Task assignee change' => 'Felelős módosítása', - '%s change the assignee of the task #%d to %s' => '%s a felelőst módosította #%d %s', + '%s changed the assignee of the task #%d to %s' => '%s a felelőst módosította #%d %s', '%s changed the assignee of the task %s to %s' => '%s a felelőst %s módosította: %s', 'New password for the user "%s"' => 'Felhasználó új jelszava: %s', 'Choose an event' => 'Válasszon eseményt', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Az átváltási árfolyammal történő bővítés sikerült', 'Unable to add this currency rate.' => 'Nem sikerült az átváltási árfolyam felvétele', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s eltávolította a %s feladathoz rendelt személyt', + '%s removed the assignee of the task %s' => '%s eltávolította a %s feladathoz rendelt személyt', 'Enable Gravatar images' => 'Gravatár képek engedélyezése', 'Information' => 'Információ', 'Check two factor authentication code' => 'Két fázisú beléptető kód ellenőrzése', diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 18a7a72d..26e091ce 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s memperbaharui tugas n°%d', '%s created the task #%d' => '%s membuat tugas n°%d', '%s closed the task #%d' => '%s menutup tugas n°%d', - '%s open the task #%d' => '%s membuka tugas n°%d', + '%s opened the task #%d' => '%s membuka tugas n°%d', '%s moved the task #%d to the column "%s"' => '%s memindahkan tugas n°%d ke kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s memindahkan tugas n°%d ke posisi n°%d dalam kolom « %s »', 'Activity' => 'Aktifitas', 'Default values are "%s"' => 'Standar nilai adalah« %s »', 'Default columns for new projects (Comma-separated)' => 'Kolom default untuk proyek baru (dipisahkan dengan koma)', 'Task assignee change' => 'Mengubah orang ditugaskan untuk tugas', - '%s change the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', + '%s changed the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', '%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s', 'New password for the user "%s"' => 'Kata sandi baru untuk pengguna « %s »', 'Choose an event' => 'Pilih acara', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Nilai tukar mata uang berhasil ditambahkan.', 'Unable to add this currency rate.' => 'Tidak dapat menambahkan nilai tukar mata uang', 'Webhook URL' => 'URL webhook', - '%s remove the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', + '%s removed the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', 'Enable Gravatar images' => 'Mengaktifkan gambar Gravatar', 'Information' => 'Informasi', 'Check two factor authentication code' => 'Cek dua faktor kode otentifikasi', diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index f6c63076..aadbfe5b 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ha aggiornato il task #%d', '%s created the task #%d' => '%s ha creato il task #%d', '%s closed the task #%d' => '%s ha chiuso il task #%d', - '%s open the task #%d' => '%s ha aperto il task #%d', + '%s opened the task #%d' => '%s ha aperto il task #%d', '%s moved the task #%d to the column "%s"' => '%s ha spostato il task #%d nella colonna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s ha spostato il task #%d nella posizione %d della colonna "%s"', 'Activity' => 'Attività', 'Default values are "%s"' => 'Valori di default "%s"', 'Default columns for new projects (Comma-separated)' => 'Colonne di default per i nuovi progetti (Separati da virgola)', 'Task assignee change' => 'Cambia l\'assegnatario del task', - '%s change the assignee of the task #%d to %s' => '%s dai l\'assegnazione del task #%d a %s', + '%s changed the assignee of the task #%d to %s' => '%s dai l\'assegnazione del task #%d a %s', '%s changed the assignee of the task %s to %s' => '%s ha cambiato l\'assegnatario del task %s a %s', 'New password for the user "%s"' => 'Nuova password per l\'utente "%s"', 'Choose an event' => 'Scegli un evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Il tasso di cambio è stato aggiunto con successo.', 'Unable to add this currency rate.' => 'Impossibile aggiungere questo tasso di cambio.', 'Webhook URL' => 'URL Webhook', - '%s remove the assignee of the task %s' => '%s rimuove l\'assegnatario del task %s', + '%s removed the assignee of the task %s' => '%s rimuove l\'assegnatario del task %s', 'Enable Gravatar images' => 'Abilita immagini Gravatar', 'Information' => 'Informazioni', 'Check two factor authentication code' => 'Controlla il codice di autenticazione "two-factor"', diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index dab731d2..03fa55ed 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s がタスク #%d を更新しました', '%s created the task #%d' => '%s がタスク #%d を追加しました', '%s closed the task #%d' => '%s がタスク #%d をクローズしました', - '%s open the task #%d' => '%s がタスク #%d をオープンしました', + '%s opened the task #%d' => '%s がタスク #%d をオープンしました', '%s moved the task #%d to the column "%s"' => '%s がタスク #%d をカラム「%s」に移動しました', '%s moved the task #%d to the position %d in the column "%s"' => '%s がタスク #%d を位置 %d カラム「%s」移動しました', 'Activity' => 'アクティビティ', 'Default values are "%s"' => 'デフォルト値は「%s」', 'Default columns for new projects (Comma-separated)' => '新規プロジェクトのデフォルトカラム (コンマで区切って入力)', 'Task assignee change' => '担当者の変更', - '%s change the assignee of the task #%d to %s' => '%s がタスク #%d の担当を %s に変更しました', + '%s changed the assignee of the task #%d to %s' => '%s がタスク #%d の担当を %s に変更しました', '%s changed the assignee of the task %s to %s' => '%s がタスク %s の担当を %s に変更しました', 'New password for the user "%s"' => 'ユーザ「%s」の新しいパスワード', 'Choose an event' => 'イベントの選択', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', 'Unable to add this currency rate.' => 'この通貨レートを追加できません。', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s がタスク「%s」の担当を解除しました。', + '%s removed the assignee of the task %s' => '%s がタスク「%s」の担当を解除しました。', 'Enable Gravatar images' => 'Gravatar イメージを有効化', 'Information' => '情報 ', 'Check two factor authentication code' => '2 段認証をチェックする', diff --git a/app/Locale/ko_KR/translations.php b/app/Locale/ko_KR/translations.php index 0b6007b1..bf140d94 100644 --- a/app/Locale/ko_KR/translations.php +++ b/app/Locale/ko_KR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s이 할일#%d을 갱신했습니다', '%s created the task #%d' => '%s이 할일#%d을 추가했습니다', '%s closed the task #%d' => '%s이 할일#%d을 닫혔습니다', - '%s open the task #%d' => '%s이 할일#%d를 오픈했습니다', + '%s opened the task #%d' => '%s이 할일#%d를 오픈했습니다', '%s moved the task #%d to the column "%s"' => '%s이 할일#%d을 칼럼"%s"로 옮겼습니다', '%s moved the task #%d to the position %d in the column "%s"' => '%s이 할일#%d을 칼럼 "%s"의 %d 위치로 이동시켰습니다', 'Activity' => '활동', 'Default values are "%s"' => '기본 값은 "%s" 입니다', 'Default columns for new projects (Comma-separated)' => '새로운 프로젝트의 기본 칼럼 (콤마(,)로 분리됨)', 'Task assignee change' => '담당자의 변경', - '%s change the assignee of the task #%d to %s' => '%s이 할일 #%d의 담당을 %s로 변경합니다', + '%s changed the assignee of the task #%d to %s' => '%s이 할일 #%d의 담당을 %s로 변경합니다', '%s changed the assignee of the task %s to %s' => '%s이 할일 %s의 담당을 %s로 변경했습니다', 'New password for the user "%s"' => '사용자 "%s"의 새로운 패스워드', 'Choose an event' => '행사의 선택', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => '통화가 성공적으로 추가되었습니다', 'Unable to add this currency rate.' => '이 통화 환율을 추가할 수 없습니다.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s이 할일 %s의 담당을 삭제했습니다', + '%s removed the assignee of the task %s' => '%s이 할일 %s의 담당을 삭제했습니다', 'Enable Gravatar images' => 'Gravatar이미지를 활성화', 'Information' => '정보', 'Check two factor authentication code' => '2단 인증을 체크한다', diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php index 3d66b0bb..cf4f399c 100644 --- a/app/Locale/my_MY/translations.php +++ b/app/Locale/my_MY/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s memperbaharui tugas n°%d', '%s created the task #%d' => '%s membuat tugas n°%d', '%s closed the task #%d' => '%s menutup tugas n°%d', - '%s open the task #%d' => '%s membuka tugas n°%d', + '%s opened the task #%d' => '%s membuka tugas n°%d', '%s moved the task #%d to the column "%s"' => '%s memindahkan tugas n°%d ke kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s memindahkan tugas n°%d ke posisi n°%d dalam kolom « %s »', 'Activity' => 'Aktifitas', 'Default values are "%s"' => 'Standar nilai adalah« %s »', 'Default columns for new projects (Comma-separated)' => 'Kolom default untuk projek baru (dipisahkan dengan koma)', 'Task assignee change' => 'Mengubah orang ditugaskan untuk tugas', - '%s change the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', + '%s changed the assignee of the task #%d to %s' => '%s rubah orang yang ditugaskan dari tugas n%d ke %s', '%s changed the assignee of the task %s to %s' => '%s mengubah orang yang ditugaskan dari tugas %s ke %s', 'New password for the user "%s"' => 'Kata laluan baru untuk pengguna « %s »', 'Choose an event' => 'Pilih sebuah acara', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Nilai tukar mata uang berhasil ditambahkan.', 'Unable to add this currency rate.' => 'Tidak dapat menambahkan nilai tukar mata uang', 'Webhook URL' => 'URL webhook', - '%s remove the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', + '%s removed the assignee of the task %s' => '%s menghapus penugasan dari tugas %s', 'Enable Gravatar images' => 'Mengaktifkan gambar Gravatar', 'Information' => 'Informasi', 'Check two factor authentication code' => 'Cek dua faktor kode otentifikasi', diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index 14e260cb..ce69deb9 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s oppdaterte oppgaven #%d', '%s created the task #%d' => '%s opprettet oppgaven #%d', '%s closed the task #%d' => '%s lukket oppgaven #%d', - '%s open the task #%d' => '%s åpnet oppgaven #%d', + '%s opened the task #%d' => '%s åpnet oppgaven #%d', '%s moved the task #%d to the column "%s"' => '%s flyttet oppgaven #%d til kolonnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttet oppgaven #%d til posisjonen %d i kolonnen "%s"', 'Activity' => 'Aktivitetslogg', 'Default values are "%s"' => 'Standardverdier er "%s"', 'Default columns for new projects (Comma-separated)' => 'Standard kolonne for nye prosjekter (komma-separert)', 'Task assignee change' => 'Endring av oppgaveansvarlig', - '%s change the assignee of the task #%d to %s' => '%s endre ansvarlig for oppgaven #%d til %s', + '%s changed the assignee of the task #%d to %s' => '%s endre ansvarlig for oppgaven #%d til %s', '%s changed the assignee of the task %s to %s' => '%s endret ansvarlig for oppgaven %s til %s', 'New password for the user "%s"' => 'Nytt passord for brukeren "%s"', 'Choose an event' => 'Velg en hendelse', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index 8b47d514..d5ba7036 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s heeft taak %d aangepast', '%s created the task #%d' => '%s heeft taak %d aangemaakt', '%s closed the task #%d' => '%s heeft taak %d gesloten', - '%s open the task #%d' => '%s a heeft taak %d geopend', + '%s opened the task #%d' => '%s a heeft taak %d geopend', '%s moved the task #%d to the column "%s"' => '%s heeft taak %d verplaatst naar kolom « %s »', '%s moved the task #%d to the position %d in the column "%s"' => '%s heeft taak %d verplaatst naar positie %d in kolom « %s »', 'Activity' => 'Activiteit', 'Default values are "%s"' => 'Standaardwaarden zijn « %s »', 'Default columns for new projects (Comma-separated)' => 'Standaard kolommen voor nieuw projecten (komma gescheiden)', 'Task assignee change' => 'Taak toegewezene verandering', - '%s change the assignee of the task #%d to %s' => '%s heeft de toegewezene voor taak %d veranderd in %s', + '%s changed the assignee of the task #%d to %s' => '%s heeft de toegewezene voor taak %d veranderd in %s', '%s changed the assignee of the task %s to %s' => '%s heeft de toegewezene voor taak %s veranderd in %s', 'New password for the user "%s"' => 'Nieuw wachtwoord voor gebruiker « %s »', 'Choose an event' => 'Kies een gebeurtenis', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', 'Webhook URL' => 'Webhook URL', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index e72649e6..f2570d7c 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s zaktualizował zadanie #%d', '%s created the task #%d' => '%s utworzył zadanie #%d', '%s closed the task #%d' => '%s zamknął zadanie #%d', - '%s open the task #%d' => '%s otworzył zadanie #%d', + '%s opened the task #%d' => '%s otworzył zadanie #%d', '%s moved the task #%d to the column "%s"' => '%s przeniósł zadanie #%d do kolumny "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s przeniósł zadanie #%d na pozycję %d w kolmnie "%s"', 'Activity' => 'Aktywność', 'Default values are "%s"' => 'Domyślne wartości: "%s"', 'Default columns for new projects (Comma-separated)' => 'Domyślne kolumny dla nowych projektów (oddzielone przecinkiem)', 'Task assignee change' => 'Zmień osobę odpowiedzialną', - '%s change the assignee of the task #%d to %s' => '%s zmienił osobę odpowiedzialną za zadanie #%d na %s', + '%s changed the assignee of the task #%d to %s' => '%s zmienił osobę odpowiedzialną za zadanie #%d na %s', '%s changed the assignee of the task %s to %s' => '%s zmienił osobę odpowiedzialną za zadanie %s na %s', 'New password for the user "%s"' => 'Nowe hasło użytkownika "%s"', 'Choose an event' => 'Wybierz zdarzenie', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Dodano kurs waluty', 'Unable to add this currency rate.' => 'Nie można dodać kursu waluty', 'Webhook URL' => 'Adres webhooka', - '%s remove the assignee of the task %s' => '%s usunął osobę przypisaną do zadania %s', + '%s removed the assignee of the task %s' => '%s usunął osobę przypisaną do zadania %s', 'Enable Gravatar images' => 'Włącz Gravatar', 'Information' => 'Informacje', 'Check two factor authentication code' => 'Sprawdź kod weryfikujący', diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 7b64f0e7..46749043 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s atualizou a tarefa #%d', '%s created the task #%d' => '%s criou a tarefa #%d', '%s closed the task #%d' => '%s finalizou a tarefa #%d', - '%s open the task #%d' => '%s abriu a tarefa #%d', + '%s opened the task #%d' => '%s abriu a tarefa #%d', '%s moved the task #%d to the column "%s"' => '%s moveu a tarefa #%d para a coluna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s moveu a tarefa #%d para a posição %d na coluna "%s"', 'Activity' => 'Atividade', 'Default values are "%s"' => 'Os valores padrão são "%s"', 'Default columns for new projects (Comma-separated)' => 'Colunas padrão para novos projetos (Separado por vírgula)', 'Task assignee change' => 'Mudar designação da tarefa', - '%s change the assignee of the task #%d to %s' => '%s mudou a designação da tarefa #%d para %s', + '%s changed the assignee of the task #%d to %s' => '%s mudou a designação da tarefa #%d para %s', '%s changed the assignee of the task %s to %s' => '%s mudou a designação da tarefa %s para %s', 'New password for the user "%s"' => 'Nova senha para o usuário "%s"', 'Choose an event' => 'Escolher um evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'A taxa de câmbio foi adicionada com sucesso.', 'Unable to add this currency rate.' => 'Impossível de adicionar essa taxa de câmbio.', 'Webhook URL' => 'URL do webhook', - '%s remove the assignee of the task %s' => '%s removeu a pessoa designada para a tarefa %s', + '%s removed the assignee of the task %s' => '%s removeu a pessoa designada para a tarefa %s', 'Enable Gravatar images' => 'Ativar imagens do Gravatar', 'Information' => 'Informações', 'Check two factor authentication code' => 'Verifique o código de autenticação em duas etapas', diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 5267b03b..4fd070d1 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s actualizou a tarefa #%d', '%s created the task #%d' => '%s criou a tarefa #%d', '%s closed the task #%d' => '%s finalizou a tarefa #%d', - '%s open the task #%d' => '%s abriu a tarefa #%d', + '%s opened the task #%d' => '%s abriu a tarefa #%d', '%s moved the task #%d to the column "%s"' => '%s moveu a tarefa #%d para a coluna "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s moveu a tarefa #%d para a posição %d na coluna "%s"', 'Activity' => 'Actividade', 'Default values are "%s"' => 'Os valores padrão são "%s"', 'Default columns for new projects (Comma-separated)' => 'Colunas padrão para novos projectos (Separado por vírgula)', 'Task assignee change' => 'Mudar assignação da tarefa', - '%s change the assignee of the task #%d to %s' => '%s mudou a assignação da tarefa #%d para %s', + '%s changed the assignee of the task #%d to %s' => '%s mudou a assignação da tarefa #%d para %s', '%s changed the assignee of the task %s to %s' => '%s mudou a assignação da tarefa %s para %s', 'New password for the user "%s"' => 'Nova senha para o utilizador "%s"', 'Choose an event' => 'Escolher um evento', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'A taxa de câmbio foi adicionada com sucesso.', 'Unable to add this currency rate.' => 'Impossível adicionar essa taxa de câmbio.', 'Webhook URL' => 'URL do webhook', - '%s remove the assignee of the task %s' => '%s removeu a pessoa assignada à tarefa %s', + '%s removed the assignee of the task %s' => '%s removeu a pessoa assignada à tarefa %s', 'Enable Gravatar images' => 'Activar imagem Gravatar', 'Information' => 'Informações', 'Check two factor authentication code' => 'Verificação do código de autenticação com factor duplo', diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index b3682f03..92fba163 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s обновил задачу #%d', '%s created the task #%d' => '%s создал задачу #%d', '%s closed the task #%d' => '%s закрыл задачу #%d', - '%s open the task #%d' => '%s открыл задачу #%d', + '%s opened the task #%d' => '%s открыл задачу #%d', '%s moved the task #%d to the column "%s"' => '%s переместил задачу #%d в колонку "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s переместил задачу #%d на позицию %d в колонке "%s"', 'Activity' => 'Активность', 'Default values are "%s"' => 'Колонки по умолчанию: "%s"', 'Default columns for new projects (Comma-separated)' => 'Колонки по умолчанию для новых проектов (разделять запятой)', 'Task assignee change' => 'Изменен назначенный', - '%s change the assignee of the task #%d to %s' => '%s сменил назначенного для задачи #%d на %s', + '%s changed the assignee of the task #%d to %s' => '%s сменил назначенного для задачи #%d на %s', '%s changed the assignee of the task %s to %s' => '%s сменил назначенного для задачи %s на %s', 'New password for the user "%s"' => 'Новый пароль для пользователя "%s"', 'Choose an event' => 'Выберите событие', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Курс валюты был успешно добавлен.', 'Unable to add this currency rate.' => 'Невозможно добавить этот курс валюты.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s удалить назначенную задачу %s', + '%s removed the assignee of the task %s' => '%s удалить назначенную задачу %s', 'Enable Gravatar images' => 'Включить Gravatar изображения', 'Information' => 'Информация', 'Check two factor authentication code' => 'Проверка кода двухфакторной авторизации', diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 157d9e2d..6a4bfc68 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s izmenjen zadatak #%d', '%s created the task #%d' => '%s kreirao zadatak #%d', '%s closed the task #%d' => '%s zatvorio zadatak #%d', - '%s open the task #%d' => '%s otvorio zadatak #%d', + '%s opened the task #%d' => '%s otvorio zadatak #%d', '%s moved the task #%d to the column "%s"' => '%s premestio zadatak #%d u kolonu "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s premestio zadatak #%d na pozycję %d w kolmnie "%s"', 'Activity' => 'Aktivnosti', 'Default values are "%s"' => 'Osnovne vrednosti su: "%s"', 'Default columns for new projects (Comma-separated)' => 'Osnovne kolone za novi projekat (Odvojeni zarezom)', 'Task assignee change' => 'Zmień osobę odpowiedzialną', - '%s change the assignee of the task #%d to %s' => '%s zamena dodele za zadatak #%d na %s', + '%s changed the assignee of the task #%d to %s' => '%s zamena dodele za zadatak #%d na %s', '%s changed the assignee of the task %s to %s' => '%s zamena dodele za zadatak %s na %s', 'New password for the user "%s"' => 'Nova lozinka za korisnika "%s"', 'Choose an event' => 'Izaberi događaj', @@ -601,7 +601,7 @@ return array( // 'The currency rate have been added successfully.' => '', // 'Unable to add this currency rate.' => '', // 'Webhook URL' => '', - // '%s remove the assignee of the task %s' => '', + // '%s removed the assignee of the task %s' => '', // 'Enable Gravatar images' => '', // 'Information' => '', // 'Check two factor authentication code' => '', diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index e42a801d..7eb46a98 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s uppdaterade uppgiften #%d', '%s created the task #%d' => '%s skapade uppgiften #%d', '%s closed the task #%d' => '%s stängde uppgiften #%d', - '%s open the task #%d' => '%s öppnade uppgiften #%d', + '%s opened the task #%d' => '%s öppnade uppgiften #%d', '%s moved the task #%d to the column "%s"' => '%s flyttade uppgiften #%d till kolumnen "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s flyttade uppgiften #%d till positionen %d i kolumnen "%s"', 'Activity' => 'Aktivitet', 'Default values are "%s"' => 'Standardvärden är "%s"', 'Default columns for new projects (Comma-separated)' => 'Standardkolumner för nya projekt (kommaseparerade)', 'Task assignee change' => 'Ändra tilldelning av uppgiften', - '%s change the assignee of the task #%d to %s' => '%s byt tilldelning av uppgiften #%d till %s', + '%s changed the assignee of the task #%d to %s' => '%s byt tilldelning av uppgiften #%d till %s', '%s changed the assignee of the task %s to %s' => '%s byt tilldelning av uppgiften %s till %s', 'New password for the user "%s"' => 'Nytt lösenord för användaren "%s"', 'Choose an event' => 'Välj en händelse', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Valutakursen har lagts till.', 'Unable to add this currency rate.' => 'Kunde inte lägga till valutakursen.', 'Webhook URL' => 'Webhook URL', - '%s remove the assignee of the task %s' => '%s ta bort tilldelningen av uppgiften %s', + '%s removed the assignee of the task %s' => '%s ta bort tilldelningen av uppgiften %s', 'Enable Gravatar images' => 'Aktivera Gravatar bilder', 'Information' => 'Information', 'Check two factor authentication code' => 'Kolla tvåfaktorsverifieringskod', diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 56adbdb8..65979753 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s ปรับปรุงงานแล้ว #%d', '%s created the task #%d' => '%s สร้างงานแล้ว #%d', '%s closed the task #%d' => '%s ปิดงานแล้ว #%d', - '%s open the task #%d' => '%s เปิดงานแล้ว #%d', + '%s opened the task #%d' => '%s เปิดงานแล้ว #%d', '%s moved the task #%d to the column "%s"' => '%s ย้ายงานแล้ว #%d ไปที่คอลัมน์ "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s ย้ายงานแล้ว #%d ไปตำแหน่ง %d ในคอลัมน์ที่ "%s"', 'Activity' => 'กิจกรรม', 'Default values are "%s"' => 'ค่าเริ่มต้น "%s"', 'Default columns for new projects (Comma-separated)' => 'คอลัมน์เริ่มต้นสำหรับโปรเจคใหม่ (Comma-separated)', 'Task assignee change' => 'เปลี่ยนการกำหนดบุคคลของงาน', - '%s change the assignee of the task #%d to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน #%d เป็น %s', + '%s changed the assignee of the task #%d to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน #%d เป็น %s', '%s changed the assignee of the task %s to %s' => '%s เปลี่ยนผู้รับผิดชอบของงาน %s เป็น %s', 'New password for the user "%s"' => 'รหัสผ่านใหม่สำหรับผู้ใช้ "%s"', 'Choose an event' => 'เลือกเหตุการณ์', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'เพิ่มอัตราค่าเงินเรียบร้อย', 'Unable to add this currency rate.' => 'ไม่สามารถเพิ่มค่าเงินนี้', // 'Webhook URL' => '', - '%s remove the assignee of the task %s' => '%s เอาผู้รับผิดชอบออกจากงาน %s', + '%s removed the assignee of the task %s' => '%s เอาผู้รับผิดชอบออกจากงาน %s', 'Enable Gravatar images' => 'สามารถใช้งานภาพ Gravatar', 'Information' => 'ข้อมูลสารสนเทศ', // 'Check two factor authentication code' => '', diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index 4f4c84cd..5a1b84b8 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s kullanıcısı #%d nolu görevi güncelledi', '%s created the task #%d' => '%s kullanıcısı #%d nolu görevi oluşturdu', '%s closed the task #%d' => '%s kullanıcısı #%d nolu görevi kapattı', - '%s open the task #%d' => '%s kullanıcısı #%d nolu görevi açtı', + '%s opened the task #%d' => '%s kullanıcısı #%d nolu görevi açtı', '%s moved the task #%d to the column "%s"' => '%s kullanıcısı #%d nolu görevi "%s" sütununa taşıdı', '%s moved the task #%d to the position %d in the column "%s"' => '%s kullanıcısı #%d nolu görevi %d pozisyonu "%s" sütununa taşıdı', 'Activity' => 'Aktivite', 'Default values are "%s"' => 'Varsayılan değerler "%s"', 'Default columns for new projects (Comma-separated)' => 'Yeni projeler için varsayılan sütunlar (virgül ile ayrılmış)', 'Task assignee change' => 'Göreve atanan kullanıcı değişikliği', - '%s change the assignee of the task #%d to %s' => '%s kullanıcısı #%d nolu görevin sorumlusunu %s olarak değiştirdi', + '%s changed the assignee of the task #%d to %s' => '%s kullanıcısı #%d nolu görevin sorumlusunu %s olarak değiştirdi', '%s changed the assignee of the task %s to %s' => '%s kullanıcısı %s görevinin sorumlusunu %s olarak değiştirdi', 'New password for the user "%s"' => '"%s" kullanıcısı için yeni şifre', 'Choose an event' => 'Bir durum seçin', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => 'Kur başarıyla eklendi', 'Unable to add this currency rate.' => 'Bu kur eklenemedi', // 'Webhook URL' => '', - '%s remove the assignee of the task %s' => '%s, %s görevinin atanan bilgisini kaldırdı', + '%s removed the assignee of the task %s' => '%s, %s görevinin atanan bilgisini kaldırdı', 'Enable Gravatar images' => 'Gravatar resimlerini kullanıma aç', 'Information' => 'Bilgi', 'Check two factor authentication code' => 'İki kademeli doğrulama kodunu kontrol et', diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index 01eaff17..f173fdff 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -386,14 +386,14 @@ return array( '%s updated the task #%d' => '%s 更新了任务 #%d', '%s created the task #%d' => '%s 创建了任务 #%d', '%s closed the task #%d' => '%s 关闭了任务 #%d', - '%s open the task #%d' => '%s 开启了任务 #%d', + '%s opened the task #%d' => '%s 开启了任务 #%d', '%s moved the task #%d to the column "%s"' => '%s 将任务 #%d 移动到栏目 "%s"', '%s moved the task #%d to the position %d in the column "%s"' => '%s将任务#%d移动到"%s"的第 %d 列', 'Activity' => '动态', 'Default values are "%s"' => '默认值为 "%s"', 'Default columns for new projects (Comma-separated)' => '新建项目的默认栏目(用逗号分开)', 'Task assignee change' => '任务分配变更', - '%s change the assignee of the task #%d to %s' => '%s 将任务 #%d 分配给了 %s', + '%s changed the assignee of the task #%d to %s' => '%s 将任务 #%d 分配给了 %s', '%s changed the assignee of the task %s to %s' => '%s 将任务 %s 分配给 %s', 'New password for the user "%s"' => '用户"%s"的新密码', 'Choose an event' => '选择一个事件', @@ -601,7 +601,7 @@ return array( 'The currency rate have been added successfully.' => '成功添加汇率。', 'Unable to add this currency rate.' => '无法添加此汇率', 'Webhook URL' => '网络钩子 URL', - '%s remove the assignee of the task %s' => '%s删除了任务%s的负责人', + '%s removed the assignee of the task %s' => '%s删除了任务%s的负责人', 'Enable Gravatar images' => '启用 Gravatar 图像', 'Information' => '信息', 'Check two factor authentication code' => '检查双重认证码', diff --git a/app/Model/NotificationModel.php b/app/Model/NotificationModel.php index 39c1f581..803d4f18 100644 --- a/app/Model/NotificationModel.php +++ b/app/Model/NotificationModel.php @@ -3,10 +3,15 @@ namespace Kanboard\Model; use Kanboard\Core\Base; +use Kanboard\EventBuilder\CommentEventBuilder; +use Kanboard\EventBuilder\EventIteratorBuilder; +use Kanboard\EventBuilder\SubtaskEventBuilder; +use Kanboard\EventBuilder\TaskEventBuilder; +use Kanboard\EventBuilder\TaskFileEventBuilder; use Kanboard\EventBuilder\TaskLinkEventBuilder; /** - * Notification + * Notification Model * * @package Kanboard\Model * @author Frederic Guillot @@ -17,150 +22,79 @@ class NotificationModel extends Base * Get the event title with author * * @access public - * @param string $event_author - * @param string $event_name - * @param array $event_data + * @param string $eventAuthor + * @param string $eventName + * @param array $eventData * @return string */ - public function getTitleWithAuthor($event_author, $event_name, array $event_data) + public function getTitleWithAuthor($eventAuthor, $eventName, array $eventData) { - switch ($event_name) { - case TaskModel::EVENT_ASSIGNEE_CHANGE: - $assignee = $event_data['task']['assignee_name'] ?: $event_data['task']['assignee_username']; + foreach ($this->getIteratorBuilder() as $builder) { + $title = $builder->buildTitleWithAuthor($eventAuthor, $eventName, $eventData); - if (! empty($assignee)) { - return e('%s change the assignee of the task #%d to %s', $event_author, $event_data['task']['id'], $assignee); - } - - return e('%s remove the assignee of the task %s', $event_author, e('#%d', $event_data['task']['id'])); - case TaskModel::EVENT_UPDATE: - return e('%s updated the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_CREATE: - return e('%s created the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_CLOSE: - return e('%s closed the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_OPEN: - return e('%s open the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_MOVE_COLUMN: - return e( - '%s moved the task #%d to the column "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['column_title'] - ); - case TaskModel::EVENT_MOVE_POSITION: - return e( - '%s moved the task #%d to the position %d in the column "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['position'], - $event_data['task']['column_title'] - ); - case TaskModel::EVENT_MOVE_SWIMLANE: - if ($event_data['task']['swimlane_id'] == 0) { - return e('%s moved the task #%d to the first swimlane', $event_author, $event_data['task']['id']); - } - - return e( - '%s moved the task #%d to the swimlane "%s"', - $event_author, - $event_data['task']['id'], - $event_data['task']['swimlane_name'] - ); - case SubtaskModel::EVENT_UPDATE: - return e('%s updated a subtask for the task #%d', $event_author, $event_data['task']['id']); - case SubtaskModel::EVENT_CREATE: - return e('%s created a subtask for the task #%d', $event_author, $event_data['task']['id']); - case SubtaskModel::EVENT_DELETE: - return e('%s removed a subtask for the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_UPDATE: - return e('%s updated a comment on the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_CREATE: - return e('%s commented on the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_DELETE: - return e('%s removed a comment on the task #%d', $event_author, $event_data['task']['id']); - case TaskFileModel::EVENT_CREATE: - return e('%s attached a file to the task #%d', $event_author, $event_data['task']['id']); - case TaskModel::EVENT_USER_MENTION: - return e('%s mentioned you in the task #%d', $event_author, $event_data['task']['id']); - case CommentModel::EVENT_USER_MENTION: - return e('%s mentioned you in a comment on the task #%d', $event_author, $event_data['task']['id']); - default: - return TaskLinkEventBuilder::getInstance($this->container) - ->buildTitleWithAuthor($event_author, $event_name, $event_data) ?: - e('Notification'); + if ($title !== '') { + return $title; + } } + + return e('Notification'); } /** * Get the event title without author * * @access public - * @param string $event_name - * @param array $event_data + * @param string $eventName + * @param array $eventData * @return string */ - public function getTitleWithoutAuthor($event_name, array $event_data) + public function getTitleWithoutAuthor($eventName, array $eventData) { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - return e('New attachment on task #%d: %s', $event_data['file']['task_id'], $event_data['file']['name']); - case CommentModel::EVENT_CREATE: - return e('New comment on task #%d', $event_data['comment']['task_id']); - case CommentModel::EVENT_UPDATE: - return e('Comment updated on task #%d', $event_data['comment']['task_id']); - case CommentModel::EVENT_DELETE: - return e('Comment removed on task #%d', $event_data['comment']['task_id']); - case SubtaskModel::EVENT_CREATE: - return e('New subtask on task #%d', $event_data['subtask']['task_id']); - case SubtaskModel::EVENT_UPDATE: - return e('Subtask updated on task #%d', $event_data['subtask']['task_id']); - case SubtaskModel::EVENT_DELETE: - return e('Subtask removed on task #%d', $event_data['subtask']['task_id']); - case TaskModel::EVENT_CREATE: - return e('New task #%d: %s', $event_data['task']['id'], $event_data['task']['title']); - case TaskModel::EVENT_UPDATE: - return e('Task updated #%d', $event_data['task']['id']); - case TaskModel::EVENT_CLOSE: - return e('Task #%d closed', $event_data['task']['id']); - case TaskModel::EVENT_OPEN: - return e('Task #%d opened', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_COLUMN: - return e('Column changed for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_POSITION: - return e('New position for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_MOVE_SWIMLANE: - return e('Swimlane changed for task #%d', $event_data['task']['id']); - case TaskModel::EVENT_ASSIGNEE_CHANGE: - return e('Assignee changed on task #%d', $event_data['task']['id']); - case TaskModel::EVENT_OVERDUE: - $nb = count($event_data['tasks']); - return $nb > 1 ? e('%d overdue tasks', $nb) : e('Task #%d is overdue', $event_data['tasks'][0]['id']); - case TaskModel::EVENT_USER_MENTION: - return e('You were mentioned in the task #%d', $event_data['task']['id']); - case CommentModel::EVENT_USER_MENTION: - return e('You were mentioned in a comment on the task #%d', $event_data['task']['id']); - default: - return TaskLinkEventBuilder::getInstance($this->container) - ->buildTitleWithoutAuthor($event_name, $event_data) ?: - e('Notification'); + foreach ($this->getIteratorBuilder() as $builder) { + $title = $builder->buildTitleWithoutAuthor($eventName, $eventData); + + if ($title !== '') { + return $title; + } } + + return e('Notification'); } /** * Get task id from event * * @access public - * @param string $event_name - * @param array $event_data + * @param string $eventName + * @param array $eventData * @return integer */ - public function getTaskIdFromEvent($event_name, array $event_data) + public function getTaskIdFromEvent($eventName, array $eventData) { - if ($event_name === TaskModel::EVENT_OVERDUE) { - return $event_data['tasks'][0]['id']; + if ($eventName === TaskModel::EVENT_OVERDUE) { + return $eventData['tasks'][0]['id']; } - - return isset($event_data['task']['id']) ? $event_data['task']['id'] : 0; + + return isset($eventData['task']['id']) ? $eventData['task']['id'] : 0; + } + + /** + * Get iterator builder + * + * @access protected + * @return EventIteratorBuilder + */ + protected function getIteratorBuilder() + { + $iterator = new EventIteratorBuilder(); + $iterator + ->withBuilder(TaskEventBuilder::getInstance($this->container)) + ->withBuilder(CommentEventBuilder::getInstance($this->container)) + ->withBuilder(SubtaskEventBuilder::getInstance($this->container)) + ->withBuilder(TaskFileEventBuilder::getInstance($this->container)) + ->withBuilder(TaskLinkEventBuilder::getInstance($this->container)) + ; + + return $iterator; } } diff --git a/app/Template/event/task_assignee_change.php b/app/Template/event/task_assignee_change.php index 7c962223..7539cd0b 100644 --- a/app/Template/event/task_assignee_change.php +++ b/app/Template/event/task_assignee_change.php @@ -8,7 +8,7 @@ $this->text->e($assignee) ) ?> - text->e($author), $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))) ?> + text->e($author), $this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'], 'project_id' => $task['project_id']))) ?> dt->datetime($date_creation) ?>

            diff --git a/tests/units/Action/TaskAssignCategoryLinkTest.php b/tests/units/Action/TaskAssignCategoryLinkTest.php index b9d7e9d9..1576f81b 100644 --- a/tests/units/Action/TaskAssignCategoryLinkTest.php +++ b/tests/units/Action/TaskAssignCategoryLinkTest.php @@ -33,7 +33,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -62,7 +62,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -91,7 +91,7 @@ class TaskAssignCategoryLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); diff --git a/tests/units/Action/TaskAssignColorLinkTest.php b/tests/units/Action/TaskAssignColorLinkTest.php index 27364bc9..77a6c90e 100644 --- a/tests/units/Action/TaskAssignColorLinkTest.php +++ b/tests/units/Action/TaskAssignColorLinkTest.php @@ -30,7 +30,7 @@ class TaskAssignColorLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertTrue($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); @@ -57,7 +57,7 @@ class TaskAssignColorLinkTest extends Base $event = TaskLinkEventBuilder::getInstance($this->container) ->withTaskLinkId(1) - ->build(); + ->buildEvent(); $this->assertFalse($action->execute($event, TaskLinkModel::EVENT_CREATE_UPDATE)); diff --git a/tests/units/EventBuilder/CommentEventBuilderTest.php b/tests/units/EventBuilder/CommentEventBuilderTest.php index a490799e..2f6a90b5 100644 --- a/tests/units/EventBuilder/CommentEventBuilderTest.php +++ b/tests/units/EventBuilder/CommentEventBuilderTest.php @@ -13,7 +13,7 @@ class CommentEventBuilderTest extends Base { $commentEventBuilder = new CommentEventBuilder($this->container); $commentEventBuilder->withCommentId(42); - $this->assertNull($commentEventBuilder->build()); + $this->assertNull($commentEventBuilder->buildEvent()); } public function testBuild() @@ -28,7 +28,7 @@ class CommentEventBuilderTest extends Base $this->assertEquals(1, $commentModel->create(array('task_id' => 1, 'comment' => 'bla bla', 'user_id' => 1))); $commentEventBuilder->withCommentId(1); - $event = $commentEventBuilder->build(); + $event = $commentEventBuilder->buildEvent(); $this->assertInstanceOf('Kanboard\Event\CommentEvent', $event); $this->assertNotEmpty($event['comment']); diff --git a/tests/units/EventBuilder/ProjectFileEventBuilderTest.php b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php index bfe22719..8f5eb87e 100644 --- a/tests/units/EventBuilder/ProjectFileEventBuilderTest.php +++ b/tests/units/EventBuilder/ProjectFileEventBuilderTest.php @@ -12,7 +12,7 @@ class ProjectFileEventBuilderTest extends Base { $projectFileEventBuilder = new ProjectFileEventBuilder($this->container); $projectFileEventBuilder->withFileId(42); - $this->assertNull($projectFileEventBuilder->build()); + $this->assertNull($projectFileEventBuilder->buildEvent()); } public function testBuild() @@ -24,7 +24,7 @@ class ProjectFileEventBuilderTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $projectFileModel->create(1, 'Test', '/tmp/test', 123)); - $event = $projectFileEventBuilder->withFileId(1)->build(); + $event = $projectFileEventBuilder->withFileId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\ProjectFileEvent', $event); $this->assertNotEmpty($event['file']); diff --git a/tests/units/EventBuilder/SubtaskEventBuilderTest.php b/tests/units/EventBuilder/SubtaskEventBuilderTest.php index 062bdfb4..fe425cb8 100644 --- a/tests/units/EventBuilder/SubtaskEventBuilderTest.php +++ b/tests/units/EventBuilder/SubtaskEventBuilderTest.php @@ -13,7 +13,7 @@ class SubtaskEventBuilderTest extends Base { $subtaskEventBuilder = new SubtaskEventBuilder($this->container); $subtaskEventBuilder->withSubtaskId(42); - $this->assertNull($subtaskEventBuilder->build()); + $this->assertNull($subtaskEventBuilder->buildEvent()); } public function testBuildWithoutChanges() @@ -27,7 +27,7 @@ class SubtaskEventBuilderTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'test'))); - $event = $subtaskEventBuilder->withSubtaskId(1)->build(); + $event = $subtaskEventBuilder->withSubtaskId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); $this->assertNotEmpty($event['subtask']); @@ -49,7 +49,7 @@ class SubtaskEventBuilderTest extends Base $event = $subtaskEventBuilder ->withSubtaskId(1) ->withValues(array('title' => 'new title', 'user_id' => 1)) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\SubtaskEvent', $event); $this->assertNotEmpty($event['subtask']); diff --git a/tests/units/EventBuilder/TaskEventBuilderTest.php b/tests/units/EventBuilder/TaskEventBuilderTest.php index e6334fe2..c89dcd85 100644 --- a/tests/units/EventBuilder/TaskEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskEventBuilderTest.php @@ -12,7 +12,7 @@ class TaskEventBuilderTest extends Base { $taskEventBuilder = new TaskEventBuilder($this->container); $taskEventBuilder->withTaskId(42); - $this->assertNull($taskEventBuilder->build()); + $this->assertNull($taskEventBuilder->buildEvent()); } public function testBuildWithTask() @@ -28,7 +28,7 @@ class TaskEventBuilderTest extends Base ->withTaskId(1) ->withTask(array('title' => 'before')) ->withChanges(array('title' => 'after')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -45,7 +45,7 @@ class TaskEventBuilderTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); - $event = $taskEventBuilder->withTaskId(1)->build(); + $event = $taskEventBuilder->withTaskId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -65,7 +65,7 @@ class TaskEventBuilderTest extends Base $event = $taskEventBuilder ->withTaskId(1) ->withChanges(array('title' => 'new title')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); @@ -86,7 +86,7 @@ class TaskEventBuilderTest extends Base ->withTaskId(1) ->withChanges(array('title' => 'new title', 'project_id' => 1)) ->withValues(array('key' => 'value')) - ->build(); + ->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskEvent', $event); $this->assertNotEmpty($event['task']); diff --git a/tests/units/EventBuilder/TaskFileEventBuilderTest.php b/tests/units/EventBuilder/TaskFileEventBuilderTest.php index c253b913..c90e18d3 100644 --- a/tests/units/EventBuilder/TaskFileEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskFileEventBuilderTest.php @@ -13,7 +13,7 @@ class TaskFileEventBuilderTest extends Base { $taskFileEventBuilder = new TaskFileEventBuilder($this->container); $taskFileEventBuilder->withFileId(42); - $this->assertNull($taskFileEventBuilder->build()); + $this->assertNull($taskFileEventBuilder->buildEvent()); } public function testBuild() @@ -27,7 +27,7 @@ class TaskFileEventBuilderTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $taskFileModel->create(1, 'Test', '/tmp/test', 123)); - $event = $taskFileEventBuilder->withFileId(1)->build(); + $event = $taskFileEventBuilder->withFileId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskFileEvent', $event); $this->assertNotEmpty($event['file']); diff --git a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php index 7364d651..18508146 100644 --- a/tests/units/EventBuilder/TaskLinkEventBuilderTest.php +++ b/tests/units/EventBuilder/TaskLinkEventBuilderTest.php @@ -13,7 +13,7 @@ class TaskLinkEventBuilderTest extends Base { $taskLinkEventBuilder = new TaskLinkEventBuilder($this->container); $taskLinkEventBuilder->withTaskLinkId(42); - $this->assertNull($taskLinkEventBuilder->build()); + $this->assertNull($taskLinkEventBuilder->buildEvent()); } public function testBuild() @@ -28,7 +28,7 @@ class TaskLinkEventBuilderTest extends Base $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $event = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + $event = $taskLinkEventBuilder->withTaskLinkId(1)->buildEvent(); $this->assertInstanceOf('Kanboard\Event\TaskLinkEvent', $event); $this->assertNotEmpty($event['task_link']); @@ -47,7 +47,7 @@ class TaskLinkEventBuilderTest extends Base $this->assertEquals(2, $taskCreationModel->create(array('title' => 'task 2', 'project_id' => 1))); $this->assertEquals(1, $taskLinkModel->create(1, 2, 1)); - $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->build(); + $eventData = $taskLinkEventBuilder->withTaskLinkId(1)->buildEvent(); $title = $taskLinkEventBuilder->buildTitleWithAuthor('Foobar', TaskLinkModel::EVENT_CREATE_UPDATE, $eventData->getAll()); $this->assertEquals('Foobar set a new internal link for the task #1', $title); -- cgit v1.2.3 From b179802a85b262529aaa46ed9cf072a570be25ce Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 14:58:40 -0400 Subject: Sync locales --- app/Locale/bs_BA/translations.php | 6 ++++++ app/Locale/cs_CZ/translations.php | 6 ++++++ app/Locale/da_DK/translations.php | 6 ++++++ app/Locale/de_DE/translations.php | 6 ++++++ app/Locale/el_GR/translations.php | 6 ++++++ app/Locale/es_ES/translations.php | 6 ++++++ app/Locale/fi_FI/translations.php | 6 ++++++ app/Locale/fr_FR/translations.php | 6 ++++++ app/Locale/hu_HU/translations.php | 6 ++++++ app/Locale/id_ID/translations.php | 6 ++++++ app/Locale/it_IT/translations.php | 6 ++++++ app/Locale/ja_JP/translations.php | 6 ++++++ app/Locale/ko_KR/translations.php | 6 ++++++ app/Locale/my_MY/translations.php | 6 ++++++ app/Locale/nb_NO/translations.php | 6 ++++++ app/Locale/nl_NL/translations.php | 6 ++++++ app/Locale/pl_PL/translations.php | 6 ++++++ app/Locale/pt_BR/translations.php | 6 ++++++ app/Locale/pt_PT/translations.php | 6 ++++++ app/Locale/ru_RU/translations.php | 6 ++++++ app/Locale/sr_Latn_RS/translations.php | 6 ++++++ app/Locale/sv_SE/translations.php | 6 ++++++ app/Locale/th_TH/translations.php | 6 ++++++ app/Locale/tr_TR/translations.php | 6 ++++++ app/Locale/zh_CN/translations.php | 6 ++++++ 25 files changed, 150 insertions(+) diff --git a/app/Locale/bs_BA/translations.php b/app/Locale/bs_BA/translations.php index f1529e02..908112df 100644 --- a/app/Locale/bs_BA/translations.php +++ b/app/Locale/bs_BA/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/cs_CZ/translations.php b/app/Locale/cs_CZ/translations.php index c7e6e536..3606e6d2 100644 --- a/app/Locale/cs_CZ/translations.php +++ b/app/Locale/cs_CZ/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/da_DK/translations.php b/app/Locale/da_DK/translations.php index 6cecfaec..d1e86739 100644 --- a/app/Locale/da_DK/translations.php +++ b/app/Locale/da_DK/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/de_DE/translations.php b/app/Locale/de_DE/translations.php index d25e7e8a..51998dc4 100644 --- a/app/Locale/de_DE/translations.php +++ b/app/Locale/de_DE/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/el_GR/translations.php b/app/Locale/el_GR/translations.php index b02207d5..e2ea0a69 100644 --- a/app/Locale/el_GR/translations.php +++ b/app/Locale/el_GR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/es_ES/translations.php b/app/Locale/es_ES/translations.php index fa59ca07..088a4fbb 100644 --- a/app/Locale/es_ES/translations.php +++ b/app/Locale/es_ES/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/fi_FI/translations.php b/app/Locale/fi_FI/translations.php index 200a9cde..316c2089 100644 --- a/app/Locale/fi_FI/translations.php +++ b/app/Locale/fi_FI/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/fr_FR/translations.php b/app/Locale/fr_FR/translations.php index 9f6cf971..19ce49b4 100644 --- a/app/Locale/fr_FR/translations.php +++ b/app/Locale/fr_FR/translations.php @@ -1227,4 +1227,10 @@ return array( '%s removed a subtask for the task %s' => '%s a supprimé une sous-tâche de la tâche %s', 'Comment removed' => 'Commentaire supprimé', 'Subtask removed' => 'Sous-tâche supprimée', + '%s set a new internal link for the task #%d' => '%s a défini un nouveau lien interne pour la tâche n°%d', + '%s removed an internal link for the task #%d' => '%s a supprimé un lien interne pour la tâche n°%d', + 'A new internal link for the task #%d have been defined' => 'Un nouveau lien interne pour la tâche n°%d a été défini', + 'Internal link removed for the task #%d' => 'Lien interne supprimé pour la tâche n°%d', + '%s set a new internal link for the task %s' => '%s a défini un nouveau lien interne pour la tâche %s', + '%s removed an internal link for the task %s' => '%s a supprimé un lien interne pour la tâche %s', ); diff --git a/app/Locale/hu_HU/translations.php b/app/Locale/hu_HU/translations.php index 781a0423..a0365940 100644 --- a/app/Locale/hu_HU/translations.php +++ b/app/Locale/hu_HU/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/id_ID/translations.php b/app/Locale/id_ID/translations.php index 26e091ce..4cdfd129 100644 --- a/app/Locale/id_ID/translations.php +++ b/app/Locale/id_ID/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/it_IT/translations.php b/app/Locale/it_IT/translations.php index aadbfe5b..334faa46 100644 --- a/app/Locale/it_IT/translations.php +++ b/app/Locale/it_IT/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ja_JP/translations.php b/app/Locale/ja_JP/translations.php index 03fa55ed..fea7283d 100644 --- a/app/Locale/ja_JP/translations.php +++ b/app/Locale/ja_JP/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ko_KR/translations.php b/app/Locale/ko_KR/translations.php index bf140d94..7ba0f456 100644 --- a/app/Locale/ko_KR/translations.php +++ b/app/Locale/ko_KR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/my_MY/translations.php b/app/Locale/my_MY/translations.php index cf4f399c..68bd12bf 100644 --- a/app/Locale/my_MY/translations.php +++ b/app/Locale/my_MY/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/nb_NO/translations.php b/app/Locale/nb_NO/translations.php index ce69deb9..c81e073c 100644 --- a/app/Locale/nb_NO/translations.php +++ b/app/Locale/nb_NO/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/nl_NL/translations.php b/app/Locale/nl_NL/translations.php index d5ba7036..d0b90ef8 100644 --- a/app/Locale/nl_NL/translations.php +++ b/app/Locale/nl_NL/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pl_PL/translations.php b/app/Locale/pl_PL/translations.php index f2570d7c..7c28190a 100644 --- a/app/Locale/pl_PL/translations.php +++ b/app/Locale/pl_PL/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pt_BR/translations.php b/app/Locale/pt_BR/translations.php index 46749043..3f5a6de5 100644 --- a/app/Locale/pt_BR/translations.php +++ b/app/Locale/pt_BR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/pt_PT/translations.php b/app/Locale/pt_PT/translations.php index 4fd070d1..4e3e0151 100644 --- a/app/Locale/pt_PT/translations.php +++ b/app/Locale/pt_PT/translations.php @@ -1226,4 +1226,10 @@ return array( '%s removed a subtask for the task %s' => '%s removeu uma sub-tarefa da tarefa %s', 'Comment removed' => 'Comentário removido', 'Subtask removed' => 'Sub-tarefa removida', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/ru_RU/translations.php b/app/Locale/ru_RU/translations.php index 92fba163..728a79f2 100644 --- a/app/Locale/ru_RU/translations.php +++ b/app/Locale/ru_RU/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/sr_Latn_RS/translations.php b/app/Locale/sr_Latn_RS/translations.php index 6a4bfc68..25779a15 100644 --- a/app/Locale/sr_Latn_RS/translations.php +++ b/app/Locale/sr_Latn_RS/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/sv_SE/translations.php b/app/Locale/sv_SE/translations.php index 7eb46a98..f4206bb1 100644 --- a/app/Locale/sv_SE/translations.php +++ b/app/Locale/sv_SE/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/th_TH/translations.php b/app/Locale/th_TH/translations.php index 65979753..ec399732 100644 --- a/app/Locale/th_TH/translations.php +++ b/app/Locale/th_TH/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/tr_TR/translations.php b/app/Locale/tr_TR/translations.php index 5a1b84b8..aa59f7e7 100644 --- a/app/Locale/tr_TR/translations.php +++ b/app/Locale/tr_TR/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); diff --git a/app/Locale/zh_CN/translations.php b/app/Locale/zh_CN/translations.php index f173fdff..99d07561 100644 --- a/app/Locale/zh_CN/translations.php +++ b/app/Locale/zh_CN/translations.php @@ -1226,4 +1226,10 @@ return array( // '%s removed a subtask for the task %s' => '', // 'Comment removed' => '', // 'Subtask removed' => '', + // '%s set a new internal link for the task #%d' => '', + // '%s removed an internal link for the task #%d' => '', + // 'A new internal link for the task #%d have been defined' => '', + // 'Internal link removed for the task #%d' => '', + // '%s set a new internal link for the task %s' => '', + // '%s removed an internal link for the task %s' => '', ); -- cgit v1.2.3 From d4606f69f6b0517f45f19d511a46004ae5dc7a5b Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 15:15:11 -0400 Subject: Minor cleanup --- .scrutinizer.yml | 11 ----------- app/Core/Base.php | 7 +++---- app/EventBuilder/EventIteratorBuilder.php | 4 ++++ config.default.php | 8 ++++---- 4 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 .scrutinizer.yml diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 25ef09c4..00000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,11 +0,0 @@ -filter: - paths: - - app/* - excluded_paths: - - app/Schema/* - - app/Template/* - - app/Locale/* - - app/Library/* - - app/constants.php - - app/common.php - - app/check_setup.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 20a2d391..6931d93a 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -185,10 +185,10 @@ abstract class Base } /** - * Load automatically models + * Load automatically dependencies * * @access public - * @param string $name Model name + * @param string $name Class name * @return mixed */ public function __get($name) @@ -206,7 +206,6 @@ abstract class Base */ public static function getInstance(Container $container) { - $self = new static($container); - return $self; + return new static($container); } } diff --git a/app/EventBuilder/EventIteratorBuilder.php b/app/EventBuilder/EventIteratorBuilder.php index afa146b6..ba821753 100644 --- a/app/EventBuilder/EventIteratorBuilder.php +++ b/app/EventBuilder/EventIteratorBuilder.php @@ -15,6 +15,10 @@ class EventIteratorBuilder implements Iterator { private $builders = array(); /** + * Set builder + * + * @access public + * @param BaseEventBuilder $builder * @return $this */ public function withBuilder(BaseEventBuilder $builder) diff --git a/config.default.php b/config.default.php index a9fd7d99..d0e93a8e 100644 --- a/config.default.php +++ b/config.default.php @@ -11,16 +11,16 @@ define('DEBUG', false); define('LOG_DRIVER', ''); // Log filename if the log driver is "file" -define('LOG_FILE', __DIR__.DIRECTORY_SEPARATOR.'data'.DIRECTORY_SEPARATOR.'debug.log'); +define('LOG_FILE', DATA_DIR.DIRECTORY_SEPARATOR.'debug.log'); // Plugins directory -define('PLUGINS_DIR', 'plugins'); +define('PLUGINS_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'plugins'); // Folder for uploaded files -define('FILES_DIR', 'data'.DIRECTORY_SEPARATOR.'files'); +define('FILES_DIR', DATA_DIR.DIRECTORY_SEPARATOR.'files'); // E-mail address for the "From" header (notifications) -define('MAIL_FROM', 'notifications@kanboard.local'); +define('MAIL_FROM', 'replace-me@kanboard.local'); // Mail transport available: "smtp", "sendmail", "mail" (PHP mail function), "postmark", "mailgun", "sendgrid" define('MAIL_TRANSPORT', 'mail'); -- cgit v1.2.3 From e0d330dda8dea91936d5b76e212603d106e45386 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 15:19:35 -0400 Subject: Move Github templates to a folder --- .github/issue_template.md | 26 ++++++++++++++++++++++++++ .github/pull_request_template.md | 8 ++++++++ CONTRIBUTING | 1 + issue_template.md | 26 -------------------------- pull_request_template.md | 8 -------- 5 files changed, 35 insertions(+), 34 deletions(-) create mode 100644 .github/issue_template.md create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING delete mode 100644 issue_template.md delete mode 100644 pull_request_template.md diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 00000000..3aede7f5 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,26 @@ +### Expected behaviour + +Tell us what should happen + + +### Actual behaviour + +Tell us what happens instead + + +### Steps to reproduce + +1. +2. +3. + + +### Configuration + +Copy and paste the configuration section from the Kanboard settings page or fill these fields: + +- Kanboard version: +- Database type and version: +- PHP version: +- OS: +- Browser: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..be59deac --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +Before to submit your pull-request: + +- Be sure that the unit tests pass +- If you create a new feature, test your code, do not introduce new bugs +- Avoid code duplication +- Small pull-requests are easier to review and can be merged quickly +- 1 pull-request == 1 feature/improvement +- Non necessary features should be implemented as plugin diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 00000000..68a20f51 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1 @@ +Read https://kanboard.net/documentation/contributing diff --git a/issue_template.md b/issue_template.md deleted file mode 100644 index 3aede7f5..00000000 --- a/issue_template.md +++ /dev/null @@ -1,26 +0,0 @@ -### Expected behaviour - -Tell us what should happen - - -### Actual behaviour - -Tell us what happens instead - - -### Steps to reproduce - -1. -2. -3. - - -### Configuration - -Copy and paste the configuration section from the Kanboard settings page or fill these fields: - -- Kanboard version: -- Database type and version: -- PHP version: -- OS: -- Browser: diff --git a/pull_request_template.md b/pull_request_template.md deleted file mode 100644 index be59deac..00000000 --- a/pull_request_template.md +++ /dev/null @@ -1,8 +0,0 @@ -Before to submit your pull-request: - -- Be sure that the unit tests pass -- If you create a new feature, test your code, do not introduce new bugs -- Avoid code duplication -- Small pull-requests are easier to review and can be merged quickly -- 1 pull-request == 1 feature/improvement -- Non necessary features should be implemented as plugin -- cgit v1.2.3 From 2a42e0e1aae35a9bb7abf054155b516ffab701d4 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:10:05 -0400 Subject: Added a new automatic action to set due date --- ChangeLog | 5 +- app/Action/TaskAssignDueDateOnCreation.php | 96 ++++++++++++++++++++++ app/ServiceProvider/ActionProvider.php | 2 + .../Action/TaskAssignDueDateOnCreationTest.php | 37 +++++++++ tests/units/Action/TaskUpdateStartDateTest.php | 3 +- 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 app/Action/TaskAssignDueDateOnCreation.php create mode 100644 tests/units/Action/TaskAssignDueDateOnCreationTest.php diff --git a/ChangeLog b/ChangeLog index ee57c86c..01ad5fbd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,7 +3,9 @@ Version 1.0.32 (unreleased) New features: -* New automated action to close tasks without activity in a specific column +* New automated actions: + - Close tasks without activity in a specific column + - Set due date automatically * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority @@ -11,6 +13,7 @@ New features: Improvements: +* Internal events management refactoring * Handle header X-Real-IP to get IP address * Display project name for task auto-complete fields * Make search attributes not case sensitive diff --git a/app/Action/TaskAssignDueDateOnCreation.php b/app/Action/TaskAssignDueDateOnCreation.php new file mode 100644 index 00000000..79ff765c --- /dev/null +++ b/app/Action/TaskAssignDueDateOnCreation.php @@ -0,0 +1,96 @@ + t('Duration in days') + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array( + 'task_id', + 'task' => array( + 'project_id', + ), + ); + } + + /** + * Execute the action (set the task color) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $values = array( + 'id' => $data['task_id'], + 'date_due' => strtotime('+'.$this->getParam('duration').'days'), + ); + + return $this->taskModificationModel->update($values, false); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return true; + } +} diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index 9383be12..c76555fa 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -3,6 +3,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; +use Kanboard\Action\TaskAssignDueDateOnCreation; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -80,6 +81,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); + $container['actionManager']->register(new TaskAssignDueDateOnCreation($container)); return $container; } diff --git a/tests/units/Action/TaskAssignDueDateOnCreationTest.php b/tests/units/Action/TaskAssignDueDateOnCreationTest.php new file mode 100644 index 00000000..26c0584e --- /dev/null +++ b/tests/units/Action/TaskAssignDueDateOnCreationTest.php @@ -0,0 +1,37 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskAssignDueDateOnCreation($this->container); + $action->setProjectId(1); + $action->setParam('duration', 4); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_CREATE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(date('Y-m-d', strtotime('+4days')), date('Y-m-d', $task['date_due'])); + } +} diff --git a/tests/units/Action/TaskUpdateStartDateTest.php b/tests/units/Action/TaskUpdateStartDateTest.php index 8d609b3e..05fac100 100644 --- a/tests/units/Action/TaskUpdateStartDateTest.php +++ b/tests/units/Action/TaskUpdateStartDateTest.php @@ -2,7 +2,6 @@ require_once __DIR__.'/../Base.php'; -use Kanboard\Event\GenericEvent; use Kanboard\Event\TaskEvent; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\TaskFinderModel; @@ -12,7 +11,7 @@ use Kanboard\Action\TaskUpdateStartDate; class TaskUpdateStartDateTest extends Base { - public function testClose() + public function testAction() { $projectModel = new ProjectModel($this->container); $taskCreationModel = new TaskCreationModel($this->container); -- cgit v1.2.3 From 9b2a32af78ef8fb5424398dc57e3c3f906026272 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:33:31 -0400 Subject: Add new automatic action to move a task to another column when closed --- ChangeLog | 1 + app/Action/Base.php | 2 +- app/Action/CommentCreation.php | 2 +- app/Action/CommentCreationMoveTaskColumn.php | 2 +- app/Action/TaskAssignCategoryColor.php | 2 +- app/Action/TaskAssignCategoryLabel.php | 2 +- app/Action/TaskAssignCategoryLink.php | 2 +- app/Action/TaskAssignColorCategory.php | 2 +- app/Action/TaskAssignColorColumn.php | 2 +- app/Action/TaskAssignColorLink.php | 2 +- app/Action/TaskAssignColorPriority.php | 2 +- app/Action/TaskAssignColorUser.php | 2 +- app/Action/TaskAssignCurrentUser.php | 2 +- app/Action/TaskAssignCurrentUserColumn.php | 2 +- app/Action/TaskAssignDueDateOnCreation.php | 2 +- app/Action/TaskAssignSpecificUser.php | 2 +- app/Action/TaskAssignUser.php | 2 +- app/Action/TaskClose.php | 2 +- app/Action/TaskCloseColumn.php | 2 +- app/Action/TaskCloseNoActivity.php | 2 +- app/Action/TaskCloseNoActivityColumn.php | 2 +- app/Action/TaskCreation.php | 2 +- app/Action/TaskDuplicateAnotherProject.php | 2 +- app/Action/TaskEmail.php | 2 +- app/Action/TaskEmailNoActivity.php | 2 +- app/Action/TaskMoveAnotherProject.php | 2 +- app/Action/TaskMoveColumnAssigned.php | 2 +- app/Action/TaskMoveColumnCategoryChange.php | 2 +- app/Action/TaskMoveColumnClosed.php | 102 ++++++++++++++++++++++++ app/Action/TaskMoveColumnUnAssigned.php | 2 +- app/Action/TaskOpen.php | 2 +- app/Action/TaskUpdateStartDate.php | 2 +- app/Model/TaskPositionModel.php | 19 ++--- app/ServiceProvider/ActionProvider.php | 2 + tests/units/Action/TaskMoveColumnClosedTest.php | 91 +++++++++++++++++++++ 35 files changed, 236 insertions(+), 39 deletions(-) create mode 100644 app/Action/TaskMoveColumnClosed.php create mode 100644 tests/units/Action/TaskMoveColumnClosedTest.php diff --git a/ChangeLog b/ChangeLog index 01ad5fbd..c9aebc48 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,6 +6,7 @@ New features: * New automated actions: - Close tasks without activity in a specific column - Set due date automatically + - Move a task to another column when closed * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority diff --git a/app/Action/Base.php b/app/Action/Base.php index e0ed8bde..9a502a08 100644 --- a/app/Action/Base.php +++ b/app/Action/Base.php @@ -7,7 +7,7 @@ use Kanboard\Event\GenericEvent; /** * Base class for automatic actions * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ abstract class Base extends \Kanboard\Core\Base diff --git a/app/Action/CommentCreation.php b/app/Action/CommentCreation.php index 60ca24f7..301d2cf9 100644 --- a/app/Action/CommentCreation.php +++ b/app/Action/CommentCreation.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Create automatically a comment from a webhook * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class CommentCreation extends Base diff --git a/app/Action/CommentCreationMoveTaskColumn.php b/app/Action/CommentCreationMoveTaskColumn.php index 8ab792ad..d5bdd807 100644 --- a/app/Action/CommentCreationMoveTaskColumn.php +++ b/app/Action/CommentCreationMoveTaskColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Add a comment of the triggering event to the task description. * - * @package action + * @package Kanboard\Action * @author Oren Ben-Kiki */ class CommentCreationMoveTaskColumn extends Base diff --git a/app/Action/TaskAssignCategoryColor.php b/app/Action/TaskAssignCategoryColor.php index 2df90b2c..9228e1ff 100644 --- a/app/Action/TaskAssignCategoryColor.php +++ b/app/Action/TaskAssignCategoryColor.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set a category automatically according to the color * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCategoryColor extends Base diff --git a/app/Action/TaskAssignCategoryLabel.php b/app/Action/TaskAssignCategoryLabel.php index 48299010..c390414e 100644 --- a/app/Action/TaskAssignCategoryLabel.php +++ b/app/Action/TaskAssignCategoryLabel.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Set a category automatically according to a label * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCategoryLabel extends Base diff --git a/app/Action/TaskAssignCategoryLink.php b/app/Action/TaskAssignCategoryLink.php index d4a4c0ec..6c4b6c96 100644 --- a/app/Action/TaskAssignCategoryLink.php +++ b/app/Action/TaskAssignCategoryLink.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskLinkModel; /** * Set a category automatically according to a task link * - * @package action + * @package Kanboard\Action * @author Olivier Maridat * @author Frederic Guillot */ diff --git a/app/Action/TaskAssignColorCategory.php b/app/Action/TaskAssignColorCategory.php index 91860be4..a136ffd2 100644 --- a/app/Action/TaskAssignColorCategory.php +++ b/app/Action/TaskAssignColorCategory.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a specific category * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorCategory extends Base diff --git a/app/Action/TaskAssignColorColumn.php b/app/Action/TaskAssignColorColumn.php index 6c674b1f..da6e3aed 100644 --- a/app/Action/TaskAssignColorColumn.php +++ b/app/Action/TaskAssignColorColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorColumn extends Base diff --git a/app/Action/TaskAssignColorLink.php b/app/Action/TaskAssignColorLink.php index 9759f622..19c37afe 100644 --- a/app/Action/TaskAssignColorLink.php +++ b/app/Action/TaskAssignColorLink.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskLinkModel; /** * Assign a color to a specific task link * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorLink extends Base diff --git a/app/Action/TaskAssignColorPriority.php b/app/Action/TaskAssignColorPriority.php index 57000ba8..37f7ffed 100644 --- a/app/Action/TaskAssignColorPriority.php +++ b/app/Action/TaskAssignColorPriority.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a priority * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorPriority extends Base diff --git a/app/Action/TaskAssignColorUser.php b/app/Action/TaskAssignColorUser.php index 385db793..468d0198 100644 --- a/app/Action/TaskAssignColorUser.php +++ b/app/Action/TaskAssignColorUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a color to a specific user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignColorUser extends Base diff --git a/app/Action/TaskAssignCurrentUser.php b/app/Action/TaskAssignCurrentUser.php index 997aa98f..dee5e7db 100644 --- a/app/Action/TaskAssignCurrentUser.php +++ b/app/Action/TaskAssignCurrentUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to the logged user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCurrentUser extends Base diff --git a/app/Action/TaskAssignCurrentUserColumn.php b/app/Action/TaskAssignCurrentUserColumn.php index e4eade33..60ada7ef 100644 --- a/app/Action/TaskAssignCurrentUserColumn.php +++ b/app/Action/TaskAssignCurrentUserColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to the logged user on column change * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignCurrentUserColumn extends Base diff --git a/app/Action/TaskAssignDueDateOnCreation.php b/app/Action/TaskAssignDueDateOnCreation.php index 79ff765c..5c6e2b61 100644 --- a/app/Action/TaskAssignDueDateOnCreation.php +++ b/app/Action/TaskAssignDueDateOnCreation.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set the due date of task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignDueDateOnCreation extends Base diff --git a/app/Action/TaskAssignSpecificUser.php b/app/Action/TaskAssignSpecificUser.php index 2c7dcacd..daf9e1df 100644 --- a/app/Action/TaskAssignSpecificUser.php +++ b/app/Action/TaskAssignSpecificUser.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Assign a task to a specific user * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskAssignSpecificUser extends Base diff --git a/app/Action/TaskAssignUser.php b/app/Action/TaskAssignUser.php index 9ea22986..8727b672 100644 --- a/app/Action/TaskAssignUser.php +++ b/app/Action/TaskAssignUser.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Assign a task to someone * - * @package action + * @package Kanboard\Actionv * @author Frederic Guillot */ class TaskAssignUser extends Base diff --git a/app/Action/TaskClose.php b/app/Action/TaskClose.php index 91e8cf43..e476e9ba 100644 --- a/app/Action/TaskClose.php +++ b/app/Action/TaskClose.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Close automatically a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskClose extends Base diff --git a/app/Action/TaskCloseColumn.php b/app/Action/TaskCloseColumn.php index 4f1ffc92..523996f4 100644 --- a/app/Action/TaskCloseColumn.php +++ b/app/Action/TaskCloseColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task in a specific column * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseColumn extends Base diff --git a/app/Action/TaskCloseNoActivity.php b/app/Action/TaskCloseNoActivity.php index 5a10510f..ea724d8c 100644 --- a/app/Action/TaskCloseNoActivity.php +++ b/app/Action/TaskCloseNoActivity.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task after when inactive * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseNoActivity extends Base diff --git a/app/Action/TaskCloseNoActivityColumn.php b/app/Action/TaskCloseNoActivityColumn.php index 7af0b7fc..b2ee5224 100644 --- a/app/Action/TaskCloseNoActivityColumn.php +++ b/app/Action/TaskCloseNoActivityColumn.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Close automatically a task after inactive and in an defined column * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCloseNoActivityColumn extends Base diff --git a/app/Action/TaskCreation.php b/app/Action/TaskCreation.php index 0620afd3..01d91228 100644 --- a/app/Action/TaskCreation.php +++ b/app/Action/TaskCreation.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Create automatically a task from a webhook * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskCreation extends Base diff --git a/app/Action/TaskDuplicateAnotherProject.php b/app/Action/TaskDuplicateAnotherProject.php index d6d8d51f..0ad7713c 100644 --- a/app/Action/TaskDuplicateAnotherProject.php +++ b/app/Action/TaskDuplicateAnotherProject.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Duplicate a task to another project * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskDuplicateAnotherProject extends Base diff --git a/app/Action/TaskEmail.php b/app/Action/TaskEmail.php index 526e9aa8..fdfe7987 100644 --- a/app/Action/TaskEmail.php +++ b/app/Action/TaskEmail.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Email a task to someone * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskEmail extends Base diff --git a/app/Action/TaskEmailNoActivity.php b/app/Action/TaskEmailNoActivity.php index c60702fb..cac4281e 100644 --- a/app/Action/TaskEmailNoActivity.php +++ b/app/Action/TaskEmailNoActivity.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Email a task with no activity * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskEmailNoActivity extends Base diff --git a/app/Action/TaskMoveAnotherProject.php b/app/Action/TaskMoveAnotherProject.php index 148b6b0c..0fa22b1b 100644 --- a/app/Action/TaskMoveAnotherProject.php +++ b/app/Action/TaskMoveAnotherProject.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another project * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskMoveAnotherProject extends Base diff --git a/app/Action/TaskMoveColumnAssigned.php b/app/Action/TaskMoveColumnAssigned.php index 1c1f657a..1cfe6743 100644 --- a/app/Action/TaskMoveColumnAssigned.php +++ b/app/Action/TaskMoveColumnAssigned.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when an assignee is set * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnAssigned extends Base diff --git a/app/Action/TaskMoveColumnCategoryChange.php b/app/Action/TaskMoveColumnCategoryChange.php index 4c2b289a..13d6ee4f 100644 --- a/app/Action/TaskMoveColumnCategoryChange.php +++ b/app/Action/TaskMoveColumnCategoryChange.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when the category is changed * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnCategoryChange extends Base diff --git a/app/Action/TaskMoveColumnClosed.php b/app/Action/TaskMoveColumnClosed.php new file mode 100644 index 00000000..3f3e2124 --- /dev/null +++ b/app/Action/TaskMoveColumnClosed.php @@ -0,0 +1,102 @@ + t('Destination column'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array( + 'task_id', + 'task' => array( + 'project_id', + 'column_id', + 'swimlane_id', + 'is_active', + ) + ); + } + + /** + * Execute the action (move the task to another column) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + return $this->taskPositionModel->movePosition( + $data['task']['project_id'], + $data['task']['id'], + $this->getParam('dest_column_id'), + 1, + $data['task']['swimlane_id'], + false, + false + ); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return $data['task']['column_id'] != $this->getParam('dest_column_id') && $data['task']['is_active'] == 0; + } +} diff --git a/app/Action/TaskMoveColumnUnAssigned.php b/app/Action/TaskMoveColumnUnAssigned.php index 0e9a8a16..ab63d624 100644 --- a/app/Action/TaskMoveColumnUnAssigned.php +++ b/app/Action/TaskMoveColumnUnAssigned.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Move a task to another column when an assignee is cleared * - * @package action + * @package Kanboard\Action * @author Francois Ferrand */ class TaskMoveColumnUnAssigned extends Base diff --git a/app/Action/TaskOpen.php b/app/Action/TaskOpen.php index 8e847b8e..49017831 100644 --- a/app/Action/TaskOpen.php +++ b/app/Action/TaskOpen.php @@ -5,7 +5,7 @@ namespace Kanboard\Action; /** * Open automatically a task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskOpen extends Base diff --git a/app/Action/TaskUpdateStartDate.php b/app/Action/TaskUpdateStartDate.php index cc016da1..160f6ee5 100644 --- a/app/Action/TaskUpdateStartDate.php +++ b/app/Action/TaskUpdateStartDate.php @@ -7,7 +7,7 @@ use Kanboard\Model\TaskModel; /** * Set the start date of task * - * @package action + * @package Kanboard\Action * @author Frederic Guillot */ class TaskUpdateStartDate extends Base diff --git a/app/Model/TaskPositionModel.php b/app/Model/TaskPositionModel.php index d6d2a0af..3d95a763 100644 --- a/app/Model/TaskPositionModel.php +++ b/app/Model/TaskPositionModel.php @@ -16,15 +16,16 @@ class TaskPositionModel extends Base * Move a task to another column or to another position * * @access public - * @param integer $project_id Project id - * @param integer $task_id Task id - * @param integer $column_id Column id - * @param integer $position Position (must be >= 1) - * @param integer $swimlane_id Swimlane id - * @param boolean $fire_events Fire events - * @return boolean + * @param integer $project_id Project id + * @param integer $task_id Task id + * @param integer $column_id Column id + * @param integer $position Position (must be >= 1) + * @param integer $swimlane_id Swimlane id + * @param boolean $fire_events Fire events + * @param bool $onlyOpen Do not move closed tasks + * @return bool */ - public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true) + public function movePosition($project_id, $task_id, $column_id, $position, $swimlane_id = 0, $fire_events = true, $onlyOpen = true) { if ($position < 1) { return false; @@ -32,7 +33,7 @@ class TaskPositionModel extends Base $task = $this->taskFinderModel->getById($task_id); - if ($task['is_active'] == TaskModel::STATUS_CLOSED) { + if ($onlyOpen && $task['is_active'] == TaskModel::STATUS_CLOSED) { return true; } diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index c76555fa..cbc60679 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -4,6 +4,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; use Kanboard\Action\TaskAssignDueDateOnCreation; +use Kanboard\Action\TaskMoveColumnClosed; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -78,6 +79,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveAnotherProject($container)); $container['actionManager']->register(new TaskMoveColumnAssigned($container)); $container['actionManager']->register(new TaskMoveColumnCategoryChange($container)); + $container['actionManager']->register(new TaskMoveColumnClosed($container)); $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); diff --git a/tests/units/Action/TaskMoveColumnClosedTest.php b/tests/units/Action/TaskMoveColumnClosedTest.php new file mode 100644 index 00000000..318b995d --- /dev/null +++ b/tests/units/Action/TaskMoveColumnClosedTest.php @@ -0,0 +1,91 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(2, $task['column_id']); + } + + public function testWhenTaskIsOpen() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertFalse($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(1, $task['column_id']); + } + + public function testWhenTaskIsAlreadyInDestinationColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'test2'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'is_active' => 0, 'column_id' => 2))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $action = new TaskMoveColumnClosed($this->container); + $action->setProjectId(1); + $action->setParam('dest_column_id', 2); + + $this->assertFalse($action->execute($event, TaskModel::EVENT_CLOSE)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals('test', $task['title']); + $this->assertEquals(2, $task['column_id']); + } +} -- cgit v1.2.3 From ca45b5592b17d3675a22b7aca49ea49dd9dd57ea Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 18:59:00 -0400 Subject: Add new automatic action to move the task to another column when not moved --- ChangeLog | 1 + app/Action/TaskMoveColumnNotMovedPeriod.php | 104 +++++++++++++++++++++ app/ServiceProvider/ActionProvider.php | 2 + .../Action/TaskMoveColumnNotMovedPeriodTest.php | 50 ++++++++++ 4 files changed, 157 insertions(+) create mode 100644 app/Action/TaskMoveColumnNotMovedPeriod.php create mode 100644 tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php diff --git a/ChangeLog b/ChangeLog index c9aebc48..1bc0eed3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,7 @@ New features: - Close tasks without activity in a specific column - Set due date automatically - Move a task to another column when closed + - Move a task to another column when not moved during a given period * Added internal task links to activity stream * Added new event for removed comments * Added search filter for task priority diff --git a/app/Action/TaskMoveColumnNotMovedPeriod.php b/app/Action/TaskMoveColumnNotMovedPeriod.php new file mode 100644 index 00000000..87e7e405 --- /dev/null +++ b/app/Action/TaskMoveColumnNotMovedPeriod.php @@ -0,0 +1,104 @@ + t('Duration in days'), + 'src_column_id' => t('Source column'), + 'dest_column_id' => t('Destination column'), + ); + } + + /** + * Get the required parameter for the event + * + * @access public + * @return string[] + */ + public function getEventRequiredParameters() + { + return array('tasks'); + } + + /** + * Execute the action (close the task) + * + * @access public + * @param array $data Event data dictionary + * @return bool True if the action was executed or false when not executed + */ + public function doAction(array $data) + { + $results = array(); + $max = $this->getParam('duration') * 86400; + + foreach ($data['tasks'] as $task) { + $duration = time() - $task['date_moved']; + + if ($duration > $max && $task['column_id'] == $this->getParam('src_column_id')) { + $results[] = $this->taskPositionModel->movePosition( + $task['project_id'], + $task['id'], + $this->getParam('dest_column_id'), + 1, + $task['swimlane_id'], + false + ); + } + } + + return in_array(true, $results, true); + } + + /** + * Check if the event data meet the action condition + * + * @access public + * @param array $data Event data dictionary + * @return bool + */ + public function hasRequiredCondition(array $data) + { + return count($data['tasks']) > 0; + } +} diff --git a/app/ServiceProvider/ActionProvider.php b/app/ServiceProvider/ActionProvider.php index cbc60679..946fbf41 100644 --- a/app/ServiceProvider/ActionProvider.php +++ b/app/ServiceProvider/ActionProvider.php @@ -5,6 +5,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Action\TaskAssignColorPriority; use Kanboard\Action\TaskAssignDueDateOnCreation; use Kanboard\Action\TaskMoveColumnClosed; +use Kanboard\Action\TaskMoveColumnNotMovedPeriod; use Pimple\Container; use Pimple\ServiceProviderInterface; use Kanboard\Core\Action\ActionManager; @@ -80,6 +81,7 @@ class ActionProvider implements ServiceProviderInterface $container['actionManager']->register(new TaskMoveColumnAssigned($container)); $container['actionManager']->register(new TaskMoveColumnCategoryChange($container)); $container['actionManager']->register(new TaskMoveColumnClosed($container)); + $container['actionManager']->register(new TaskMoveColumnNotMovedPeriod($container)); $container['actionManager']->register(new TaskMoveColumnUnAssigned($container)); $container['actionManager']->register(new TaskOpen($container)); $container['actionManager']->register(new TaskUpdateStartDate($container)); diff --git a/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php b/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php new file mode 100644 index 00000000..7fa16cf2 --- /dev/null +++ b/tests/units/Action/TaskMoveColumnNotMovedPeriodTest.php @@ -0,0 +1,50 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test'))); + $this->assertEquals(2, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => 3))); + $this->assertEquals(3, $taskCreationModel->create(array('project_id' => 1, 'title' => 'test', 'column_id' => 2))); + + $this->container['db']->table(TaskModel::TABLE)->in('id', array(2, 3))->update(array('date_moved' => strtotime('-10days'))); + + $tasks = $taskFinderModel->getAll(1); + $event = new TaskListEvent(array('tasks' => $tasks, 'project_id' => 1)); + + $action = new TaskMoveColumnNotMovedPeriod($this->container); + $action->setProjectId(1); + $action->setParam('duration', 2); + $action->setParam('src_column_id', 2); + $action->setParam('dest_column_id', 3); + + $this->assertTrue($action->execute($event, TaskModel::EVENT_DAILY_CRONJOB)); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(1, $task['column_id']); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(3, $task['column_id']); + + $task = $taskFinderModel->getById(3); + $this->assertNotEmpty($task); + $this->assertEquals(3, $task['column_id']); + } +} -- cgit v1.2.3 From 8e6e335c9d99ff710ecd70dff293f15a25bf9a98 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 19:21:32 -0400 Subject: Update webhooks documentation --- doc/ru_RU/webhooks.markdown | 99 ++------ doc/webhooks.markdown | 546 +++++++++++++++++++++++++++++++------------- 2 files changed, 401 insertions(+), 244 deletions(-) diff --git a/doc/ru_RU/webhooks.markdown b/doc/ru_RU/webhooks.markdown index c598abf9..dbba0867 100644 --- a/doc/ru_RU/webhooks.markdown +++ b/doc/ru_RU/webhooks.markdown @@ -1,16 +1,10 @@ -Web Hooks -========= - - +Webhooks +======== Webhooks служат для взаимодействия с внешними приложениями. Webhook посылает уведомление стороннему приложению о событиях, которые произошли в Канборд. - - Webhooks могут быть использованы для создания задач вызовом простого URL (Вы можете сделать это и при помощи API) - - - - Обращение к внешнему приложению может происходить автоматически, когда наступает какое-либо событие в Канборд (создана задача, обновлен комментарий и т.д.) @@ -18,89 +12,36 @@ Webhooks служат для взаимодействия с внешними п Как написать webhook приемник во внешнем приложении?[¶](#how-to-write-a-web-hook-receiver "Ссылка на этот заголовок") --------------------------------------------------------------------------------------------------------------------- - - Все внутренние события в Канборде могут быть посланы во внешний URL. - - - Webhook URL (url приемник внешнего приложения) может быть задан в **Настройки** -\> **Webhooks** -\> **Webhook URL** - - - - Когда в Канборде происходит событие, Канборд обращается к указанному URL автоматически - - - - Данные конвертируются в формат JSON и передаются с помощью POST HTTP запроса - - - - Webhook ключ передается в составе запроса в виде строкового параметра. Таким образом, вы можете проверить, что запрос на самом деле пришел из Канборда. - - - - **Созданный вами URL должен среагировать в течении 1 секунды**. Это желательно сделать потому, что запросы являются синхронными (ограничения языка PHP) и возможны тормоза в пользовательском интерфейсе, если скрипт будет слишком медленный! ### Список поддерживаемых событий[¶](#list-of-supported-events "Ссылка на этот заголовок") - - -- comment.create (комментарий.создать) - - - -- comment.update (комментарий.обновить) - - - -- file.create (файл.создать) - - - -- task.move.project (задача.переместить.проект) - - - -- task.move.column (задача.переместить.колонка) - - - -- task.move.position (задача.переместить.место) - - - -- task.move.swimlane (задача.переместить.дорожка) - - - -- task.update (задача.обновить) - - - -- task.create (задача.создать) - - - -- task.close (задача.закрыть) - - - -- task.open (задача.открыть) - - - -- task.assignee\_change (задача.назначить\_изменить) - - - -- subtask.update (подзадача.обновить) - - - -- subtask.create (подзадача.создать) +- comment.create +- comment.update +- comment.delete +- file.create +- task.move.project +- task.move.column +- task.move.position +- task.move.swimlane +- task.update +- task.create +- task.close +- task.open +- task.assignee_change +- subtask.update +- subtask.create +- subtask.delete +- task_internal_link.create_update +- task_internal_link.delete diff --git a/doc/webhooks.markdown b/doc/webhooks.markdown index 628c7e38..e43ab9ce 100644 --- a/doc/webhooks.markdown +++ b/doc/webhooks.markdown @@ -1,5 +1,5 @@ -Web Hooks -========= +Webhooks +======== Webhooks are useful to perform actions with external applications. @@ -21,6 +21,7 @@ All internal events of Kanboard can be sent to an external URL. - comment.create - comment.update +- comment.delete - file.create - task.move.project - task.move.column @@ -33,6 +34,9 @@ All internal events of Kanboard can be sent to an external URL. - task.assignee_change - subtask.update - subtask.create +- subtask.delete +- task_internal_link.create_update +- task_internal_link.delete ### Example of HTTP request @@ -43,19 +47,65 @@ Content-Type: application/json Connection: close { - "event_name": "task.move.column", - "event_data": { - "task_id": "1", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "2", - "dst_column_id": "1", - "date_moved": "1431991532", - "recurrence_status": "0", - "recurrence_trigger": "0" - } + "event_name": "task.move.column", + "event_data": { + "task_id": "4", + "task": { + "id": "4", + "reference": "", + "title": "My task", + "description": "", + "date_creation": "1469314356", + "date_completed": null, + "date_modification": "1469315422", + "date_due": "1469491200", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "green", + "project_id": "1", + "column_id": "1", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "priority": "0", + "swimlane_id": "0", + "date_moved": "1469315422", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Backlog", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + }, + "changes": { + "src_column_id": "2", + "dst_column_id": "1", + "date_moved": "1469315398" + }, + "project_id": "1", + "position": 1, + "column_id": "1", + "swimlane_id": "0", + "src_column_id": "2", + "dst_column_id": "1", + "date_moved": "1469315398", + "recurrence_status": "0", + "recurrence_trigger": "0" + } } ``` @@ -80,26 +130,51 @@ Task creation: ```json { - "event_name": "task.create", - "event_data": { - "title": "Demo", - "description": "", - "project_id": "1", - "owner_id": "1", - "category_id": 0, - "swimlane_id": 0, - "column_id": "2", - "color_id": "yellow", - "score": 0, - "time_estimated": 0, - "date_due": 0, - "creator_id": 1, - "date_creation": 1431991532, - "date_modification": 1431991532, - "date_moved": 1431991532, - "position": 1, - "task_id": 1 - } + "event_name": "task.create", + "event_data": { + "task_id": 5, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315481", + "date_due": "0", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "orange", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -107,113 +182,121 @@ Task modification: ```json { - "event_name": "task.update", - "event_data": { - "id": "1", - "title": "Demo", - "description": "", - "date_creation": "1431991532", - "color_id": "yellow", - "project_id": "1", - "column_id": "1", - "owner_id": "1", - "position": "1", - "is_active": "1", - "date_completed": null, - "score": "0", - "date_due": "0", - "category_id": "2", - "creator_id": "1", - "date_modification": 1431991603, - "reference": "", - "date_started": 1431993600, - "time_spent": 0, - "time_estimated": 0, - "swimlane_id": "0", - "date_moved": "1431991572", - "recurrence_status": "0", - "recurrence_trigger": "0", - "recurrence_factor": "0", - "recurrence_timeframe": "0", - "recurrence_basedate": "0", - "recurrence_parent": null, - "recurrence_child": null, - "task_id": "1", - "changes": { - "category_id": "2" + "event_name": "task.update", + "event_data": { + "task_id": "5", + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + }, + "changes": { + "description": "New description", + "color_id": "purple", + "date_due": 1469836800 + } } - } } ``` Task update events have a field called `changes` that contains updated values. -Move a task to another column: - -```json -{ - "event_name": "task.move.column", - "event_data": { - "task_id": "1", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "2", - "dst_column_id": "1", - "date_moved": "1431991532", - "recurrence_status": "0", - "recurrence_trigger": "0" - } -} -``` - -Move a task to another position: - -```json -{ - "event_name": "task.move.position", - "event_data": { - "task_id": "2", - "project_id": "1", - "position": 1, - "column_id": "1", - "swimlane_id": "0", - "src_column_id": "1", - "dst_column_id": "1", - "date_moved": "1431996905", - "recurrence_status": "0", - "recurrence_trigger": "0" - } -} -``` - Comment creation: ```json { - "event_name": "comment.create", - "event_data": { - "id": 1, - "task_id": "1", - "user_id": "1", - "comment": "test", - "date_creation": 1431991615 - } -} -``` - -Comment modification: - -``` -{ - "event_name": "comment.update", - "event_data": { - "id": "1", - "task_id": "1", - "user_id": "1", - "comment": "test edit" - } + "event_name": "comment.create", + "event_data": { + "comment": { + "id": "1", + "task_id": "5", + "user_id": "1", + "date_creation": "1469315727", + "comment": "My comment.", + "reference": null, + "username": "admin", + "name": null, + "email": null, + "avatar_path": null + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -221,28 +304,65 @@ Subtask creation: ```json { - "event_name": "subtask.create", - "event_data": { - "id": 3, - "task_id": "1", - "title": "Test", - "user_id": "1", - "time_estimated": "2", - "position": 3 - } -} -``` - -Subtask modification: - -```json -{ - "event_name": "subtask.update", - "event_data": { - "id": "1", - "status": 1, - "task_id": "1" - } + "event_name": "subtask.create", + "event_data": { + "subtask": { + "id": "1", + "title": "My subtask", + "status": "0", + "time_estimated": "0", + "time_spent": "0", + "task_id": "5", + "user_id": "1", + "position": "1", + "username": "admin", + "name": null, + "timer_start_date": 0, + "status_name": "Todo", + "is_timer_started": false + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` @@ -250,22 +370,118 @@ File upload: ```json { - "event_name": "file.create", - "event_data": { - "task_id": "1", - "name": "test.png" - } + "event_name": "task.file.create", + "event_data": { + "file": { + "id": "1", + "name": "kanboard-latest.zip", + "path": "tasks/5/6f32893e467e76671965b1ec58c06a2440823752", + "is_image": "0", + "task_id": "5", + "date": "1469315613", + "user_id": "1", + "size": "4907308" + }, + "task": { + "id": "5", + "reference": "", + "title": "My new task", + "description": "New description", + "date_creation": "1469315481", + "date_completed": null, + "date_modification": "1469315531", + "date_due": "1469836800", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "purple", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "3", + "category_id": "0", + "priority": "2", + "swimlane_id": "0", + "date_moved": "1469315481", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Ready", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` -Screenshot created: +Task link creation: ```json { - "event_name": "file.create", - "event_data": { - "task_id": "2", - "name": "Screenshot taken May 19, 2015 at 10:56 AM" - } + "event_name": "task_internal_link.create_update", + "event_data": { + "task_link": { + "id": "2", + "opposite_task_id": "5", + "task_id": "4", + "link_id": "3", + "label": "is blocked by", + "opposite_link_id": "2" + }, + "task": { + "id": "4", + "reference": "", + "title": "My task", + "description": "", + "date_creation": "1469314356", + "date_completed": null, + "date_modification": "1469315422", + "date_due": "1469491200", + "date_started": "0", + "time_estimated": "0", + "time_spent": "0", + "color_id": "green", + "project_id": "1", + "column_id": "1", + "owner_id": "1", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "priority": "0", + "swimlane_id": "0", + "date_moved": "1469315422", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": null, + "recurrence_child": null, + "category_name": null, + "swimlane_name": null, + "project_name": "Demo Project", + "default_swimlane": "Default swimlane", + "column_title": "Backlog", + "assignee_username": "admin", + "assignee_name": null, + "creator_username": "admin", + "creator_name": null + } + } } ``` -- cgit v1.2.3 From adb5023cfc075ce5d6f73a4ba5b4ab51f6c500c0 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 20:30:06 -0400 Subject: Add unit test for ProjectMetricJob --- app/Core/Base.php | 1 + app/ServiceProvider/JobProvider.php | 5 +++ app/Subscriber/ProjectDailySummarySubscriber.php | 7 +--- tests/units/Job/ProjectMetricJobTest.php | 47 ++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 tests/units/Job/ProjectMetricJobTest.php diff --git a/app/Core/Base.php b/app/Core/Base.php index 6931d93a..41f5d2e0 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -157,6 +157,7 @@ use Pimple\Container; * @property \Kanboard\Job\TaskLinkEventJob $taskLinkEventJob * @property \Kanboard\Job\ProjectFileEventJob $projectFileEventJob * @property \Kanboard\Job\NotificationJob $notificationJob + * @property \Kanboard\Job\ProjectMetricJob $projectMetricJob * @property \Psr\Log\LoggerInterface $logger * @property \PicoDb\Database $db * @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher diff --git a/app/ServiceProvider/JobProvider.php b/app/ServiceProvider/JobProvider.php index 5b42794b..2194b11c 100644 --- a/app/ServiceProvider/JobProvider.php +++ b/app/ServiceProvider/JobProvider.php @@ -5,6 +5,7 @@ namespace Kanboard\ServiceProvider; use Kanboard\Job\CommentEventJob; use Kanboard\Job\NotificationJob; use Kanboard\Job\ProjectFileEventJob; +use Kanboard\Job\ProjectMetricJob; use Kanboard\Job\SubtaskEventJob; use Kanboard\Job\TaskEventJob; use Kanboard\Job\TaskFileEventJob; @@ -57,6 +58,10 @@ class JobProvider implements ServiceProviderInterface return new NotificationJob($c); }); + $container['projectMetricJob'] = $container->factory(function ($c) { + return new ProjectMetricJob($c); + }); + return $container; } } diff --git a/app/Subscriber/ProjectDailySummarySubscriber.php b/app/Subscriber/ProjectDailySummarySubscriber.php index 7e3c11c3..eaa9d468 100644 --- a/app/Subscriber/ProjectDailySummarySubscriber.php +++ b/app/Subscriber/ProjectDailySummarySubscriber.php @@ -3,7 +3,6 @@ namespace Kanboard\Subscriber; use Kanboard\Event\TaskEvent; -use Kanboard\Job\ProjectMetricJob; use Kanboard\Model\TaskModel; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -22,9 +21,7 @@ class ProjectDailySummarySubscriber extends BaseSubscriber implements EventSubsc public function execute(TaskEvent $event) { - if (isset($event['project_id'])) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $this->queueManager->push(ProjectMetricJob::getInstance($this->container)->withParams($event['project_id'])); - } + $this->logger->debug('Subscriber executed: '.__METHOD__); + $this->queueManager->push($this->projectMetricJob->withParams($event['task']['project_id'])); } } diff --git a/tests/units/Job/ProjectMetricJobTest.php b/tests/units/Job/ProjectMetricJobTest.php new file mode 100644 index 00000000..e5b0474d --- /dev/null +++ b/tests/units/Job/ProjectMetricJobTest.php @@ -0,0 +1,47 @@ +container); + $projectMetricJob->withParams(123); + + $this->assertSame( + array(123), + $projectMetricJob->getJobParams() + ); + } + + public function testJob() + { + $this->container['projectDailyColumnStatsModel'] = $this + ->getMockBuilder('\Kanboard\Model\ProjectDailyColumnStatsModel') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('updateTotals')) + ->getMock(); + + $this->container['projectDailyStatsModel'] = $this + ->getMockBuilder('\Kanboard\Model\ProjectDailyStatsModel') + ->setConstructorArgs(array($this->container)) + ->setMethods(array('updateTotals')) + ->getMock(); + + $this->container['projectDailyColumnStatsModel'] + ->expects($this->once()) + ->method('updateTotals') + ->with(42, date('Y-m-d')); + + $this->container['projectDailyStatsModel'] + ->expects($this->once()) + ->method('updateTotals') + ->with(42, date('Y-m-d')); + + $job = new ProjectMetricJob($this->container); + $job->execute(42); + } +} -- cgit v1.2.3 From 220bc9cdcc483e71d5df629e9c7eb26c562b969f Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 20:58:16 -0400 Subject: Add unit test RecurringTaskSubscriber --- app/Subscriber/RecurringTaskSubscriber.php | 14 +- .../Subscriber/RecurringTaskSubscriberTest.php | 164 +++++++++++++++++++++ 2 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 tests/units/Subscriber/RecurringTaskSubscriberTest.php diff --git a/app/Subscriber/RecurringTaskSubscriber.php b/app/Subscriber/RecurringTaskSubscriber.php index 21cd3996..3e2848f8 100644 --- a/app/Subscriber/RecurringTaskSubscriber.php +++ b/app/Subscriber/RecurringTaskSubscriber.php @@ -19,12 +19,13 @@ class RecurringTaskSubscriber extends BaseSubscriber implements EventSubscriberI public function onMove(TaskEvent $event) { $this->logger->debug('Subscriber executed: '.__METHOD__); + $task = $event['task']; - if ($event['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING) { - if ($event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_FIRST_COLUMN && $this->columnModel->getFirstColumnId($event['project_id']) == $event['src_column_id']) { - $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); - } elseif ($event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_LAST_COLUMN && $this->columnModel->getLastColumnId($event['project_id']) == $event['dst_column_id']) { - $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); + if ($task['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING) { + if ($task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_FIRST_COLUMN && $this->columnModel->getFirstColumnId($task['project_id']) == $event['src_column_id']) { + $this->taskRecurrenceModel->duplicateRecurringTask($task['id']); + } elseif ($task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_LAST_COLUMN && $this->columnModel->getLastColumnId($task['project_id']) == $event['dst_column_id']) { + $this->taskRecurrenceModel->duplicateRecurringTask($task['id']); } } } @@ -32,8 +33,9 @@ class RecurringTaskSubscriber extends BaseSubscriber implements EventSubscriberI public function onClose(TaskEvent $event) { $this->logger->debug('Subscriber executed: '.__METHOD__); + $task = $event['task']; - if ($event['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING && $event['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_CLOSE) { + if ($task['recurrence_status'] == TaskModel::RECURRING_STATUS_PENDING && $task['recurrence_trigger'] == TaskModel::RECURRING_TRIGGER_CLOSE) { $this->taskRecurrenceModel->duplicateRecurringTask($event['task_id']); } } diff --git a/tests/units/Subscriber/RecurringTaskSubscriberTest.php b/tests/units/Subscriber/RecurringTaskSubscriberTest.php new file mode 100644 index 00000000..d6aba7cf --- /dev/null +++ b/tests/units/Subscriber/RecurringTaskSubscriberTest.php @@ -0,0 +1,164 @@ +container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceFirstColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_FIRST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('src_column_id' => 1)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceFirstColumnWithWrongColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_FIRST_COLUMN, + 'column_id' => 2, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('src_column_id' => 2)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceLastColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_LAST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('dst_column_id' => 4)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceLastColumnWithWrongColumn() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_LAST_COLUMN, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withValues(array('dst_column_id' => 2)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(1, $taskFinderModel->countByProjectId(1)); + } + + public function testWithRecurrenceOnClose() + { + $projectModel = new ProjectModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + $subscriber = new RecurringTaskSubscriber($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array( + 'title' => 'test', + 'project_id' => 1, + 'recurrence_status' => TaskModel::RECURRING_STATUS_PENDING, + 'recurrence_trigger' => TaskModel::RECURRING_TRIGGER_CLOSE, + ))); + + $event = TaskEventBuilder::getInstance($this->container) + ->withTaskId(1) + ->withChanges(array('is_active' => 0)) + ->buildEvent(); + + $subscriber->onMove($event); + $subscriber->onClose($event); + + $this->assertEquals(2, $taskFinderModel->countByProjectId(1)); + } +} -- cgit v1.2.3 From 2a7ca0405cdafe26578326c12cdd6b072e8d90ae Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:14:33 -0400 Subject: Create new class SubtaskPositionModel --- app/Controller/SubtaskController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 33 ----------- app/Model/SubtaskPositionModel.php | 47 ++++++++++++++++ app/ServiceProvider/ClassProvider.php | 1 + tests/units/Model/SubtaskModelTest.php | 65 ---------------------- tests/units/Model/SubtaskPositionModelTest.php | 77 ++++++++++++++++++++++++++ 7 files changed, 127 insertions(+), 99 deletions(-) create mode 100644 app/Model/SubtaskPositionModel.php create mode 100644 tests/units/Model/SubtaskPositionModelTest.php diff --git a/app/Controller/SubtaskController.php b/app/Controller/SubtaskController.php index 93dab5cd..7502d84f 100644 --- a/app/Controller/SubtaskController.php +++ b/app/Controller/SubtaskController.php @@ -168,7 +168,7 @@ class SubtaskController extends BaseController $values = $this->request->getJson(); if (! empty($values) && $this->helper->user->hasProjectAccess('SubtaskController', 'movePosition', $project_id)) { - $result = $this->subtaskModel->changePosition($task_id, $values['subtask_id'], $values['position']); + $result = $this->subtaskPositionModel->changePosition($task_id, $values['subtask_id'], $values['position']); $this->response->json(array('result' => $result)); } else { throw new AccessForbiddenException(); diff --git a/app/Core/Base.php b/app/Core/Base.php index 41f5d2e0..0230b671 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -90,6 +90,7 @@ use Pimple\Container; * @property \Kanboard\Model\ProjectTaskPriorityModel $projectTaskPriorityModel * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel + * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel * @property \Kanboard\Model\TagDuplicationModel $tagDuplicationModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index f3fc72ba..5a4e87a2 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -272,39 +272,6 @@ class SubtaskModel extends Base return $this->db->table(self::TABLE)->eq('task_id', $task_id)->update(array('status' => self::STATUS_DONE)); } - /** - * Save subtask position - * - * @access public - * @param integer $task_id - * @param integer $subtask_id - * @param integer $position - * @return boolean - */ - public function changePosition($task_id, $subtask_id, $position) - { - if ($position < 1 || $position > $this->db->table(self::TABLE)->eq('task_id', $task_id)->count()) { - return false; - } - - $subtask_ids = $this->db->table(self::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id'); - $offset = 1; - $results = array(); - - foreach ($subtask_ids as $current_subtask_id) { - if ($offset == $position) { - $offset++; - } - - $results[] = $this->db->table(self::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset)); - $offset++; - } - - $results[] = $this->db->table(self::TABLE)->eq('id', $subtask_id)->update(array('position' => $position)); - - return !in_array(false, $results, true); - } - /** * Change the status of subtask * diff --git a/app/Model/SubtaskPositionModel.php b/app/Model/SubtaskPositionModel.php new file mode 100644 index 00000000..3c26465d --- /dev/null +++ b/app/Model/SubtaskPositionModel.php @@ -0,0 +1,47 @@ + $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->count()) { + return false; + } + + $subtask_ids = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->neq('id', $subtask_id)->asc('position')->findAllByColumn('id'); + $offset = 1; + $results = array(); + + foreach ($subtask_ids as $current_subtask_id) { + if ($offset == $position) { + $offset++; + } + + $results[] = $this->db->table(SubtaskModel::TABLE)->eq('id', $current_subtask_id)->update(array('position' => $offset)); + $offset++; + } + + $results[] = $this->db->table(SubtaskModel::TABLE)->eq('id', $subtask_id)->update(array('position' => $position)); + + return !in_array(false, $results, true); + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index e32c0d43..d1415d8c 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -60,6 +60,7 @@ class ClassProvider implements ServiceProviderInterface 'ProjectUserRoleModel', 'RememberMeSessionModel', 'SubtaskModel', + 'SubtaskPositionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', 'TagDuplicationModel', diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 7e438651..3b25bb3b 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -229,71 +229,6 @@ class SubtaskModelTest extends Base $this->assertEquals(2, $subtasks[1]['position']); } - public function testChangePosition() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); - $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1))); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(2, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 3, 2)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(3, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(2, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 2, 1)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(2, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(1, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 2, 2)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(1, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(2, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(3, $subtasks[2]['id']); - - $this->assertTrue($subtaskModel->changePosition(1, 1, 3)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertEquals(1, $subtasks[0]['position']); - $this->assertEquals(2, $subtasks[0]['id']); - $this->assertEquals(2, $subtasks[1]['position']); - $this->assertEquals(3, $subtasks[1]['id']); - $this->assertEquals(3, $subtasks[2]['position']); - $this->assertEquals(1, $subtasks[2]['id']); - - $this->assertFalse($subtaskModel->changePosition(1, 2, 0)); - $this->assertFalse($subtaskModel->changePosition(1, 2, 4)); - } - public function testConvertToTask() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskPositionModelTest.php b/tests/units/Model/SubtaskPositionModelTest.php new file mode 100644 index 00000000..92412392 --- /dev/null +++ b/tests/units/Model/SubtaskPositionModelTest.php @@ -0,0 +1,77 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskPositionModel = new SubtaskPositionModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 1))); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(2, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 3, 2)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(3, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(2, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 2, 1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(2, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(1, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 2, 2)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(1, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(2, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(3, $subtasks[2]['id']); + + $this->assertTrue($subtaskPositionModel->changePosition(1, 1, 3)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertEquals(1, $subtasks[0]['position']); + $this->assertEquals(2, $subtasks[0]['id']); + $this->assertEquals(2, $subtasks[1]['position']); + $this->assertEquals(3, $subtasks[1]['id']); + $this->assertEquals(3, $subtasks[2]['position']); + $this->assertEquals(1, $subtasks[2]['id']); + + $this->assertFalse($subtaskPositionModel->changePosition(1, 2, 0)); + $this->assertFalse($subtaskPositionModel->changePosition(1, 2, 4)); + } +} -- cgit v1.2.3 From f216e345ba2ad7486037c393c0475a1371ca2b00 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:22:24 -0400 Subject: Create new class SubtaskTaskConversionModel --- app/Controller/SubtaskConverterController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 27 -------------- app/Model/SubtaskTaskConversionModel.php | 41 ++++++++++++++++++++++ app/ServiceProvider/ClassProvider.php | 1 + tests/units/Model/SubtaskModelTest.php | 24 ------------- .../units/Model/SubtaskTaskConversionModelTest.php | 37 +++++++++++++++++++ 7 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 app/Model/SubtaskTaskConversionModel.php create mode 100644 tests/units/Model/SubtaskTaskConversionModelTest.php diff --git a/app/Controller/SubtaskConverterController.php b/app/Controller/SubtaskConverterController.php index 65bcd2da..404c50d0 100644 --- a/app/Controller/SubtaskConverterController.php +++ b/app/Controller/SubtaskConverterController.php @@ -26,7 +26,7 @@ class SubtaskConverterController extends BaseController $project = $this->getProject(); $subtask = $this->getSubtask(); - $task_id = $this->subtaskModel->convertToTask($project['id'], $subtask['id']); + $task_id = $this->subtaskTaskConversionModel->convertToTask($project['id'], $subtask['id']); if ($task_id !== false) { $this->flash->success(t('Subtask converted to task successfully.')); diff --git a/app/Core/Base.php b/app/Core/Base.php index 0230b671..8b9bf085 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -91,6 +91,7 @@ use Pimple\Container; * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel + * @property \Kanboard\Model\SubtaskTaskConversionModel $subtaskTaskConversionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel * @property \Kanboard\Model\TagDuplicationModel $tagDuplicationModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 5a4e87a2..2ac6095c 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -368,31 +368,4 @@ class SubtaskModel extends Base } }); } - - /** - * Convert a subtask to a task - * - * @access public - * @param integer $project_id - * @param integer $subtask_id - * @return integer - */ - public function convertToTask($project_id, $subtask_id) - { - $subtask = $this->getById($subtask_id); - - $task_id = $this->taskCreationModel->create(array( - 'project_id' => $project_id, - 'title' => $subtask['title'], - 'time_estimated' => $subtask['time_estimated'], - 'time_spent' => $subtask['time_spent'], - 'owner_id' => $subtask['user_id'], - )); - - if ($task_id !== false) { - $this->remove($subtask_id); - } - - return $task_id; - } } diff --git a/app/Model/SubtaskTaskConversionModel.php b/app/Model/SubtaskTaskConversionModel.php new file mode 100644 index 00000000..8bf83d76 --- /dev/null +++ b/app/Model/SubtaskTaskConversionModel.php @@ -0,0 +1,41 @@ +subtaskModel->getById($subtask_id); + + $task_id = $this->taskCreationModel->create(array( + 'project_id' => $project_id, + 'title' => $subtask['title'], + 'time_estimated' => $subtask['time_estimated'], + 'time_spent' => $subtask['time_spent'], + 'owner_id' => $subtask['user_id'], + )); + + if ($task_id !== false) { + $this->subtaskModel->remove($subtask_id); + } + + return $task_id; + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index d1415d8c..ad69d5fb 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -61,6 +61,7 @@ class ClassProvider implements ServiceProviderInterface 'RememberMeSessionModel', 'SubtaskModel', 'SubtaskPositionModel', + 'SubtaskTaskConversionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', 'TagDuplicationModel', diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 3b25bb3b..d270e177 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -229,30 +229,6 @@ class SubtaskModelTest extends Base $this->assertEquals(2, $subtasks[1]['position']); } - public function testConvertToTask() - { - $taskCreationModel = new TaskCreationModel($this->container); - $taskFinderModel = new TaskFinderModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1, 'time_spent' => 2, 'time_estimated' => 3))); - $task_id = $subtaskModel->convertToTask(1, 1); - - $this->assertNotFalse($task_id); - $this->assertEmpty($subtaskModel->getById(1)); - - $task = $taskFinderModel->getById($task_id); - $this->assertEquals('subtask #1', $task['title']); - $this->assertEquals(1, $task['project_id']); - $this->assertEquals(1, $task['owner_id']); - $this->assertEquals(2, $task['time_spent']); - $this->assertEquals(3, $task['time_estimated']); - } - public function testGetProjectId() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskTaskConversionModelTest.php b/tests/units/Model/SubtaskTaskConversionModelTest.php new file mode 100644 index 00000000..51a623b2 --- /dev/null +++ b/tests/units/Model/SubtaskTaskConversionModelTest.php @@ -0,0 +1,37 @@ +container); + $taskFinderModel = new TaskFinderModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskConversion = new SubtaskTaskConversionModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1, 'time_spent' => 2, 'time_estimated' => 3))); + $task_id = $subtaskConversion->convertToTask(1, 1); + + $this->assertNotFalse($task_id); + $this->assertEmpty($subtaskModel->getById(1)); + + $task = $taskFinderModel->getById($task_id); + $this->assertEquals('subtask #1', $task['title']); + $this->assertEquals(1, $task['project_id']); + $this->assertEquals(1, $task['owner_id']); + $this->assertEquals(2, $task['time_spent']); + $this->assertEquals(3, $task['time_estimated']); + } +} -- cgit v1.2.3 From 24555080fd3ca8607f0a798b5a0e4be98ff131f8 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 21:48:59 -0400 Subject: Create new class SubtaskStatusModel --- app/Controller/SubtaskRestrictionController.php | 2 +- app/Controller/SubtaskStatusController.php | 2 +- app/Core/Base.php | 1 + app/Model/SubtaskModel.php | 184 +++++----------- app/Model/SubtaskStatusModel.php | 85 ++++++++ app/Model/TaskStatusModel.php | 2 +- app/ServiceProvider/ClassProvider.php | 1 + app/Subscriber/BootstrapSubscriber.php | 2 +- tests/units/Model/SubtaskModelTest.php | 110 ---------- tests/units/Model/SubtaskStatusModelTest.php | 123 +++++++++++ tests/units/Model/SubtaskTimeTrackingModelTest.php | 240 +++++++++++++++++++++ tests/units/Model/SubtaskTimeTrackingTest.php | 240 --------------------- 12 files changed, 506 insertions(+), 486 deletions(-) create mode 100644 app/Model/SubtaskStatusModel.php create mode 100644 tests/units/Model/SubtaskStatusModelTest.php create mode 100644 tests/units/Model/SubtaskTimeTrackingModelTest.php delete mode 100644 tests/units/Model/SubtaskTimeTrackingTest.php diff --git a/app/Controller/SubtaskRestrictionController.php b/app/Controller/SubtaskRestrictionController.php index 084fc0d9..cb642e1c 100644 --- a/app/Controller/SubtaskRestrictionController.php +++ b/app/Controller/SubtaskRestrictionController.php @@ -27,7 +27,7 @@ class SubtaskRestrictionController extends BaseController SubtaskModel::STATUS_TODO => t('Todo'), SubtaskModel::STATUS_DONE => t('Done'), ), - 'subtask_inprogress' => $this->subtaskModel->getSubtaskInProgress($this->userSession->getId()), + 'subtask_inprogress' => $this->subtaskStatusModel->getSubtaskInProgress($this->userSession->getId()), 'subtask' => $subtask, 'task' => $task, ))); diff --git a/app/Controller/SubtaskStatusController.php b/app/Controller/SubtaskStatusController.php index 699951fe..d4d356c3 100644 --- a/app/Controller/SubtaskStatusController.php +++ b/app/Controller/SubtaskStatusController.php @@ -20,7 +20,7 @@ class SubtaskStatusController extends BaseController $task = $this->getTask(); $subtask = $this->getSubtask(); - $status = $this->subtaskModel->toggleStatus($subtask['id']); + $status = $this->subtaskStatusModel->toggleStatus($subtask['id']); if ($this->request->getIntegerParam('refresh-table') === 0) { $subtask['status'] = $status; diff --git a/app/Core/Base.php b/app/Core/Base.php index 8b9bf085..563013bd 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -91,6 +91,7 @@ use Pimple\Container; * @property \Kanboard\Model\RememberMeSessionModel $rememberMeSessionModel * @property \Kanboard\Model\SubtaskModel $subtaskModel * @property \Kanboard\Model\SubtaskPositionModel $subtaskPositionModel + * @property \Kanboard\Model\SubtaskStatusModel $subtaskStatusModel * @property \Kanboard\Model\SubtaskTaskConversionModel $subtaskTaskConversionModel * @property \Kanboard\Model\SubtaskTimeTrackingModel $subtaskTimeTrackingModel * @property \Kanboard\Model\SwimlaneModel $swimlaneModel diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 2ac6095c..568e27a4 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -21,25 +21,13 @@ class SubtaskModel extends Base const TABLE = 'subtasks'; /** - * Task "done" status - * - * @var integer - */ - const STATUS_DONE = 2; - - /** - * Task "in progress" status - * - * @var integer - */ - const STATUS_INPROGRESS = 1; - - /** - * Task "todo" status + * Subtask status * * @var integer */ const STATUS_TODO = 0; + const STATUS_INPROGRESS = 1; + const STATUS_DONE = 2; /** * Events @@ -81,26 +69,6 @@ class SubtaskModel extends Base ); } - /** - * Add subtask status status to the resultset - * - * @access public - * @param array $subtasks Subtasks - * @return array - */ - public function addStatusName(array $subtasks) - { - $status = $this->getStatusList(); - - foreach ($subtasks as &$subtask) { - $subtask['status_name'] = $status[$subtask['status']]; - $subtask['timer_start_date'] = isset($subtask['timer_start_date']) ? $subtask['timer_start_date'] : 0; - $subtask['is_timer_started'] = ! empty($subtask['timer_start_date']); - } - - return $subtasks; - } - /** * Get the query to fetch subtasks assigned to a user * @@ -176,35 +144,6 @@ class SubtaskModel extends Base return $this->db->table(self::TABLE)->eq('id', $subtask_id)->findOne(); } - /** - * Prepare data before insert/update - * - * @access public - * @param array $values Form values - */ - public function prepare(array &$values) - { - $this->helper->model->removeFields($values, array('another_subtask')); - $this->helper->model->resetFields($values, array('time_estimated', 'time_spent')); - } - - /** - * Prepare data before insert - * - * @access public - * @param array $values Form values - */ - public function prepareCreation(array &$values) - { - $this->prepare($values); - - $values['position'] = $this->getLastPosition($values['task_id']) + 1; - $values['status'] = isset($values['status']) ? $values['status'] : self::STATUS_TODO; - $values['time_estimated'] = isset($values['time_estimated']) ? $values['time_estimated'] : 0; - $values['time_spent'] = isset($values['time_spent']) ? $values['time_spent'] : 0; - $values['user_id'] = isset($values['user_id']) ? $values['user_id'] : 0; - } - /** * Get the position of the last column for a given project * @@ -260,74 +199,6 @@ class SubtaskModel extends Base return $result; } - /** - * Close all subtasks of a task - * - * @access public - * @param integer $task_id - * @return boolean - */ - public function closeAll($task_id) - { - return $this->db->table(self::TABLE)->eq('task_id', $task_id)->update(array('status' => self::STATUS_DONE)); - } - - /** - * Change the status of subtask - * - * @access public - * @param integer $subtask_id - * @return boolean|integer - */ - public function toggleStatus($subtask_id) - { - $subtask = $this->getById($subtask_id); - $status = ($subtask['status'] + 1) % 3; - - $values = array( - 'id' => $subtask['id'], - 'status' => $status, - 'task_id' => $subtask['task_id'], - ); - - if (empty($subtask['user_id']) && $this->userSession->isLogged()) { - $values['user_id'] = $this->userSession->getId(); - } - - return $this->update($values) ? $status : false; - } - - /** - * Get the subtask in progress for this user - * - * @access public - * @param integer $user_id - * @return array - */ - public function getSubtaskInProgress($user_id) - { - return $this->db->table(self::TABLE) - ->eq('status', self::STATUS_INPROGRESS) - ->eq('user_id', $user_id) - ->findOne(); - } - - /** - * Return true if the user have a subtask in progress - * - * @access public - * @param integer $user_id - * @return boolean - */ - public function hasSubtaskInProgress($user_id) - { - return $this->configModel->get('subtask_restriction') == 1 && - $this->db->table(self::TABLE) - ->eq('status', self::STATUS_INPROGRESS) - ->eq('user_id', $user_id) - ->exists(); - } - /** * Remove * @@ -368,4 +239,53 @@ class SubtaskModel extends Base } }); } + + /** + * Prepare data before insert/update + * + * @access protected + * @param array $values Form values + */ + protected function prepare(array &$values) + { + $this->helper->model->removeFields($values, array('another_subtask')); + $this->helper->model->resetFields($values, array('time_estimated', 'time_spent')); + } + + /** + * Prepare data before insert + * + * @access protected + * @param array $values Form values + */ + protected function prepareCreation(array &$values) + { + $this->prepare($values); + + $values['position'] = $this->getLastPosition($values['task_id']) + 1; + $values['status'] = isset($values['status']) ? $values['status'] : self::STATUS_TODO; + $values['time_estimated'] = isset($values['time_estimated']) ? $values['time_estimated'] : 0; + $values['time_spent'] = isset($values['time_spent']) ? $values['time_spent'] : 0; + $values['user_id'] = isset($values['user_id']) ? $values['user_id'] : 0; + } + + /** + * Add subtask status status to the resultset + * + * @access public + * @param array $subtasks Subtasks + * @return array + */ + public function addStatusName(array $subtasks) + { + $status = $this->getStatusList(); + + foreach ($subtasks as &$subtask) { + $subtask['status_name'] = $status[$subtask['status']]; + $subtask['timer_start_date'] = isset($subtask['timer_start_date']) ? $subtask['timer_start_date'] : 0; + $subtask['is_timer_started'] = ! empty($subtask['timer_start_date']); + } + + return $subtasks; + } } diff --git a/app/Model/SubtaskStatusModel.php b/app/Model/SubtaskStatusModel.php new file mode 100644 index 00000000..26cbb67d --- /dev/null +++ b/app/Model/SubtaskStatusModel.php @@ -0,0 +1,85 @@ +db->table(SubtaskModel::TABLE) + ->eq('status', SubtaskModel::STATUS_INPROGRESS) + ->eq('user_id', $user_id) + ->findOne(); + } + + /** + * Return true if the user have a subtask in progress + * + * @access public + * @param integer $user_id + * @return boolean + */ + public function hasSubtaskInProgress($user_id) + { + return $this->configModel->get('subtask_restriction') == 1 && + $this->db->table(SubtaskModel::TABLE) + ->eq('status', SubtaskModel::STATUS_INPROGRESS) + ->eq('user_id', $user_id) + ->exists(); + } + + /** + * Change the status of subtask + * + * @access public + * @param integer $subtask_id + * @return boolean|integer + */ + public function toggleStatus($subtask_id) + { + $subtask = $this->subtaskModel->getById($subtask_id); + $status = ($subtask['status'] + 1) % 3; + + $values = array( + 'id' => $subtask['id'], + 'status' => $status, + 'task_id' => $subtask['task_id'], + ); + + if (empty($subtask['user_id']) && $this->userSession->isLogged()) { + $values['user_id'] = $this->userSession->getId(); + } + + return $this->subtaskModel->update($values) ? $status : false; + } + + /** + * Close all subtasks of a task + * + * @access public + * @param integer $task_id + * @return boolean + */ + public function closeAll($task_id) + { + return $this->db + ->table(SubtaskModel::TABLE) + ->eq('task_id', $task_id) + ->update(array('status' => SubtaskModel::STATUS_DONE)); + } +} diff --git a/app/Model/TaskStatusModel.php b/app/Model/TaskStatusModel.php index ea304beb..dc114698 100644 --- a/app/Model/TaskStatusModel.php +++ b/app/Model/TaskStatusModel.php @@ -45,7 +45,7 @@ class TaskStatusModel extends Base */ public function close($task_id) { - $this->subtaskModel->closeAll($task_id); + $this->subtaskStatusModel->closeAll($task_id); return $this->changeStatus($task_id, TaskModel::STATUS_CLOSED, time(), TaskModel::EVENT_CLOSE); } diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index ad69d5fb..9a71148b 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -61,6 +61,7 @@ class ClassProvider implements ServiceProviderInterface 'RememberMeSessionModel', 'SubtaskModel', 'SubtaskPositionModel', + 'SubtaskStatusModel', 'SubtaskTaskConversionModel', 'SubtaskTimeTrackingModel', 'SwimlaneModel', diff --git a/app/Subscriber/BootstrapSubscriber.php b/app/Subscriber/BootstrapSubscriber.php index 7d12e9ae..3618f30f 100644 --- a/app/Subscriber/BootstrapSubscriber.php +++ b/app/Subscriber/BootstrapSubscriber.php @@ -21,7 +21,7 @@ class BootstrapSubscriber extends BaseSubscriber implements EventSubscriberInter $this->actionManager->attachEvents(); if ($this->userSession->isLogged()) { - $this->sessionStorage->hasSubtaskInProgress = $this->subtaskModel->hasSubtaskInProgress($this->userSession->getId()); + $this->sessionStorage->hasSubtaskInProgress = $this->subtaskStatusModel->hasSubtaskInProgress($this->userSession->getId()); } } diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index d270e177..23183d22 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -5,7 +5,6 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\ProjectModel; -use Kanboard\Model\TaskFinderModel; class SubtaskModelTest extends Base { @@ -74,115 +73,6 @@ class SubtaskModelTest extends Base $this->assertEmpty($subtask); } - public function testToggleStatusWithoutSession() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - } - - public function testToggleStatusWithSession() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(0, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - // Set the current logged user - $this->container['sessionStorage']->user = array('id' => 1); - - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskModel->toggleStatus(1)); - - $subtask = $subtaskModel->getById(1); - $this->assertNotEmpty($subtask); - $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); - $this->assertEquals(1, $subtask['user_id']); - $this->assertEquals(1, $subtask['task_id']); - } - - public function testCloseAll() - { - $taskCreationModel = new TaskCreationModel($this->container); - $subtaskModel = new SubtaskModel($this->container); - $projectModel = new ProjectModel($this->container); - - $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); - $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); - - $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); - - $this->assertTrue($subtaskModel->closeAll(1)); - - $subtasks = $subtaskModel->getAll(1); - $this->assertNotEmpty($subtasks); - - foreach ($subtasks as $subtask) { - $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); - } - } - public function testDuplicate() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskStatusModelTest.php b/tests/units/Model/SubtaskStatusModelTest.php new file mode 100644 index 00000000..af4c3955 --- /dev/null +++ b/tests/units/Model/SubtaskStatusModelTest.php @@ -0,0 +1,123 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + } + + public function testToggleStatusWithSession() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(0, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + // Set the current logged user + $this->container['sessionStorage']->user = array('id' => 1); + + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_INPROGRESS, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtaskStatusModel->toggleStatus(1)); + + $subtask = $subtaskModel->getById(1); + $this->assertNotEmpty($subtask); + $this->assertEquals(SubtaskModel::STATUS_TODO, $subtask['status']); + $this->assertEquals(1, $subtask['user_id']); + $this->assertEquals(1, $subtask['task_id']); + } + + public function testCloseAll() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $subtaskStatusModel = new SubtaskStatusModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + + $this->assertTrue($subtaskStatusModel->closeAll(1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + + foreach ($subtasks as $subtask) { + $this->assertEquals(SubtaskModel::STATUS_DONE, $subtask['status']); + } + } +} diff --git a/tests/units/Model/SubtaskTimeTrackingModelTest.php b/tests/units/Model/SubtaskTimeTrackingModelTest.php new file mode 100644 index 00000000..cfee5b14 --- /dev/null +++ b/tests/units/Model/SubtaskTimeTrackingModelTest.php @@ -0,0 +1,240 @@ +container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->hasTimer(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->hasTimer(1, 1)); + $this->assertFalse($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $this->assertFalse($subtaskTimeTrackingModel->hasTimer(1, 1)); + } + + public function testGetTimerStatus() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->container['sessionStorage']->user = array('id' => 1); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1))); + + // Nothing started + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(0, $subtasks[0]['timer_start_date']); + $this->assertFalse($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(0, $subtask['timer_start_date']); + $this->assertFalse($subtask['is_timer_started']); + + // Start the clock + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(time(), $subtasks[0]['timer_start_date'], '', 3); + $this->assertTrue($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(time(), $subtask['timer_start_date'], '', 3); + $this->assertTrue($subtask['is_timer_started']); + + // Stop the clock + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $subtasks = $subtaskModel->getAll(1); + $this->assertNotEmpty($subtasks); + $this->assertEquals(0, $subtasks[0]['timer_start_date']); + $this->assertFalse($subtasks[0]['is_timer_started']); + + $subtask = $subtaskModel->getById(1, true); + $this->assertNotEmpty($subtask); + $this->assertEquals(0, $subtask['timer_start_date']); + $this->assertFalse($subtask['is_timer_started']); + } + + public function testLogStartTime() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(1, $timesheet); + $this->assertNotEmpty($timesheet[0]['start']); + $this->assertEmpty($timesheet[0]['end']); + $this->assertEquals(1, $timesheet[0]['user_id']); + $this->assertEquals(1, $timesheet[0]['subtask_id']); + } + + public function testLogStartEnd() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + // No start time + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertEmpty($timesheet); + + // Log start and end time + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + sleep(1); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(1, $timesheet); + $this->assertNotEmpty($timesheet[0]['start']); + $this->assertNotEmpty($timesheet[0]['end']); + $this->assertEquals(1, $timesheet[0]['user_id']); + $this->assertEquals(1, $timesheet[0]['subtask_id']); + $this->assertNotEquals($timesheet[0]['start'], $timesheet[0]['end']); + } + + public function testCalculateSubtaskTime() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2, 'time_estimated' => 3.3))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 1.1, 'time_estimated' => 4.4))); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(1); + $this->assertCount(2, $time); + $this->assertEquals(3.3, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(7.7, $time['time_estimated'], 'Total estimated', 0.01); + } + + public function testUpdateSubtaskTimeSpent() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logStartTime(2, 1)); + + // Fake start time + $this->container['db']->table(SubtaskTimeTrackingModel::TABLE)->update(array('start' => time() - 3600)); + + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(1, 1)); + $this->assertTrue($subtaskTimeTrackingModel->logEndTime(2, 1)); + + $timesheet = $subtaskTimeTrackingModel->getUserTimesheet(1); + $this->assertNotEmpty($timesheet); + $this->assertCount(2, $timesheet); + $this->assertEquals(3600, $timesheet[0]['end'] - $timesheet[0]['start'], 'Wrong timestamps', 1); + $this->assertEquals(3600, $timesheet[1]['end'] - $timesheet[1]['start'], 'Wrong timestamps', 1); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(1); + $this->assertEquals(4.2, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); + + $time = $subtaskTimeTrackingModel->calculateSubtaskTime(2); + $this->assertEquals(0, $time['time_spent'], 'Total spent', 0.01); + $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); + } + + public function testUpdateTaskTimeTracking() + { + $taskFinderModel = new TaskFinderModel($this->container); + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'test 2', 'project_id' => 1, 'time_estimated' => 1.5, 'time_spent' => 0.5))); + $this->assertEquals(3, $taskCreationModel->create(array('title' => 'test 3', 'project_id' => 1, 'time_estimated' => 4, 'time_spent' => 2))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_spent' => 2.2))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 1))); + + $this->assertEquals(3, $subtaskModel->create(array('title' => 'subtask #3', 'task_id' => 2, 'time_spent' => 3.4))); + $this->assertEquals(4, $subtaskModel->create(array('title' => 'subtask #4', 'task_id' => 2, 'time_estimated' => 1.25))); + + $this->assertEquals(5, $subtaskModel->create(array('title' => 'subtask #5', 'task_id' => 3, 'time_spent' => 8))); + + $subtaskTimeTrackingModel->updateTaskTimeTracking(1); + $subtaskTimeTrackingModel->updateTaskTimeTracking(2); + $subtaskTimeTrackingModel->updateTaskTimeTracking(3); + + $task = $taskFinderModel->getById(1); + $this->assertNotEmpty($task); + $this->assertEquals(2.2, $task['time_spent'], 'Total spent', 0.01); + $this->assertEquals(1, $task['time_estimated'], 'Total estimated', 0.01); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(3.4, $task['time_spent'], 'Total spent', 0.01); + $this->assertEquals(1.25, $task['time_estimated'], 'Total estimated', 0.01); + + $task = $taskFinderModel->getById(3); + $this->assertNotEmpty($task); + $this->assertEquals(0, $task['time_estimated']); + $this->assertEquals(8, $task['time_spent']); + + $this->assertTrue($subtaskModel->remove(3)); + $this->assertTrue($subtaskModel->remove(4)); + + $subtaskTimeTrackingModel->updateTaskTimeTracking(2); + + $task = $taskFinderModel->getById(2); + $this->assertNotEmpty($task); + $this->assertEquals(0, $task['time_estimated']); + $this->assertEquals(0, $task['time_spent']); + } +} diff --git a/tests/units/Model/SubtaskTimeTrackingTest.php b/tests/units/Model/SubtaskTimeTrackingTest.php deleted file mode 100644 index d5ae62ae..00000000 --- a/tests/units/Model/SubtaskTimeTrackingTest.php +++ /dev/null @@ -1,240 +0,0 @@ -container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - $this->assertFalse($st->hasTimer(1, 1)); - $this->assertTrue($st->logStartTime(1, 1)); - $this->assertTrue($st->hasTimer(1, 1)); - $this->assertFalse($st->logStartTime(1, 1)); - $this->assertTrue($st->logEndTime(1, 1)); - $this->assertFalse($st->hasTimer(1, 1)); - } - - public function testGetTimerStatus() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->container['sessionStorage']->user = array('id' => 1); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1, 'user_id' => 1))); - - // Nothing started - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(0, $subtasks[0]['timer_start_date']); - $this->assertFalse($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(0, $subtask['timer_start_date']); - $this->assertFalse($subtask['is_timer_started']); - - // Start the clock - $this->assertTrue($st->logStartTime(1, 1)); - - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(time(), $subtasks[0]['timer_start_date'], '', 3); - $this->assertTrue($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(time(), $subtask['timer_start_date'], '', 3); - $this->assertTrue($subtask['is_timer_started']); - - // Stop the clock - $this->assertTrue($st->logEndTime(1, 1)); - $subtasks = $s->getAll(1); - $this->assertNotEmpty($subtasks); - $this->assertEquals(0, $subtasks[0]['timer_start_date']); - $this->assertFalse($subtasks[0]['is_timer_started']); - - $subtask = $s->getById(1, true); - $this->assertNotEmpty($subtask); - $this->assertEquals(0, $subtask['timer_start_date']); - $this->assertFalse($subtask['is_timer_started']); - } - - public function testLogStartTime() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - $this->assertTrue($st->logStartTime(1, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(1, $timesheet); - $this->assertNotEmpty($timesheet[0]['start']); - $this->assertEmpty($timesheet[0]['end']); - $this->assertEquals(1, $timesheet[0]['user_id']); - $this->assertEquals(1, $timesheet[0]['subtask_id']); - } - - public function testLogStartEnd() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); - - // No start time - $this->assertTrue($st->logEndTime(1, 1)); - $timesheet = $st->getUserTimesheet(1); - $this->assertEmpty($timesheet); - - // Log start and end time - $this->assertTrue($st->logStartTime(1, 1)); - sleep(1); - $this->assertTrue($st->logEndTime(1, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(1, $timesheet); - $this->assertNotEmpty($timesheet[0]['start']); - $this->assertNotEmpty($timesheet[0]['end']); - $this->assertEquals(1, $timesheet[0]['user_id']); - $this->assertEquals(1, $timesheet[0]['subtask_id']); - $this->assertNotEquals($timesheet[0]['start'], $timesheet[0]['end']); - } - - public function testCalculateSubtaskTime() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2, 'time_estimated' => 3.3))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 1.1, 'time_estimated' => 4.4))); - - $time = $st->calculateSubtaskTime(1); - $this->assertCount(2, $time); - $this->assertEquals(3.3, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(7.7, $time['time_estimated'], 'Total estimated', 0.01); - } - - public function testUpdateSubtaskTimeSpent() - { - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); - $this->assertEquals(1, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_spent' => 2.2))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1))); - - $this->assertTrue($st->logStartTime(1, 1)); - $this->assertTrue($st->logStartTime(2, 1)); - - // Fake start time - $this->container['db']->table(SubtaskTimeTrackingModel::TABLE)->update(array('start' => time() - 3600)); - - $this->assertTrue($st->logEndTime(1, 1)); - $this->assertTrue($st->logEndTime(2, 1)); - - $timesheet = $st->getUserTimesheet(1); - $this->assertNotEmpty($timesheet); - $this->assertCount(2, $timesheet); - $this->assertEquals(3600, $timesheet[0]['end'] - $timesheet[0]['start'], 'Wrong timestamps', 1); - $this->assertEquals(3600, $timesheet[1]['end'] - $timesheet[1]['start'], 'Wrong timestamps', 1); - - $time = $st->calculateSubtaskTime(1); - $this->assertEquals(4.2, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); - - $time = $st->calculateSubtaskTime(2); - $this->assertEquals(0, $time['time_spent'], 'Total spent', 0.01); - $this->assertEquals(0, $time['time_estimated'], 'Total estimated', 0.01); - } - - public function testUpdateTaskTimeTracking() - { - $tf = new TaskFinderModel($this->container); - $tc = new TaskCreationModel($this->container); - $s = new SubtaskModel($this->container); - $st = new SubtaskTimeTrackingModel($this->container); - $p = new ProjectModel($this->container); - - $this->assertEquals(1, $p->create(array('name' => 'test1'))); - - $this->assertEquals(1, $tc->create(array('title' => 'test 1', 'project_id' => 1))); - $this->assertEquals(2, $tc->create(array('title' => 'test 2', 'project_id' => 1, 'time_estimated' => 1.5, 'time_spent' => 0.5))); - $this->assertEquals(3, $tc->create(array('title' => 'test 3', 'project_id' => 1, 'time_estimated' => 4, 'time_spent' => 2))); - - $this->assertEquals(1, $s->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_spent' => 2.2))); - $this->assertEquals(2, $s->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 1))); - - $this->assertEquals(3, $s->create(array('title' => 'subtask #3', 'task_id' => 2, 'time_spent' => 3.4))); - $this->assertEquals(4, $s->create(array('title' => 'subtask #4', 'task_id' => 2, 'time_estimated' => 1.25))); - - $this->assertEquals(5, $s->create(array('title' => 'subtask #5', 'task_id' => 3, 'time_spent' => 8))); - - $st->updateTaskTimeTracking(1); - $st->updateTaskTimeTracking(2); - $st->updateTaskTimeTracking(3); - - $task = $tf->getById(1); - $this->assertNotEmpty($task); - $this->assertEquals(2.2, $task['time_spent'], 'Total spent', 0.01); - $this->assertEquals(1, $task['time_estimated'], 'Total estimated', 0.01); - - $task = $tf->getById(2); - $this->assertNotEmpty($task); - $this->assertEquals(3.4, $task['time_spent'], 'Total spent', 0.01); - $this->assertEquals(1.25, $task['time_estimated'], 'Total estimated', 0.01); - - $task = $tf->getById(3); - $this->assertNotEmpty($task); - $this->assertEquals(0, $task['time_estimated']); - $this->assertEquals(8, $task['time_spent']); - - $this->assertTrue($s->remove(3)); - $this->assertTrue($s->remove(4)); - - $st->updateTaskTimeTracking(2); - - $task = $tf->getById(2); - $this->assertNotEmpty($task); - $this->assertEquals(0, $task['time_estimated']); - $this->assertEquals(0, $task['time_spent']); - } -} -- cgit v1.2.3 From 5884c65a02a13dd396525d0b8d1720d1c062a96e Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 22:50:20 -0400 Subject: Remove SubtaskTimeTrackingSubscriber --- app/Model/SubtaskModel.php | 15 ++++--- app/Model/SubtaskStatusModel.php | 3 ++ app/Model/SubtaskTimeTrackingModel.php | 23 ++++++++++- app/ServiceProvider/EventDispatcherProvider.php | 2 - app/Subscriber/SubtaskTimeTrackingSubscriber.php | 48 ---------------------- tests/units/Job/SubtaskEventJobTest.php | 2 +- tests/units/Model/SubtaskModelTest.php | 42 ++++++++++++++++++- tests/units/Model/SubtaskTimeTrackingModelTest.php | 37 +++++++++++++++++ 8 files changed, 114 insertions(+), 58 deletions(-) delete mode 100644 app/Subscriber/SubtaskTimeTrackingSubscriber.php diff --git a/app/Model/SubtaskModel.php b/app/Model/SubtaskModel.php index 568e27a4..608ffce7 100644 --- a/app/Model/SubtaskModel.php +++ b/app/Model/SubtaskModel.php @@ -173,6 +173,7 @@ class SubtaskModel extends Base $subtask_id = $this->db->table(self::TABLE)->persist($values); if ($subtask_id !== false) { + $this->subtaskTimeTrackingModel->updateTaskTimeTracking($values['task_id']); $this->queueManager->push($this->subtaskEventJob->withParams($subtask_id, self::EVENT_CREATE)); } @@ -183,17 +184,21 @@ class SubtaskModel extends Base * Update * * @access public - * @param array $values Form values - * @param bool $fire_events If true, will be called an event + * @param array $values + * @param bool $fire_event * @return bool */ - public function update(array $values, $fire_events = true) + public function update(array $values, $fire_event = true) { $this->prepare($values); $result = $this->db->table(self::TABLE)->eq('id', $values['id'])->save($values); - if ($result && $fire_events) { - $this->queueManager->push($this->subtaskEventJob->withParams($values['id'], self::EVENT_UPDATE, $values)); + if ($result) { + $this->subtaskTimeTrackingModel->updateTaskTimeTracking($values['task_id']); + + if ($fire_event) { + $this->queueManager->push($this->subtaskEventJob->withParams($values['id'], self::EVENT_UPDATE, $values)); + } } return $result; diff --git a/app/Model/SubtaskStatusModel.php b/app/Model/SubtaskStatusModel.php index 26cbb67d..c99d6055 100644 --- a/app/Model/SubtaskStatusModel.php +++ b/app/Model/SubtaskStatusModel.php @@ -63,8 +63,11 @@ class SubtaskStatusModel extends Base if (empty($subtask['user_id']) && $this->userSession->isLogged()) { $values['user_id'] = $this->userSession->getId(); + $subtask['user_id'] = $values['user_id']; } + $this->subtaskTimeTrackingModel->toggleTimer($subtask_id, $subtask['user_id'], $status); + return $this->subtaskModel->update($values) ? $status : false; } diff --git a/app/Model/SubtaskTimeTrackingModel.php b/app/Model/SubtaskTimeTrackingModel.php index 062e594a..3b1b97e4 100644 --- a/app/Model/SubtaskTimeTrackingModel.php +++ b/app/Model/SubtaskTimeTrackingModel.php @@ -159,6 +159,28 @@ class SubtaskTimeTrackingModel extends Base return $this->db->table(self::TABLE)->eq('subtask_id', $subtask_id)->eq('user_id', $user_id)->eq('end', 0)->exists(); } + /** + * Start or stop timer according to subtask status + * + * @access public + * @param integer $subtask_id + * @param integer $user_id + * @param integer $status + * @return boolean + */ + public function toggleTimer($subtask_id, $user_id, $status) + { + if ($this->configModel->get('subtask_time_tracking') == 1) { + if ($status == SubtaskModel::STATUS_INPROGRESS) { + return $this->subtaskTimeTrackingModel->logStartTime($subtask_id, $user_id); + } elseif ($status == SubtaskModel::STATUS_DONE) { + return $this->subtaskTimeTrackingModel->logEndTime($subtask_id, $user_id); + } + } + + return false; + } + /** * Log start time * @@ -252,7 +274,6 @@ class SubtaskTimeTrackingModel extends Base { $subtask = $this->subtaskModel->getById($subtask_id); - // Fire the event subtask.update return $this->subtaskModel->update(array( 'id' => $subtask['id'], 'time_spent' => $subtask['time_spent'] + $time_spent, diff --git a/app/ServiceProvider/EventDispatcherProvider.php b/app/ServiceProvider/EventDispatcherProvider.php index 57543fe4..ebf42cbf 100644 --- a/app/ServiceProvider/EventDispatcherProvider.php +++ b/app/ServiceProvider/EventDispatcherProvider.php @@ -11,7 +11,6 @@ use Kanboard\Subscriber\BootstrapSubscriber; use Kanboard\Subscriber\NotificationSubscriber; use Kanboard\Subscriber\ProjectDailySummarySubscriber; use Kanboard\Subscriber\ProjectModificationDateSubscriber; -use Kanboard\Subscriber\SubtaskTimeTrackingSubscriber; use Kanboard\Subscriber\TransitionSubscriber; use Kanboard\Subscriber\RecurringTaskSubscriber; @@ -31,7 +30,6 @@ class EventDispatcherProvider implements ServiceProviderInterface $container['dispatcher']->addSubscriber(new ProjectDailySummarySubscriber($container)); $container['dispatcher']->addSubscriber(new ProjectModificationDateSubscriber($container)); $container['dispatcher']->addSubscriber(new NotificationSubscriber($container)); - $container['dispatcher']->addSubscriber(new SubtaskTimeTrackingSubscriber($container)); $container['dispatcher']->addSubscriber(new TransitionSubscriber($container)); $container['dispatcher']->addSubscriber(new RecurringTaskSubscriber($container)); diff --git a/app/Subscriber/SubtaskTimeTrackingSubscriber.php b/app/Subscriber/SubtaskTimeTrackingSubscriber.php deleted file mode 100644 index 7e39c126..00000000 --- a/app/Subscriber/SubtaskTimeTrackingSubscriber.php +++ /dev/null @@ -1,48 +0,0 @@ - 'updateTaskTime', - SubtaskModel::EVENT_DELETE => 'updateTaskTime', - SubtaskModel::EVENT_UPDATE => array( - array('logStartEnd', 10), - array('updateTaskTime', 0), - ) - ); - } - - public function updateTaskTime(SubtaskEvent $event) - { - if (isset($event['task_id'])) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $this->subtaskTimeTrackingModel->updateTaskTimeTracking($event['task_id']); - } - } - - public function logStartEnd(SubtaskEvent $event) - { - if (isset($event['status']) && $this->configModel->get('subtask_time_tracking') == 1) { - $this->logger->debug('Subscriber executed: '.__METHOD__); - $subtask = $this->subtaskModel->getById($event['id']); - - if (empty($subtask['user_id'])) { - return false; - } - - if ($subtask['status'] == SubtaskModel::STATUS_INPROGRESS) { - return $this->subtaskTimeTrackingModel->logStartTime($subtask['id'], $subtask['user_id']); - } else { - return $this->subtaskTimeTrackingModel->logEndTime($subtask['id'], $subtask['user_id']); - } - } - } -} diff --git a/tests/units/Job/SubtaskEventJobTest.php b/tests/units/Job/SubtaskEventJobTest.php index 66c3db05..bdc30b51 100644 --- a/tests/units/Job/SubtaskEventJobTest.php +++ b/tests/units/Job/SubtaskEventJobTest.php @@ -41,7 +41,7 @@ class SubtaskEventJobTest extends Base $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'before'))); - $this->assertTrue($subtaskModel->update(array('id' => 1, 'title' => 'after'))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'title' => 'after'))); $this->assertTrue($subtaskModel->remove(1)); $called = $this->container['dispatcher']->getCalledListeners(); diff --git a/tests/units/Model/SubtaskModelTest.php b/tests/units/Model/SubtaskModelTest.php index 23183d22..eed37cf3 100644 --- a/tests/units/Model/SubtaskModelTest.php +++ b/tests/units/Model/SubtaskModelTest.php @@ -5,6 +5,7 @@ require_once __DIR__.'/../Base.php'; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; use Kanboard\Model\ProjectModel; +use Kanboard\Model\TaskFinderModel; class SubtaskModelTest extends Base { @@ -30,6 +31,24 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $subtask['position']); } + public function testCreationUpdateTaskTimeTracking() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'time_estimated' => 5, 'time_spent' => 5))); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(7, $task['time_estimated']); + $this->assertEquals(6, $task['time_spent']); + } + public function testModification() { $taskCreationModel = new TaskCreationModel($this->container); @@ -40,7 +59,7 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); - $this->assertTrue($subtaskModel->update(array('id' => 1, 'user_id' => 1, 'status' => SubtaskModel::STATUS_INPROGRESS))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'user_id' => 1, 'status' => SubtaskModel::STATUS_INPROGRESS))); $subtask = $subtaskModel->getById(1); $this->assertNotEmpty($subtask); @@ -54,6 +73,27 @@ class SubtaskModelTest extends Base $this->assertEquals(1, $subtask['position']); } + public function testModificationUpdateTaskTimeTracking() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $projectModel = new ProjectModel($this->container); + $taskFinderModel = new TaskFinderModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1))); + + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #1', 'task_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 2, 'task_id' => 1, 'time_estimated' => 2, 'time_spent' => 1))); + $this->assertTrue($subtaskModel->update(array('id' => 1, 'task_id' => 1, 'time_estimated' => 5, 'time_spent' => 5))); + + $task = $taskFinderModel->getById(1); + $this->assertEquals(7, $task['time_estimated']); + $this->assertEquals(6, $task['time_spent']); + } + public function testRemove() { $taskCreationModel = new TaskCreationModel($this->container); diff --git a/tests/units/Model/SubtaskTimeTrackingModelTest.php b/tests/units/Model/SubtaskTimeTrackingModelTest.php index cfee5b14..8b0fe698 100644 --- a/tests/units/Model/SubtaskTimeTrackingModelTest.php +++ b/tests/units/Model/SubtaskTimeTrackingModelTest.php @@ -2,6 +2,7 @@ require_once __DIR__.'/../Base.php'; +use Kanboard\Model\ConfigModel; use Kanboard\Model\TaskFinderModel; use Kanboard\Model\TaskCreationModel; use Kanboard\Model\SubtaskModel; @@ -10,6 +11,42 @@ use Kanboard\Model\ProjectModel; class SubtaskTimeTrackingModelTest extends Base { + public function testToggleTimer() + { + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_TODO)); + $this->assertTrue($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_INPROGRESS)); + $this->assertTrue($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_DONE)); + } + + public function testToggleTimerWhenFeatureDisabled() + { + $configModel = new ConfigModel($this->container); + $configModel->save(array('subtask_time_tracking' => '0')); + $this->container['memoryCache']->flush(); + + $taskCreationModel = new TaskCreationModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskTimeTrackingModel = new SubtaskTimeTrackingModel($this->container); + $projectModel = new ProjectModel($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'test1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'test 1', 'project_id' => 1, 'column_id' => 1, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('title' => 'subtask #2', 'task_id' => 1, 'user_id' => 1))); + + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_TODO)); + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_INPROGRESS)); + $this->assertFalse($subtaskTimeTrackingModel->toggleTimer(1, 1, SubtaskModel::STATUS_DONE)); + } + public function testHasTimer() { $taskCreationModel = new TaskCreationModel($this->container); -- cgit v1.2.3 From df57b0f2c8b73959b6bcf237027d1c44670f961e Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sat, 23 Jul 2016 23:06:51 -0400 Subject: Simplify mail subject for notifications --- app/Notification/MailNotification.php | 80 +++-------------------- tests/units/Notification/MailNotificationTest.php | 4 +- 2 files changed, 10 insertions(+), 74 deletions(-) diff --git a/app/Notification/MailNotification.php b/app/Notification/MailNotification.php index 2d27179c..a5f51b89 100644 --- a/app/Notification/MailNotification.php +++ b/app/Notification/MailNotification.php @@ -4,10 +4,6 @@ namespace Kanboard\Notification; use Kanboard\Core\Base; use Kanboard\Core\Notification\NotificationInterface; -use Kanboard\Model\TaskModel; -use Kanboard\Model\TaskFileModel; -use Kanboard\Model\CommentModel; -use Kanboard\Model\SubtaskModel; /** * Email Notification @@ -76,76 +72,16 @@ class MailNotification extends Base implements NotificationInterface * Get the mail subject for a given template name * * @access public - * @param string $event_name Event name - * @param array $event_data Event data - * @return string - */ - public function getMailSubject($event_name, array $event_data) - { - switch ($event_name) { - case TaskFileModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New attachment'), $event_data); - break; - case CommentModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New comment'), $event_data); - break; - case CommentModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Comment updated'), $event_data); - break; - case SubtaskModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New subtask'), $event_data); - break; - case SubtaskModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Subtask updated'), $event_data); - break; - case TaskModel::EVENT_CREATE: - $subject = $this->getStandardMailSubject(e('New task'), $event_data); - break; - case TaskModel::EVENT_UPDATE: - $subject = $this->getStandardMailSubject(e('Task updated'), $event_data); - break; - case TaskModel::EVENT_CLOSE: - $subject = $this->getStandardMailSubject(e('Task closed'), $event_data); - break; - case TaskModel::EVENT_OPEN: - $subject = $this->getStandardMailSubject(e('Task opened'), $event_data); - break; - case TaskModel::EVENT_MOVE_COLUMN: - $subject = $this->getStandardMailSubject(e('Column change'), $event_data); - break; - case TaskModel::EVENT_MOVE_POSITION: - $subject = $this->getStandardMailSubject(e('Position change'), $event_data); - break; - case TaskModel::EVENT_MOVE_SWIMLANE: - $subject = $this->getStandardMailSubject(e('Swimlane change'), $event_data); - break; - case TaskModel::EVENT_ASSIGNEE_CHANGE: - $subject = $this->getStandardMailSubject(e('Assignee change'), $event_data); - break; - case TaskModel::EVENT_USER_MENTION: - case CommentModel::EVENT_USER_MENTION: - $subject = $this->getStandardMailSubject(e('Mentioned'), $event_data); - break; - case TaskModel::EVENT_OVERDUE: - $subject = e('[%s] Overdue tasks', $event_data['project_name']); - break; - default: - $subject = e('Notification'); - } - - return $subject; - } - - /** - * Get the mail subject for a given label - * - * @access private - * @param string $label Label - * @param array $data Template data + * @param string $eventName Event name + * @param array $eventData Event data * @return string */ - private function getStandardMailSubject($label, array $data) + public function getMailSubject($eventName, array $eventData) { - return sprintf('[%s][%s] %s (#%d)', $data['task']['project_name'], $label, $data['task']['title'], $data['task']['id']); + return sprintf( + '[%s] %s', + $eventData['task']['project_name'], + $this->notificationModel->getTitleWithoutAuthor($eventName, $eventData) + ); } } diff --git a/tests/units/Notification/MailNotificationTest.php b/tests/units/Notification/MailNotificationTest.php index 6579d9bc..05f1f882 100644 --- a/tests/units/Notification/MailNotificationTest.php +++ b/tests/units/Notification/MailNotificationTest.php @@ -56,7 +56,7 @@ class MailNotificationTest extends Base 'changes' => array() ); $this->assertNotEmpty($mailNotification->getMailContent($eventName, $eventData)); - $this->assertNotEmpty($mailNotification->getMailSubject($eventName, $eventData)); + $this->assertStringStartsWith('[test] ', $mailNotification->getMailSubject($eventName, $eventData)); } } @@ -84,7 +84,7 @@ class MailNotificationTest extends Base ->with( $this->equalTo('test@localhost'), $this->equalTo('admin'), - $this->equalTo('[test][New task] test (#1)'), + $this->equalTo('[test] New task #1: test'), $this->stringContains('test') ); -- cgit v1.2.3 From 506ebf3bac302a63be7c32a03b872a9eefa689fc Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 10:08:57 -0400 Subject: Fixed typo in template that prevent project permissions to be duplicated --- ChangeLog | 1 + app/Template/project_creation/create.php | 2 +- app/Template/project_view/duplicate.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 1bc0eed3..6da73200 100644 --- a/ChangeLog +++ b/ChangeLog @@ -24,6 +24,7 @@ Improvements: Bug fixes: +* Fixed typo in template that prevent project permissions to be duplicated * Fixed search query with multiple assignees (nested OR conditions) * Fixed Markdown editor auto-grow on the task form (Safari) * Fixed compatibility issue with PHP 5.3 for OAuthUserProvider class diff --git a/app/Template/project_creation/create.php b/app/Template/project_creation/create.php index d00883ba..b90b15c4 100644 --- a/app/Template/project_creation/create.php +++ b/app/Template/project_creation/create.php @@ -19,7 +19,7 @@

            - form->checkbox('projectPermission', t('Permissions'), 1, true) ?> + form->checkbox('projectPermissionModel', t('Permissions'), 1, true) ?> form->checkbox('categoryModel', t('Categories'), 1, true) ?> diff --git a/app/Template/project_view/duplicate.php b/app/Template/project_view/duplicate.php index d66ff591..561378d1 100644 --- a/app/Template/project_view/duplicate.php +++ b/app/Template/project_view/duplicate.php @@ -11,7 +11,7 @@ form->csrf() ?> - form->checkbox('projectPermission', t('Permissions'), 1, true) ?> + form->checkbox('projectPermissionModel', t('Permissions'), 1, true) ?> form->checkbox('categoryModel', t('Categories'), 1, true) ?> -- cgit v1.2.3 From 51b2193fc43a25f309a8510b64027d40bf21e12d Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 12:09:41 -0400 Subject: Move dashboard pagination into separate classes --- app/Controller/DashboardController.php | 72 ++------------------ app/Controller/UserListController.php | 7 +- app/Core/Base.php | 4 ++ app/Model/ProjectDuplicationModel.php | 2 +- app/Model/ProjectModel.php | 2 +- app/Model/TaskFinderModel.php | 86 ++++++++++++------------ app/Pagination/ProjectPagination.php | 35 ++++++++++ app/Pagination/SubtaskPagination.php | 36 ++++++++++ app/Pagination/TaskPagination.php | 35 ++++++++++ app/Pagination/UserPagination.php | 32 +++++++++ app/ServiceProvider/ClassProvider.php | 6 ++ app/Template/dashboard/projects.php | 4 +- app/Template/dashboard/subtasks.php | 4 +- app/Template/dashboard/tasks.php | 8 +-- tests/units/Model/TaskFinderModelTest.php | 2 +- tests/units/Pagination/ProjectPaginationTest.php | 35 ++++++++++ tests/units/Pagination/SubtaskPaginationTest.php | 36 ++++++++++ tests/units/Pagination/TaskPaginationTest.php | 30 +++++++++ tests/units/Pagination/UserPaginationTest.php | 27 ++++++++ 19 files changed, 337 insertions(+), 126 deletions(-) create mode 100644 app/Pagination/ProjectPagination.php create mode 100644 app/Pagination/SubtaskPagination.php create mode 100644 app/Pagination/TaskPagination.php create mode 100644 app/Pagination/UserPagination.php create mode 100644 tests/units/Pagination/ProjectPaginationTest.php create mode 100644 tests/units/Pagination/SubtaskPaginationTest.php create mode 100644 tests/units/Pagination/TaskPaginationTest.php create mode 100644 tests/units/Pagination/UserPaginationTest.php diff --git a/app/Controller/DashboardController.php b/app/Controller/DashboardController.php index 44874546..0133499f 100644 --- a/app/Controller/DashboardController.php +++ b/app/Controller/DashboardController.php @@ -2,9 +2,6 @@ namespace Kanboard\Controller; -use Kanboard\Model\ProjectModel; -use Kanboard\Model\SubtaskModel; - /** * Dashboard Controller * @@ -13,63 +10,6 @@ use Kanboard\Model\SubtaskModel; */ class DashboardController extends BaseController { - /** - * Get project pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getProjectPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'projects', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder(ProjectModel::TABLE.'.name') - ->setQuery($this->projectModel->getQueryColumnStats($this->projectPermissionModel->getActiveProjectIds($user_id))) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects'); - } - - /** - * Get task pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getTaskPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'tasks', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder('tasks.id') - ->setQuery($this->taskFinderModel->getUserQuery($user_id)) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'tasks'); - } - - /** - * Get subtask pagination - * - * @access private - * @param integer $user_id - * @param string $action - * @param integer $max - * @return \Kanboard\Core\Paginator - */ - private function getSubtaskPaginator($user_id, $action, $max) - { - return $this->paginator - ->setUrl('DashboardController', $action, array('pagination' => 'subtasks', 'user_id' => $user_id)) - ->setMax($max) - ->setOrder('tasks.id') - ->setQuery($this->subtaskModel->getUserQuery($user_id, array(SubTaskModel::STATUS_TODO, SubtaskModel::STATUS_INPROGRESS))) - ->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks'); - } - /** * Dashboard overview * @@ -81,9 +21,9 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/show', array( 'title' => t('Dashboard'), - 'project_paginator' => $this->getProjectPaginator($user['id'], 'show', 10), - 'task_paginator' => $this->getTaskPaginator($user['id'], 'show', 10), - 'subtask_paginator' => $this->getSubtaskPaginator($user['id'], 'show', 10), + 'project_paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'show', 10), + 'task_paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'show', 10), + 'subtask_paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'show', 10), 'user' => $user, ))); } @@ -99,7 +39,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/tasks', array( 'title' => t('My tasks'), - 'paginator' => $this->getTaskPaginator($user['id'], 'tasks', 50), + 'paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'tasks', 50), 'user' => $user, ))); } @@ -115,7 +55,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/subtasks', array( 'title' => t('My subtasks'), - 'paginator' => $this->getSubtaskPaginator($user['id'], 'subtasks', 50), + 'paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'subtasks', 50), 'user' => $user, ))); } @@ -131,7 +71,7 @@ class DashboardController extends BaseController $this->response->html($this->helper->layout->dashboard('dashboard/projects', array( 'title' => t('My projects'), - 'paginator' => $this->getProjectPaginator($user['id'], 'projects', 25), + 'paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'projects', 25), 'user' => $user, ))); } diff --git a/app/Controller/UserListController.php b/app/Controller/UserListController.php index 31fcdd44..888583fa 100644 --- a/app/Controller/UserListController.php +++ b/app/Controller/UserListController.php @@ -17,12 +17,7 @@ class UserListController extends BaseController */ public function show() { - $paginator = $this->paginator - ->setUrl('UserListController', 'show') - ->setMax(30) - ->setOrder('username') - ->setQuery($this->userModel->getQuery()) - ->calculate(); + $paginator = $this->userPagination->getListingPaginator(); $this->response->html($this->helper->layout->app('user_list/show', array( 'title' => t('Users').' ('.$paginator->getTotal().')', diff --git a/app/Core/Base.php b/app/Core/Base.php index 563013bd..68604785 100644 --- a/app/Core/Base.php +++ b/app/Core/Base.php @@ -122,6 +122,10 @@ use Pimple\Container; * @property \Kanboard\Model\UserNotificationFilterModel $userNotificationFilterModel * @property \Kanboard\Model\UserUnreadNotificationModel $userUnreadNotificationModel * @property \Kanboard\Model\UserMetadataModel $userMetadataModel + * @property \Kanboard\Pagination\TaskPagination $taskPagination + * @property \Kanboard\Pagination\SubtaskPagination $subtaskPagination + * @property \Kanboard\Pagination\ProjectPagination $projectPagination + * @property \Kanboard\Pagination\UserPagination $userPagination * @property \Kanboard\Validator\ActionValidator $actionValidator * @property \Kanboard\Validator\AuthValidator $authValidator * @property \Kanboard\Validator\ColumnValidator $columnValidator diff --git a/app/Model/ProjectDuplicationModel.php b/app/Model/ProjectDuplicationModel.php index 94b83c80..d32fa367 100644 --- a/app/Model/ProjectDuplicationModel.php +++ b/app/Model/ProjectDuplicationModel.php @@ -159,7 +159,7 @@ class ProjectDuplicationModel extends Base } /** - * Make sure that the creator of the duplicated project is alsp owner + * Make sure that the creator of the duplicated project is also owner * * @access private * @param integer $dst_project_id diff --git a/app/Model/ProjectModel.php b/app/Model/ProjectModel.php index 850531c9..d2019b72 100644 --- a/app/Model/ProjectModel.php +++ b/app/Model/ProjectModel.php @@ -318,7 +318,7 @@ class ProjectModel extends Base public function getQueryColumnStats(array $project_ids) { if (empty($project_ids)) { - return $this->db->table(ProjectModel::TABLE)->limit(0); + return $this->db->table(ProjectModel::TABLE)->eq(ProjectModel::TABLE.'.id', 0); } return $this->db diff --git a/app/Model/TaskFinderModel.php b/app/Model/TaskFinderModel.php index 7268052c..924f339b 100644 --- a/app/Model/TaskFinderModel.php +++ b/app/Model/TaskFinderModel.php @@ -63,19 +63,19 @@ class TaskFinderModel extends Base return $this->db ->table(TaskModel::TABLE) ->columns( - 'tasks.id', - 'tasks.title', - 'tasks.date_due', - 'tasks.date_creation', - 'tasks.project_id', - 'tasks.color_id', - 'tasks.priority', - 'tasks.time_spent', - 'tasks.time_estimated', - 'tasks.is_active', - 'tasks.creator_id', - 'projects.name AS project_name', - 'columns.title AS column_title' + TaskModel::TABLE.'.id', + TaskModel::TABLE.'.title', + TaskModel::TABLE.'.date_due', + TaskModel::TABLE.'.date_creation', + TaskModel::TABLE.'.project_id', + TaskModel::TABLE.'.color_id', + TaskModel::TABLE.'.priority', + TaskModel::TABLE.'.time_spent', + TaskModel::TABLE.'.time_estimated', + TaskModel::TABLE.'.is_active', + TaskModel::TABLE.'.creator_id', + ProjectModel::TABLE.'.name AS project_name', + ColumnModel::TABLE.'.title AS column_title' ) ->join(ProjectModel::TABLE, 'id', 'project_id') ->join(ColumnModel::TABLE, 'id', 'column_id') @@ -103,36 +103,36 @@ class TaskFinderModel extends Base '(SELECT COUNT(*) FROM '.TaskLinkModel::TABLE.' WHERE '.TaskLinkModel::TABLE.'.task_id = tasks.id) AS nb_links', '(SELECT COUNT(*) FROM '.TaskExternalLinkModel::TABLE.' WHERE '.TaskExternalLinkModel::TABLE.'.task_id = tasks.id) AS nb_external_links', '(SELECT DISTINCT 1 FROM '.TaskLinkModel::TABLE.' WHERE '.TaskLinkModel::TABLE.'.task_id = tasks.id AND '.TaskLinkModel::TABLE.'.link_id = 9) AS is_milestone', - 'tasks.id', - 'tasks.reference', - 'tasks.title', - 'tasks.description', - 'tasks.date_creation', - 'tasks.date_modification', - 'tasks.date_completed', - 'tasks.date_started', - 'tasks.date_due', - 'tasks.color_id', - 'tasks.project_id', - 'tasks.column_id', - 'tasks.swimlane_id', - 'tasks.owner_id', - 'tasks.creator_id', - 'tasks.position', - 'tasks.is_active', - 'tasks.score', - 'tasks.category_id', - 'tasks.priority', - 'tasks.date_moved', - 'tasks.recurrence_status', - 'tasks.recurrence_trigger', - 'tasks.recurrence_factor', - 'tasks.recurrence_timeframe', - 'tasks.recurrence_basedate', - 'tasks.recurrence_parent', - 'tasks.recurrence_child', - 'tasks.time_estimated', - 'tasks.time_spent', + TaskModel::TABLE.'.id', + TaskModel::TABLE.'.reference', + TaskModel::TABLE.'.title', + TaskModel::TABLE.'.description', + TaskModel::TABLE.'.date_creation', + TaskModel::TABLE.'.date_modification', + TaskModel::TABLE.'.date_completed', + TaskModel::TABLE.'.date_started', + TaskModel::TABLE.'.date_due', + TaskModel::TABLE.'.color_id', + TaskModel::TABLE.'.project_id', + TaskModel::TABLE.'.column_id', + TaskModel::TABLE.'.swimlane_id', + TaskModel::TABLE.'.owner_id', + TaskModel::TABLE.'.creator_id', + TaskModel::TABLE.'.position', + TaskModel::TABLE.'.is_active', + TaskModel::TABLE.'.score', + TaskModel::TABLE.'.category_id', + TaskModel::TABLE.'.priority', + TaskModel::TABLE.'.date_moved', + TaskModel::TABLE.'.recurrence_status', + TaskModel::TABLE.'.recurrence_trigger', + TaskModel::TABLE.'.recurrence_factor', + TaskModel::TABLE.'.recurrence_timeframe', + TaskModel::TABLE.'.recurrence_basedate', + TaskModel::TABLE.'.recurrence_parent', + TaskModel::TABLE.'.recurrence_child', + TaskModel::TABLE.'.time_estimated', + TaskModel::TABLE.'.time_spent', UserModel::TABLE.'.username AS assignee_username', UserModel::TABLE.'.name AS assignee_name', UserModel::TABLE.'.email AS assignee_email', diff --git a/app/Pagination/ProjectPagination.php b/app/Pagination/ProjectPagination.php new file mode 100644 index 00000000..8f1fa87c --- /dev/null +++ b/app/Pagination/ProjectPagination.php @@ -0,0 +1,35 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'projects', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(ProjectModel::TABLE.'.name') + ->setQuery($this->projectModel->getQueryColumnStats($this->projectPermissionModel->getActiveProjectIds($user_id))) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'projects'); + } +} diff --git a/app/Pagination/SubtaskPagination.php b/app/Pagination/SubtaskPagination.php new file mode 100644 index 00000000..f0cd6148 --- /dev/null +++ b/app/Pagination/SubtaskPagination.php @@ -0,0 +1,36 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'subtasks', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(TaskModel::TABLE.'.id') + ->setQuery($this->subtaskModel->getUserQuery($user_id, array(SubtaskModel::STATUS_TODO, SubtaskModel::STATUS_INPROGRESS))) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'subtasks'); + } +} diff --git a/app/Pagination/TaskPagination.php b/app/Pagination/TaskPagination.php new file mode 100644 index 00000000..a395ab84 --- /dev/null +++ b/app/Pagination/TaskPagination.php @@ -0,0 +1,35 @@ +paginator + ->setUrl('DashboardController', $method, array('pagination' => 'tasks', 'user_id' => $user_id)) + ->setMax($max) + ->setOrder(TaskModel::TABLE.'.id') + ->setQuery($this->taskFinderModel->getUserQuery($user_id)) + ->calculateOnlyIf($this->request->getStringParam('pagination') === 'tasks'); + } +} diff --git a/app/Pagination/UserPagination.php b/app/Pagination/UserPagination.php new file mode 100644 index 00000000..430b7d2f --- /dev/null +++ b/app/Pagination/UserPagination.php @@ -0,0 +1,32 @@ +paginator + ->setUrl('UserListController', 'show') + ->setMax(30) + ->setOrder(UserModel::TABLE.'.username') + ->setQuery($this->userModel->getQuery()) + ->calculate(); + } +} diff --git a/app/ServiceProvider/ClassProvider.php b/app/ServiceProvider/ClassProvider.php index 9a71148b..aab41c74 100644 --- a/app/ServiceProvider/ClassProvider.php +++ b/app/ServiceProvider/ClassProvider.php @@ -122,6 +122,12 @@ class ClassProvider implements ServiceProviderInterface 'TaskExport', 'TransitionExport', ), + 'Pagination' => array( + 'TaskPagination', + 'SubtaskPagination', + 'ProjectPagination', + 'UserPagination', + ), 'Core' => array( 'DateParser', 'Lexer', diff --git a/app/Template/dashboard/projects.php b/app/Template/dashboard/projects.php index 962e4d83..3a7f1d86 100644 --- a/app/Template/dashboard/projects.php +++ b/app/Template/dashboard/projects.php @@ -6,8 +6,8 @@ - - + + diff --git a/app/Template/dashboard/subtasks.php b/app/Template/dashboard/subtasks.php index 8e0aa3ce..ca550e4c 100644 --- a/app/Template/dashboard/subtasks.php +++ b/app/Template/dashboard/subtasks.php @@ -6,10 +6,10 @@
            order('Id', 'id') ?>order('', 'is_private') ?>order('Id', \Kanboard\Model\ProjectModel::TABLE.'.id') ?>order('', \Kanboard\Model\ProjectModel::TABLE.'.is_private') ?> order(t('Project'), \Kanboard\Model\ProjectModel::TABLE.'.name') ?>
            - + - + getCollection() as $subtask): ?> diff --git a/app/Template/dashboard/tasks.php b/app/Template/dashboard/tasks.php index b3257c33..d9cb4f9e 100644 --- a/app/Template/dashboard/tasks.php +++ b/app/Template/dashboard/tasks.php @@ -6,12 +6,12 @@
            order('Id', 'tasks.id') ?>order('Id', \Kanboard\Model\TaskModel::TABLE.'.id') ?> order(t('Project'), 'project_name') ?> order(t('Task'), 'task_name') ?>order(t('Subtask'), 'title') ?>order(t('Subtask'), \Kanboard\Model\SubtaskModel::TABLE.'.title') ?>
            - + - - + + - + getCollection() as $task): ?> diff --git a/tests/units/Model/TaskFinderModelTest.php b/tests/units/Model/TaskFinderModelTest.php index 72da3b6d..b2e2bd84 100644 --- a/tests/units/Model/TaskFinderModelTest.php +++ b/tests/units/Model/TaskFinderModelTest.php @@ -9,7 +9,7 @@ use Kanboard\Model\ProjectModel; class TaskFinderModelTest extends Base { - public function testGetTasksForDashboard() + public function testGetTasksForDashboardWithHiddenColumn() { $taskCreationModel = new TaskCreationModel($this->container); $taskFinderModel = new TaskFinderModel($this->container); diff --git a/tests/units/Pagination/ProjectPaginationTest.php b/tests/units/Pagination/ProjectPaginationTest.php new file mode 100644 index 00000000..35532d0d --- /dev/null +++ b/tests/units/Pagination/ProjectPaginationTest.php @@ -0,0 +1,35 @@ +container); + $projectUserRoleModel = new ProjectUserRoleModel($this->container); + $userModel = new UserModel($this->container); + $projectPagination = new ProjectPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(2, $projectModel->create(array('name' => 'Project #2', 'is_private' => 1))); + $this->assertEquals(3, $projectModel->create(array('name' => 'Project #3'))); + $this->assertEquals(4, $projectModel->create(array('name' => 'Project #4', 'is_private' => 1))); + + $this->assertEquals(2, $userModel->create(array('username' => 'test'))); + $this->assertTrue($projectUserRoleModel->addUser(1, 2, Role::PROJECT_MANAGER)); + $this->assertTrue($projectUserRoleModel->addUser(2, 2, Role::PROJECT_MANAGER)); + + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->getCollection()); + $this->assertCount(0, $projectPagination->getDashboardPaginator(3, 'projects', 5)->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.id')->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.is_private')->getCollection()); + $this->assertCount(2, $projectPagination->getDashboardPaginator(2, 'projects', 5)->setOrder(ProjectModel::TABLE.'.name')->getCollection()); + } +} diff --git a/tests/units/Pagination/SubtaskPaginationTest.php b/tests/units/Pagination/SubtaskPaginationTest.php new file mode 100644 index 00000000..26a51a8b --- /dev/null +++ b/tests/units/Pagination/SubtaskPaginationTest.php @@ -0,0 +1,36 @@ +container); + $projectModel = new ProjectModel($this->container); + $subtaskModel = new SubtaskModel($this->container); + $subtaskPagination = new SubtaskPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1))); + $this->assertEquals(1, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(2, $subtaskModel->create(array('task_id' => 2, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(3, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1', 'user_id' => 1))); + $this->assertEquals(4, $subtaskModel->create(array('task_id' => 2, 'title' => 'subtask #1'))); + $this->assertEquals(5, $subtaskModel->create(array('task_id' => 1, 'title' => 'subtask #1'))); + + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->getCollection()); + $this->assertCount(0, $subtaskPagination->getDashboardPaginator(2, 'subtasks', 5)->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder(TaskModel::TABLE.'.id')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder('project_name')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder('task_name')->getCollection()); + $this->assertCount(3, $subtaskPagination->getDashboardPaginator(1, 'subtasks', 5)->setOrder(SubtaskModel::TABLE.'.title')->getCollection()); + } +} diff --git a/tests/units/Pagination/TaskPaginationTest.php b/tests/units/Pagination/TaskPaginationTest.php new file mode 100644 index 00000000..027212e2 --- /dev/null +++ b/tests/units/Pagination/TaskPaginationTest.php @@ -0,0 +1,30 @@ +container); + $projectModel = new ProjectModel($this->container); + $taskPagination = new TaskPagination($this->container); + + $this->assertEquals(1, $projectModel->create(array('name' => 'Project #1'))); + $this->assertEquals(1, $taskCreationModel->create(array('title' => 'Task #1', 'project_id' => 1))); + $this->assertEquals(2, $taskCreationModel->create(array('title' => 'Task #2', 'project_id' => 1, 'column_id' => 2, 'owner_id' => 1))); + + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->getCollection()); + $this->assertCount(0, $taskPagination->getDashboardPaginator(2, 'tasks', 5)->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.id')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder('project_name')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.title')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.priority')->getCollection()); + $this->assertCount(1, $taskPagination->getDashboardPaginator(1, 'tasks', 5)->setOrder(TaskModel::TABLE.'.date_due')->getCollection()); + } +} diff --git a/tests/units/Pagination/UserPaginationTest.php b/tests/units/Pagination/UserPaginationTest.php new file mode 100644 index 00000000..c475aacd --- /dev/null +++ b/tests/units/Pagination/UserPaginationTest.php @@ -0,0 +1,27 @@ +container); + $userPagination = new UserPagination($this->container); + + $this->assertEquals(2, $userModel->create(array('username' => 'test1'))); + $this->assertEquals(3, $userModel->create(array('username' => 'test2'))); + + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('id')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('username')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('name')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('email')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('role')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('twofactor_activated')->setDirection('DESC')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('is_ldap_user')->getCollection()); + $this->assertCount(3, $userPagination->getListingPaginator()->setOrder('is_active')->getCollection()); + } +} -- cgit v1.2.3 From a6d22bf2715347d4f340376efee75dc57176c8b6 Mon Sep 17 00:00:00 2001 From: Frederic Guillot Date: Sun, 24 Jul 2016 13:00:59 -0400 Subject: Remove username for dashboard sidebar and change titles --- app/Controller/DashboardController.php | 18 ++++++++++-------- app/Template/dashboard/sidebar.php | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/Controller/DashboardController.php b/app/Controller/DashboardController.php index 0133499f..f32f8552 100644 --- a/app/Controller/DashboardController.php +++ b/app/Controller/DashboardController.php @@ -20,7 +20,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/show', array( - 'title' => t('Dashboard'), + 'title' => t('Dashboard for %s', $this->helper->user->getFullname($user)), 'project_paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'show', 10), 'task_paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'show', 10), 'subtask_paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'show', 10), @@ -38,7 +38,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/tasks', array( - 'title' => t('My tasks'), + 'title' => t('Tasks overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->taskPagination->getDashboardPaginator($user['id'], 'tasks', 50), 'user' => $user, ))); @@ -54,7 +54,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/subtasks', array( - 'title' => t('My subtasks'), + 'title' => t('Subtasks overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->subtaskPagination->getDashboardPaginator($user['id'], 'subtasks', 50), 'user' => $user, ))); @@ -70,7 +70,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/projects', array( - 'title' => t('My projects'), + 'title' => t('Projects overview for %s', $this->helper->user->getFullname($user)), 'paginator' => $this->projectPagination->getDashboardPaginator($user['id'], 'projects', 25), 'user' => $user, ))); @@ -86,7 +86,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/activity', array( - 'title' => t('My activity stream'), + 'title' => t('Activity stream for %s', $this->helper->user->getFullname($user)), 'events' => $this->helper->projectActivity->getProjectsEvents($this->projectPermissionModel->getActiveProjectIds($user['id']), 100), 'user' => $user, ))); @@ -99,9 +99,11 @@ class DashboardController extends BaseController */ public function calendar() { + $user = $this->getUser(); + $this->response->html($this->helper->layout->dashboard('dashboard/calendar', array( - 'title' => t('My calendar'), - 'user' => $this->getUser(), + 'title' => t('Calendar for %s', $this->helper->user->getFullname($user)), + 'user' => $user, ))); } @@ -115,7 +117,7 @@ class DashboardController extends BaseController $user = $this->getUser(); $this->response->html($this->helper->layout->dashboard('dashboard/notifications', array( - 'title' => t('My notifications'), + 'title' => t('Notifications for %s', $this->helper->user->getFullname($user)), 'notifications' => $this->userUnreadNotificationModel->getAll($user['id']), 'user' => $user, ))); diff --git a/app/Template/dashboard/sidebar.php b/app/Template/dashboard/sidebar.php index 86cc20f8..df4e91a5 100644 --- a/app/Template/dashboard/sidebar.php +++ b/app/Template/dashboard/sidebar.php @@ -1,5 +1,4 @@
            order('Id', 'tasks.id') ?>order('Id', \Kanboard\Model\TaskModel::TABLE.'.id') ?> order(t('Project'), 'project_name') ?>order(t('Task'), 'title') ?>order(t('Priority'), 'tasks.priority') ?>order(t('Task'), \Kanboard\Model\TaskModel::TABLE.'.title') ?>order(t('Priority'), \Kanboard\Model\TaskModel::TABLE.'.priority') ?> order(t('Due date'), 'date_due') ?>order(t('Due date'), \Kanboard\Model\TaskModel::TABLE.'.date_due') ?> order(t('Column'), 'column_title') ?>
            + " + >
            diff --git a/app/Template/project_header/dropdown.php b/app/Template/project_header/dropdown.php index 79a1b389..f8901289 100644 --- a/app/Template/project_header/dropdown.php +++ b/app/Template/project_header/dropdown.php @@ -20,14 +20,6 @@ "> -
          • - - - - -
          • user->hasProjectAccess('TaskCreationController', 'show', $project['id'])): ?> diff --git a/assets/css/app.min.css b/assets/css/app.min.css index 2ddd4a8b..daf3fb24 100644 --- a/assets/css/app.min.css +++ b/assets/css/app.min.css @@ -1 +1 @@ -a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file +a:focus,a:hover,th a{text-decoration:none}h3,label{margin-top:10px}.tooltip-arrow.bottom:after,.tooltip-arrow.top{top:-10px}.form-errors,.ui-tooltip li,ul.no-bullet li{list-style-type:none}.table-fixed td,.table-fixed th,.tooltip-arrow,header h1{overflow:hidden}#board td,td{vertical-align:top}.table-fixed td,.task-board-collapsed,div.ganttview-vtheader-series-name,header h1{text-overflow:ellipsis;white-space:nowrap}blockquote,body,li,ol,p,table,td,th,tr,ul{margin:0;padding:0;font-size:100%}form,table{margin-bottom:20px}body{margin-left:10px;margin-right:10px;padding-bottom:10px;color:#333;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;text-rendering:optimizeLegibility}.page{clear:both}ul.no-bullet li{margin-left:0}.pull-right{text-align:right}hr{border:0;height:0;border-top:1px solid rgba(0,0,0,.1);border-bottom:1px solid rgba(255,255,255,.3)}.chosen-select{min-height:27px}#ui-datepicker-div{font-size:.8em}#app-loading-icon{position:fixed;right:3px;bottom:3px}.web-notification-icon{color:#36C}.web-notification-icon:focus,.web-notification-icon:hover{color:#000}a:hover,h1,h2,h3,th a{color:#333}.smaller{font-size:.85em}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;border:1px dotted #aaa}h1,h2,h3{font-weight:400}h2{font-size:1.3em;margin-bottom:10px}h3{font-size:1.2em}table{width:100%;border-collapse:collapse;border-spacing:0;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.page-header h2 a,a.btn,header a{text-decoration:none}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.btn,.draggable-item,.task-board-change-assignee,label{cursor:pointer}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}label{display:block}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{color:#888;border:1px solid #ccc;width:300px;max-width:95%;font-size:100%;height:25px;padding-bottom:0;font-family:sans-serif;margin-top:10px;-webkit-appearance:none;appearance:none}input[type=number]:focus,input[type=date]:focus,input[type=email]:focus,input[type=password]:focus,input[type=text]:focus,textarea:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}input.form-numeric,input[type=number]{width:70px}.tag-autocomplete,textarea{width:400px}textarea{border:1px solid #ccc;max-width:99%;height:200px;font-size:100%;font-family:sans-serif}select{max-width:95%}select:focus{outline:0}span.select2-container{margin-top:2px}::-webkit-input-placeholder{color:#ddd;padding-top:2px}::-ms-input-placeholder{color:#ddd;padding-top:2px}::-moz-placeholder{color:#ddd;padding-top:2px}.form-actions{padding-top:20px;clear:both}input.form-error,textarea.form-error{border:2px solid #b94a48}input.form-error:focus,textarea.form-error:focus{box-shadow:none;border:2px solid #b94a48}.form-required{color:red;padding-left:5px;font-weight:700}.form-errors{color:#b94a48}ul.form-errors li{margin-left:0}.form-help{font-size:.8em;color:brown;margin-bottom:15px}.form-inline{padding:0;margin:0;border:none}.form-inline label{display:inline}.form-inline input,.form-inline select{margin:0 15px 0 0}.form-inline .form-required{display:none}.form-inline-group{display:inline}input.form-date,input.form-datetime{width:150px}input.form-input-large{width:400px}input.form-input-small{width:150px}.form-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row}.form-column{margin-right:25px}.form-login{width:350px;margin:8% auto 0}.form-login li{margin-left:25px;line-height:25px}.form-login h2{margin-bottom:30px;font-size:1.5em;font-weight:700}.popover-form{margin-bottom:0}.reset-password{margin-top:20px}.reset-password a{font-size:.8em;color:#999}.btn{font-size:1.1em;font-weight:400;-webkit-appearance:none;appearance:none;display:inline-block;color:#333;background:#f5f5f5;border:1px solid #ddd;border-radius:2px;padding:3px 10px;margin:0}.btn:hover{border:1px solid #bbb;color:#000;background:#fafafa}.btn-red{border-color:#b0281a;background:#d14836;color:#fff}.btn-red:focus,.btn-red:hover{color:#fff;background:#c53727}.btn-blue{border-color:#3079ed;background:#4d90fe;color:#fff}.btn-blue:focus,.btn-blue:hover{border-color:#2f5bb7;background:#357ae8;color:#fff}.btn:disabled{color:#ccc;border:1px solid #ccc;background:#f7f7f7}.buttons-header{font-size:.9em;margin-bottom:15px}.alert{padding:8px 35px 8px 14px;margin-top:5px;margin-bottom:5px;color:#c09853;background-color:#fcf8e3;border:1px solid #fbeed5;border-radius:4px}.alert-success{color:#468847;background-color:#dff0d8;border-color:#d6e9c6}.alert-error{color:#b94a48;background-color:#f2dede;border-color:#eed3d7}.alert-info{color:#3a87ad;background-color:#d9edf7;border-color:#bce8f1}.alert-normal{color:#333;background-color:#f0f0f0;border-color:#ddd}.alert ul{margin-top:10px;margin-bottom:10px}.alert-fade-out,.ui-tooltip-content .markdown p{margin-bottom:0}.alert li{margin-left:25px}.alert-fade-out{text-align:center;position:fixed;bottom:0;left:20%;width:60%;padding-top:5px;padding-bottom:5px;border-width:1px 0 0;border-radius:4px 4px 0 0;z-index:9999}.tooltip-arrow.bottom,.tooltip-arrow.top:after{bottom:-10px}div.ui-tooltip{min-width:200px;max-width:600px;font-size:.85em}.tooltip-arrow{width:20px;height:10px;position:absolute}.tooltip-arrow.align-left{left:10px}.tooltip-arrow.align-right{right:10px}.tooltip-arrow:after{background:#fff;border:1px solid #aaa;box-shadow:0 0 5px #aaa;content:"";position:absolute;width:14px;height:14px;-webkit-transform:rotate(45deg);-ms-transform:rotate(45deg);transform:rotate(45deg)}.textarea-dropdown,ul.dropdown-submenu-open{list-style:none;box-shadow:0 1px 3px rgba(0,0,0,.15)}.tooltip-arrow.align-left:after{left:0}.tooltip-arrow.align-right:after{right:0}.tooltip-large{width:600px}.tooltip .fa-info-circle{color:#999;font-size:.95em}header{margin-top:10px;padding-bottom:10px;border-bottom:1px solid #dedede}header h1{margin:0;padding:0;max-width:70%;float:left}header ul{text-align:right;font-size:.9em}header li{display:inline;padding-left:30px}header a{color:#333}header a:hover{color:#666}nav .active a{color:#333;font-weight:700}.logo a{opacity:.5;color:#d40000}.logo span{color:#333}.logo a:hover{opacity:.8;color:#333}.logo a:focus span,.logo a:hover span{color:#d40000}header .user-links .dropdown{margin-left:15px}header h1 .tooltip{opacity:.3;font-size:.6em}.page-header{margin-bottom:20px}.page-header h2{margin:0;padding:0;font-size:1.4em;font-weight:700;border-bottom:1px dotted #ccc}.page-header h2 a{color:#333}.page-header h2 a:focus,.page-header h2 a:hover{color:#aaa}.page-header ul{text-align:left;margin-top:5px;display:inline-block}.menu-inline li,.page-header li{display:inline;padding-right:15px;font-size:.95em}.page-header li.active a{color:#333;text-decoration:none;font-weight:700}.page-header li.active a:focus,.page-header li.active a:hover{text-decoration:underline}.menu-inline{margin-bottom:5px}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}.board-column-collapsed{display:none}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.file-thumbnail img:hover,.task-board-icons a{opacity:.5}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}.project-overview-columns,.task-summary-columns{display:-webkit-flex;-webkit-flex-direction:row}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:flex;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.avatar-letter,.pagination,.project-overview-column,div.ganttview-hzheader-day,div.ganttview-hzheader-month{text-align:center}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555}.listing{border-radius:4px;padding:8px 35px 8px 14px;margin-bottom:20px;border:1px solid #ddd;color:#333;background-color:#fcfcfc;overflow:auto}.activity-title,.sidebar>ul li{border-bottom:1px dotted #efefef}.listing li{list-style-type:square;margin-left:20px;margin-bottom:3px}.activity-event,.listing ul,.sidebar>ul li:last-child{margin-bottom:15px}.listing ul{margin-top:15px}.activity-event{padding:10px}.activity-event:hover{background:#fafafa}.activity-date{margin-left:10px;font-weight:400;color:#999;font-size:.8em}.activity-content{margin-left:55px}.activity-title{font-weight:700;color:#000}.activity-description{font-size:.95em;color:#555;margin-top:10px}.activity-description li{list-style-type:circle}.activity-description ul{margin-top:10px;margin-left:20px}.dashboard-project-stats span{font-size:.75em;margin-right:10px;color:#999}.dashboard-project-stats strong{font-size:1.2em}.dashboard-table-link{font-weight:700;color:#444;text-decoration:none}.dashboard-table-link:focus,.dashboard-table-link:hover{color:#999}.pagination-next{margin-left:5px}.pagination-previous{margin-right:5px}#popover-container{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);overflow:auto;z-index:100}#popover-content{position:absolute;width:70%;left:15%;top:1%;padding:15px;background:#fff;overflow:auto;max-height:90%}#main .confirm{max-width:700px;font-size:1.1em}.sidebar-container{margin-top:10px;height:100%;width:100%;display:-ms-flexbox;display:-webkit-box;display:-moz-box;display:-ms-box;display:box;-ms-flex-direction:row;-webkit-box-orient:horizontal;-moz-box-orient:horizontal;-ms-box-orient:horizontal;box-orient:horizontal}.sidebar-content{padding-left:10px;-ms-flex:1;-webkit-box-flex:1;-moz-box-flex:1;-ms-box-flex:1;box-flex:1}.sidebar{padding-right:10px;border-right:1px dotted #eee;font-size:.95em;max-width:240px;min-width:190px;width:18%;-ms-flex:0 100px;-webkit-box-flex:0;-moz-box-flex:0;-ms-box-flex:0;box-flex:0}.sidebar h2{margin-top:0}.sidebar>ul a{text-decoration:none;color:#999;font-weight:300}.sidebar>ul a:hover{color:#333}.sidebar>ul li{list-style-type:none;line-height:35px;padding-left:13px}.sidebar>ul li:hover{border-left:5px solid #555;padding-left:8px}.sidebar>ul li.active{border-left:5px solid #333;padding-left:8px}.sidebar>ul li.active a{color:#333;font-weight:700}.sidebar-icons>ul li{padding-left:0}.sidebar-icons>ul li.active,.sidebar-icons>ul li:hover{padding-left:0;border-left:none}.sidebar>ul li.active a:focus,.sidebar>ul li.active a:hover{color:#555}@media only screen and (max-width:1024px){body{font-size:.85em}.form-tab{max-width:404px}.form-inline-group input[type=submit],.form-inline-group label{display:block}.form-inline-group input[type=submit]{margin-top:20px}td>input[type=text]{max-width:150px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:1024px) and (orientation:landscape){header{padding-bottom:4px}div.chosen-container{font-size:.9em}input[type=number],input[type=date],input[type=email],input[type=password],input[type=text]{height:18px}.page-header .form-input-large{width:300px}}@media only screen and (max-width:640px){.hide-mobile{display:none}}.dropdown{display:inline;position:relative}.dropdown ul{display:none}ul.dropdown-submenu-open{display:block;position:absolute;z-index:1000;min-width:285px;margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}.dropdown-submenu-open li,.textarea-dropdown li{display:block;margin:0;padding:8px 10px;font-size:.85em;border-bottom:1px solid #f8f8f8;cursor:pointer}.dropdown-submenu-open li.no-hover{cursor:default}.dropdown-submenu-open li:last-child,.textarea-dropdown li:last-child{border:none}.dropdown-submenu-open li:not(.no-hover):hover,.textarea-dropdown .active,.textarea-dropdown li:hover{background:#4078C0;color:#fff}.dropdown-submenu-open li:hover a,.textarea-dropdown .active a,.textarea-dropdown li:hover a{color:#fff}.dropdown-submenu-open a,.textarea-dropdown a{text-decoration:none;color:#333}.dropdown-submenu-open a:focus{text-decoration:underline}.page-header .dropdown{padding-right:10px}.dropdown-menu-link-icon,.dropdown-menu-link-text{color:#333;text-decoration:none}.dropdown-menu-link-text:hover{text-decoration:underline}.textarea-dropdown{margin:3px 0 0 1px;padding:6px 0;background-color:#fff;border:1px solid #b2b2b2;border-radius:3px}#file-dropzone,#screenshot-zone{position:relative;border:2px dashed #ccc;width:99%;height:250px;overflow:auto}#file-dropzone-inner,#screenshot-inner{position:absolute;left:0;bottom:48%;width:100%;text-align:center;color:#aaa}#screenshot-zone.screenshot-pasted{border:2px solid #333}#file-list{margin:20px}#file-list li{list-style-type:none;padding-top:8px;padding-bottom:8px;border-bottom:1px dotted #ddd;width:95%}#file-list li.file-error{font-weight:700;color:#b94a48}.project-header{margin-top:8px;margin-bottom:20px}.action-menu{color:#333;text-decoration:none}.action-menu:focus,.action-menu:hover{text-decoration:underline}.filter-box{display:inline-block;position:relative;font-size:0;margin-bottom:20px}.filter-box form,.project-header .filter-box{margin:0}.filter-box input[type=text]{margin:0;font-size:16px;height:26px;border-color:#ddd;border-top-left-radius:5px;border-bottom-left-radius:5px;vertical-align:top}.filter-box input[type=text]:focus{color:#000;border-color:rgba(82,168,236,.8);outline:0;box-shadow:0 0 8px rgba(82,168,236,.6)}.filter-box div.dropdown{background:#fafafa;display:inline-block;font-size:16px;border:1px solid #ddd;border-left:none;margin:0;padding:0 8px 0 5px;height:27px}.filter-box div.dropdown:last-child{border-top-right-radius:5px;border-bottom-right-radius:5px}.filter-box div.dropdown a{line-height:27px}div.ganttview-grid,div.ganttview-grid-row-cell,div.ganttview-hzheader-day,div.ganttview-hzheader-month,div.ganttview-vtheader,div.ganttview-vtheader-item-name,div.ganttview-vtheader-series{float:left}div.ganttview-grid-row-cell.last,div.ganttview-hzheader-day.last,div.ganttview-hzheader-month.last{border-right:none}div.ganttview{border:1px solid #999}div.ganttview-hzheader-month{width:60px;height:20px;border-right:1px solid #d0d0d0;line-height:20px;overflow:hidden}div.ganttview-hzheader-day{width:20px;height:20px;border-right:1px solid #f0f0f0;border-top:1px solid #d0d0d0;line-height:20px;color:#777}div.ganttview-vtheader{margin-top:41px;width:400px;overflow:hidden;background-color:#fff}div.ganttview-vtheader-item{color:#666}div.ganttview-vtheader-series-name{width:400px;height:31px;line-height:31px;padding-left:3px;border-top:1px solid #d0d0d0;font-size:.9em;overflow:hidden}div.ganttview-vtheader-series-name a{color:#666;text-decoration:none}div.ganttview-vtheader-series-name a:hover{color:#333;text-decoration:underline}div.ganttview-vtheader-series-name a i{color:#000}div.ganttview-vtheader-series-name a:hover i{color:#666}div.ganttview-slide-container{overflow:auto;border-left:1px solid #999}div.ganttview-grid-row-cell{width:20px;height:31px;border-right:1px solid #f0f0f0;border-top:1px solid #f0f0f0}div.ganttview-grid-row-cell.ganttview-weekend{background-color:#fafafa}div.ganttview-blocks{margin-top:40px}div.ganttview-block-container{height:28px;padding-top:4px}div.ganttview-block{position:relative;height:25px;background-color:#E5ECF9;border:1px solid silver;border-radius:3px}.ganttview-block-movable{cursor:move}div.ganttview-block-not-defined{border-color:#000;background-color:#000}div.ganttview-block-text{position:absolute;height:12px;font-size:.7em;color:#999;padding:2px 3px}div.ganttview-block div.ui-resizable-handle.ui-resizable-s{bottom:0}.project-creation-options{max-width:500px;border-left:3px dotted #efefef;margin-top:20px;padding-left:15px;padding-bottom:5px;padding-top:5px}.project-overview-columns{display:flex;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-align-items:center;align-items:center;-webkit-justify-content:center;justify-content:center;margin-bottom:20px;font-size:1.4em}.project-overview-column{margin-right:80px;padding:3px 15px;border:1px dashed #ddd;border-radius:8px}.project-overview-column strong{font-size:1.3em;color:#444}.project-overview-column span{font-size:.8em;color:#777}.file-thumbnails{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:flex-start;justify-content:flex-start}.file-thumbnail{width:250px;border:1px solid #efefef;border-radius:5px;margin-bottom:20px;box-shadow:4px 2px 10px -6px rgba(0,0,0,.55);margin-right:15px}.file-thumbnail img{border-top-left-radius:5px;border-top-right-radius:5px}.file-thumbnail-content{padding-left:8px;padding-right:8px}.file-thumbnail-title{font-weight:700;font-size:.9em;color:#555}.file-thumbnail-description{font-size:.8em;color:#aaa;margin-top:8px;margin-bottom:5px}.accordion-collapsed,.accordion-content{margin-bottom:25px}.file-viewer{position:relative}.file-viewer img{max-width:95%;max-height:85%;margin-top:10px}.views{display:inline-block;margin-left:10px;margin-right:10px;font-size:.9em}.views li{background:#fafafa;border-left:1px solid #ddd;border-top:1px solid #ddd;border-bottom:1px solid #ddd;display:inline;padding:5px 8px}.views a{color:#555;text-decoration:none}.views a:hover{color:#333;text-decoration:underline}.menu-inline li.active a,.views li.active a{font-weight:700;color:#000;text-decoration:none}.views li:first-child{border-top-left-radius:5px;border-bottom-left-radius:5px}.views li:last-child{border-right:1px solid #ddd;border-top-right-radius:5px;border-bottom-right-radius:5px}.accordion-title{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAADCAYAAABS3WWCAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5RDgxQzc2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5RDgxQzg2RjQ5MTFFMjhEMUNENzFGRUMwRjhBRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RDlEODFDNTZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RDlEODFDNjZGNDkxMUUyOEQxQ0Q3MUZFQzBGOEFFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvXFWFAAAAAYSURBVHjaYvj//z8D0/Pnz/8zgFgAAQYAS5UJscReGMIAAAAASUVORK5CYII=) 0 10px repeat-x}.accordion-title h3{display:inline;padding-right:5px;background:#fff}.accordion-content{margin-top:15px}.accordion-toggle{color:#333;text-decoration:none}.accordion-toggle:focus,.accordion-toggle:hover{color:#999}.accordion-toggle:before{content:"\f0d7"}.accordion-collapsed .accordion-toggle:before{content:"\f0da"}.accordion-collapsed .accordion-content{display:none}.avatar img{vertical-align:bottom}.avatar-left{float:left;margin-right:10px}.avatar-inline{display:inline-block;margin-right:3px}.avatar-48 div,.avatar-48 img{border-radius:30px}.avatar-48 .avatar-letter{line-height:48px;width:48px;font-size:25px}.avatar-20 div,.avatar-20 img{border-radius:10px}.avatar-20 .avatar-letter{line-height:20px;width:20px;font-size:11px}.avatar-letter{color:#fff} \ No newline at end of file diff --git a/assets/css/print.min.css b/assets/css/print.min.css index 080626b3..d63b1216 100644 --- a/assets/css/print.min.css +++ b/assets/css/print.min.css @@ -1 +1 @@ -a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{overflow:auto;min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file +a:hover,th a{text-decoration:none;color:#333}.table-fixed td,.table-fixed th{overflow:hidden}#board td,td{vertical-align:top}#comments form,.board-column-collapsed,.page-header,.sidebar,header{display:none}.table-fixed td,.task-board-collapsed{white-space:nowrap;text-overflow:ellipsis}a{color:#36C;border:none}a:focus{outline:0;color:#DF5353;text-decoration:none;border:1px dotted #aaa}table{width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:20px;font-size:.95em}#calendar table{margin-bottom:0}td,th{border:1px solid #eee;padding:.5em 3px}th{background:#fbfbfb;text-align:left}td li{margin-left:20px}.table-small{font-size:.8em}th a:focus,th a:hover{text-decoration:underline}.table-fixed{table-layout:fixed;white-space:nowrap}.table-stripped tr:nth-child(odd){background:#fefefe}.column-3{width:3%}.column-5{width:5%}.column-8{width:7.5%}.column-10{width:10%}.column-12{width:12%}.column-15{width:15%}.column-18{width:18%}.column-20{width:20%}.column-25{width:25%}.column-30{width:30%}.column-35{width:35%}.column-40{width:40%}.column-50{width:50%}.column-60{width:60%}.column-70,.column-80{width:70%}.draggable-row-handle{cursor:move;color:#dedede}.draggable-row-handle:hover{color:#333}tr.draggable-item-selected{background:#fff;border:2px solid #666;box-shadow:4px 2px 10px -4px rgba(0,0,0,.55)}tr.draggable-item-selected td{border-top:none;border-bottom:none}tr.draggable-item-selected td:first-child{border-left:none}tr.draggable-item-selected td:last-child{border-right:none}.table-stripped tr.draggable-item-hover,tr.draggable-item-hover{background:#FEFFF2}.public-board{margin-top:5px}.public-task{max-width:800px;margin:5px auto 0}#board-container{overflow-x:auto}#board{table-layout:fixed;margin-bottom:0}#board th.board-column-header{width:240px}.board-container-compact{overflow-x:initial}@media all and (-ms-high-contrast:active),(-ms-high-contrast:none){.board-container-compact #board{table-layout:auto}}#board th.board-column-header.board-column-compact{width:initial}td.board-column-task-collapsed{font-weight:700;background-color:#fbfbfb}#board th.board-column-header-collapsed{width:28px;min-width:28px;text-align:center;overflow:hidden}.board-rotation-wrapper{position:relative;padding:8px 4px;min-height:150px;overflow:hidden}.board-rotation{white-space:nowrap;-webkit-backface-visibility:hidden;-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg);-webkit-transform-origin:0 100%;-moz-transform-origin:0 100%;-ms-transform-origin:0 100%;transform-origin:0 100%}.board-column-title .dropdown-menu{text-decoration:none}.board-add-icon{float:left;padding:0 5px}.board-add-icon a{text-decoration:none;color:#36C;font-size:150%;line-height:70%}.board-add-icon a:focus,.board-add-icon a:hover{text-decoration:none;color:red}.board-column-header-task-count{color:#999;font-weight:400}th.board-column-header-collapsed .board-column-header-task-count{font-size:.85em}a.board-swimlane-toggle{font-size:.95em;text-decoration:none}a.board-swimlane-toggle:focus,a.board-swimlane-toggle:hover{color:#000;text-decoration:none;border:none}.board-task-list{min-height:60px}.board-task-list-limit{background-color:#DF5353}.draggable-item{cursor:pointer;user-select:none;-webkit-user-select:none;-moz-user-select:none}.draggable-placeholder{border:2px dashed #000;background:#fafafa;height:70px;margin-bottom:10px}.task-board,div.draggable-item-selected{border:1px solid #000}.task-board-sort-handle{float:left;padding-right:5px}.task-table .dropdown-menu{color:#000;text-decoration:none;font-weight:700}.task-table .dropdown-menu:focus,.task-table .dropdown-menu:hover{text-decoration:underline}td.task-table a{color:#000;text-decoration:none}td.task-table a:hover{text-decoration:underline}.task-board{position:relative;margin-bottom:4px;padding:2px;font-size:.85em;word-wrap:break-word}div.task-board-recent{border-width:2px}div.task-board-status-closed{user-select:none;border:1px dotted #555}.task-board a{color:#000;text-decoration:none}.task-board .dropdown-menu{font-weight:700}.task-board-collapsed{overflow:hidden}.task-board-saving-state{opacity:.3}.task-board-category:hover,.task-board-change-assignee:hover{opacity:.6}.task-board-saving-icon{position:absolute;margin:auto;width:100%;text-align:center;color:#000}.task-board-title{font-size:1.15em;margin-top:5px;margin-bottom:8px}.task-board-title a:hover{text-decoration:underline}.task-board-category-container{text-align:right;margin-top:8px;margin-bottom:8px}.task-board-category{font-weight:500;color:#000;border:1px solid #555;padding:1px 2px;border-radius:4px}.task-tags li{display:inline;margin:0 4px 0 0;padding:2px;color:#666;border:1px solid #666;border-radius:2px}.task-summary-container .task-tags{margin-top:10px}.task-board-avatars{text-align:right;float:right}.task-board-change-assignee{cursor:pointer}.task-board-icons{text-align:right;margin-top:4px;margin-bottom:2px}.task-board-icons a{opacity:.5}.task-board-icons span{opacity:.5;margin-left:2px}.task-board-icons a:hover,.task-board-icons span:hover{opacity:1}.task-board-date{font-weight:700;color:#000}span.task-board-date-today{color:#0000D9;opacity:1}span.task-board-date-overdue{color:#D90000;opacity:1}.task-board .task-score{font-weight:700}.task-board-age{display:inline-block;font-size:.9em}span.task-board-age-total{border:1px solid #666;padding:1px 3px;border-top-left-radius:3px;border-bottom-left-radius:3px}span.task-board-age-column{border:1px solid #666;border-left:none;margin-left:-5px;padding:1px 3px;border-top-right-radius:3px;border-bottom-right-radius:3px}#task-summary{margin-bottom:15px}#task-summary h2{color:#666;font-size:2.5em;margin-top:0;padding-top:0}.task-summary-buttons{margin-top:10px;font-size:.85em}.task-summary-container{border:2px solid #000;border-radius:8px;padding:15px}.task-summary-columns{display:-webkit-flex;display:flex;-webkit-flex-direction:row;flex-direction:row;-webkit-justify-content:space-between;justify-content:space-between}.task-summary-column{font-size:.9em;color:#666}.task-summary-column span{color:#555}.task-summary-column li{line-height:23px}.task-show-title{border:2px solid #000;border-radius:8px;margin-bottom:20px}.task-show-title h2{color:#555;font-size:1.8em;margin:0;padding:8px}.comment-actions,.comment-content,.comment-title{margin-left:55px}.task-link-closed{text-decoration:line-through}.flag-milestone{color:green}.color-picker{width:180px}.color-picker-option{height:25px}.color-picker-square{display:inline-block;width:18px;height:18px;margin-right:5px;border:1px solid #000}.color-picker-label{display:inline-block;vertical-align:bottom;padding-bottom:3px}#select2-form-color_id-results li.select2-results__option{padding:3px}.assign-me{font-size:.8em;vertical-align:bottom}.subtasks-table td,.task-links-table td{vertical-align:middle}.comment-sorting{text-align:right;font-size:.5em}.comment-sorting a{color:#555;font-weight:400;text-decoration:none}.comment-sorting a:hover{color:#aaa}.comment{padding:5px;margin-bottom:15px}.comment-title,.form-column div.CodeMirror,.markdown blockquote,.markdown h1,.markdown p{margin-bottom:10px}.comment:hover{background:#fafafa}.comment-title{border-bottom:1px dotted #eee}.comment-username{font-weight:700;font-size:1.1em}.comment-date{color:#999;font-size:.7em;font-weight:200}.comment-actions{font-size:.8em;margin-top:8px}.subtasks-table,.task-links-table{font-size:.85em}.comment-actions li{display:inline}.comment-actions a{color:#999;text-decoration:none}.markdown h1,.markdown h2,.markdown h3,.markdown h4{text-decoration:underline}.comment-actions a:focus,.comment-actions a:hover{color:#333;text-decoration:underline}.task-links-task-count{color:#999}.markdown-editor-container{max-width:400px}div.CodeMirror,div.CodeMirror-scroll{max-height:250px;min-height:200px}.markdown-editor-small div.CodeMirror,.markdown-editor-small div.CodeMirror-scroll{min-height:100px;max-height:180px}.markdown{line-height:1.4em}.markdown h1{margin-top:5px;font-size:1.5em;font-weight:700}.markdown h2{font-size:1.2em;font-weight:700}.markdown h3,.markdown h4{font-size:1.1em}.markdown ol,.markdown ul{margin-left:25px;margin-top:10px;margin-bottom:10px}.markdown pre{background:#fbfbfb;padding:10px;border-radius:5px;border:1px solid #ddd;overflow:auto;color:#444}.markdown blockquote{font-style:italic;border-left:3px solid #ddd;padding-left:10px;margin-left:20px}.markdown img{display:block;max-width:80%;margin-top:10px}.documentation{margin:0 auto;padding:20px;max-width:850px;background:#fefefe;border:1px solid #ccc;border-radius:5px;font-size:1.1em;color:#555}.documentation img{border:1px solid #333}.documentation h1{text-decoration:none;font-size:1.8em;margin-bottom:30px}.documentation h2{font-size:1.3em;text-decoration:none;border-bottom:1px solid #ccc;margin-bottom:25px}.documentation li{line-height:30px}.user-mention-link{font-weight:700;color:#000;text-decoration:none}.user-mention-link:hover{color:#555} \ No newline at end of file diff --git a/assets/css/src/board.css b/assets/css/src/board.css index 95e04108..ff3844b7 100644 --- a/assets/css/src/board.css +++ b/assets/css/src/board.css @@ -126,7 +126,6 @@ a.board-swimlane-toggle:focus { /* board task list */ .board-task-list { - overflow: auto; min-height: 60px; } diff --git a/assets/js/app.min.js b/assets/js/app.min.js index 2a8178e4..7f3ba893 100644 --- a/assets/js/app.min.js +++ b/assets/js/app.min.js @@ -1,2 +1,2 @@ -"use strict";var Kanboard={};Kanboard.Accordion=function(t){this.app=t},Kanboard.Accordion.prototype.listen=function(){$(document).on("click",".accordion-toggle",function(t){var e=$(this).parents(".accordion-section");t.preventDefault(),e.hasClass("accordion-collapsed")?(e.find(".accordion-content").show(),e.removeClass("accordion-collapsed")):(e.find(".accordion-content").hide(),e.addClass("accordion-collapsed"))})},Kanboard.App=function(){this.controllers={}},Kanboard.App.prototype.get=function(t){return this.controllers[t]},Kanboard.App.prototype.execute=function(){for(var t in Kanboard)if("App"!==t){var e=new Kanboard[t](this);this.controllers[t]=e,"function"==typeof e.execute&&e.execute(),"function"==typeof e.listen&&e.listen(),"function"==typeof e.focus&&e.focus(),"function"==typeof e.keyboardShortcuts&&e.keyboardShortcuts()}this.focus(),this.chosen(),this.keyboardShortcuts(),this.datePicker(),this.autoComplete(),this.tagAutoComplete()},Kanboard.App.prototype.keyboardShortcuts=function(){var t=this;Mousetrap.bindGlobal("mod+enter",function(){var e=$("form");1==e.length?e.submit():e.length>1&&("INPUT"===document.activeElement.tagName||"TEXTAREA"===document.activeElement.tagName?$(document.activeElement).parents("form").submit():t.get("Popover").isOpen()&&$("#popover-container form").submit())}),Mousetrap.bind("b",function(t){t.preventDefault(),$("#board-selector").trigger("chosen:open")}),Mousetrap.bindGlobal("esc",function(){t.get("Popover").close(),t.get("Dropdown").close()}),Mousetrap.bind("?",function(){t.get("Popover").open($("body").data("keyboard-shortcut-url"))})},Kanboard.App.prototype.focus=function(){$(document).on("focus",".auto-select",function(){$(this).select()}),$(document).on("mouseup",".auto-select",function(t){t.preventDefault()})},Kanboard.App.prototype.chosen=function(){$(".chosen-select").each(function(){var t=$(this).data("search-threshold");void 0===t&&(t=10),$(this).chosen({width:"180px",no_results_text:$(this).data("notfound"),disable_search_threshold:t})}),$(".select-auto-redirect").change(function(){var t=new RegExp($(this).data("redirect-regex"),"g");window.location=$(this).data("redirect-url").replace(t,$(this).val())})},Kanboard.App.prototype.datePicker=function(){var t=$("body"),e=t.data("js-date-format"),a=t.data("js-time-format"),o=t.data("js-lang");$.datepicker.setDefaults($.datepicker.regional[o]),$.timepicker.setDefaults($.timepicker.regional[o]),$(".form-date").datepicker({showOtherMonths:!0,selectOtherMonths:!0,dateFormat:e,constrainInput:!1}),$(".form-datetime").datetimepicker({dateFormat:e,timeFormat:a,constrainInput:!1})},Kanboard.App.prototype.tagAutoComplete=function(){$(".tag-autocomplete").select2({tags:!0})},Kanboard.App.prototype.autoComplete=function(){$(".autocomplete").each(function(){var t=$(this),e=t.data("dst-field"),a=t.data("dst-extra-field");""==$("#form-"+e).val()&&t.parent().find("button[type=submit]").attr("disabled","disabled"),t.autocomplete({source:t.data("search-url"),minLength:1,select:function(o,n){$("input[name="+e+"]").val(n.item.id),a&&$("input[name="+a+"]").val(n.item[a]),t.parent().find("button[type=submit]").removeAttr("disabled")}})})},Kanboard.App.prototype.hasId=function(t){return!!document.getElementById(t)},Kanboard.App.prototype.showLoadingIcon=function(){$("body").append(' ')},Kanboard.App.prototype.hideLoadingIcon=function(){$("#app-loading-icon").remove()},Kanboard.App.prototype.formatDuration=function(t){return t>=86400?Math.round(t/86400)+"d":t>=3600?Math.round(t/3600)+"h":t>=60?Math.round(t/60)+"m":t+"s"},Kanboard.App.prototype.isVisible=function(){var t="";return"undefined"!=typeof document.hidden?t="visibilityState":"undefined"!=typeof document.mozHidden?t="mozVisibilityState":"undefined"!=typeof document.msHidden?t="msVisibilityState":"undefined"!=typeof document.webkitHidden&&(t="webkitVisibilityState"),""!=t?"visible"==document[t]:!0},Kanboard.AvgTimeColumnChart=function(t){this.app=t},Kanboard.AvgTimeColumnChart.prototype.execute=function(){this.app.hasId("analytic-avg-time-column")&&this.show()},Kanboard.AvgTimeColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=[t.data("label")],o=[];for(var n in e)a.push(e[n].average),o.push(e[n].title);c3.generate({data:{columns:[a],type:"bar"},bar:{width:{ratio:.5}},axis:{x:{type:"category",categories:o},y:{tick:{format:this.app.formatDuration}}},legend:{show:!1}})},Kanboard.BoardCollapsedMode=function(t){this.app=t},Kanboard.BoardCollapsedMode.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("s",function(){t.toggle()})},Kanboard.BoardCollapsedMode.prototype.toggle=function(){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$('.filter-display-mode:not([style="display: none;"]) a').attr("href"),success:function(e){$(".filter-display-mode").toggle(),t.app.get("BoardDragAndDrop").refresh(e)}})},Kanboard.BoardColumnScrolling=function(t){this.app=t},Kanboard.BoardColumnScrolling.prototype.execute=function(){this.app.hasId("board")&&(this.render(),$(window).on("load",this.render),$(window).resize(this.render))},Kanboard.BoardColumnScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-height",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardColumnScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnScrolling.prototype.toggle=function(){var t=localStorage.getItem("column_scroll");void 0==t&&(t=1),localStorage.setItem("column_scroll",0==t?1:0),this.render()},Kanboard.BoardColumnScrolling.prototype.render=function(){var t=$(".board-task-list"),e=$(".board-rotation-wrapper"),a=$(".filter-max-height"),o=$(".filter-min-height");if(0==localStorage.getItem("column_scroll")){var n=80;a.show(),o.hide(),e.css("min-height",""),t.each(function(){var t=$(this).height();t>n&&(n=t)}),t.css("min-height",n),t.css("height","")}else if(a.hide(),o.show(),$(".board-swimlane").length>1)t.each(function(){$(this).height()>500?$(this).css("height",500):($(this).css("min-height",320),e.css("min-height",320))});else{var n=$(window).height()-170;t.css("height",n),e.css("min-height",n)}},Kanboard.BoardColumnView=function(t){this.app=t},Kanboard.BoardColumnView.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardColumnView.prototype.listen=function(){var t=this;$(document).on("click",".board-toggle-column-view",function(){t.toggle($(this).data("column-id"))})},Kanboard.BoardColumnView.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardColumnView.prototype.render=function(){var t=this;$(".board-column-header").each(function(){var e=$(this).data("column-id");localStorage.getItem("hidden_column_"+e)&&t.hideColumn(e)})},Kanboard.BoardColumnView.prototype.toggle=function(t){localStorage.getItem("hidden_column_"+t)?this.showColumn(t):this.hideColumn(t)},Kanboard.BoardColumnView.prototype.hideColumn=function(t){$(".board-column-"+t+" .board-column-expanded").hide(),$(".board-column-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t+" .board-column-expanded").hide(),$(".board-column-header-"+t+" .board-column-collapsed").show(),$(".board-column-header-"+t).each(function(){$(this).removeClass("board-column-compact"),$(this).addClass("board-column-header-collapsed")}),$(".board-column-"+t).each(function(){$(this).addClass("board-column-task-collapsed")}),$(".board-column-"+t+" .board-rotation").each(function(){$(this).css("width",$(".board-column-"+t).height())}),localStorage.setItem("hidden_column_"+t,1)},Kanboard.BoardColumnView.prototype.showColumn=function(t){$(".board-column-"+t+" .board-column-expanded").show(),$(".board-column-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t+" .board-column-expanded").show(),$(".board-column-header-"+t+" .board-column-collapsed").hide(),$(".board-column-header-"+t).removeClass("board-column-header-collapsed"),$(".board-column-"+t).removeClass("board-column-task-collapsed"),0==localStorage.getItem("horizontal_scroll")&&$(".board-column-header-"+t).addClass("board-column-compact"),localStorage.removeItem("hidden_column_"+t)},Kanboard.BoardHorizontalScrolling=function(t){this.app=t},Kanboard.BoardHorizontalScrolling.prototype.execute=function(){this.app.hasId("board")&&this.render()},Kanboard.BoardHorizontalScrolling.prototype.listen=function(){var t=this;$(document).on("click",".filter-toggle-scrolling",function(e){e.preventDefault(),t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("c",function(){t.toggle()})},Kanboard.BoardHorizontalScrolling.prototype.onBoardRendered=function(){this.render()},Kanboard.BoardHorizontalScrolling.prototype.toggle=function(){var t=localStorage.getItem("horizontal_scroll")||1;localStorage.setItem("horizontal_scroll",0==t?1:0),this.render()},Kanboard.BoardHorizontalScrolling.prototype.render=function(){0==localStorage.getItem("horizontal_scroll")?($(".filter-wide").show(),$(".filter-compact").hide(),$("#board-container").addClass("board-container-compact"),$("#board th:not(.board-column-header-collapsed)").addClass("board-column-compact")):($(".filter-wide").hide(),$(".filter-compact").show(),$("#board-container").removeClass("board-container-compact"),$("#board th").removeClass("board-column-compact"))},Kanboard.BoardPolling=function(t){this.app=t},Kanboard.BoardPolling.prototype.execute=function(){if(this.app.hasId("board")){var t=parseInt($("#board").attr("data-check-interval"));t>0&&window.setInterval(this.check.bind(this),1e3*t)}},Kanboard.BoardPolling.prototype.check=function(){if(this.app.isVisible()&&!this.app.get("BoardDragAndDrop").savingInProgress){var t=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:$("#board").data("check-url"),statusCode:{200:function(e){t.app.get("BoardDragAndDrop").refresh(e)},304:function(){t.app.hideLoadingIcon()}}})}},Kanboard.BoardTask=function(t){this.app=t},Kanboard.BoardTask.prototype.listen=function(){var t=this;$(document).on("click",".task-board-change-assignee",function(e){e.preventDefault(),e.stopPropagation(),t.app.get("Popover").open($(this).data("url"))}),$(document).on("click",".task-board",function(t){"A"!=t.target.tagName&&"IMG"!=t.target.tagName&&(window.location=$(this).data("task-url"))})},Kanboard.BoardTask.prototype.keyboardShortcuts=function(){var t=this;t.app.hasId("board")&&Mousetrap.bind("n",function(){t.app.get("Popover").open($("#board").data("task-creation-url"))})},Kanboard.BurndownChart=function(t){this.app=t},Kanboard.BurndownChart.prototype.execute=function(){this.app.hasId("analytic-burndown")&&this.show()},Kanboard.BurndownChart.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[[t.data("label-total")]],o=[],n=d3.time.format("%Y-%m-%d"),r=d3.time.format(t.data("date-format")),i=0;i0&&(void 0==a[0][i]&&a[0].push(0),a[0][i]+=e[i][s]),0==s&&o.push(r(n.parse(e[i][s]))));c3.generate({data:{columns:a},axis:{x:{type:"category",categories:o}}})},Kanboard.Calendar=function(t){this.app=t},Kanboard.Calendar.prototype.execute=function(){var t=$("#calendar");1==t.length&&this.show(t)},Kanboard.Calendar.prototype.show=function(t){t.fullCalendar({lang:$("body").data("js-lang"),editable:!0,eventLimit:!0,defaultView:"month",header:{left:"prev,next today",center:"title",right:"month,agendaWeek,agendaDay"},eventDrop:function(e){$.ajax({cache:!1,url:t.data("save-url"),contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({task_id:e.id,date_due:e.start.format()})})},viewRender:function(){var e=t.data("check-url"),a={start:t.fullCalendar("getView").start.format(),end:t.fullCalendar("getView").end.format()};for(var o in a)e+="&"+o+"="+a[o];$.getJSON(e,function(e){t.fullCalendar("removeEvents"),t.fullCalendar("addEventSource",e),t.fullCalendar("rerenderEvents")})}})},Kanboard.Column=function(t){this.app=t},Kanboard.Column.prototype.listen=function(){this.dragAndDrop()},Kanboard.Column.prototype.dragAndDrop=function(){var t=this;$(".draggable-row-handle").mouseenter(function(){$(this).parent().parent().addClass("draggable-item-hover")}).mouseleave(function(){$(this).parent().parent().removeClass("draggable-item-hover")}),$(".columns-table tbody").sortable({forcePlaceholderSize:!0,handle:"td:first i",helper:function(t,e){return e.children().each(function(){$(this).width($(this).width())}),e},stop:function(e,a){var o=a.item;o.removeClass("draggable-item-selected"),t.savePosition(o.data("column-id"),o.index()+1)},start:function(t,e){e.item.addClass("draggable-item-selected")}}).disableSelection()},Kanboard.Column.prototype.savePosition=function(t,e){var a=$(".columns-table").data("save-position-url"),o=this;this.app.showLoadingIcon(),$.ajax({cache:!1,url:a,contentType:"application/json",type:"POST",processData:!1,data:JSON.stringify({column_id:t,position:e}),complete:function(){o.app.hideLoadingIcon()}})},Kanboard.CompareHoursColumnChart=function(t){this.app=t},Kanboard.CompareHoursColumnChart.prototype.execute=function(){this.app.hasId("analytic-compare-hours")&&this.show()},Kanboard.CompareHoursColumnChart.prototype.show=function(){var t=$("#chart"),e=t.data("metrics"),a=t.data("label-open"),o=t.data("label-closed"),n=[t.data("label-spent")],r=[t.data("label-estimated")],i=[];for(var s in e)n.push(parseFloat(e[s].time_spent)),r.push(parseFloat(e[s].time_estimated)),i.push("open"==s?a:o);c3.generate({data:{columns:[n,r],type:"bar"},bar:{width:{ratio:.2}},axis:{x:{type:"category",categories:i}},legend:{show:!0}})},Kanboard.CumulativeFlowDiagram=function(t){this.app=t},Kanboard.CumulativeFlowDiagram.prototype.execute=function(){this.app.hasId("analytic-cfd")&&this.show()},Kanboard.CumulativeFlowDiagram.prototype.show=function(){for(var t=$("#chart"),e=t.data("metrics"),a=[],o=[],n=[],r=d3.time.format("%Y-%m-%d"),i=d3.time.format(t.data("date-format")),s=0;s0&&o.push(e[s][d])):(a[d].push(e[s][d]),0==d&&n.push(i(r.parse(e[s][d]))));c3.generate({data:{columns:a,type:"area-spline",groups:[o]},axis:{x:{type:"category",categories:n}}})},Kanboard.Dropdown=function(t){this.app=t},Kanboard.Dropdown.prototype.listen=function(){var t=this;$(document).on("click",function(){t.close()}),$(document).on("click",".dropdown-menu",function(e){e.preventDefault(),e.stopImmediatePropagation(),t.close();var a=$(this).next("ul"),o=$(this).offset();$("body").append(jQuery("
            ",{id:"dropdown"})),a.clone().appendTo("#dropdown");var n=$("#dropdown ul");n.addClass("dropdown-submenu-open");var r=n.outerHeight(),i=n.outerWidth();o.top+r-$(window).scrollTop()<$(window).height()||$(window).scrollTop()+o.top$(window).width()?n.css("left",o.left-i+$(this).outerWidth()):n.css("left",o.left)}),$(document).on("click",".dropdown-submenu-open li",function(t){$(t.target).is("li")&&$(this).find("a:visible")[0].click()})},Kanboard.Dropdown.prototype.close=function(){$("#dropdown").remove()},Kanboard.Dropdown.prototype.onPopoverOpened=function(){this.close()},Kanboard.FileUpload=function(t){this.app=t,this.files=[],this.currentFile=0},Kanboard.FileUpload.prototype.onPopoverOpened=function(){var t=document.getElementById("file-dropzone"),e=this;t&&(t.ondragover=t.ondragenter=function(t){t.stopPropagation(),t.preventDefault()},t.ondrop=function(t){t.stopPropagation(),t.preventDefault(),e.files=t.dataTransfer.files,e.show(),$("#file-error-max-size").hide()},$(document).on("click","#file-browser",function(t){t.preventDefault(),$("#file-form-element").get(0).click()}),$(document).on("click","#file-upload-button",function(t){t.preventDefault(),e.currentFile=0,e.checkFiles()}),$("#file-form-element").change(function(){e.files=document.getElementById("file-form-element").files,e.show(),$("#file-error-max-size").hide()}))},Kanboard.FileUpload.prototype.show=function(){if($("#file-list").remove(),this.files.length>0){$("#file-upload-button").prop("disabled",!1),$("#file-dropzone-inner").hide();for(var t=jQuery("