Merge pull request #2934 from nextcloud/enh/advanced-search
This commit is contained in:
@@ -141,5 +141,7 @@ return [
|
||||
['name' => 'comments_api#delete', 'url' => '/api/v{apiVersion}/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'],
|
||||
|
||||
['name' => 'overview_api#upcomingCards', 'url' => '/api/v{apiVersion}/overview/upcoming', 'verb' => 'GET'],
|
||||
|
||||
['name' => 'search#search', 'url' => '/api/v{apiVersion}/search', 'verb' => 'GET'],
|
||||
]
|
||||
];
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
{
|
||||
"name": "nextcloud/deck",
|
||||
"type": "project",
|
||||
"license": "AGPLv3",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Julius Härtl",
|
||||
"email": "jus@bitgrid.net"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"cogpowered/finediff": "0.3.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-master",
|
||||
"christophwurst/nextcloud": "^21@dev",
|
||||
"phpunit/phpunit": "^8",
|
||||
"nextcloud/coding-standard": "^0.5.0",
|
||||
"symfony/event-dispatcher": "^4.0",
|
||||
"vimeo/psalm": "^4.3",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"classmap-authoritative": true
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"name": "nextcloud/deck",
|
||||
"type": "project",
|
||||
"license": "AGPLv3",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Julius Härtl",
|
||||
"email": "jus@bitgrid.net"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"cogpowered/finediff": "0.3.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"roave/security-advisories": "dev-master",
|
||||
"christophwurst/nextcloud": "^21@dev",
|
||||
"phpunit/phpunit": "^8",
|
||||
"nextcloud/coding-standard": "^0.5.0",
|
||||
"symfony/event-dispatcher": "^4.0",
|
||||
"vimeo/psalm": "^4.3",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"classmap-authoritative": true
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l",
|
||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
||||
"cs:fix": "php-cs-fixer fix",
|
||||
"psalm": "psalm",
|
||||
"psalm:fix": "psalm --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType"
|
||||
}
|
||||
"psalm:fix": "psalm --alter --issues=InvalidReturnType,InvalidNullableReturnType,MismatchingDocblockParamType,MismatchingDocblockReturnType,MissingParamType,InvalidFalsableReturnType",
|
||||
"test": [
|
||||
"@test:unit",
|
||||
"@test:integration"
|
||||
],
|
||||
"test:unit": "phpunit -c tests/phpunit.xml",
|
||||
"test:integration": "phpunit -c tests/phpunit.integration.xml && cd tests/integration && ./run.sh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +69,25 @@ The **sharing tab** allows you to add users or even groups to your boards.
|
||||
**Deleted objects** allows you to return previously deleted stacks or cards.
|
||||
The **Timeline** allows you to see everything that happened in your boards. Everything!
|
||||
|
||||
## Search
|
||||
|
||||
Deck provides a global search either through the unified search in the Nextcloud header or with the inline search next to the board controls.
|
||||
This search allows advanced filtering of cards across all board of the logged in user.
|
||||
|
||||
For example the search `project tag:ToDo assigned:alice assigned:bob` will return all cards where the card title or description contains project **and** the tag ToDo is set **and** the user alice is assigned **and** the user bob is assigned.
|
||||
|
||||
### Supported search filters
|
||||
|
||||
| Filter | Operators | Query |
|
||||
| ----------- | ----------------- | ------------------------------------------------------------ |
|
||||
| title | `:` | text token used for a case-insentitive search on the cards title |
|
||||
| description | `:` | text token used for a case-insentitive search on the cards description |
|
||||
| list | `:` | text token used for a case-insentitive search on the cards list name |
|
||||
| tag | `:` | text token used for a case-insentitive search on the assigned tags |
|
||||
| date | `:` | 'overdue', 'today', 'week', 'month', 'none' |
|
||||
| | `>` `<` `>=` `<=` | Compare the card due date to the passed date (see [supported date formats](https://www.php.net/manual/de/datetime.formats.php)) Card due dates are always considered UTC for comparison |
|
||||
| assigned | `:` | id or displayname of a user or group for a search on the assigned users or groups |
|
||||
|
||||
Other text tokens will be used to perform a case-insensitive search on the card title and description
|
||||
|
||||
In addition wuotes can be used to pass a query with spaces, e.g. `"Exact match with spaces"` or `title:"My card"`.
|
||||
|
||||
@@ -47,6 +47,7 @@ use OCA\Deck\Listeners\FullTextSearchEventListener;
|
||||
use OCA\Deck\Middleware\DefaultBoardMiddleware;
|
||||
use OCA\Deck\Middleware\ExceptionMiddleware;
|
||||
use OCA\Deck\Notification\Notifier;
|
||||
use OCA\Deck\Search\CardCommentProvider;
|
||||
use OCA\Deck\Search\DeckProvider;
|
||||
use OCA\Deck\Service\PermissionService;
|
||||
use OCA\Deck\Sharing\DeckShareProvider;
|
||||
@@ -95,9 +96,7 @@ class Application extends App implements IBootstrap {
|
||||
$context->injectFn(Closure::fromCallable([$this, 'registerCollaborationResources']));
|
||||
|
||||
$context->injectFn(function (IManager $shareManager) {
|
||||
if (method_exists($shareManager, 'registerShareProvider')) {
|
||||
$shareManager->registerShareProvider(DeckShareProvider::class);
|
||||
}
|
||||
$shareManager->registerShareProvider(DeckShareProvider::class);
|
||||
});
|
||||
|
||||
$context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) {
|
||||
@@ -122,6 +121,7 @@ class Application extends App implements IBootstrap {
|
||||
});
|
||||
|
||||
$context->registerSearchProvider(DeckProvider::class);
|
||||
$context->registerSearchProvider(CardCommentProvider::class);
|
||||
$context->registerDashboardWidget(DeckWidget::class);
|
||||
|
||||
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
|
||||
|
||||
59
lib/Controller/SearchController.php
Normal file
59
lib/Controller/SearchController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Controller;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Service\SearchService;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\OCSController;
|
||||
use OCP\IRequest;
|
||||
|
||||
class SearchController extends OCSController {
|
||||
|
||||
/**
|
||||
* @var SearchService
|
||||
*/
|
||||
private $searchService;
|
||||
|
||||
public function __construct(string $appName, IRequest $request, SearchService $searchService) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->searchService = $searchService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*/
|
||||
public function search(string $term, ?int $limit = null, ?int $cursor = null): DataResponse {
|
||||
$cards = $this->searchService->searchCards($term, $limit, $cursor);
|
||||
return new DataResponse(array_map(function (Card $card) {
|
||||
$json = $card->jsonSerialize();
|
||||
$json['relatedStack'] = $card->getRelatedStack();
|
||||
$json['relatedBoard'] = $card->getRelatedBoard();
|
||||
return $json;
|
||||
}, $cards));
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,9 @@ class Card extends RelationalEntity {
|
||||
protected $notified = false;
|
||||
protected $deletedAt = 0;
|
||||
protected $commentsUnread = 0;
|
||||
|
||||
protected $relatedStack = null;
|
||||
protected $relatedBoard = null;
|
||||
|
||||
private $databaseType = 'sqlite';
|
||||
|
||||
@@ -73,6 +76,9 @@ class Card extends RelationalEntity {
|
||||
$this->addRelation('participants');
|
||||
$this->addRelation('commentsUnread');
|
||||
$this->addResolvable('owner');
|
||||
|
||||
$this->addRelation('relatedStack');
|
||||
$this->addRelation('relatedBoard');
|
||||
}
|
||||
|
||||
public function setDatabaseType($type) {
|
||||
@@ -119,6 +125,8 @@ class Card extends RelationalEntity {
|
||||
$json['duedate'] = $this->getDuedate(true);
|
||||
unset($json['notified']);
|
||||
unset($json['descriptionPrev']);
|
||||
unset($json['relatedStack']);
|
||||
unset($json['relatedBoard']);
|
||||
return $json;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,11 +23,16 @@
|
||||
|
||||
namespace OCA\Deck\Db;
|
||||
|
||||
use DateTime;
|
||||
use Exception;
|
||||
use OCA\Deck\AppInfo\Application;
|
||||
use OCA\Deck\Search\Query\SearchQuery;
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\IGroupManager;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Notification\IManager;
|
||||
|
||||
@@ -37,6 +42,8 @@ class CardMapper extends QBMapper implements IPermissionMapper {
|
||||
private $labelMapper;
|
||||
/** @var IUserManager */
|
||||
private $userManager;
|
||||
/** @var IGroupManager */
|
||||
private $groupManager;
|
||||
/** @var IManager */
|
||||
private $notificationManager;
|
||||
private $databaseType;
|
||||
@@ -46,13 +53,15 @@ class CardMapper extends QBMapper implements IPermissionMapper {
|
||||
IDBConnection $db,
|
||||
LabelMapper $labelMapper,
|
||||
IUserManager $userManager,
|
||||
IGroupManager $groupManager,
|
||||
IManager $notificationManager,
|
||||
$databaseType = 'sqlite',
|
||||
$databaseType = 'sqlite3',
|
||||
$database4ByteSupport = true
|
||||
) {
|
||||
parent::__construct($db, 'deck_cards', Card::class);
|
||||
$this->labelMapper = $labelMapper;
|
||||
$this->userManager = $userManager;
|
||||
$this->groupManager = $groupManager;
|
||||
$this->notificationManager = $notificationManager;
|
||||
$this->databaseType = $databaseType;
|
||||
$this->database4ByteSupport = $database4ByteSupport;
|
||||
@@ -117,7 +126,7 @@ class CardMapper extends QBMapper implements IPermissionMapper {
|
||||
->addOrderBy('id');
|
||||
/** @var Card $card */
|
||||
$card = $this->findEntity($qb);
|
||||
$labels = $this->labelMapper->findAssignedLabelsForCard($card->id);
|
||||
$labels = $this->labelMapper->findAssignedLabelsForCard($card->getId());
|
||||
$card->setLabels($labels);
|
||||
$this->mapOwner($card);
|
||||
return $card;
|
||||
@@ -260,25 +269,209 @@ class CardMapper extends QBMapper implements IPermissionMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
public function search($boardIds, $term, $limit = null, $offset = null) {
|
||||
public function search(array $boardIds, SearchQuery $query, int $limit = null, int $offset = null): array {
|
||||
$qb = $this->queryCardsByBoards($boardIds);
|
||||
$qb->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||
$qb->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||
$qb->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->iLike('c.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%')),
|
||||
$qb->expr()->iLike('c.description', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%'))
|
||||
)
|
||||
);
|
||||
$this->extendQueryByFilter($qb, $query);
|
||||
|
||||
if (count($query->getTextTokens()) > 0) {
|
||||
$tokenMatching = $qb->expr()->andX(
|
||||
...array_map(function (string $token) use ($qb) {
|
||||
return $qb->expr()->orX(
|
||||
$qb->expr()->iLike(
|
||||
'c.title',
|
||||
$qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR),
|
||||
IQueryBuilder::PARAM_STR
|
||||
),
|
||||
$qb->expr()->iLike(
|
||||
'c.description',
|
||||
$qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR),
|
||||
IQueryBuilder::PARAM_STR
|
||||
)
|
||||
);
|
||||
}, $query->getTextTokens())
|
||||
);
|
||||
$qb->andWhere(
|
||||
$tokenMatching
|
||||
);
|
||||
}
|
||||
|
||||
$qb->groupBy('c.id');
|
||||
$qb->orderBy('c.last_modified', 'DESC');
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
$qb->andWhere($qb->expr()->lt('c.last_modified', $qb->createNamedParameter($offset, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
return $this->findEntities($qb);
|
||||
|
||||
$result = $qb->execute();
|
||||
$entities = [];
|
||||
while ($row = $result->fetch()) {
|
||||
$entities[] = Card::fromRow($row);
|
||||
}
|
||||
$result->closeCursor();
|
||||
return $entities;
|
||||
}
|
||||
|
||||
public function searchComments(array $boardIds, SearchQuery $query, int $limit = null, int $offset = null): array {
|
||||
if (count($query->getTextTokens()) === 0) {
|
||||
return [];
|
||||
}
|
||||
$qb = $this->queryCardsByBoards($boardIds);
|
||||
$this->extendQueryByFilter($qb, $query);
|
||||
|
||||
$qb->innerJoin('c', 'comments', 'comments', $qb->expr()->andX(
|
||||
$qb->expr()->eq('comments.object_id', 'c.id', IQueryBuilder::PARAM_STR),
|
||||
$qb->expr()->eq('comments.object_type', $qb->createNamedParameter(Application::COMMENT_ENTITY_TYPE, IQueryBuilder::PARAM_STR))
|
||||
));
|
||||
$qb->selectAlias('comments.id', 'comment_id');
|
||||
|
||||
$tokenMatching = $qb->expr()->andX(
|
||||
...array_map(function (string $token) use ($qb) {
|
||||
return $qb->expr()->iLike(
|
||||
'comments.message',
|
||||
$qb->createNamedParameter('%' . $this->db->escapeLikeParameter($token) . '%', IQueryBuilder::PARAM_STR),
|
||||
IQueryBuilder::PARAM_STR
|
||||
);
|
||||
}, $query->getTextTokens())
|
||||
);
|
||||
$qb->andWhere(
|
||||
$tokenMatching
|
||||
);
|
||||
|
||||
$qb->groupBy('comments.id');
|
||||
$qb->orderBy('comments.id', 'DESC');
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->andWhere($qb->expr()->lt('comments.id', $qb->createNamedParameter($offset, IQueryBuilder::PARAM_INT)));
|
||||
}
|
||||
|
||||
$result = $qb->execute();
|
||||
$entities = $result->fetchAll();
|
||||
$result->closeCursor();
|
||||
return $entities;
|
||||
}
|
||||
|
||||
private function extendQueryByFilter(IQueryBuilder $qb, SearchQuery $query) {
|
||||
$qb->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||
$qb->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||
$qb->innerJoin('s', 'deck_boards', 'b', $qb->expr()->eq('b.id', 's.board_id'));
|
||||
$qb->andWhere($qb->expr()->eq('b.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
|
||||
|
||||
foreach ($query->getTitle() as $title) {
|
||||
$qb->andWhere($qb->expr()->iLike('c.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($title->getValue()) . '%', IQueryBuilder::PARAM_STR)));
|
||||
}
|
||||
|
||||
foreach ($query->getDescription() as $description) {
|
||||
$qb->andWhere($qb->expr()->iLike('c.description', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($description->getValue()) . '%', IQueryBuilder::PARAM_STR)));
|
||||
}
|
||||
|
||||
foreach ($query->getStack() as $stack) {
|
||||
$qb->andWhere($qb->expr()->iLike('s.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($stack->getValue()) . '%', IQueryBuilder::PARAM_STR)));
|
||||
}
|
||||
|
||||
if (count($query->getTag())) {
|
||||
foreach ($query->getTag() as $index => $tag) {
|
||||
$qb->innerJoin('c', 'deck_assigned_labels', 'al' . $index, $qb->expr()->eq('c.id', 'al' . $index . '.card_id'));
|
||||
$qb->innerJoin('al'. $index, 'deck_labels', 'l' . $index, $qb->expr()->eq('al' . $index . '.label_id', 'l' . $index . '.id'));
|
||||
$qb->andWhere($qb->expr()->iLike('l' . $index . '.title', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($tag->getValue()) . '%', IQueryBuilder::PARAM_STR)));
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($query->getDuedate() as $duedate) {
|
||||
$dueDateColumn = $this->databaseType === 'sqlite3' ? $qb->createFunction('DATETIME(`c`.`duedate`)') : 'c.duedate';
|
||||
$date = $duedate->getValue();
|
||||
$supportedFilters = ['overdue', 'today', 'week', 'month', 'none'];
|
||||
if (in_array($date, $supportedFilters, true)) {
|
||||
$currentDate = new DateTime();
|
||||
$rangeDate = new DateTime();
|
||||
if ($date === 'overdue') {
|
||||
$qb->andWhere($qb->expr()->lt($dueDateColumn, $this->dateTimeParameter($qb, $currentDate)));
|
||||
} elseif ($date === 'today') {
|
||||
$rangeDate = $rangeDate->add(new \DateInterval('P1D'));
|
||||
$qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate)));
|
||||
$qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate)));
|
||||
} elseif ($date === 'week') {
|
||||
$rangeDate = $rangeDate->add(new \DateInterval('P7D'));
|
||||
$qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate)));
|
||||
$qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate)));
|
||||
} elseif ($date === 'month') {
|
||||
$rangeDate = $rangeDate->add(new \DateInterval('P1M'));
|
||||
$qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $currentDate)));
|
||||
$qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $rangeDate)));
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('c.duedate'));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$date = new DateTime($date);
|
||||
if ($duedate->getComparator() === SearchQuery::COMPARATOR_LESS) {
|
||||
$qb->andWhere($qb->expr()->lt($dueDateColumn, $this->dateTimeParameter($qb, $date)));
|
||||
} elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_LESS_EQUAL) {
|
||||
// take the end of the day to include due dates at the same day (as datetime does't allow just setting the day)
|
||||
$date->setTime(23, 59, 59);
|
||||
$qb->andWhere($qb->expr()->lte($dueDateColumn, $this->dateTimeParameter($qb, $date)));
|
||||
} elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_MORE) {
|
||||
// take the end of the day to exclude due dates at the same day (as datetime does't allow just setting the day)
|
||||
$date->setTime(23, 59, 59);
|
||||
$qb->andWhere($qb->expr()->gt($dueDateColumn, $this->dateTimeParameter($qb, $date)));
|
||||
} elseif ($duedate->getComparator() === SearchQuery::COMPARATOR_MORE_EQUAL) {
|
||||
$qb->andWhere($qb->expr()->gte($dueDateColumn, $this->dateTimeParameter($qb, $date)));
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Invalid date, ignoring
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($query->getAssigned()) > 0) {
|
||||
foreach ($query->getAssigned() as $index => $assignment) {
|
||||
$qb->innerJoin('c', 'deck_assigned_users', 'au' . $index, $qb->expr()->eq('c.id', 'au' . $index . '.card_id'));
|
||||
$assignedQueryValue = $assignment->getValue();
|
||||
$searchUsers = $this->userManager->searchDisplayName($assignment->getValue());
|
||||
$users = array_filter($searchUsers, function (IUser $user) use ($assignedQueryValue) {
|
||||
return (mb_strtolower($user->getDisplayName()) === mb_strtolower($assignedQueryValue) || $user->getUID() === $assignedQueryValue);
|
||||
});
|
||||
$groups = $this->groupManager->search($assignment->getValue());
|
||||
foreach ($searchUsers as $user) {
|
||||
$groups = array_merge($groups, $this->groupManager->getUserIdGroups($user->getUID()));
|
||||
}
|
||||
|
||||
$assignmentSearches = [];
|
||||
$hasAssignedMatches = false;
|
||||
foreach ($users as $user) {
|
||||
$hasAssignedMatches = true;
|
||||
$assignmentSearches[] = $qb->expr()->andX(
|
||||
$qb->expr()->eq('au' . $index . '.participant', $qb->createNamedParameter($user->getUID(), IQueryBuilder::PARAM_STR)),
|
||||
$qb->expr()->eq('au' . $index . '.type', $qb->createNamedParameter(Assignment::TYPE_USER, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
}
|
||||
foreach ($groups as $group) {
|
||||
$hasAssignedMatches = true;
|
||||
$assignmentSearches[] = $qb->expr()->andX(
|
||||
$qb->expr()->eq('au' . $index . '.participant', $qb->createNamedParameter($group->getGID(), IQueryBuilder::PARAM_STR)),
|
||||
$qb->expr()->eq('au' . $index . '.type', $qb->createNamedParameter(Assignment::TYPE_GROUP, IQueryBuilder::PARAM_INT))
|
||||
);
|
||||
}
|
||||
if (!$hasAssignedMatches) {
|
||||
return [];
|
||||
}
|
||||
$qb->andWhere($qb->expr()->orX(...$assignmentSearches));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function dateTimeParameter(IQueryBuilder $qb, DateTime $dateTime) {
|
||||
if ($this->databaseType === 'sqlite3') {
|
||||
return $qb->createFunction('DATETIME("' . $dateTime->format('Y-m-d\TH:i:s') . '")');
|
||||
}
|
||||
return $qb->createNamedParameter($dateTime, IQueryBuilder::PARAM_DATE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function searchRaw($boardIds, $term, $limit = null, $offset = null) {
|
||||
$qb = $this->queryCardsByBoards($boardIds)
|
||||
->select('s.board_id', 'board_id')
|
||||
|
||||
@@ -36,6 +36,7 @@ use OCA\Deck\Provider\DeckProvider;
|
||||
use OCA\Deck\Service\FullTextSearchService;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\FullTextSearch\Exceptions\FullTextSearchAppNotAvailableException;
|
||||
use OCP\FullTextSearch\IFullTextSearchManager;
|
||||
use OCP\FullTextSearch\Model\IIndex;
|
||||
use Psr\Container\ContainerInterface;
|
||||
@@ -97,6 +98,8 @@ class FullTextSearchEventListener implements IEventListener {
|
||||
DeckProvider::DECK_PROVIDER_ID, $cards, IIndex::INDEX_META
|
||||
);
|
||||
}
|
||||
} catch (FullTextSearchAppNotAvailableException $e) {
|
||||
// Skip silently if no full text search app is available
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error when handling deck full text search event', ['exception' => $e]);
|
||||
}
|
||||
|
||||
84
lib/Search/CardCommentProvider.php
Normal file
84
lib/Search/CardCommentProvider.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search;
|
||||
|
||||
use OCA\Deck\Service\SearchService;
|
||||
use OCP\IL10N;
|
||||
use OCP\IUser;
|
||||
use OCP\Search\IProvider;
|
||||
use OCP\Search\ISearchQuery;
|
||||
use OCP\Search\SearchResult;
|
||||
|
||||
class CardCommentProvider implements IProvider {
|
||||
|
||||
/** @var SearchService */
|
||||
private $searchService;
|
||||
/** @var IL10N */
|
||||
private $l10n;
|
||||
|
||||
public function __construct(
|
||||
SearchService $searchService,
|
||||
IL10N $l10n
|
||||
) {
|
||||
$this->searchService = $searchService;
|
||||
$this->l10n = $l10n;
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return 'deck-comment';
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->l10n->t('Card comments');
|
||||
}
|
||||
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$cursor = $query->getCursor() !== null ? (int)$query->getCursor() : null;
|
||||
$results = $this->searchService->searchComments($query->getTerm(), $query->getLimit(), $cursor);
|
||||
if (count($results) < $query->getLimit()) {
|
||||
return SearchResult::complete(
|
||||
$this->l10n->t('Card comments'),
|
||||
$results
|
||||
);
|
||||
}
|
||||
|
||||
return SearchResult::paginated(
|
||||
$this->l10n->t('Card comments'),
|
||||
$results,
|
||||
$results[count($results) - 1]->getCommentId()
|
||||
);
|
||||
}
|
||||
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
// Negative value to force showing deck providers on first position if the app is opened
|
||||
// This provider always has an order 1 higher than the default DeckProvider
|
||||
if ($route === 'deck.Page.index') {
|
||||
return -4;
|
||||
}
|
||||
return 11;
|
||||
}
|
||||
}
|
||||
51
lib/Search/CommentSearchResultEntry.php
Normal file
51
lib/Search/CommentSearchResultEntry.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search;
|
||||
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\Search\SearchResultEntry;
|
||||
|
||||
class CommentSearchResultEntry extends SearchResultEntry {
|
||||
private $commentId;
|
||||
|
||||
public function __construct(string $commentId, string $commentMessage, string $commentAuthor, Card $card, IURLGenerator $urlGenerator, IL10N $l10n) {
|
||||
parent::__construct(
|
||||
'',
|
||||
// TRANSLATORS This is describing the author and card title related to a comment e.g. "Jane on MyTask"
|
||||
$l10n->t('%s on %s', [$commentAuthor, $card->getTitle()]),
|
||||
$commentMessage,
|
||||
$urlGenerator->linkToRouteAbsolute('deck.page.index') . '#/board/' . $card->getRelatedBoard()->getId() . '/card/' . $card->getId() . '/comments/' . $commentId, // $commentId
|
||||
'icon-comment');
|
||||
$this->commentId = $commentId;
|
||||
}
|
||||
|
||||
public function getCommentId(): string {
|
||||
return $this->commentId;
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,7 @@ namespace OCA\Deck\Search;
|
||||
|
||||
use OCA\Deck\Db\Board;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
use OCA\Deck\Db\StackMapper;
|
||||
use OCA\Deck\Service\BoardService;
|
||||
use OCA\Deck\Service\SearchService;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\Search\IProvider;
|
||||
@@ -40,31 +38,19 @@ use OCP\Search\SearchResult;
|
||||
class DeckProvider implements IProvider {
|
||||
|
||||
/**
|
||||
* @var BoardService
|
||||
* @var SearchService
|
||||
*/
|
||||
private $boardService;
|
||||
/**
|
||||
* @var CardMapper
|
||||
*/
|
||||
private $cardMapper;
|
||||
/**
|
||||
* @var StackMapper
|
||||
*/
|
||||
private $stackMapper;
|
||||
private $searchService;
|
||||
/**
|
||||
* @var IURLGenerator
|
||||
*/
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
BoardService $boardService,
|
||||
StackMapper $stackMapper,
|
||||
CardMapper $cardMapper,
|
||||
SearchService $searchService,
|
||||
IURLGenerator $urlGenerator
|
||||
) {
|
||||
$this->boardService = $boardService;
|
||||
$this->stackMapper = $stackMapper;
|
||||
$this->cardMapper = $cardMapper;
|
||||
$this->searchService = $searchService;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
@@ -77,37 +63,34 @@ class DeckProvider implements IProvider {
|
||||
}
|
||||
|
||||
public function search(IUser $user, ISearchQuery $query): SearchResult {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
|
||||
$matchedBoards = array_filter($this->boardService->getUserBoards(), static function (Board $board) use ($query) {
|
||||
return mb_stripos($board->getTitle(), $query->getTerm()) > -1;
|
||||
});
|
||||
|
||||
$matchedCards = $this->cardMapper->search(array_map(static function (Board $board) {
|
||||
return $board->getId();
|
||||
}, $boards), $query->getTerm(), $query->getLimit(), $query->getCursor());
|
||||
|
||||
$self = $this;
|
||||
$cursor = $query->getCursor() !== null ? (int)$query->getCursor() : null;
|
||||
$boardResults = $this->searchService->searchBoards($query->getTerm(), $query->getLimit(), $cursor);
|
||||
$cardResults = $this->searchService->searchCards($query->getTerm(), $query->getLimit(), $cursor);
|
||||
$results = array_merge(
|
||||
array_map(function (Board $board) {
|
||||
return new BoardSearchResultEntry($board, $this->urlGenerator);
|
||||
}, $matchedBoards),
|
||||
|
||||
array_map(function (Card $card) use ($self) {
|
||||
$board = $self->boardService->find($self->cardMapper->findBoardId($card->getId()));
|
||||
$stack = $self->stackMapper->find($card->getStackId());
|
||||
return new CardSearchResultEntry($board, $stack, $card, $this->urlGenerator);
|
||||
}, $matchedCards)
|
||||
}, $boardResults),
|
||||
array_map(function (Card $card) {
|
||||
return new CardSearchResultEntry($card->getRelatedBoard(), $card->getRelatedStack(), $card, $this->urlGenerator);
|
||||
}, $cardResults)
|
||||
);
|
||||
|
||||
return SearchResult::complete(
|
||||
if (count($cardResults) < $query->getLimit()) {
|
||||
return SearchResult::complete(
|
||||
'Deck',
|
||||
$results
|
||||
);
|
||||
}
|
||||
|
||||
return SearchResult::paginated(
|
||||
'Deck',
|
||||
$results
|
||||
$results,
|
||||
$cardResults[count($results) - 1]->getLastModified()
|
||||
);
|
||||
}
|
||||
|
||||
public function getOrder(string $route, array $routeParameters): int {
|
||||
if ($route === 'deck.page.index') {
|
||||
if ($route === 'deck.Page.index') {
|
||||
return -5;
|
||||
}
|
||||
return 10;
|
||||
|
||||
125
lib/Search/FilterStringParser.php
Normal file
125
lib/Search/FilterStringParser.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search;
|
||||
|
||||
use OCA\Deck\Search\Query\DateQueryParameter;
|
||||
use OCA\Deck\Search\Query\SearchQuery;
|
||||
use OCA\Deck\Search\Query\StringQueryParameter;
|
||||
use OCP\IL10N;
|
||||
|
||||
class FilterStringParser {
|
||||
|
||||
/**
|
||||
* @var IL10N
|
||||
*/
|
||||
private $l10n;
|
||||
|
||||
public function __construct(IL10N $l10n) {
|
||||
$this->l10n = $l10n;
|
||||
}
|
||||
|
||||
public function parse(?string $filter): SearchQuery {
|
||||
$query = new SearchQuery();
|
||||
if (empty($filter)) {
|
||||
return $query;
|
||||
}
|
||||
/**
|
||||
* Match search tokens that are separated by spaces
|
||||
* do not match spaces that are surrounded by single or double quotes
|
||||
* in order to still match quotes
|
||||
* e.g.:
|
||||
* - test
|
||||
* - test:query
|
||||
* - test:<123
|
||||
* - test:"1 2 3"
|
||||
* - test:>="2020-01-01"
|
||||
*/
|
||||
$searchQueryExpression = '/((\w+:(<|<=|>|>=)?)?("([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|\'([^\'\\\\]*(?:\\\\.[^\'\\\\]*)*)\')|[^\s]+)/';
|
||||
preg_match_all($searchQueryExpression, $filter, $matches, PREG_SET_ORDER, 0);
|
||||
foreach ($matches as $match) {
|
||||
$token = $match[0];
|
||||
if (!$this->parseFilterToken($query, $token)) {
|
||||
$query->addTextToken($this->removeQuotes($token));
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function parseFilterToken(SearchQuery $query, string $token): bool {
|
||||
if (strpos($token, ':') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$type, $param] = explode(':', $token, 2);
|
||||
$type = strtolower($type);
|
||||
|
||||
$qualifier = null;
|
||||
|
||||
switch ($type) {
|
||||
case 'date':
|
||||
$comparator = SearchQuery::COMPARATOR_EQUAL;
|
||||
$value = $param;
|
||||
if ($param[0] === '<' || $param[0] === '>') {
|
||||
$orEquals = $param[1] === '=';
|
||||
$value = $orEquals ? substr($param, 2) : substr($param, 1);
|
||||
$comparator = (
|
||||
($param[0] === '<' ? SearchQuery::COMPARATOR_LESS : 0) |
|
||||
($param[0] === '>' ? SearchQuery::COMPARATOR_MORE : 0) |
|
||||
($orEquals ? SearchQuery::COMPARATOR_EQUAL : 0)
|
||||
);
|
||||
}
|
||||
$query->addDuedate(new DateQueryParameter('date', $comparator, $this->removeQuotes($value)));
|
||||
return true;
|
||||
case 'title':
|
||||
$query->addTitle(new StringQueryParameter('title', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param)));
|
||||
return true;
|
||||
case 'description':
|
||||
$query->addDescription(new StringQueryParameter('description', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param)));
|
||||
return true;
|
||||
case 'list':
|
||||
$query->addStack(new StringQueryParameter('list', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param)));
|
||||
return true;
|
||||
case 'tag':
|
||||
$query->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param)));
|
||||
return true;
|
||||
case 'assigned':
|
||||
$query->addAssigned(new StringQueryParameter('assigned', SearchQuery::COMPARATOR_EQUAL, $this->removeQuotes($param)));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function removeQuotes(string $token): string {
|
||||
if (mb_strlen($token) > 1) {
|
||||
$token = ($token[0] === '"' && $token[mb_strlen($token) - 1] === '"') ? mb_substr($token, 1, -1) : $token;
|
||||
$token = ($token[0] === '\'' && $token[mb_strlen($token) - 1] === '\'') ? mb_substr($token, 1, -1) : $token;
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
49
lib/Search/Query/AQueryParameter.php
Normal file
49
lib/Search/Query/AQueryParameter.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search\Query;
|
||||
|
||||
class AQueryParameter {
|
||||
|
||||
/** @var string */
|
||||
protected $field;
|
||||
/** @var int */
|
||||
protected $comparator;
|
||||
/** @var mixed */
|
||||
protected $value;
|
||||
|
||||
public function getValue() {
|
||||
if (is_string($this->value) && mb_strlen($this->value) > 1) {
|
||||
$param = ($this->value[0] === '"' && $this->value[mb_strlen($this->value) - 1] === '"') ? mb_substr($this->value, 1, -1): $this->value;
|
||||
return $param;
|
||||
}
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getComparator(): int {
|
||||
return $this->comparator;
|
||||
}
|
||||
}
|
||||
38
lib/Search/Query/DateQueryParameter.php
Normal file
38
lib/Search/Query/DateQueryParameter.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search\Query;
|
||||
|
||||
class DateQueryParameter extends AQueryParameter {
|
||||
/** @var string|null */
|
||||
protected $value;
|
||||
|
||||
public function __construct(string $field, int $comparator, ?string $value) {
|
||||
$this->field = $field;
|
||||
$this->comparator = $comparator;
|
||||
$this->value = $value;
|
||||
}
|
||||
}
|
||||
109
lib/Search/Query/SearchQuery.php
Normal file
109
lib/Search/Query/SearchQuery.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search\Query;
|
||||
|
||||
class SearchQuery {
|
||||
public const COMPARATOR_EQUAL = 1;
|
||||
|
||||
public const COMPARATOR_LESS = 2;
|
||||
public const COMPARATOR_MORE = 4;
|
||||
|
||||
public const COMPARATOR_LESS_EQUAL = 3;
|
||||
public const COMPARATOR_MORE_EQUAL = 5;
|
||||
|
||||
/** @var string[] */
|
||||
private $textTokens = [];
|
||||
/** @var StringQueryParameter[] */
|
||||
private $title = [];
|
||||
/** @var StringQueryParameter[] */
|
||||
private $description = [];
|
||||
/** @var StringQueryParameter[] */
|
||||
private $stack = [];
|
||||
/** @var StringQueryParameter[] */
|
||||
private $tag = [];
|
||||
/** @var StringQueryParameter[] */
|
||||
private $assigned = [];
|
||||
/** @var DateQueryParameter[] */
|
||||
private $duedate = [];
|
||||
|
||||
|
||||
public function addTextToken(string $textToken): void {
|
||||
$this->textTokens[] = $textToken;
|
||||
}
|
||||
|
||||
public function getTextTokens(): array {
|
||||
return $this->textTokens;
|
||||
}
|
||||
|
||||
public function addTitle(StringQueryParameter $title): void {
|
||||
$this->title[] = $title;
|
||||
}
|
||||
|
||||
public function getTitle(): array {
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function addDescription(StringQueryParameter $description): void {
|
||||
$this->description[] = $description;
|
||||
}
|
||||
|
||||
public function getDescription(): array {
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function addStack(StringQueryParameter $stack): void {
|
||||
$this->stack[] = $stack;
|
||||
}
|
||||
|
||||
public function getStack(): array {
|
||||
return $this->stack;
|
||||
}
|
||||
|
||||
public function addTag(StringQueryParameter $tag): void {
|
||||
$this->tag[] = $tag;
|
||||
}
|
||||
|
||||
public function getTag(): array {
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function addAssigned(StringQueryParameter $assigned): void {
|
||||
$this->assigned[] = $assigned;
|
||||
}
|
||||
|
||||
public function getAssigned(): array {
|
||||
return $this->assigned;
|
||||
}
|
||||
|
||||
public function addDuedate(DateQueryParameter $date): void {
|
||||
$this->duedate[] = $date;
|
||||
}
|
||||
|
||||
public function getDuedate(): array {
|
||||
return $this->duedate;
|
||||
}
|
||||
}
|
||||
39
lib/Search/Query/StringQueryParameter.php
Normal file
39
lib/Search/Query/StringQueryParameter.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Search\Query;
|
||||
|
||||
class StringQueryParameter extends AQueryParameter {
|
||||
|
||||
/** @var string */
|
||||
protected $value;
|
||||
|
||||
public function __construct(string $field, int $comparator, string $value) {
|
||||
$this->field = $field;
|
||||
$this->comparator = $comparator;
|
||||
$this->value = $value;
|
||||
}
|
||||
}
|
||||
@@ -511,7 +511,6 @@ class BoardService {
|
||||
$acl->setPermissionShare($share);
|
||||
$acl->setPermissionManage($manage);
|
||||
|
||||
/* Notify users about the shared board */
|
||||
$this->notificationHelper->sendBoardShared($boardId, $acl);
|
||||
|
||||
$newAcl = $this->aclMapper->insert($acl);
|
||||
@@ -599,6 +598,9 @@ class BoardService {
|
||||
$this->assignedUsersMapper->delete($assignement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->notificationHelper->sendBoardShared($acl->getBoardId(), $acl);
|
||||
|
||||
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $acl, ActivityManager::SUBJECT_BOARD_UNSHARE);
|
||||
$this->changeHelper->boardChanged($acl->getBoardId());
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ namespace OCA\Deck\Service;
|
||||
use OCA\Deck\Activity\ActivityManager;
|
||||
use OCA\Deck\Activity\ChangeSet;
|
||||
use OCA\Deck\Db\AssignmentMapper;
|
||||
use OCA\Deck\Db\Board;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
use OCA\Deck\Db\Acl;
|
||||
@@ -108,6 +107,11 @@ class CardService {
|
||||
$lastRead = $this->commentsManager->getReadMark('deckCard', (string)$card->getId(), $user);
|
||||
$count = $this->commentsManager->getNumberOfCommentsForObject('deckCard', (string)$card->getId(), $lastRead);
|
||||
$card->setCommentsUnread($count);
|
||||
|
||||
$stack = $this->stackMapper->find($card->getStackId());
|
||||
$board = $this->boardService->find($stack->getBoardId());
|
||||
$card->setRelatedStack($stack);
|
||||
$card->setRelatedBoard($board);
|
||||
}
|
||||
|
||||
public function fetchDeleted($boardId) {
|
||||
@@ -119,22 +123,6 @@ class CardService {
|
||||
return $cards;
|
||||
}
|
||||
|
||||
public function search(string $term, int $limit = null, int $offset = null): array {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
$boardIds = array_map(static function (Board $board) {
|
||||
return $board->getId();
|
||||
}, $boards);
|
||||
return $this->cardMapper->search($boardIds, $term, $limit, $offset);
|
||||
}
|
||||
|
||||
public function searchRaw(string $term, int $limit = null, int $offset = null): array {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
$boardIds = array_map(static function (Board $board) {
|
||||
return $board->getId();
|
||||
}, $boards);
|
||||
return $this->cardMapper->searchRaw($boardIds, $term, $limit, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $cardId
|
||||
* @return \OCA\Deck\Db\RelationalEntity
|
||||
|
||||
@@ -35,7 +35,7 @@ use OCP\IDBConnection;
|
||||
use OCP\IL10N;
|
||||
use OCP\IPreview;
|
||||
use OCP\IRequest;
|
||||
use OCP\Share;
|
||||
use OCP\Share\Exceptions\ShareNotFound;
|
||||
use OCP\Share\IManager;
|
||||
use OCP\Share\IShare;
|
||||
|
||||
@@ -140,9 +140,10 @@ class FilesAppService implements IAttachmentService, ICustomAttachmentService {
|
||||
}
|
||||
|
||||
public function display(Attachment $attachment) {
|
||||
/** @psalm-suppress InvalidCatch */
|
||||
try {
|
||||
$share = $this->shareProvider->getShareById($attachment->getId());
|
||||
} catch (Share\Exceptions\ShareNotFound $e) {
|
||||
} catch (ShareNotFound $e) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
$file = $share->getNode();
|
||||
|
||||
117
lib/Service/SearchService.php
Normal file
117
lib/Service/SearchService.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace OCA\Deck\Service;
|
||||
|
||||
use OCA\Deck\Db\Board;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
use OCA\Deck\Search\CommentSearchResultEntry;
|
||||
use OCA\Deck\Search\FilterStringParser;
|
||||
use OCP\Comments\ICommentsManager;
|
||||
use OCP\IL10N;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUserManager;
|
||||
|
||||
class SearchService {
|
||||
|
||||
/** @var BoardService */
|
||||
private $boardService;
|
||||
/** @var CardMapper */
|
||||
private $cardMapper;
|
||||
/** @var CardService */
|
||||
private $cardService;
|
||||
/** @var ICommentsManager */
|
||||
private $commentsManager;
|
||||
/** @var FilterStringParser */
|
||||
private $filterStringParser;
|
||||
/** @var IUserManager */
|
||||
private $userManager;
|
||||
/** @var IL10N */
|
||||
private $l10n;
|
||||
/** @var IURLGenerator */
|
||||
private $urlGenerator;
|
||||
|
||||
public function __construct(
|
||||
BoardService $boardService,
|
||||
CardMapper $cardMapper,
|
||||
CardService $cardService,
|
||||
ICommentsManager $commentsManager,
|
||||
FilterStringParser $filterStringParser,
|
||||
IUserManager $userManager,
|
||||
IL10N $l10n,
|
||||
IURLGenerator $urlGenerator
|
||||
) {
|
||||
$this->boardService = $boardService;
|
||||
$this->cardMapper = $cardMapper;
|
||||
$this->cardService = $cardService;
|
||||
$this->commentsManager = $commentsManager;
|
||||
$this->filterStringParser = $filterStringParser;
|
||||
$this->userManager = $userManager;
|
||||
$this->l10n = $l10n;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
}
|
||||
|
||||
public function searchCards(string $term, int $limit = null, ?int $cursor = null): array {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
$boardIds = array_map(static function (Board $board) {
|
||||
return $board->getId();
|
||||
}, $boards);
|
||||
$matchedCards = $this->cardMapper->search($boardIds, $this->filterStringParser->parse($term), $limit, $cursor);
|
||||
|
||||
$self = $this;
|
||||
return array_map(function (Card $card) use ($self) {
|
||||
$self->cardService->enrich($card);
|
||||
return $card;
|
||||
}, $matchedCards);
|
||||
}
|
||||
|
||||
public function searchBoards(string $term, ?int $limit, ?int $cursor): array {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
return array_filter($boards, static function (Board $board) use ($term) {
|
||||
return mb_stripos(mb_strtolower($board->getTitle()), mb_strtolower($term)) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
public function searchComments(string $term, ?int $limit = null, ?int $cursor = null): array {
|
||||
$boards = $this->boardService->getUserBoards();
|
||||
$boardIds = array_map(static function (Board $board) {
|
||||
return $board->getId();
|
||||
}, $boards);
|
||||
$matchedComments = $this->cardMapper->searchComments($boardIds, $this->filterStringParser->parse($term), $limit, $cursor);
|
||||
|
||||
$self = $this;
|
||||
return array_map(function ($cardRow) use ($self) {
|
||||
$comment = $this->commentsManager->get($cardRow['comment_id']);
|
||||
unset($cardRow['comment_id']);
|
||||
$card = Card::fromRow($cardRow);
|
||||
$self->cardService->enrich($card);
|
||||
$user = $this->userManager->get($comment->getActorId());
|
||||
$displayName = $user ? $user->getDisplayName() : '';
|
||||
return new CommentSearchResultEntry($comment->getId(), $comment->getMessage(), $displayName, $card, $this->urlGenerator, $this->l10n);
|
||||
}, $matchedComments);
|
||||
}
|
||||
}
|
||||
@@ -562,6 +562,7 @@ class DeckShareProvider implements \OCP\Share\IShareProvider {
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws ShareNotFound
|
||||
*/
|
||||
public function getShareById($id, $recipientId = null) {
|
||||
$qb = $this->dbConnection->getQueryBuilder();
|
||||
|
||||
@@ -33,8 +33,14 @@
|
||||
({{ t('deck', 'Archived cards') }})
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="board" class="board-actions">
|
||||
<div v-if="canManage && !showArchived && !board.archived"
|
||||
<div class="board-actions">
|
||||
<div v-if="searchQuery || true" class="deck-search">
|
||||
<input type="search"
|
||||
class="icon-search"
|
||||
:value="searchQuery"
|
||||
@input="$store.commit('setSearchQuery', $event.target.value)">
|
||||
</div>
|
||||
<div v-if="board && canManage && !showArchived && !board.archived"
|
||||
id="stack-add"
|
||||
v-click-outside="hideAddStack">
|
||||
<Actions v-if="!isAddStackVisible">
|
||||
@@ -57,7 +63,7 @@
|
||||
value="">
|
||||
</form>
|
||||
</div>
|
||||
<div class="board-action-buttons">
|
||||
<div v-if="board" class="board-action-buttons">
|
||||
<Popover @show="filterVisible=true" @hide="filterVisible=false">
|
||||
<Actions slot="trigger" :title="t('deck', 'Apply filter')">
|
||||
<ActionButton v-if="isFilterActive" icon="icon-filter_set" />
|
||||
@@ -237,6 +243,7 @@ export default {
|
||||
]),
|
||||
...mapState({
|
||||
compactMode: state => state.compactMode,
|
||||
searchQuery: state => state.searchQuery,
|
||||
}),
|
||||
detailsRoute() {
|
||||
return {
|
||||
@@ -374,6 +381,13 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.deck-search {
|
||||
input[type=search] {
|
||||
background-position: 5px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter--item {
|
||||
input + label {
|
||||
display: block;
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<p />
|
||||
</div>
|
||||
</transition>
|
||||
<GlobalSearchResults />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -75,10 +76,12 @@ import { mapState, mapGetters } from 'vuex'
|
||||
import Controls from '../Controls'
|
||||
import Stack from './Stack'
|
||||
import { EmptyContent } from '@nextcloud/vue'
|
||||
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||
|
||||
export default {
|
||||
name: 'Board',
|
||||
components: {
|
||||
GlobalSearchResults,
|
||||
Controls,
|
||||
Container,
|
||||
Draggable,
|
||||
@@ -178,13 +181,17 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.board {
|
||||
padding-left: $board-spacing;
|
||||
position: relative;
|
||||
height: calc(100% - 44px);
|
||||
overflow-x: scroll;
|
||||
max-height: calc(100% - 44px);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<template>
|
||||
<AppSidebar v-if="currentBoard && currentCard"
|
||||
:active="tabId"
|
||||
:title="title"
|
||||
:subtitle="subtitle"
|
||||
:title-editable="titleEditable"
|
||||
@@ -65,7 +66,7 @@
|
||||
:order="2"
|
||||
:name="t('deck', 'Comments')"
|
||||
icon="icon-comment">
|
||||
<CardSidebarTabComments :card="currentCard" />
|
||||
<CardSidebarTabComments :card="currentCard" :tab-query="tabQuery" />
|
||||
</AppSidebarTab>
|
||||
|
||||
<AppSidebarTab v-if="hasActivity"
|
||||
@@ -109,6 +110,16 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
tabId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
tabQuery: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,11 @@ export default {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
tabQuery: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -26,12 +26,16 @@
|
||||
|
||||
<template>
|
||||
<AttachmentDragAndDrop v-if="card" :card-id="card.id" class="drop-upload--card">
|
||||
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit}"
|
||||
<div :class="{'compact': compactMode, 'current-card': currentCard, 'has-labels': card.labels && card.labels.length > 0, 'is-editing': editing, 'card__editable': canEdit, 'card__archived': card.archived }"
|
||||
tag="div"
|
||||
class="card"
|
||||
@click="openCard">
|
||||
<div v-if="standalone" class="card-related">
|
||||
<div :style="{backgroundColor: '#' + board.color}" class="board-bullet" />
|
||||
{{ board.title }} » {{ stack.title }}
|
||||
</div>
|
||||
<div class="card-upper">
|
||||
<h3 v-if="compactMode || isArchived || showArchived || !canEdit">
|
||||
<h3 v-if="compactMode || isArchived || showArchived || !canEdit || standalone">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<h3 v-else-if="!editing">
|
||||
@@ -98,6 +102,10 @@ export default {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
standalone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -114,6 +122,12 @@ export default {
|
||||
...mapGetters([
|
||||
'isArchived',
|
||||
]),
|
||||
board() {
|
||||
return this.$store.getters.boardById(this?.stack?.boardId)
|
||||
},
|
||||
stack() {
|
||||
return this.$store.getters.stackById(this?.card?.stackId)
|
||||
},
|
||||
canEdit() {
|
||||
if (this.currentBoard) {
|
||||
return !this.currentBoard.archived && this.$store.getters.canEdit
|
||||
@@ -233,6 +247,9 @@ export default {
|
||||
&.card__editable .card-controls {
|
||||
margin-right: 0;
|
||||
}
|
||||
&.card__archived {
|
||||
background-color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.duedate {
|
||||
@@ -244,6 +261,24 @@ export default {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-related {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
padding-bottom: 0px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
|
||||
.board-bullet {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
margin-top: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.compact {
|
||||
min-height: 44px;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template>
|
||||
<div v-if="card">
|
||||
<div @click.stop.prevent>
|
||||
<Actions v-if="canEdit && !isArchived">
|
||||
<Actions>
|
||||
<ActionButton v-if="showArchived === false && !isCurrentUserAssigned"
|
||||
icon="icon-user"
|
||||
:close-after-click="true"
|
||||
@@ -43,7 +43,7 @@
|
||||
{{ t('deck', 'Card details') }}
|
||||
</ActionButton>
|
||||
<ActionButton icon="icon-archive" :close-after-click="true" @click="archiveUnarchiveCard()">
|
||||
{{ showArchived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
||||
{{ card.archived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
|
||||
</ActionButton>
|
||||
<ActionButton v-if="showArchived === false"
|
||||
icon="icon-delete"
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GlobalSearchResults />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,6 +84,7 @@ import Controls from '../Controls'
|
||||
import CardItem from '../cards/CardItem'
|
||||
import { mapGetters } from 'vuex'
|
||||
import moment from '@nextcloud/moment'
|
||||
import GlobalSearchResults from '../search/GlobalSearchResults'
|
||||
|
||||
const FILTER_UPCOMING = 'upcoming'
|
||||
|
||||
@@ -92,6 +95,7 @@ const SUPPORTED_FILTERS = [
|
||||
export default {
|
||||
name: 'Overview',
|
||||
components: {
|
||||
GlobalSearchResults,
|
||||
Controls,
|
||||
CardItem,
|
||||
},
|
||||
@@ -203,6 +207,8 @@ export default {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overview {
|
||||
|
||||
199
src/components/search/GlobalSearchResults.vue
Normal file
199
src/components/search/GlobalSearchResults.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!--
|
||||
- @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
-
|
||||
- @author Julius Härtl <jus@bitgrid.net>
|
||||
-
|
||||
- @license GNU AGPL version 3 or any later version
|
||||
-
|
||||
- This program is free software: you can redistribute it and/or modify
|
||||
- it under the terms of the GNU Affero General Public License as
|
||||
- published by the Free Software Foundation, either version 3 of the
|
||||
- License, or (at your option) any later version.
|
||||
-
|
||||
- This program is distributed in the hope that it will be useful,
|
||||
- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
- GNU Affero General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU Affero General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div v-if="searchQuery!==''" class="global-search">
|
||||
<h2><RichText :text="t('deck', 'Search for {searchQuery} in all boards')" :arguments="queryStringArgs" /></h2>
|
||||
<Actions>
|
||||
<ActionButton icon="icon-close" @click="$store.commit('setSearchQuery', '')" />
|
||||
</Actions>
|
||||
<div class="search-wrapper">
|
||||
<div v-if="loading || filteredResults.length > 0" class="search-results">
|
||||
<CardItem v-for="card in filteredResults"
|
||||
:id="card.id"
|
||||
:key="card.id"
|
||||
:standalone="true" />
|
||||
<Placeholder v-if="loading" />
|
||||
<InfiniteLoading :identifier="searchQuery" @infinite="infiniteHandler">
|
||||
<div slot="spinner" />
|
||||
<div slot="no-more" />
|
||||
<div slot="no-results">
|
||||
{{ t('deck', 'No results found') }}
|
||||
</div>
|
||||
</InfiniteLoading>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ t('deck', 'No results found') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CardItem from '../cards/CardItem'
|
||||
import { mapState } from 'vuex'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateOcsUrl } from '@nextcloud/router'
|
||||
import InfiniteLoading from 'vue-infinite-loading'
|
||||
import RichText from '@juliushaertl/vue-richtext'
|
||||
import Placeholder from './Placeholder'
|
||||
import { Actions, ActionButton } from '@nextcloud/vue'
|
||||
|
||||
const createCancelToken = () => axios.CancelToken.source()
|
||||
|
||||
function search({ query, cursor }) {
|
||||
const cancelToken = createCancelToken()
|
||||
|
||||
const request = async() => axios.get(generateOcsUrl('apps/deck/api/v1.0', 2) + '/search', {
|
||||
cancelToken: cancelToken.token,
|
||||
params: {
|
||||
term: query,
|
||||
limit: 20,
|
||||
cursor,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
request,
|
||||
cancel: cancelToken.cancel,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'GlobalSearchResults',
|
||||
components: { CardItem, InfiniteLoading, RichText, Placeholder, Actions, ActionButton },
|
||||
data() {
|
||||
return {
|
||||
results: [],
|
||||
cancel: null,
|
||||
loading: false,
|
||||
cursor: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
searchQuery: state => state.searchQuery,
|
||||
}),
|
||||
filteredResults() {
|
||||
const sortFn = (a, b) => a.archived - b.archived || b.lastModified - a.lastModified
|
||||
if (this.$route.params.id) {
|
||||
return this.results.filter((result) => result.relatedBoard.id.toString() !== this.$route.params.id.toString()).sort(sortFn)
|
||||
}
|
||||
return [...this.results].sort(sortFn)
|
||||
},
|
||||
queryStringArgs() {
|
||||
return {
|
||||
searchQuery: this.searchQuery,
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchQuery() {
|
||||
this.cursor = null
|
||||
this.loading = true
|
||||
this.search()
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
infiniteHandler($state) {
|
||||
this.loading = true
|
||||
this.search().then((data) => {
|
||||
if (data.length) {
|
||||
$state.loaded()
|
||||
} else {
|
||||
$state.complete()
|
||||
}
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async search() {
|
||||
if (this.cancel) {
|
||||
this.cancel()
|
||||
}
|
||||
const { request, cancel } = await search({ query: this.searchQuery, cursor: this.cursor })
|
||||
this.cancel = cancel
|
||||
const { data } = await request()
|
||||
|
||||
if (this.cursor === null) {
|
||||
this.results = []
|
||||
}
|
||||
if (data.ocs.data.length > 0) {
|
||||
data.ocs.data.forEach((card) => {
|
||||
this.$store.dispatch('addCardData', card)
|
||||
})
|
||||
this.results = [...this.results, ...data.ocs.data]
|
||||
this.cursor = data.ocs.data[data.ocs.data.length - 1].lastModified
|
||||
}
|
||||
return data.ocs.data
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../css/variables.scss';
|
||||
|
||||
.global-search {
|
||||
width: 100%;
|
||||
padding: $board-spacing + $stack-spacing;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
min-height: 35vh;
|
||||
max-height: 50vh;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
z-index: 1010;
|
||||
position: relative;
|
||||
|
||||
.action-item.icon-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.search-wrapper {
|
||||
overflow: scroll;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
h2::v-deep span {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 3px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > div {
|
||||
flex-grow: 0;
|
||||
}
|
||||
}
|
||||
&::v-deep .card {
|
||||
width: $stack-width;
|
||||
margin-right: $stack-spacing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
src/components/search/Placeholder.vue
Normal file
115
src/components/search/Placeholder.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="card--placeholder">
|
||||
<svg class="card-placeholder__gradient">
|
||||
<defs>
|
||||
<linearGradient id="card-placeholder__gradient">
|
||||
<stop offset="0%" :stop-color="light">
|
||||
<animate attributeName="stop-color"
|
||||
:values="`${light}; ${light}; ${dark}; ${dark}; ${light}`"
|
||||
dur="2s"
|
||||
repeatCount="indefinite" />
|
||||
</stop>
|
||||
<stop offset="100%" :stop-color="dark">
|
||||
<animate attributeName="stop-color"
|
||||
:values="`${dark}; ${light}; ${light}; ${dark}; ${dark}`"
|
||||
dur="2s"
|
||||
repeatCount="indefinite" />
|
||||
</stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg
|
||||
class="card-placeholder__placeholder"
|
||||
:class="{ 'standalone': standalone }"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="url(#card-placeholder__gradient)">
|
||||
<rect class="card-placeholder__placeholder-line-header" :style="{width: `calc(${randWidth()}%)`}" />
|
||||
<rect class="card-placeholder__placeholder-line-one" />
|
||||
<rect class="card-placeholder__placeholder-line-two" :style="{width: `calc(${randWidth()}%)`}" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Placeholder',
|
||||
data() {
|
||||
return {
|
||||
light: null,
|
||||
dark: null,
|
||||
standalone: true,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const styles = getComputedStyle(document.documentElement)
|
||||
this.dark = styles.getPropertyValue('--color-placeholder-dark')
|
||||
this.light = styles.getPropertyValue('--color-placeholder-light')
|
||||
},
|
||||
|
||||
methods: {
|
||||
randWidth() {
|
||||
return Math.floor(Math.random() * 20) + 40
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../css/variables.scss';
|
||||
$clickable-area: 44px;
|
||||
|
||||
.card--placeholder {
|
||||
width: $stack-width;
|
||||
margin-right: $stack-spacing;
|
||||
padding: $card-padding;
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
box-shadow: 0 0 2px 0 var(--color-box-shadow);
|
||||
border-radius: var(--border-radius-large);
|
||||
font-size: 100%;
|
||||
margin-bottom: $card-spacing;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.card-placeholder__gradient {
|
||||
position: fixed;
|
||||
height: 0;
|
||||
width: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.card-placeholder__placeholder {
|
||||
width: 100%;
|
||||
&-line-header,
|
||||
&-line-one,
|
||||
&-line-two {
|
||||
width: 100%;
|
||||
height: 1em;
|
||||
x: 0;
|
||||
}
|
||||
&-line-header {
|
||||
visibility: hidden;
|
||||
}
|
||||
&-line-one {
|
||||
y: 5px;
|
||||
}
|
||||
|
||||
&-line-two {
|
||||
y: 25px;
|
||||
}
|
||||
|
||||
&.standalone {
|
||||
.card-placeholder__placeholder-line-header {
|
||||
visibility: visible;
|
||||
y: 5px;
|
||||
}
|
||||
.card-placeholder__placeholder-line-one {
|
||||
y: 40px;
|
||||
}
|
||||
|
||||
.card-placeholder__placeholder-line-two {
|
||||
y: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -116,7 +116,7 @@ export default new Router({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'card/:cardId',
|
||||
path: 'card/:cardId/:tabId?/:tabQuery?',
|
||||
name: 'card',
|
||||
components: {
|
||||
sidebar: CardSidebar,
|
||||
@@ -130,6 +130,8 @@ export default new Router({
|
||||
sidebar: (route) => {
|
||||
return {
|
||||
id: parseInt(route.params.cardId, 10),
|
||||
tabId: route.params.tabId,
|
||||
tabQuery: route.params.tabQuery,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
import { CardApi } from './../services/CardApi'
|
||||
import moment from 'moment'
|
||||
import Vue from 'vue'
|
||||
|
||||
const apiClient = new CardApi()
|
||||
@@ -86,8 +87,90 @@ export default {
|
||||
return true
|
||||
}
|
||||
|
||||
return card.title.toLowerCase().includes(getters.getSearchQuery.toLowerCase())
|
||||
|| card.description.toLowerCase().includes(getters.getSearchQuery.toLowerCase())
|
||||
let hasMatch = true
|
||||
const matches = getters.getSearchQuery.match(/(?:[^\s"]+|"[^"]*")+/g)
|
||||
|
||||
const filterOutQuotes = (q) => {
|
||||
if (q[0] === '"' && q[q.length - 1] === '"') {
|
||||
return q.substr(1, -1)
|
||||
}
|
||||
return q
|
||||
}
|
||||
for (const match of matches) {
|
||||
let [filter, query] = match.indexOf(':') !== -1 ? match.split(/:(.+)/) : [null, match]
|
||||
|
||||
if (filter === 'title') {
|
||||
hasMatch = hasMatch && card.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||
} else if (filter === 'description') {
|
||||
hasMatch = hasMatch && card.description.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||
} else if (filter === 'list') {
|
||||
const stack = this.getters.stackById(card.stackId)
|
||||
if (!stack) {
|
||||
return false
|
||||
}
|
||||
hasMatch = hasMatch && stack.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())
|
||||
} else if (filter === 'tag') {
|
||||
hasMatch = hasMatch && card.labels.findIndex((label) => label.title.toLowerCase().includes(filterOutQuotes(query).toLowerCase())) !== -1
|
||||
} else if (filter === 'date') {
|
||||
const datediffHour = ((new Date(card.duedate) - new Date()) / 3600 / 1000)
|
||||
query = filterOutQuotes(query)
|
||||
switch (query) {
|
||||
case 'overdue':
|
||||
hasMatch = hasMatch && (card.overdue === 3)
|
||||
break
|
||||
case 'today':
|
||||
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 24 && card.duedate !== null)
|
||||
break
|
||||
case 'week':
|
||||
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 7 * 24 && card.duedate !== null)
|
||||
break
|
||||
case 'month':
|
||||
hasMatch = hasMatch && (datediffHour > 0 && datediffHour <= 30 * 24 && card.duedate !== null)
|
||||
break
|
||||
case 'none':
|
||||
hasMatch = hasMatch && (card.duedate === null)
|
||||
break
|
||||
}
|
||||
|
||||
if (card.duedate === null || !hasMatch) {
|
||||
return false
|
||||
}
|
||||
const comparator = query[0] + (query[1] === '=' ? '=' : '')
|
||||
const isValidComparator = ['<', '<=', '>', '>='].indexOf(comparator) !== -1
|
||||
const parsedCardDate = moment(card.duedate)
|
||||
const parsedDate = moment(query.substr(isValidComparator ? comparator.length : 0))
|
||||
switch (comparator) {
|
||||
case '<':
|
||||
hasMatch = hasMatch && parsedCardDate.isBefore(parsedDate)
|
||||
break
|
||||
case '<=':
|
||||
hasMatch = hasMatch && parsedCardDate.isSameOrBefore(parsedDate)
|
||||
break
|
||||
case '>':
|
||||
hasMatch = hasMatch && parsedCardDate.isAfter(parsedDate)
|
||||
break
|
||||
case '>=':
|
||||
hasMatch = hasMatch && parsedCardDate.isSameOrAfter(parsedDate)
|
||||
break
|
||||
default:
|
||||
hasMatch = hasMatch && parsedCardDate.isSame(parsedDate)
|
||||
break
|
||||
}
|
||||
|
||||
} else if (filter === 'assigned') {
|
||||
hasMatch = hasMatch && card.assignedUsers.findIndex((assignment) => {
|
||||
return assignment.participant.primaryKey.toLowerCase() === filterOutQuotes(query).toLowerCase()
|
||||
|| assignment.participant.displayname.toLowerCase() === filterOutQuotes(query).toLowerCase()
|
||||
}) !== -1
|
||||
} else {
|
||||
hasMatch = hasMatch && (card.title.toLowerCase().includes(filterOutQuotes(match).toLowerCase())
|
||||
|| card.description.toLowerCase().includes(filterOutQuotes(match).toLowerCase()))
|
||||
}
|
||||
if (!hasMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt)
|
||||
},
|
||||
@@ -210,7 +293,7 @@ export default {
|
||||
}
|
||||
|
||||
const updatedCard = await apiClient[call](card)
|
||||
commit('deleteCard', updatedCard)
|
||||
commit('updateCard', updatedCard)
|
||||
},
|
||||
async assignCardToUser({ commit }, { card, assignee }) {
|
||||
const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type)
|
||||
@@ -236,5 +319,14 @@ export default {
|
||||
const updatedCard = await apiClient.updateCard(card)
|
||||
commit('updateCardProperty', { property: 'duedate', card: updatedCard })
|
||||
},
|
||||
|
||||
addCardData({ commit }, cardData) {
|
||||
const card = { ...cardData }
|
||||
commit('addStack', card.relatedStack)
|
||||
commit('addBoard', card.relatedBoard)
|
||||
delete card.relatedStack
|
||||
delete card.relatedBoard
|
||||
commit('addCard', card)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ export default new Vuex.Store({
|
||||
boards: state => {
|
||||
return state.boards
|
||||
},
|
||||
boardById: state => (id) => {
|
||||
return state.boards.find((board) => board.id === id)
|
||||
},
|
||||
assignables: state => {
|
||||
return [
|
||||
...state.assignableUsers.map((user) => ({ ...user, type: 0 })),
|
||||
|
||||
@@ -5,10 +5,7 @@ default:
|
||||
- '%paths.base%/../features/'
|
||||
contexts:
|
||||
- ServerContext:
|
||||
baseUrl: http://localhost:8080/index.php/ocs/
|
||||
admin:
|
||||
- admin
|
||||
- admin
|
||||
regular_user_password: 123456
|
||||
- BoardContext:
|
||||
baseUrl: http://localhost:8080/
|
||||
- RequestContext
|
||||
- BoardContext
|
||||
- SearchContext
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Behat\Behat\Context\Context;
|
||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
|
||||
use Behat\Gherkin\Node\TableNode;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
@@ -16,25 +17,35 @@ class BoardContext implements Context {
|
||||
/** @var array last card response */
|
||||
private $card = null;
|
||||
|
||||
/** @var ServerContext */
|
||||
private $serverContext;
|
||||
|
||||
/** @BeforeScenario */
|
||||
public function gatherContexts(BeforeScenarioScope $scope) {
|
||||
$environment = $scope->getEnvironment();
|
||||
|
||||
$this->serverContext = $environment->getContext('ServerContext');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^creates a board named "([^"]*)" with color "([^"]*)"$/
|
||||
*/
|
||||
public function createsABoardNamedWithColor($title, $color) {
|
||||
$this->sendJSONrequest('POST', '/index.php/apps/deck/boards', [
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/boards', [
|
||||
'title' => $title,
|
||||
'color' => $color
|
||||
]);
|
||||
$this->response->getBody()->seek(0);
|
||||
$this->board = json_decode((string)$this->response->getBody(), true);
|
||||
$this->getResponse()->getBody()->seek(0);
|
||||
$this->board = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @When /^fetches the board named "([^"]*)"$/
|
||||
*/
|
||||
public function fetchesTheBoardNamed($boardName) {
|
||||
$this->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $this->board['id'], []);
|
||||
$this->response->getBody()->seek(0);
|
||||
$this->board = json_decode((string)$this->response->getBody(), true);
|
||||
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $this->board['id'], []);
|
||||
$this->getResponse()->getBody()->seek(0);
|
||||
$this->board = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +59,7 @@ class BoardContext implements Context {
|
||||
];
|
||||
$tableRows = isset($permissions) ? $permissions->getRowsHash() : [];
|
||||
$result = array_merge($defaults, $tableRows);
|
||||
$this->sendJSONrequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/acl', [
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/acl', [
|
||||
'type' => 0,
|
||||
'participant' => $user,
|
||||
'permissionEdit' => $result['permissionEdit'] === '1',
|
||||
@@ -68,7 +79,7 @@ class BoardContext implements Context {
|
||||
];
|
||||
$tableRows = isset($permissions) ? $permissions->getRowsHash() : [];
|
||||
$result = array_merge($defaults, $tableRows);
|
||||
$this->sendJSONrequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/acl', [
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/boards/' . $this->board['id'] . '/acl', [
|
||||
'type' => 1,
|
||||
'participant' => $group,
|
||||
'permissionEdit' => $result['permissionEdit'] === '1',
|
||||
@@ -82,38 +93,38 @@ class BoardContext implements Context {
|
||||
* @When /^fetching the board list$/
|
||||
*/
|
||||
public function fetchingTheBoardList() {
|
||||
$this->sendJSONrequest('GET', '/index.php/apps/deck/boards');
|
||||
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards');
|
||||
}
|
||||
|
||||
/**
|
||||
* @When /^fetching the board with id "([^"]*)"$/
|
||||
*/
|
||||
public function fetchingTheBoardWithId($id) {
|
||||
$this->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $id);
|
||||
$this->requestContext->sendJSONrequest('GET', '/index.php/apps/deck/boards/' . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^create a stack named "([^"]*)"$/
|
||||
*/
|
||||
public function createAStackNamed($name) {
|
||||
$this->sendJSONrequest('POST', '/index.php/apps/deck/stacks', [
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/stacks', [
|
||||
'title' => $name,
|
||||
'boardId' => $this->board['id']
|
||||
]);
|
||||
$this->response->getBody()->seek(0);
|
||||
$this->stack = json_decode((string)$this->response->getBody(), true);
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
$this->stack = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^create a card named "([^"]*)"$/
|
||||
*/
|
||||
public function createACardNamed($name) {
|
||||
$this->sendJSONrequest('POST', '/index.php/apps/deck/cards', [
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/cards', [
|
||||
'title' => $name,
|
||||
'stackId' => $this->stack['id']
|
||||
]);
|
||||
$this->response->getBody()->seek(0);
|
||||
$this->card = json_decode((string)$this->response->getBody(), true);
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
$this->card = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,4 +162,70 @@ class BoardContext implements Context {
|
||||
]);
|
||||
$this->serverContext->creatingShare($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^set the description to "([^"]*)"$/
|
||||
*/
|
||||
public function setTheDescriptionTo($description) {
|
||||
$this->requestContext->sendJSONrequest('PUT', '/index.php/apps/deck/cards/' . $this->card['id'], array_merge(
|
||||
$this->card,
|
||||
['description' => $description]
|
||||
));
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
$this->card = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^set the card attribute "([^"]*)" to "([^"]*)"$/
|
||||
*/
|
||||
public function setCardAttribute($attribute, $value) {
|
||||
$this->requestContext->sendJSONrequest('PUT', '/index.php/apps/deck/cards/' . $this->card['id'], array_merge(
|
||||
$this->card,
|
||||
[$attribute => $value]
|
||||
));
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
$this->card = json_decode((string)$this->getResponse()->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^set the card duedate to "([^"]*)"$/
|
||||
*/
|
||||
public function setTheCardDuedateTo($arg1) {
|
||||
$date = new DateTime($arg1);
|
||||
$this->setCardAttribute('duedate', $date->format(DateTimeInterface::ATOM));
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^assign the card to the user "([^"]*)"$/
|
||||
*/
|
||||
public function assignTheCardToTheUser($user) {
|
||||
$this->assignToCard($user, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^assign the card to the group "([^"]*)"$/
|
||||
*/
|
||||
public function assignTheCardToTheGroup($user) {
|
||||
$this->assignToCard($user, 1);
|
||||
}
|
||||
|
||||
private function assignToCard($participant, $type) {
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/cards/' . $this->card['id'] .'/assign', [
|
||||
'userId' => $participant,
|
||||
'type' => $type
|
||||
]);
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Given /^assign the tag "([^"]*)" to the card$/
|
||||
*/
|
||||
public function assignTheTagToTheCard($tag) {
|
||||
$filteredLabels = array_filter($this->board['labels'], function ($label) use ($tag) {
|
||||
return $label['title'] === $tag;
|
||||
});
|
||||
$label = array_shift($filteredLabels);
|
||||
$this->requestContext->sendJSONrequest('POST', '/index.php/apps/deck/cards/' . $this->card['id'] .'/label/' . $label['id']);
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
}
|
||||
}
|
||||
|
||||
140
tests/integration/features/bootstrap/RequestContext.php
Normal file
140
tests/integration/features/bootstrap/RequestContext.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
|
||||
use Behat\Gherkin\Node\TableNode;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Behat\Behat\Context\Context;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
class RequestContext implements Context {
|
||||
private $response;
|
||||
|
||||
/** @var ServerContext */
|
||||
private $serverContext;
|
||||
|
||||
/** @BeforeScenario */
|
||||
public function gatherContexts(BeforeScenarioScope $scope) {
|
||||
$environment = $scope->getEnvironment();
|
||||
|
||||
$this->serverContext = $environment->getContext('ServerContext');
|
||||
}
|
||||
|
||||
private function getBaseUrl() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should have a status code :code
|
||||
* @param string $code
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldHaveStatusCode($code) {
|
||||
$currentCode = $this->response->getStatusCode();
|
||||
if ($currentCode !== (int)$code) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %s as code got %s',
|
||||
$code,
|
||||
$currentCode
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^the response Content-Type should be "([^"]*)"$/
|
||||
* @param string $contentType
|
||||
*/
|
||||
public function theResponseContentTypeShouldbe($contentType) {
|
||||
Assert::assertEquals($contentType, $this->response->getHeader('Content-Type')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should be a JSON array with the following mandatory values
|
||||
* @param TableNode $table
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldBeAJsonArrayWithTheFollowingMandatoryValues(TableNode $table) {
|
||||
$this->response->getBody()->seek(0);
|
||||
$expectedValues = $table->getColumnsHash();
|
||||
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
|
||||
foreach ($expectedValues as $value) {
|
||||
if ((string)$realResponseArray[$value['key']] !== (string)$value['value']) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %s for key %s got %s',
|
||||
(string)$value['value'],
|
||||
$value['key'],
|
||||
(string)$realResponseArray[$value['key']]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should be a JSON array with a length of :length
|
||||
* @param int $length
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldBeAJsonArrayWithALengthOf($length) {
|
||||
$this->response->getBody()->seek(0);
|
||||
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
|
||||
if ((int)count($realResponseArray) !== (int)$length) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %d as length got %d',
|
||||
$length,
|
||||
count($realResponseArray)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendJSONrequest($method, $url, $data = []) {
|
||||
$client = new Client;
|
||||
try {
|
||||
$this->response = $client->request(
|
||||
$method,
|
||||
rtrim($this->serverContext->getBaseUrl(), '/') . '/' . ltrim($url, '/'),
|
||||
[
|
||||
'cookies' => $this->serverContext->getCookieJar(),
|
||||
'json' => $data,
|
||||
'headers' => [
|
||||
'requesttoken' => $this->serverContext->getReqestToken(),
|
||||
]
|
||||
]
|
||||
);
|
||||
} catch (ClientException $e) {
|
||||
$this->response = $e->getResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public function sendOCSRequest($method, $url, $data = []) {
|
||||
$client = new Client;
|
||||
try {
|
||||
$this->response = $client->request(
|
||||
$method,
|
||||
rtrim($this->serverContext->getBaseUrl(), '/') . '/ocs/v2.php/' . ltrim($url, '/'),
|
||||
[
|
||||
'cookies' => $this->serverContext->getCookieJar(),
|
||||
'json' => $data,
|
||||
'headers' => [
|
||||
'requesttoken' => $this->serverContext->getReqestToken(),
|
||||
'OCS-APIREQUEST' => 'true',
|
||||
'Accept' => 'application/json'
|
||||
]
|
||||
]
|
||||
);
|
||||
} catch (ClientException $e) {
|
||||
$this->response = $e->getResponse();
|
||||
}
|
||||
}
|
||||
|
||||
public function getResponse(): ResponseInterface {
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,46 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
|
||||
use Behat\Gherkin\Node\TableNode;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
|
||||
trait RequestTrait {
|
||||
private $baseUrl;
|
||||
private $adminUser;
|
||||
private $regularUser;
|
||||
private $cookieJar;
|
||||
|
||||
private $response;
|
||||
|
||||
public function __construct($baseUrl, $admin = 'admin', $regular_user_password = '123456') {
|
||||
$this->baseUrl = $baseUrl;
|
||||
$this->adminUser = $admin === 'admin' ? ['admin', 'admin'] : $admin;
|
||||
$this->regularUser = $regular_user_password;
|
||||
}
|
||||
|
||||
/** @var ServerContext */
|
||||
private $serverContext;
|
||||
/** @var RequestContext */
|
||||
protected $requestContext;
|
||||
|
||||
/** @BeforeScenario */
|
||||
public function gatherContexts(BeforeScenarioScope $scope) {
|
||||
public function gatherRequestTraitContext(BeforeScenarioScope $scope) {
|
||||
$environment = $scope->getEnvironment();
|
||||
|
||||
$this->serverContext = $environment->getContext('ServerContext');
|
||||
$this->requestContext = $environment->getContext('RequestContext');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should have a status code :code
|
||||
* @param string $code
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldHaveStatusCode($code) {
|
||||
$currentCode = $this->response->getStatusCode();
|
||||
if ($currentCode !== (int)$code) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %s as code got %s',
|
||||
$code,
|
||||
$currentCode
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^the response Content-Type should be "([^"]*)"$/
|
||||
* @param string $contentType
|
||||
*/
|
||||
public function theResponseContentTypeShouldbe($contentType) {
|
||||
Assert::assertEquals($contentType, $this->response->getHeader('Content-Type')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should be a JSON array with the following mandatory values
|
||||
* @param TableNode $table
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldBeAJsonArrayWithTheFollowingMandatoryValues(TableNode $table) {
|
||||
$this->response->getBody()->seek(0);
|
||||
$expectedValues = $table->getColumnsHash();
|
||||
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
|
||||
foreach ($expectedValues as $value) {
|
||||
if ((string)$realResponseArray[$value['key']] !== (string)$value['value']) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %s for key %s got %s',
|
||||
(string)$value['value'],
|
||||
$value['key'],
|
||||
(string)$realResponseArray[$value['key']]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then the response should be a JSON array with a length of :length
|
||||
* @param int $length
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function theResponseShouldBeAJsonArrayWithALengthOf($length) {
|
||||
$this->response->getBody()->seek(0);
|
||||
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
|
||||
if ((int)count($realResponseArray) !== (int)$length) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Expected %d as length got %d',
|
||||
$length,
|
||||
count($realResponseArray)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendJSONrequest($method, $url, $data = []) {
|
||||
$client = new Client;
|
||||
try {
|
||||
$this->response = $client->request(
|
||||
$method,
|
||||
$this->baseUrl . $url,
|
||||
[
|
||||
'cookies' => $this->serverContext->getCookieJar(),
|
||||
'json' => $data,
|
||||
'headers' => [
|
||||
'requesttoken' => $this->serverContext->getReqestToken()
|
||||
]
|
||||
]
|
||||
);
|
||||
} catch (ClientException $e) {
|
||||
$this->response = $e->getResponse();
|
||||
}
|
||||
public function getResponse() {
|
||||
return $this->requestContext->getResponse();
|
||||
}
|
||||
}
|
||||
|
||||
81
tests/integration/features/bootstrap/SearchContext.php
Normal file
81
tests/integration/features/bootstrap/SearchContext.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use Behat\Behat\Context\Context;
|
||||
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
|
||||
use PHPUnit\Framework\Assert;
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
class SearchContext implements Context {
|
||||
use RequestTrait;
|
||||
|
||||
/** @var BoardContext */
|
||||
protected $boardContext;
|
||||
|
||||
private $searchResults;
|
||||
|
||||
/** @BeforeScenario */
|
||||
public function gatherContexts(BeforeScenarioScope $scope) {
|
||||
$environment = $scope->getEnvironment();
|
||||
|
||||
$this->boardContext = $environment->getContext('BoardContext');
|
||||
}
|
||||
|
||||
/**
|
||||
* @When /^searching for "([^"]*)"$/
|
||||
* @param string $term
|
||||
*/
|
||||
public function searchingFor(string $term) {
|
||||
$this->requestContext->sendOCSRequest('GET', '/apps/deck/api/v1.0/search?term=' . urlencode($term), []);
|
||||
$this->requestContext->getResponse()->getBody()->seek(0);
|
||||
$data = (string)$this->getResponse()->getBody();
|
||||
$this->searchResults = json_decode($data, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @When /^searching for '([^']*)'$/
|
||||
* @param string $term
|
||||
*/
|
||||
public function searchingForQuotes(string $term) {
|
||||
$this->searchingFor($term);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^the board "([^"]*)" is found$/
|
||||
*/
|
||||
public function theBoardIsFound($arg1) {
|
||||
$ocsData = $this->searchResults['ocs']['data'];
|
||||
$found = false;
|
||||
foreach ($ocsData as $result) {
|
||||
if ($result['title'] === $arg1) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
Assert::assertTrue($found, 'Board can be found');
|
||||
}
|
||||
|
||||
private function cardIsFound($arg1) {
|
||||
$ocsData = $this->searchResults['ocs']['data'];
|
||||
$found = false;
|
||||
foreach ($ocsData as $result) {
|
||||
if ($result['title'] === $arg1) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^the card "([^"]*)" is found$/
|
||||
*/
|
||||
public function theCardIsFound($arg1) {
|
||||
Assert::assertTrue($this->cardIsFound($arg1), 'Card can be found');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Then /^the card "([^"]*)" is not found$/
|
||||
*/
|
||||
public function theCardIsNotFound($arg1) {
|
||||
Assert::assertFalse($this->cardIsFound($arg1), 'Card can not be found');
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,14 @@ use GuzzleHttp\Cookie\CookieJar;
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
class ServerContext implements Context {
|
||||
use WebDav;
|
||||
use WebDav {
|
||||
WebDav::__construct as private __tConstruct;
|
||||
}
|
||||
|
||||
public function __construct($baseUrl) {
|
||||
$this->rawBaseUrl = $baseUrl;
|
||||
$this->__tConstruct($baseUrl . '/index.php/ocs/', ['admin', 'admin'], '123456');
|
||||
}
|
||||
|
||||
/** @var string */
|
||||
private $mappedUserId;
|
||||
@@ -28,6 +35,10 @@ class ServerContext implements Context {
|
||||
$this->asAn($user);
|
||||
}
|
||||
|
||||
public function getBaseUrl(): string {
|
||||
return $this->rawBaseUrl;
|
||||
}
|
||||
|
||||
public function getCookieJar(): CookieJar {
|
||||
return $this->cookieJar;
|
||||
}
|
||||
|
||||
@@ -4,23 +4,6 @@ Feature: decks
|
||||
Given user "admin" exists
|
||||
Given user "user0" exists
|
||||
|
||||
Scenario: Request the main frontend page
|
||||
Given Logging in using web as "admin"
|
||||
When Sending a "GET" to "/index.php/apps/deck" without requesttoken
|
||||
Then the HTTP status code should be "200"
|
||||
|
||||
Scenario: Fetch the board list
|
||||
Given Logging in using web as "admin"
|
||||
When fetching the board list
|
||||
Then the response should have a status code "200"
|
||||
And the response Content-Type should be "application/json; charset=utf-8"
|
||||
|
||||
Scenario: Fetch board details of a nonexisting board
|
||||
Given Logging in using web as "admin"
|
||||
When fetching the board with id "99999999"
|
||||
Then the response should have a status code "403"
|
||||
And the response Content-Type should be "application/json; charset=utf-8"
|
||||
|
||||
Scenario: Create a new board
|
||||
Given Logging in using web as "admin"
|
||||
When creates a board named "MyBoard" with color "000000"
|
||||
|
||||
259
tests/integration/features/search.feature
Normal file
259
tests/integration/features/search.feature
Normal file
@@ -0,0 +1,259 @@
|
||||
Feature: Searching for cards
|
||||
|
||||
Background:
|
||||
Given user "admin" exists
|
||||
Given user "user0" exists
|
||||
Given Logging in using web as "admin"
|
||||
When creates a board named "MyBoard" with color "000000"
|
||||
When create a stack named "ToDo"
|
||||
And create a card named "Example task 1"
|
||||
And create a card named "Example task 2"
|
||||
When create a stack named "In progress"
|
||||
And create a card named "Progress task 1"
|
||||
And create a card named "Progress task 2"
|
||||
When create a stack named "Done"
|
||||
And create a card named "Done task 1"
|
||||
And set the description to "Done task description 1"
|
||||
And create a card named "Done task 2"
|
||||
And set the description to "Done task description 2"
|
||||
And shares the board with user "user0"
|
||||
|
||||
|
||||
Scenario: Search for a card with multiple terms
|
||||
When searching for "Example task"
|
||||
Then the card "Example task 1" is found
|
||||
Then the card "Example task 2" is found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
|
||||
Scenario: Search for a card in a specific list
|
||||
When searching for "task list:Done"
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
|
||||
Scenario: Search for a card with one term
|
||||
When searching for "task"
|
||||
Then the card "Example task 1" is found
|
||||
Then the card "Example task 2" is found
|
||||
Then the card "Progress task 1" is found
|
||||
Then the card "Progress task 2" is found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
|
||||
Scenario: Search for a card with an differently cased term
|
||||
When searching for "tAsk"
|
||||
Then the card "Example task 1" is found
|
||||
Then the card "Example task 2" is found
|
||||
Then the card "Progress task 1" is found
|
||||
Then the card "Progress task 2" is found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
|
||||
Scenario: Search for a card title
|
||||
When searching for 'title:"Done task 1"'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is not found
|
||||
|
||||
Scenario: Search for a card description
|
||||
When searching for 'description:"Done task description"'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
|
||||
Scenario: Search for a non-existing card description
|
||||
When searching for 'description:"Example"'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
|
||||
Scenario: Search on shared boards
|
||||
Given Logging in using web as "user0"
|
||||
When searching for "task"
|
||||
Then the card "Example task 1" is found
|
||||
Then the card "Example task 2" is found
|
||||
Then the card "Progress task 1" is found
|
||||
Then the card "Progress task 2" is found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
|
||||
Scenario: Search for a card due date
|
||||
Given create a card named "Overdue task"
|
||||
And set the card attribute "duedate" to "2020-12-12"
|
||||
And create a card named "Future task"
|
||||
And set the card attribute "duedate" to "3000-12-12"
|
||||
And create a card named "Tomorrow task"
|
||||
And set the card duedate to "tomorrow"
|
||||
When searching for 'date:overdue'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is found
|
||||
Then the card "Future task" is not found
|
||||
|
||||
Scenario: Search for a card due date
|
||||
And create a card named "Overdue task"
|
||||
And set the card attribute "duedate" to "2020-12-12"
|
||||
And create a card named "Future task"
|
||||
And set the card attribute "duedate" to "3000-12-12"
|
||||
And create a card named "Tomorrow task"
|
||||
And set the card duedate to "+12 hours"
|
||||
And create a card named "Next week task"
|
||||
And set the card duedate to "+5 days"
|
||||
|
||||
When searching for 'date:today'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is not found
|
||||
Then the card "Future task" is not found
|
||||
Then the card "Tomorrow task" is found
|
||||
Then the card "Next week task" is not found
|
||||
|
||||
When searching for 'date:week'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is not found
|
||||
Then the card "Future task" is not found
|
||||
Then the card "Tomorrow task" is found
|
||||
Then the card "Next week task" is found
|
||||
|
||||
When searching for 'date:month'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is not found
|
||||
Then the card "Future task" is not found
|
||||
Then the card "Tomorrow task" is found
|
||||
Then the card "Next week task" is found
|
||||
|
||||
When searching for 'date:none'
|
||||
Then the card "Example task 1" is found
|
||||
Then the card "Example task 2" is found
|
||||
Then the card "Progress task 1" is found
|
||||
Then the card "Progress task 2" is found
|
||||
Then the card "Done task 1" is found
|
||||
Then the card "Done task 2" is found
|
||||
Then the card "Overdue task" is not found
|
||||
Then the card "Future task" is not found
|
||||
Then the card "Tomorrow task" is not found
|
||||
Then the card "Next week task" is not found
|
||||
|
||||
When searching for 'date:<"+7 days"'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is found
|
||||
Then the card "Future task" is not found
|
||||
Then the card "Tomorrow task" is found
|
||||
Then the card "Next week task" is found
|
||||
|
||||
When searching for 'date:>"+10 days"'
|
||||
Then the card "Example task 1" is not found
|
||||
Then the card "Example task 2" is not found
|
||||
Then the card "Progress task 1" is not found
|
||||
Then the card "Progress task 2" is not found
|
||||
Then the card "Done task 1" is not found
|
||||
Then the card "Done task 2" is not found
|
||||
Then the card "Overdue task" is not found
|
||||
Then the card "Future task" is found
|
||||
Then the card "Tomorrow task" is not found
|
||||
Then the card "Next week task" is not found
|
||||
|
||||
Scenario: Search for assigned user
|
||||
Given user "user1" exists
|
||||
And shares the board with user "user1"
|
||||
Given create a card named "Assigned card to user1"
|
||||
And assign the card to the user "user1"
|
||||
When searching for 'assigned:user1'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Assigned card to user1" is found
|
||||
|
||||
Scenario: Search for assigned user by displayname
|
||||
Given user "ada" with displayname "Ada Lovelace" exists
|
||||
And shares the board with user "ada"
|
||||
Given create a card named "Assigned card to ada"
|
||||
And assign the card to the user "ada"
|
||||
When searching for 'assigned:"Ada Lovelace"'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Assigned card to ada" is found
|
||||
|
||||
Scenario: Search for assigned users
|
||||
Given user "user1" exists
|
||||
And shares the board with user "user1"
|
||||
Given create a card named "Assigned card to user0"
|
||||
And assign the card to the user "user0"
|
||||
Given create a card named "Assigned card to user01"
|
||||
And assign the card to the user "user0"
|
||||
And assign the card to the user "user1"
|
||||
When searching for 'assigned:user0 assigned:user1'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Assigned card to user0" is not found
|
||||
And the card "Assigned card to user01" is found
|
||||
|
||||
Scenario: Search for assigned group
|
||||
Given user "user1" exists
|
||||
And shares the board with user "user1"
|
||||
Given group "group1" exists
|
||||
And shares the board with group "group1"
|
||||
Given user "user1" belongs to group "group1"
|
||||
Given create a card named "Assigned card to group1"
|
||||
And assign the card to the group "group1"
|
||||
When searching for 'assigned:user1'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Assigned card to group1" is found
|
||||
|
||||
When searching for 'assigned:group1'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Assigned card to group1" is found
|
||||
|
||||
Scenario: Search for assigned tag
|
||||
Given create a card named "Labeled card"
|
||||
# Default labels from boards are used for this test case
|
||||
And assign the tag "Finished" to the card
|
||||
When searching for 'tag:Finished'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Labeled card" is found
|
||||
|
||||
Given create a card named "Multi labeled card"
|
||||
And assign the tag "Finished" to the card
|
||||
And assign the tag "To review" to the card
|
||||
When searching for 'tag:Finished tag:Later'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Multi labeled card" is not found
|
||||
|
||||
When searching for 'tag:Finished tag:"To review"'
|
||||
Then the card "Example task 1" is not found
|
||||
And the card "Labeled card" is not found
|
||||
And the card "Multi labeled card" is found
|
||||
@@ -10,11 +10,6 @@
|
||||
<code>(int)$subjectParams['comment']</code>
|
||||
</InvalidScalarArgument>
|
||||
</file>
|
||||
<file src="lib/AppInfo/Application.php">
|
||||
<RedundantCondition occurrences="1">
|
||||
<code>method_exists($shareManager, 'registerShareProvider')</code>
|
||||
</RedundantCondition>
|
||||
</file>
|
||||
<file src="lib/Command/UserExport.php">
|
||||
<ImplementedReturnTypeMismatch occurrences="1">
|
||||
<code>void</code>
|
||||
@@ -121,12 +116,16 @@
|
||||
</UndefinedClass>
|
||||
</file>
|
||||
<file src="lib/Db/CardMapper.php">
|
||||
<InvalidArgument occurrences="1"/>
|
||||
<InvalidScalarArgument occurrences="1">
|
||||
<code>$entity->getId()</code>
|
||||
</InvalidScalarArgument>
|
||||
<ParamNameMismatch occurrences="1">
|
||||
<code>$cardId</code>
|
||||
</ParamNameMismatch>
|
||||
<UndefinedInterfaceMethod occurrences="1">
|
||||
<code>getUserIdGroups</code>
|
||||
</UndefinedInterfaceMethod>
|
||||
</file>
|
||||
<file src="lib/Db/ChangeHelper.php">
|
||||
<UndefinedThisPropertyAssignment occurrences="3">
|
||||
@@ -271,7 +270,7 @@
|
||||
<code>$this->rootFolder</code>
|
||||
<code>$this->rootFolder</code>
|
||||
<code>IRootFolder</code>
|
||||
<code>Share\Exceptions\ShareNotFound</code>
|
||||
<code>ShareNotFound</code>
|
||||
</MissingDependency>
|
||||
</file>
|
||||
<file src="lib/Service/PermissionService.php">
|
||||
@@ -280,6 +279,13 @@
|
||||
<code>\OCA\Circles\Api\v1\Circles</code>
|
||||
</UndefinedClass>
|
||||
</file>
|
||||
<file src="lib/Service/SearchService.php">
|
||||
<UndefinedThisPropertyFetch occurrences="3">
|
||||
<code>$this->l10n</code>
|
||||
<code>$this->urlGenerator</code>
|
||||
<code>$this->userManager</code>
|
||||
</UndefinedThisPropertyFetch>
|
||||
</file>
|
||||
<file src="lib/Service/StackService.php">
|
||||
<UndefinedClass occurrences="1">
|
||||
<code>BadRquestException</code>
|
||||
@@ -292,7 +298,7 @@
|
||||
<InvalidReturnType occurrences="1">
|
||||
<code>getSharesInFolder</code>
|
||||
</InvalidReturnType>
|
||||
<MissingDependency occurrences="7">
|
||||
<MissingDependency occurrences="8">
|
||||
<code>GenericShareException</code>
|
||||
<code>GenericShareException</code>
|
||||
<code>ShareNotFound</code>
|
||||
@@ -300,6 +306,7 @@
|
||||
<code>ShareNotFound</code>
|
||||
<code>ShareNotFound</code>
|
||||
<code>ShareNotFound</code>
|
||||
<code>ShareNotFound</code>
|
||||
</MissingDependency>
|
||||
</file>
|
||||
<file src="lib/Sharing/Listener.php">
|
||||
|
||||
@@ -111,7 +111,7 @@ class UserExportTest extends \Test\TestCase {
|
||||
->method('find')
|
||||
->willReturn($cards[0]);
|
||||
$this->assignedUserMapper->expects($this->exactly(count($boards) * count($stacks) * count($cards)))
|
||||
->method('find')
|
||||
->method('findAll')
|
||||
->willReturn([]);
|
||||
$result = $this->invokePrivate($this->userExport, 'execute', [$input, $output]);
|
||||
}
|
||||
|
||||
@@ -132,9 +132,15 @@ class BoardMapperTest extends MapperTestUtility {
|
||||
|
||||
public function testFindAll() {
|
||||
$actual = $this->boardMapper->findAll();
|
||||
$this->assertEquals($this->boards[0]->getId(), $actual[0]->getId());
|
||||
$this->assertEquals($this->boards[1]->getId(), $actual[1]->getId());
|
||||
$this->assertEquals($this->boards[2]->getId(), $actual[2]->getId());
|
||||
$this->assertEquals(1, count(array_filter($actual, function ($card) {
|
||||
return $card->getId() === $this->boards[0]->getId();
|
||||
})));
|
||||
$this->assertEquals(1, count(array_filter($actual, function ($card) {
|
||||
return $card->getId() === $this->boards[1]->getId();
|
||||
})));
|
||||
$this->assertEquals(1, count(array_filter($actual, function ($card) {
|
||||
return $card->getId() === $this->boards[2]->getId();
|
||||
})));
|
||||
}
|
||||
|
||||
public function testFindAllToDelete() {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
namespace OCA\Deck\Notification;
|
||||
|
||||
use OCA\Deck\Db\Acl;
|
||||
use OCA\Deck\Db\AssignedUsersMapper;
|
||||
use OCA\Deck\Db\AssignmentMapper;
|
||||
use OCA\Deck\Db\Board;
|
||||
use OCA\Deck\Db\BoardMapper;
|
||||
use OCA\Deck\Db\Card;
|
||||
@@ -59,7 +59,7 @@ class NotificationHelperTest extends \Test\TestCase {
|
||||
protected $cardMapper;
|
||||
/** @var BoardMapper|MockObject */
|
||||
protected $boardMapper;
|
||||
/** @var AssignedUsersMapper|MockObject */
|
||||
/** @var AssignmentMapper|MockObject */
|
||||
protected $assignedUsersMapper;
|
||||
/** @var PermissionService|MockObject */
|
||||
protected $permissionService;
|
||||
@@ -78,7 +78,7 @@ class NotificationHelperTest extends \Test\TestCase {
|
||||
parent::setUp();
|
||||
$this->cardMapper = $this->createMock(CardMapper::class);
|
||||
$this->boardMapper = $this->createMock(BoardMapper::class);
|
||||
$this->assignedUsersMapper = $this->createMock(AssignedUsersMapper::class);
|
||||
$this->assignedUsersMapper = $this->createMock(AssignmentMapper::class);
|
||||
$this->permissionService = $this->createMock(PermissionService::class);
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->notificationManager = $this->createMock(IManager::class);
|
||||
|
||||
133
tests/unit/Search/FilterStringParserTest.php
Normal file
133
tests/unit/Search/FilterStringParserTest.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/*
|
||||
* @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @author Julius Härtl <jus@bitgrid.net>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Deck\Search;
|
||||
|
||||
use OCA\Deck\Search\Query\DateQueryParameter;
|
||||
use OCA\Deck\Search\Query\SearchQuery;
|
||||
use OCA\Deck\Search\Query\StringQueryParameter;
|
||||
use OCP\IL10N;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FilterStringParserTest extends TestCase {
|
||||
private $l10n;
|
||||
private $parser;
|
||||
|
||||
public function setUp(): void {
|
||||
$this->l10n = $this->createMock(IL10N::class);
|
||||
$this->parser = new FilterStringParser($this->l10n);
|
||||
}
|
||||
|
||||
public function testParseEmpty() {
|
||||
$result = $this->parser->parse(null);
|
||||
$expected = new SearchQuery();
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTextTokens() {
|
||||
$result = $this->parser->parse('a b c');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTextToken('a');
|
||||
$expected->addTextToken('b');
|
||||
$expected->addTextToken('c');
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTextToken() {
|
||||
$result = $this->parser->parse('abc');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTextToken('abc');
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTextTokenQuotes() {
|
||||
$result = $this->parser->parse('a b c "a b c" tag:abc tag:"a b c" tag:\'d e f\'');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTextToken('a');
|
||||
$expected->addTextToken('b');
|
||||
$expected->addTextToken('c');
|
||||
$expected->addTextToken('a b c');
|
||||
$expected->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, 'abc'));
|
||||
$expected->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, 'a b c'));
|
||||
$expected->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, 'd e f'));
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTagComparatorNotSupported() {
|
||||
$result = $this->parser->parse('tag:<"a tag"');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTag(new StringQueryParameter('tag', SearchQuery::COMPARATOR_EQUAL, '<"a tag"'));
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTextTokenQuotesSingle() {
|
||||
$result = $this->parser->parse('a b c \'a b c\'');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTextToken('a');
|
||||
$expected->addTextToken('b');
|
||||
$expected->addTextToken('c');
|
||||
$expected->addTextToken('a b c');
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function testParseTextTokenQuotesWrong() {
|
||||
$result = $this->parser->parse('"a b" c"');
|
||||
$expected = new SearchQuery();
|
||||
$expected->addTextToken('a b');
|
||||
$expected->addTextToken('c"');
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function dataParseDate() {
|
||||
return [
|
||||
['date:today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_EQUAL, 'today')], []],
|
||||
['date:>today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_MORE, 'today')], []],
|
||||
['date:>=today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_MORE_EQUAL, 'today')], []],
|
||||
['date:<today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_LESS, 'today')], []],
|
||||
['date:<=today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_LESS_EQUAL, 'today')], []],
|
||||
['date:<+today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_LESS, '+today')], []],
|
||||
['date:<>today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_LESS, '>today')], []],
|
||||
['date:=today', [new DateQueryParameter('date', SearchQuery::COMPARATOR_EQUAL, '=today')], []],
|
||||
['date:today todo', [new DateQueryParameter('date', SearchQuery::COMPARATOR_EQUAL, 'today')], ['todo']],
|
||||
['date:"last day of next month" todo', [new DateQueryParameter('date', SearchQuery::COMPARATOR_EQUAL, 'last day of next month')], ['todo']],
|
||||
['date:"last day of next month" "todo task" task', [new DateQueryParameter('date', SearchQuery::COMPARATOR_EQUAL, 'last day of next month')], ['todo task', 'task']],
|
||||
];
|
||||
}
|
||||
/**
|
||||
* @dataProvider dataParseDate
|
||||
*/
|
||||
public function testParseDate($query, $dates, array $tokens) {
|
||||
$result = $this->parser->parse($query);
|
||||
$expected = new SearchQuery();
|
||||
foreach ($dates as $date) {
|
||||
$expected->addDuedate($date);
|
||||
}
|
||||
foreach ($tokens as $token) {
|
||||
$expected->addTextToken($token);
|
||||
}
|
||||
Assert::assertEquals($expected, $result);
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ class BoardServiceTest extends TestCase {
|
||||
$assignment = new Assignment();
|
||||
$assignment->setParticipant('admin');
|
||||
$this->assignedUsersMapper->expects($this->once())
|
||||
->method('findByUserId')
|
||||
->method('findByParticipant')
|
||||
->with('admin')
|
||||
->willReturn([$assignment]);
|
||||
$this->assignedUsersMapper->expects($this->once())
|
||||
|
||||
@@ -25,9 +25,11 @@ namespace OCA\Deck\Service;
|
||||
|
||||
use OCA\Deck\Activity\ActivityManager;
|
||||
use OCA\Deck\Db\AssignmentMapper;
|
||||
use OCA\Deck\Db\Board;
|
||||
use OCA\Deck\Db\Card;
|
||||
use OCA\Deck\Db\CardMapper;
|
||||
use OCA\Deck\Db\ChangeHelper;
|
||||
use OCA\Deck\Db\Stack;
|
||||
use OCA\Deck\Db\StackMapper;
|
||||
use OCA\Deck\Db\BoardMapper;
|
||||
use OCA\Deck\Db\LabelMapper;
|
||||
@@ -126,6 +128,17 @@ class CardServiceTest extends TestCase {
|
||||
$this->userManager->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn($user);
|
||||
$this->commentsManager->expects($this->once())
|
||||
->method('getNumberOfCommentsForObject')
|
||||
->willReturn(0);
|
||||
$boardMock = $this->createMock(Board::class);
|
||||
$stackMock = $this->createMock(Stack::class);
|
||||
$this->stackMapper->expects($this->any())
|
||||
->method('find')
|
||||
->willReturn($stackMock);
|
||||
$this->boardService->expects($this->any())
|
||||
->method('find')
|
||||
->willReturn($boardMock);
|
||||
$card = new Card();
|
||||
$card->setId(1337);
|
||||
$this->cardMapper->expects($this->any())
|
||||
@@ -133,12 +146,14 @@ class CardServiceTest extends TestCase {
|
||||
->with(123)
|
||||
->willReturn($card);
|
||||
$this->assignedUsersMapper->expects($this->any())
|
||||
->method('find')
|
||||
->method('findAll')
|
||||
->with(1337)
|
||||
->willReturn(['user1', 'user2']);
|
||||
$cardExpected = new Card();
|
||||
$cardExpected->setId(1337);
|
||||
$cardExpected->setAssignedUsers(['user1', 'user2']);
|
||||
$cardExpected->setRelatedBoard($boardMock);
|
||||
$cardExpected->setRelatedStack($stackMock);
|
||||
$this->assertEquals($cardExpected, $this->cardService->find(123));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user