Merge pull request #2934 from nextcloud/enh/advanced-search

This commit is contained in:
Julius Härtl
2021-04-13 14:30:18 -01:00
committed by GitHub
48 changed files with 2286 additions and 283 deletions

View File

@@ -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'],
]
];

View File

@@ -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"
}
}

View File

@@ -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"`.

View File

@@ -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);

View 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));
}
}

View File

@@ -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;
}

View File

@@ -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')

View File

@@ -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]);
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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());

View File

@@ -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

View File

@@ -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();

View 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);
}
}

View File

@@ -562,6 +562,7 @@ class DeckShareProvider implements \OCP\Share\IShareProvider {
/**
* @inheritDoc
* @throws ShareNotFound
*/
public function getShareById($id, $recipientId = null) {
$qb = $this->dbConnection->getQueryBuilder();

View File

@@ -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;

View File

@@ -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;
}
/**

View File

@@ -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 {

View File

@@ -49,6 +49,11 @@ export default {
type: Object,
default: undefined,
},
tabQuery: {
type: String,
required: false,
default: null,
},
},
data() {
return {

View File

@@ -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;

View File

@@ -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"

View File

@@ -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 {

View 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>

View 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>

View File

@@ -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,
}
},
},

View File

@@ -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)
},
},
}

View File

@@ -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 })),

View File

@@ -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

View File

@@ -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);
}
}

View 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;
}
}

View File

@@ -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();
}
}

View 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');
}
}

View File

@@ -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;
}

View File

@@ -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"

View 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

View File

@@ -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-&gt;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-&gt;rootFolder</code>
<code>$this-&gt;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-&gt;l10n</code>
<code>$this-&gt;urlGenerator</code>
<code>$this-&gt;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">

View File

@@ -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]);
}

View File

@@ -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() {

View File

@@ -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);

View 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);
}
}

View File

@@ -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())

View File

@@ -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));
}