Add autocompletion in textarea for user mentions
This commit is contained in:
@@ -3,6 +3,7 @@ Version 1.0.23 (unreleased)
|
|||||||
|
|
||||||
New features:
|
New features:
|
||||||
|
|
||||||
|
- Added support of user mentions (@username)
|
||||||
- Added report to compare working hours between open and closed tasks
|
- Added report to compare working hours between open and closed tasks
|
||||||
- Added the possiblity to define custom routes from plugins
|
- Added the possiblity to define custom routes from plugins
|
||||||
|
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -5,7 +5,7 @@ CSS_PRINT = $(addprefix assets/css/src/, $(addsuffix .css, print links table boa
|
|||||||
CSS_VENDOR = $(addprefix assets/css/vendor/, $(addsuffix .css, jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min))
|
CSS_VENDOR = $(addprefix assets/css/vendor/, $(addsuffix .css, jquery-ui.min jquery-ui-timepicker-addon.min chosen.min fullcalendar.min font-awesome.min c3.min))
|
||||||
|
|
||||||
JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt Task Project TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart CompareHoursColumnChart Router))
|
JS_APP = $(addprefix assets/js/src/, $(addsuffix .js, Popover Dropdown Tooltip Markdown Sidebar Search App Screenshot Calendar Board Swimlane Gantt Task Project TaskRepartitionChart UserRepartitionChart CumulativeFlowDiagram BurndownChart AvgTimeColumnChart TaskTimeColumnChart LeadCycleTimeChart CompareHoursColumnChart Router))
|
||||||
JS_VENDOR = $(addprefix assets/js/vendor/, $(addsuffix .js, jquery-1.11.3.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min))
|
JS_VENDOR = $(addprefix assets/js/vendor/, $(addsuffix .js, jquery-1.11.3.min jquery-ui.min jquery-ui-timepicker-addon.min jquery.ui.touch-punch.min chosen.jquery.min moment.min fullcalendar.min mousetrap.min mousetrap-global-bind.min jquery.textcomplete))
|
||||||
JS_LANG = $(addprefix assets/js/vendor/lang/, $(addsuffix .js, cs da de es fi fr hu id it ja nl nb pl pt pt-br ru sv sr th tr zh-cn))
|
JS_LANG = $(addprefix assets/js/vendor/lang/, $(addsuffix .js, cs da de es fi fr hu id it ja nl nb pl pt pt-br ru sv sr th tr zh-cn))
|
||||||
|
|
||||||
all: css js
|
all: css js
|
||||||
|
|||||||
@@ -21,4 +21,17 @@ class UserHelper extends Base
|
|||||||
$users = $this->userFilterAutoCompleteFormatter->create($search)->filterByUsernameOrByName()->format();
|
$users = $this->userFilterAutoCompleteFormatter->create($search)->filterByUsernameOrByName()->format();
|
||||||
$this->response->json($users);
|
$this->response->json($users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User mention autocompletion (Ajax)
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
*/
|
||||||
|
public function mention()
|
||||||
|
{
|
||||||
|
$project_id = $this->request->getStringParam('project_id');
|
||||||
|
$query = $this->request->getStringParam('q');
|
||||||
|
$users = $this->projectPermission->findUsernames($project_id, $query);
|
||||||
|
$this->response->json($users);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
app/Model/ProjectGroupRoleFilter.php
Normal file
89
app/Model/ProjectGroupRoleFilter.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project Group Role Filter
|
||||||
|
*
|
||||||
|
* @package model
|
||||||
|
* @author Frederic Guillot
|
||||||
|
*/
|
||||||
|
class ProjectGroupRoleFilter extends Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Query
|
||||||
|
*
|
||||||
|
* @access protected
|
||||||
|
* @var \PicoDb\Table
|
||||||
|
*/
|
||||||
|
protected $query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize filter
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @return UserFilter
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->query = $this->db->table(ProjectGroupRole::TABLE);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all results of the filter
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param string $column
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function findAll($column = '')
|
||||||
|
{
|
||||||
|
if ($column !== '') {
|
||||||
|
return $this->query->asc($column)->findAllByColumn($column);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->query->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PicoDb query
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @return \PicoDb\Table
|
||||||
|
*/
|
||||||
|
public function getQuery()
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by project id
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param integer $project_id
|
||||||
|
* @return ProjectUserRoleFilter
|
||||||
|
*/
|
||||||
|
public function filterByProjectId($project_id)
|
||||||
|
{
|
||||||
|
$this->query->eq(ProjectGroupRole::TABLE.'.project_id', $project_id);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by username
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param string $input
|
||||||
|
* @return ProjectUserRoleFilter
|
||||||
|
*/
|
||||||
|
public function startWithUsername($input)
|
||||||
|
{
|
||||||
|
$this->query
|
||||||
|
->join(GroupMember::TABLE, 'group_id', 'group_id', ProjectGroupRole::TABLE)
|
||||||
|
->join(User::TABLE, 'id', 'user_id', GroupMember::TABLE)
|
||||||
|
->ilike(User::TABLE.'.username', $input.'%');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,25 @@ class ProjectPermission extends Base
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all usernames (fetch users from groups)
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param integer $project_id
|
||||||
|
* @param string $input
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function findUsernames($project_id, $input)
|
||||||
|
{
|
||||||
|
$userMembers = $this->projectUserRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username');
|
||||||
|
$groupMembers = $this->projectGroupRoleFilter->create()->filterByProjectId($project_id)->startWithUsername($input)->findAll('username');
|
||||||
|
$members = array_unique(array_merge($userMembers, $groupMembers));
|
||||||
|
|
||||||
|
sort($members);
|
||||||
|
|
||||||
|
return $members;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if everybody is allowed for the project
|
* Return true if everybody is allowed for the project
|
||||||
*
|
*
|
||||||
|
|||||||
88
app/Model/ProjectUserRoleFilter.php
Normal file
88
app/Model/ProjectUserRoleFilter.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project User Role Filter
|
||||||
|
*
|
||||||
|
* @package model
|
||||||
|
* @author Frederic Guillot
|
||||||
|
*/
|
||||||
|
class ProjectUserRoleFilter extends Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Query
|
||||||
|
*
|
||||||
|
* @access protected
|
||||||
|
* @var \PicoDb\Table
|
||||||
|
*/
|
||||||
|
protected $query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize filter
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @return UserFilter
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->query = $this->db->table(ProjectUserRole::TABLE);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all results of the filter
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param string $column
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function findAll($column = '')
|
||||||
|
{
|
||||||
|
if ($column !== '') {
|
||||||
|
return $this->query->asc($column)->findAllByColumn($column);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->query->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PicoDb query
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @return \PicoDb\Table
|
||||||
|
*/
|
||||||
|
public function getQuery()
|
||||||
|
{
|
||||||
|
return $this->query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by project id
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param integer $project_id
|
||||||
|
* @return ProjectUserRoleFilter
|
||||||
|
*/
|
||||||
|
public function filterByProjectId($project_id)
|
||||||
|
{
|
||||||
|
$this->query->eq(ProjectUserRole::TABLE.'.project_id', $project_id);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter by username
|
||||||
|
*
|
||||||
|
* @access public
|
||||||
|
* @param string $input
|
||||||
|
* @return ProjectUserRoleFilter
|
||||||
|
*/
|
||||||
|
public function startWithUsername($input)
|
||||||
|
{
|
||||||
|
$this->query
|
||||||
|
->join(User::TABLE, 'id', 'user_id')
|
||||||
|
->ilike(User::TABLE.'.username', $input.'%');
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ class AuthenticationProvider implements ServiceProviderInterface
|
|||||||
$acl->add('Taskmodification', '*', Role::PROJECT_MEMBER);
|
$acl->add('Taskmodification', '*', Role::PROJECT_MEMBER);
|
||||||
$acl->add('Taskstatus', '*', Role::PROJECT_MEMBER);
|
$acl->add('Taskstatus', '*', Role::PROJECT_MEMBER);
|
||||||
$acl->add('Timer', '*', Role::PROJECT_MEMBER);
|
$acl->add('Timer', '*', Role::PROJECT_MEMBER);
|
||||||
|
$acl->add('UserHelper', array('mention'), Role::PROJECT_MEMBER);
|
||||||
|
|
||||||
return $acl;
|
return $acl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ class ClassProvider implements ServiceProviderInterface
|
|||||||
'ProjectNotification',
|
'ProjectNotification',
|
||||||
'ProjectMetadata',
|
'ProjectMetadata',
|
||||||
'ProjectGroupRole',
|
'ProjectGroupRole',
|
||||||
|
'ProjectGroupRoleFilter',
|
||||||
'ProjectUserRole',
|
'ProjectUserRole',
|
||||||
|
'ProjectUserRoleFilter',
|
||||||
'RememberMeSession',
|
'RememberMeSession',
|
||||||
'Subtask',
|
'Subtask',
|
||||||
'SubtaskExport',
|
'SubtaskExport',
|
||||||
|
|||||||
@@ -17,7 +17,18 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="write-area">
|
<div class="write-area">
|
||||||
<?= $this->form->textarea('comment', $values, $errors, array(! isset($skip_cancel) ? 'autofocus' : '', 'required', 'placeholder="'.t('Leave a comment').'"'), 'comment-textarea') ?>
|
<?= $this->form->textarea(
|
||||||
|
'comment',
|
||||||
|
$values,
|
||||||
|
$errors,
|
||||||
|
array(
|
||||||
|
! isset($skip_cancel) ? 'autofocus' : '',
|
||||||
|
'required',
|
||||||
|
'placeholder="'.t('Leave a comment').'"',
|
||||||
|
'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $task['project_id'])).'"',
|
||||||
|
),
|
||||||
|
'comment-textarea'
|
||||||
|
) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-area">
|
<div class="preview-area">
|
||||||
<div class="markdown"></div>
|
<div class="markdown"></div>
|
||||||
|
|||||||
@@ -22,7 +22,16 @@
|
|||||||
|
|
||||||
<div class="form-tabs">
|
<div class="form-tabs">
|
||||||
<div class="write-area">
|
<div class="write-area">
|
||||||
<?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"')) ?>
|
<?= $this->form->textarea(
|
||||||
|
'description',
|
||||||
|
$values,
|
||||||
|
$errors,
|
||||||
|
array(
|
||||||
|
'placeholder="'.t('Leave a description').'"',
|
||||||
|
'tabindex="2"',
|
||||||
|
'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $values['project_id'])).'"'
|
||||||
|
)
|
||||||
|
) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-area">
|
<div class="preview-area">
|
||||||
<div class="markdown"></div>
|
<div class="markdown"></div>
|
||||||
|
|||||||
@@ -17,7 +17,17 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="write-area">
|
<div class="write-area">
|
||||||
<?= $this->form->textarea('description', $values, $errors, array('autofocus', 'placeholder="'.t('Leave a description').'"'), 'task-show-description-textarea') ?>
|
<?= $this->form->textarea(
|
||||||
|
'description',
|
||||||
|
$values,
|
||||||
|
$errors,
|
||||||
|
array(
|
||||||
|
'autofocus',
|
||||||
|
'placeholder="'.t('Leave a description').'"',
|
||||||
|
'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $task['project_id'])).'"'
|
||||||
|
),
|
||||||
|
'task-show-description-textarea'
|
||||||
|
) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-area">
|
<div class="preview-area">
|
||||||
<div class="markdown"></div>
|
<div class="markdown"></div>
|
||||||
|
|||||||
@@ -13,7 +13,16 @@
|
|||||||
<?= $this->form->label(t('Description'), 'description') ?>
|
<?= $this->form->label(t('Description'), 'description') ?>
|
||||||
<div class="form-tabs">
|
<div class="form-tabs">
|
||||||
<div class="write-area">
|
<div class="write-area">
|
||||||
<?= $this->form->textarea('description', $values, $errors, array('placeholder="'.t('Leave a description').'"', 'tabindex="2"')) ?>
|
<?= $this->form->textarea(
|
||||||
|
'description',
|
||||||
|
$values,
|
||||||
|
$errors,
|
||||||
|
array(
|
||||||
|
'placeholder="'.t('Leave a description').'"',
|
||||||
|
'tabindex="2"',
|
||||||
|
'data-mention-search-url="'.$this->url->href('UserHelper', 'mention', array('project_id' => $task['project_id'])).'"'
|
||||||
|
)
|
||||||
|
) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-area">
|
<div class="preview-area">
|
||||||
<div class="markdown"></div>
|
<div class="markdown"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ ul.dropdown-submenu-open {
|
|||||||
box-shadow: 0px 1px 3px rgba(0,0,0,0.15);
|
box-shadow: 0px 1px 3px rgba(0,0,0,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-dropdown li,
|
||||||
.dropdown-submenu-open li {
|
.dropdown-submenu-open li {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -34,19 +35,25 @@ ul.dropdown-submenu-open {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-dropdown li:last-child,
|
||||||
.dropdown-submenu-open li:last-child {
|
.dropdown-submenu-open li:last-child {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-dropdown .active,
|
||||||
|
.textarea-dropdown li:hover,
|
||||||
.dropdown-submenu-open li:hover {
|
.dropdown-submenu-open li:hover {
|
||||||
background: #4078C0;
|
background: #4078C0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-dropdown .active a,
|
||||||
|
.textarea-dropdown li:hover a,
|
||||||
.dropdown-submenu-open li:hover a {
|
.dropdown-submenu-open li:hover a {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textarea-dropdown a,
|
||||||
.dropdown-submenu-open a {
|
.dropdown-submenu-open a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -59,3 +66,14 @@ ul.dropdown-submenu-open {
|
|||||||
.page-header .dropdown {
|
.page-header .dropdown {
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* textarea dropdown */
|
||||||
|
.textarea-dropdown {
|
||||||
|
list-style: none;
|
||||||
|
margin: 3px 0 0 1px;
|
||||||
|
padding: 6px 0;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #b2b2b2;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0px 1px 3px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|||||||
1229
assets/js/app.js
1229
assets/js/app.js
File diff suppressed because one or more lines are too long
@@ -40,6 +40,21 @@ Dropdown.prototype.listen = function() {
|
|||||||
$(this).find('a:visible')[0].click(); // Calling native click() not the jQuery one
|
$(this).find('a:visible')[0].click(); // Calling native click() not the jQuery one
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User mention autocomplete
|
||||||
|
$('textarea[data-mention-search-url]').textcomplete([{
|
||||||
|
match: /(^|\s)@(\w*)$/,
|
||||||
|
search: function (term, callback) {
|
||||||
|
var url = $('textarea[data-mention-search-url]').data('mention-search-url');
|
||||||
|
$.getJSON(url, { q: term })
|
||||||
|
.done(function (resp) { callback(resp); })
|
||||||
|
.fail(function () { callback([]); });
|
||||||
|
},
|
||||||
|
replace: function (value) {
|
||||||
|
return '$1@' + value + ' ';
|
||||||
|
},
|
||||||
|
cache: true
|
||||||
|
}], {className: "textarea-dropdown"});
|
||||||
};
|
};
|
||||||
|
|
||||||
Dropdown.prototype.close = function() {
|
Dropdown.prototype.close = function() {
|
||||||
|
|||||||
1227
assets/js/vendor/jquery.textcomplete.js
vendored
Normal file
1227
assets/js/vendor/jquery.textcomplete.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,33 @@ use Kanboard\Core\Security\Role;
|
|||||||
|
|
||||||
class ProjectPermissionTest extends Base
|
class ProjectPermissionTest extends Base
|
||||||
{
|
{
|
||||||
|
public function testFindByUsernames()
|
||||||
|
{
|
||||||
|
$userModel = new User($this->container);
|
||||||
|
$projectModel = new Project($this->container);
|
||||||
|
$groupModel = new Group($this->container);
|
||||||
|
$groupMemberModel = new GroupMember($this->container);
|
||||||
|
$groupRoleModel = new ProjectGroupRole($this->container);
|
||||||
|
$userRoleModel = new ProjectUserRole($this->container);
|
||||||
|
$projectPermissionModel = new ProjectPermission($this->container);
|
||||||
|
|
||||||
|
$this->assertEquals(1, $projectModel->create(array('name' => 'Project 1')));
|
||||||
|
|
||||||
|
$this->assertEquals(2, $userModel->create(array('username' => 'user1')));
|
||||||
|
$this->assertEquals(3, $userModel->create(array('username' => 'user2')));
|
||||||
|
$this->assertEquals(4, $userModel->create(array('username' => 'user3')));
|
||||||
|
|
||||||
|
$this->assertEquals(1, $groupModel->create('Group A'));
|
||||||
|
$this->assertTrue($groupMemberModel->addUser(1, 2));
|
||||||
|
|
||||||
|
$this->assertTrue($groupRoleModel->addGroup(1, 1, Role::PROJECT_MEMBER));
|
||||||
|
$this->assertTrue($userRoleModel->addUser(1, 3, Role::PROJECT_MANAGER));
|
||||||
|
|
||||||
|
$this->assertEquals(array('user1', 'user2'), $projectPermissionModel->findUsernames(1, 'us'));
|
||||||
|
$this->assertEmpty($projectPermissionModel->findUsernames(1, 'a'));
|
||||||
|
$this->assertEmpty($projectPermissionModel->findUsernames(2, 'user'));
|
||||||
|
}
|
||||||
|
|
||||||
public function testGetQueryByRole()
|
public function testGetQueryByRole()
|
||||||
{
|
{
|
||||||
$userModel = new User($this->container);
|
$userModel = new User($this->container);
|
||||||
|
|||||||
Reference in New Issue
Block a user