Add suggest menu for task ID

This commit is contained in:
Frederic Guillot 2016-12-03 12:56:12 -05:00
parent 4b22db5400
commit 23d862aef8
No known key found for this signature in database
GPG Key ID: 92D77191BA7FBC99
24 changed files with 382 additions and 36 deletions

View File

@ -1,13 +1,18 @@
Version 1.0.35
--------------
New features:
* Rewrite of Markdown editor (remove CodeMirror)
* Suggest menu for task ID and user mentions in Markdown editor
Improvements:
* Add button to close inline popups
* Simplify `.htaccess` to avoid potential issues with possible specific Apache configurations
* Replace notifications Javascript code by CSS
* Refactoring of user mentions job
* Remove nitrous installer
* Remove Nitrous installer
Breaking changes:

View File

@ -5,8 +5,12 @@ namespace Kanboard\Controller;
use Kanboard\Filter\TaskIdExclusionFilter;
use Kanboard\Filter\TaskIdFilter;
use Kanboard\Filter\TaskProjectsFilter;
use Kanboard\Filter\TaskStartsWithIdFilter;
use Kanboard\Filter\TaskStatusFilter;
use Kanboard\Filter\TaskTitleFilter;
use Kanboard\Formatter\TaskAutoCompleteFormatter;
use Kanboard\Formatter\TaskSuggestMenuFormatter;
use Kanboard\Model\TaskModel;
/**
* Task Ajax Controller
@ -19,7 +23,6 @@ class TaskAjaxController extends BaseController
/**
* Task auto-completion (Ajax)
*
* @access public
*/
public function autocomplete()
{
@ -46,4 +49,24 @@ class TaskAjaxController extends BaseController
$this->response->json($filter->format(new TaskAutoCompleteFormatter($this->container)));
}
}
/**
* Task ID suggest menu
*/
public function suggest()
{
$taskId = $this->request->getIntegerParam('search');
$projectIds = $this->projectPermissionModel->getActiveProjectIds($this->userSession->getId());
if (empty($projectIds)) {
$this->response->json(array());
} else {
$filter = $this->taskQuery
->withFilter(new TaskProjectsFilter($projectIds))
->withFilter(new TaskStatusFilter(TaskModel::STATUS_OPEN))
->withFilter(new TaskStartsWithIdFilter($taskId));
$this->response->json($filter->format(new TaskSuggestMenuFormatter($this->container)));
}
}
}

View File

@ -36,7 +36,7 @@ class UserAjaxController extends BaseController
public function mention()
{
$project_id = $this->request->getStringParam('project_id');
$query = $this->request->getStringParam('q');
$query = $this->request->getStringParam('search');
$users = $this->projectPermissionModel->findUsernames($project_id, $query);
$this->response->json(

View File

@ -90,7 +90,7 @@ class Markdown extends Parsedown
$user_id = $this->container['userModel']->getIdByUsername($matches[1]);
if (! empty($user_id)) {
$url = $this->container['helper']->url->href('UserViewController', 'profile', array('user_id' => $user_id), false, '', true);
$url = $this->container['helper']->url->href('UserViewController', 'profile', array('user_id' => $user_id));
return array(
'extent' => strlen($matches[0]),
@ -125,7 +125,10 @@ class Markdown extends Parsedown
array(
'token' => $token,
'task_id' => $task_id,
)
),
false,
'',
true
);
}

View File

@ -0,0 +1,38 @@
<?php
namespace Kanboard\Filter;
use Kanboard\Core\Filter\FilterInterface;
use Kanboard\Model\TaskModel;
/**
* Class TaskIdSearchFilter
*
* @package Kanboard\Filter
* @author Frederic Guillot
*/
class TaskStartsWithIdFilter extends BaseFilter implements FilterInterface
{
/**
* Get search attribute
*
* @access public
* @return string[]
*/
public function getAttributes()
{
return array('starts_with_id');
}
/**
* Apply filter
*
* @access public
* @return FilterInterface
*/
public function apply()
{
$this->query->ilike('CAST('.TaskModel::TABLE.'.id AS CHAR(8))', $this->value.'%');
return $this;
}
}

View File

@ -14,6 +14,20 @@ use Kanboard\Model\TaskModel;
*/
class TaskAutoCompleteFormatter extends BaseFormatter implements FormatterInterface
{
protected $limit = 25;
/**
* Limit number of results
*
* @param $limit
* @return $this
*/
public function withLimit($limit)
{
$this->limit = $limit;
return $this;
}
/**
* Apply formatter
*
@ -22,11 +36,15 @@ class TaskAutoCompleteFormatter extends BaseFormatter implements FormatterInterf
*/
public function format()
{
$tasks = $this->query->columns(
$tasks = $this->query
->columns(
TaskModel::TABLE.'.id',
TaskModel::TABLE.'.title',
ProjectModel::TABLE.'.name AS project_name'
)->asc(TaskModel::TABLE.'.id')->findAll();
)
->asc(TaskModel::TABLE.'.id')
->limit($this->limit)
->findAll();
foreach ($tasks as &$task) {
$task['value'] = $task['title'];

View File

@ -0,0 +1,63 @@
<?php
namespace Kanboard\Formatter;
use Kanboard\Core\Filter\FormatterInterface;
use Kanboard\Model\ProjectModel;
use Kanboard\Model\TaskModel;
/**
* Class TaskSuggestMenuFormatter
*
* @package Kanboard\Formatter
* @author Frederic Guillot
*/
class TaskSuggestMenuFormatter extends BaseFormatter implements FormatterInterface
{
protected $limit = 25;
/**
* Limit number of results
*
* @param $limit
* @return $this
*/
public function withLimit($limit)
{
$this->limit = $limit;
return $this;
}
/**
* Apply formatter
*
* @access public
* @return mixed
*/
public function format()
{
$result = array();
$tasks = $this->query
->columns(
TaskModel::TABLE.'.id',
TaskModel::TABLE.'.title',
ProjectModel::TABLE.'.name AS project_name'
)
->asc(TaskModel::TABLE.'.id')
->limit($this->limit)
->findAll();
foreach ($tasks as $task) {
$html = '#'.$task['id'].' ';
$html .= $this->helper->text->e($task['title']).' ';
$html .= '<small>'.$this->helper->text->e($task['project_name']).'</small>';
$result[] = array(
'value' => (string) $task['id'],
'html' => $html,
);
}
return $result;
}
}

View File

@ -220,11 +220,16 @@ class FormHelper extends Base
'labelPreview' => t('Preview'),
'labelWrite' => t('Write'),
'placeholder' => t('Write your text in Markdown'),
'autofocus' => isset($attributes['autofocus']) && $attributes['autofocus']
'autofocus' => isset($attributes['autofocus']) && $attributes['autofocus'],
'suggestOptions' => array(
'triggers' => array(
'#' => $this->helper->url->to('TaskAjaxController', 'suggest', array('search' => 'SEARCH_TERM')),
)
),
);
if (isset($values['project_id'])) {
$params['mentionUrl'] = $this->helper->url->to('UserAjaxController', 'mention', array('project_id' => $values['project_id']));
$params['suggestOptions']['triggers']['@'] = $this->helper->url->to('UserAjaxController', 'mention', array('project_id' => $values['project_id'], 'search' => 'SEARCH_TERM'));
}
$html = '<div class="js-text-editor" data-params=\''.json_encode($params, JSON_HEX_APOS).'\'></div>';

View File

@ -6,6 +6,6 @@
<h3><?= t('New comment') ?></h3>
<?php endif ?>
<?= $this->text->markdown($comment['comment']) ?>
<?= $this->text->markdown($comment['comment'], true) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -2,6 +2,6 @@
<h3><?= t('Comment removed') ?></h3>
<?= $this->text->markdown($comment['comment']) ?>
<?= $this->text->markdown($comment['comment'], true) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -2,6 +2,6 @@
<h3><?= t('Comment updated') ?></h3>
<?= $this->text->markdown($comment['comment']) ?>
<?= $this->text->markdown($comment['comment'], true) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -2,6 +2,6 @@
<p><?= $this->text->e($task['title']) ?></p>
<?= $this->text->markdown($comment['comment']) ?>
<?= $this->text->markdown($comment['comment'], true) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -14,7 +14,7 @@
<?php if (! empty($task['description'])): ?>
<h2><?= t('Description') ?></h2>
<?= $this->text->markdown($task['description']) ?: t('There is no description.') ?>
<?= $this->text->markdown($task['description'], true) ?: t('There is no description.') ?>
<?php endif ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -37,7 +37,7 @@
<?php if (! empty($task['description'])): ?>
<h2><?= t('Description') ?></h2>
<?= $this->text->markdown($task['description']) ?>
<?= $this->text->markdown($task['description'], true) ?>
<?php endif ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -1,4 +1,4 @@
<h2><?= $this->text->e($task['title']) ?> (#<?= $task['id'] ?>)</h2>
<?= $this->render('task/changes', array('changes' => $changes, 'task' => $task)) ?>
<?= $this->render('task/changes', array('changes' => $changes, 'task' => $task, 'public' => true)) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -2,6 +2,6 @@
<p><?= $this->text->e($task['title']) ?></p>
<h2><?= t('Description') ?></h2>
<?= $this->text->markdown($task['description']) ?>
<?= $this->text->markdown($task['description'], true) ?>
<?= $this->render('notification/footer', array('task' => $task, 'application_url' => $application_url)) ?>

View File

@ -69,6 +69,10 @@
<?php if (! empty($changes['description'])): ?>
<p><strong><?= t('The description has been modified:') ?></strong></p>
<?php if (isset($public)): ?>
<div class="markdown"><?= $this->text->markdown($task['description'], true) ?></div>
<?php else: ?>
<div class="markdown"><?= $this->text->markdown($task['description']) ?></div>
<?php endif ?>
<?php endif ?>
<?php endif ?>

File diff suppressed because one or more lines are too long

View File

@ -139,13 +139,16 @@ KB.component('suggest-menu', function(containerElement, options) {
return null;
}
function fetchItems(trigger, text, value) {
if (typeof value === 'string') {
KB.http.get(value).success(function (response) {
function fetchItems(trigger, text, params) {
if (typeof params === 'string') {
var regex = new RegExp('SEARCH_TERM', 'g');
var url = params.replace(regex, text);
KB.http.get(url).success(function (response) {
onItemFetched(trigger, text, response);
});
} else {
onItemFetched(trigger, text, value);
onItemFetched(trigger, text, params);
}
}

View File

@ -70,8 +70,8 @@ KB.component('text-editor', function (containerElement, options) {
textarea = textareaElement.build();
if (options.mentionUrl) {
KB.getComponent('suggest-menu', textarea, {triggers: {'@': options.mentionUrl}}).render();
if (options.suggestOptions) {
KB.getComponent('suggest-menu', textarea, options.suggestOptions).render();
}
return KB.dom('div')

View File

@ -0,0 +1,103 @@
<?php
use Kanboard\Filter\TaskStartsWithIdFilter;
use Kanboard\Model\ProjectModel;
use Kanboard\Model\TaskCreationModel;
use Kanboard\Model\TaskFinderModel;
require_once __DIR__.'/../Base.php';
class TaskStartsWithIdFilterTest extends Base
{
public function testManyResults()
{
$taskFinderModel = new TaskFinderModel($this->container);
$projectModel = new ProjectModel($this->container);
$taskCreationModel = new TaskCreationModel($this->container);
$query = $taskFinderModel->getExtendedQuery();
$this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
for ($i = 1; $i <= 20; $i++) {
$this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'Task #'.$i)));
}
$filter = new TaskStartsWithIdFilter();
$filter->withQuery($query);
$filter->withValue(1);
$filter->apply();
$tasks = $query->findAll();
$this->assertCount(11, $tasks);
$this->assertEquals('Task #1', $tasks[0]['title']);
$this->assertEquals('Task #19', $tasks[10]['title']);
}
public function testOneResult()
{
$taskFinderModel = new TaskFinderModel($this->container);
$projectModel = new ProjectModel($this->container);
$taskCreationModel = new TaskCreationModel($this->container);
$query = $taskFinderModel->getExtendedQuery();
$this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
for ($i = 1; $i <= 20; $i++) {
$this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'Task #'.$i)));
}
$filter = new TaskStartsWithIdFilter();
$filter->withQuery($query);
$filter->withValue(3);
$filter->apply();
$tasks = $query->findAll();
$this->assertCount(1, $tasks);
$this->assertEquals('Task #3', $tasks[0]['title']);
}
public function testEmptyResult()
{
$taskFinderModel = new TaskFinderModel($this->container);
$projectModel = new ProjectModel($this->container);
$taskCreationModel = new TaskCreationModel($this->container);
$query = $taskFinderModel->getExtendedQuery();
$this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
for ($i = 1; $i <= 20; $i++) {
$this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'Task #'.$i)));
}
$filter = new TaskStartsWithIdFilter();
$filter->withQuery($query);
$filter->withValue(30);
$filter->apply();
$tasks = $query->findAll();
$this->assertCount(0, $tasks);
}
public function testWithTwoDigits()
{
$taskFinderModel = new TaskFinderModel($this->container);
$projectModel = new ProjectModel($this->container);
$taskCreationModel = new TaskCreationModel($this->container);
$query = $taskFinderModel->getExtendedQuery();
$this->assertEquals(1, $projectModel->create(array('name' => 'Test')));
for ($i = 1; $i <= 20; $i++) {
$this->assertNotFalse($taskCreationModel->create(array('project_id' => 1, 'title' => 'Task #'.$i)));
}
$filter = new TaskStartsWithIdFilter();
$filter->withQuery($query);
$filter->withValue(11);
$filter->apply();
$tasks = $query->findAll();
$this->assertCount(1, $tasks);
$this->assertEquals('Task #11', $tasks[0]['title']);
}
}

View File

@ -0,0 +1,39 @@
<?php
use Kanboard\Formatter\TaskSuggestMenuFormatter;
use Kanboard\Model\ProjectModel;
use Kanboard\Model\TaskCreationModel;
require_once __DIR__.'/../Base.php';
class TaskSuggestMenuFormatterTest extends Base
{
public function testFormat()
{
$projectModel = new ProjectModel($this->container);
$taskCreationModel = new TaskCreationModel($this->container);
$taskSuggestMenuFormatter = new TaskSuggestMenuFormatter($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)));
$result = $taskSuggestMenuFormatter
->withQuery($this->container['taskFinderModel']->getExtendedQuery())
->format()
;
$expected = array(
array(
'value' => '1',
'html' => '#1 Task 1 <small>My Project</small>',
),
array(
'value' => '2',
'html' => '#2 Task 2 <small>My Project</small>',
),
);
$this->assertSame($expected, $result);
}
}

View File

@ -0,0 +1,42 @@
<?php
use Kanboard\Formatter\UserMentionFormatter;
require_once __DIR__.'/../Base.php';
class UserMentionFormatterTest extends Base
{
public function testFormat()
{
$userMentionFormatter = new UserMentionFormatter($this->container);
$users = array(
array(
'id' => 1,
'username' => 'someone',
'name' => 'Someone',
'email' => 'test@localhost',
'avatar_path' => 'avatar_image',
),
array(
'id' => 2,
'username' => 'somebody',
'name' => '',
'email' => '',
'avatar_path' => '',
)
);
$expected = array(
array(
'value' => 'someone',
'html' => '<div class="avatar avatar-20 avatar-inline"><img src="?controller=AvatarFileController&amp;action=image&amp;user_id=1&amp;size=20" alt="Someone" title="Someone"></div> someone <small>Someone</small>',
),
array(
'value' => 'somebody',
'html' => '<div class="avatar avatar-20 avatar-inline"><div class="avatar-letter" style="background-color: rgb(191, 210, 121)" title="somebody">S</div></div> somebody',
),
);
$this->assertSame($expected, $userMentionFormatter->withUsers($users)->format());
}
}

View File

@ -32,7 +32,7 @@ class TextHelperTest extends Base
);
$this->assertEquals(
'<p>Task <a href="?controller=TaskViewController&amp;action=readonly&amp;token='.$project['token'].'&amp;task_id=1">#1</a></p>',
'<p>Task <a href="http://localhost/?controller=TaskViewController&amp;action=readonly&amp;token='.$project['token'].'&amp;task_id=1">#1</a></p>',
$helper->markdown('Task #1', true)
);
@ -47,12 +47,12 @@ class TextHelperTest extends Base
public function testMarkdownUserLink()
{
$h = new TextHelper($this->container);
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a> @notfound</p>', $h->markdown('Text @admin @notfound'));
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>,</p>', $h->markdown('Text @admin,'));
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>!</p>', $h->markdown('Text @admin!'));
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>? </p>', $h->markdown('Text @admin? '));
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>.</p>', $h->markdown('Text @admin.'));
$this->assertEquals('<p>Text <a href="http://localhost/?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>: test</p>', $h->markdown('Text @admin: test'));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a> @notfound</p>', $h->markdown('Text @admin @notfound'));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>,</p>', $h->markdown('Text @admin,'));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>!</p>', $h->markdown('Text @admin!'));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>? </p>', $h->markdown('Text @admin? '));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>.</p>', $h->markdown('Text @admin.'));
$this->assertEquals('<p>Text <a href="?controller=UserViewController&amp;action=profile&amp;user_id=1" class="user-mention-link">@admin</a>: test</p>', $h->markdown('Text @admin: test'));
$this->assertEquals('<p>Text @admin @notfound</p>', $h->markdown('Text @admin @notfound', true));
}