Merge pull request #2430 from nextcloud/enh/due-notify-settings

This commit is contained in:
Julius Härtl
2020-11-09 21:08:02 +01:00
committed by GitHub
18 changed files with 715 additions and 109 deletions

View File

@@ -9,6 +9,7 @@
@include icon-black-white('filter_set', 'deck', 1);
@include icon-black-white('attach', 'deck', 1);
@include icon-black-white('reply', 'deck', 1);
@include icon-black-white('notifications-dark', 'deck', 1);
.icon-toggle-compact-collapsed {
@include icon-color('toggle-view-expand', 'deck', $color-black);

View File

@@ -115,6 +115,10 @@ Returns an array of board items
"deletedAt": 0,
"id": 10,
"lastModified": 1586269585,
"settings": {
"notify-due": "off",
"calendar": true
}
}
]
```
@@ -952,6 +956,77 @@ For now only `deck_file` is supported as an attachment type.
The following endpoints are available through the Nextcloud OCS endpoint, which is available at `/ocs/v2.php/apps/deck/api/v1.0/`.
This has the benefit that both the web UI as well as external integrations can use the same API.
## Config
Deck stores user and app configuration values globally and per board. The GET endpoint allows to fetch the current global configuration while board settings will be exposed through the board element on the regular API endpoints.
### GET /api/v1.0/config - Fetch app configuration values
#### Response
| Config key | Description | Value |
| --- | --- |
| calendar | Determines if the calendar/tasks integration through the CalDAV backend is enabled for the user (boolean) |
| groupLimit | Determines if creating new boards is limited to certain groups of the instance. The resulting output is an array of group objects with the id and the displayname (Admin only)|
```
{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200,
"message": "OK"
},
"data": {
"calendar": true,
"groupLimit": [
{
"id": "admin",
"displayname": "admin"
}
]
}
}
}
```
### POST /api/v1.0/config/{id}/{key} - Set a config value
#### Request parameters
| Parameter | Type | Description |
| --------- | ------- | --------------------------------------- |
| id | Integer | The id of the board |
| key | String | The config key to set, prefixed with `board:{boardId}:` for board specific settings |
| value | String | The value that should be stored for the config key |
##### Board configuration
| Key | Value |
| --- | ----- |
| notify-due | `off`, `assigned` or `all` |
| calendar | Boolean |
#### Example request
```
curl -X POST 'https://admin:admin@nextcloud.local/ocs/v2.php/apps/deck/api/v1.0/config/calendar' -H 'Accept: application/json' -H "Content-Type: application/json" -H 'OCS-APIRequest: true' --data-raw '{"value":false}'
{
"ocs": {
"meta": {
"status": "ok",
"statuscode": 200,
"message": "OK"
},
"data": false
}
}
```
## Comments
### GET /cards/{cardId}/comments - List comments

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" version="1.1" viewBox="0 0 16 16">
<path d="m8 2c-0.5523 0-1 0.4477-1 1 0 0.0472 0.021 0.0873 0.0273 0.1328-1.7366 0.4362-3.0273 1.9953-3.0273 3.8672v2l-1 1v1h10v-1l-1-1v-2c0-1.8719-1.291-3.431-3.0273-3.8672 0.0063-0.0455 0.0273-0.0856 0.0273-0.1328 0-0.5523-0.4477-1-1-1zm-2 10c0 1.1046 0.8954 2 2 2s2-0.8954 2-2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -33,11 +33,14 @@ class CalendarPlugin implements ICalendarProvider {
/** @var DeckCalendarBackend */
private $backend;
/** @var ConfigService */
private $configService;
/** @var bool */
private $calendarIntegrationEnabled;
public function __construct(DeckCalendarBackend $backend, ConfigService $configService) {
$this->backend = $backend;
$this->configService = $configService;
$this->calendarIntegrationEnabled = $configService->get('calendar');
}
@@ -50,9 +53,12 @@ class CalendarPlugin implements ICalendarProvider {
return [];
}
$configService = $this->configService;
return array_map(function (Board $board) use ($principalUri) {
return new Calendar($principalUri, 'board-' . $board->getId(), $board, $this->backend);
}, $this->backend->getBoards());
}, array_filter($this->backend->getBoards(), function ($board) use ($configService) {
return $configService->isCalendarEnabled($board->getId());
}));
}
public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool {

View File

@@ -21,27 +21,35 @@
*
*/
declare(strict_types=1);
namespace OCA\Deck\Db;
use OCA\Deck\NotFoundException;
use OCA\Deck\Service\CirclesService;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IUserManager;
class AssignedUsersMapper extends DeckMapper implements IPermissionMapper {
private $cardMapper;
private $userManager;
/**
* @var IGroupManager
*/
private $groupManager;
class AssignedUsersMapper extends QBMapper implements IPermissionMapper {
public function __construct(IDBConnection $db, CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager) {
/** @var CardMapper */
private $cardMapper;
/** @var IUserManager */
private $userManager;
/** @var IGroupManager */
private $groupManager;
/** @var CirclesService */
private $circleService;
public function __construct(IDBConnection $db, CardMapper $cardMapper, IUserManager $userManager, IGroupManager $groupManager, CirclesService $circleService) {
parent::__construct($db, 'deck_assigned_users', AssignedUsers::class);
$this->cardMapper = $cardMapper;
$this->userManager = $userManager;
$this->groupManager = $groupManager;
$this->circleService = $circleService;
}
/**
@@ -51,9 +59,12 @@ class AssignedUsersMapper extends DeckMapper implements IPermissionMapper {
* @return array|Entity
*/
public function find($cardId) {
$sql = 'SELECT * FROM `*PREFIX*deck_assigned_users` ' .
'WHERE `card_id` = ?';
$users = $this->findEntities($sql, [$cardId]);
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('deck_assigned_users')
->where($qb->expr()->eq('card_id', $qb->createNamedParameter($cardId)));
/** @var AssignedUsers[] $users */
$users = $this->findEntities($qb);
foreach ($users as &$user) {
$this->mapParticipant($user);
}
@@ -61,9 +72,12 @@ class AssignedUsersMapper extends DeckMapper implements IPermissionMapper {
}
public function findByUserId($uid) {
$sql = 'SELECT * FROM `*PREFIX*deck_assigned_users` ' .
'WHERE `participant` = ?';
return $this->findEntities($sql, [$uid]);
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('deck_assigned_users')
->where($qb->expr()->eq('participant', $qb->createNamedParameter($uid)));
/** @var AssignedUsers[] $users */
return $this->findEntities($qb);
}
@@ -81,24 +95,43 @@ class AssignedUsersMapper extends DeckMapper implements IPermissionMapper {
* @param Entity $entity
* @return null|Entity
*/
public function insert(Entity $entity) {
public function insert(Entity $entity): Entity {
$origin = $this->getOrigin($entity);
if ($origin !== null) {
/** @var AssignedUsers $assignment */
$assignment = parent::insert($entity);
$this->mapParticipant($assignment);
return $assignment;
if ($origin === null) {
throw new NotFoundException('No origin found for assignment');
}
return null;
/** @var AssignedUsers $assignment */
$assignment = parent::insert($entity);
$this->mapParticipant($assignment);
return $assignment;
}
public function mapParticipant(AssignedUsers &$assignment) {
public function mapParticipant(AssignedUsers $assignment): void {
$self = $this;
$assignment->resolveRelation('participant', function () use (&$self, &$assignment) {
return $self->getOrigin($assignment);
});
}
public function isUserAssigned($cardId, $userId): bool {
$assignments = $this->find($cardId);
/** @var AssignedUsers $assignment */
foreach ($assignments as $assignment) {
$origin = $this->getOrigin($assignment);
if ($origin instanceof User && $assignment->getParticipant() === $userId) {
return true;
}
if ($origin instanceof Group && $this->groupManager->isInGroup($userId, $assignment->getParticipant())) {
return true;
}
if ($origin instanceof Circle && $this->circleService->isUserInCircle($assignment->getParticipant(), $userId)) {
return true;
}
}
return false;
}
private function getOrigin(AssignedUsers $assignment) {
if ($assignment->getType() === AssignedUsers::TYPE_USER) {
$origin = $this->userManager->get($assignment->getParticipant());
@@ -109,7 +142,7 @@ class AssignedUsersMapper extends DeckMapper implements IPermissionMapper {
return $origin ? new Group($origin) : null;
}
if ($assignment->getType() === AssignedUsers::TYPE_CIRCLE) {
$origin = $this->groupManager->get($assignment->getParticipant());
$origin = $this->circleService->getCircle($assignment->getParticipant());
return $origin ? new Circle($origin) : null;
}
return null;

View File

@@ -37,6 +37,8 @@ class Board extends RelationalEntity {
protected $deletedAt = 0;
protected $lastModified = 0;
protected $settings = [];
public function __construct() {
$this->addType('id', 'integer');
$this->addType('shared', 'integer');
@@ -49,6 +51,7 @@ class Board extends RelationalEntity {
$this->addRelation('users');
$this->addRelation('permissions');
$this->addRelation('stacks');
$this->addRelation('settings');
$this->addResolvable('owner');
$this->shared = -1;
}

View File

@@ -25,7 +25,6 @@ namespace OCA\Deck\Db;
use Exception;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
@@ -82,7 +81,8 @@ class CardMapper extends QBMapper implements IPermissionMapper {
}
// make sure we only reset the notification flag if the duedate changes
if (in_array('duedate', $entity->getUpdatedFields(), true)) {
$updatedFields = $entity->getUpdatedFields();
if (isset($updatedFields['duedate']) && $updatedFields['duedate']) {
try {
/** @var Card $existing */
$existing = $this->find($entity->getId());
@@ -243,6 +243,7 @@ class CardMapper extends QBMapper implements IPermissionMapper {
$qb->select('id','title','duedate','notified')
->from('deck_cards')
->where($qb->expr()->lt('duedate', $qb->createFunction('NOW()')))
->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false)))
->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
return $this->findEntities($qb);

View File

@@ -57,4 +57,8 @@ class RelationalObject implements \JsonSerializable {
throw new \Exception('jsonSerialize is not implemented on ' . get_class($this));
}
}
public function getPrimaryKey(): string {
return $this->primaryKey;
}
}

View File

@@ -24,14 +24,18 @@
namespace OCA\Deck\Notification;
use DateTime;
use OCA\Deck\AppInfo\Application;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AssignedUsersMapper;
use OCA\Deck\Db\Board;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\User;
use OCA\Deck\Service\ConfigService;
use OCA\Deck\Service\PermissionService;
use OCP\Comments\IComment;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\Notification\IManager;
class NotificationHelper {
@@ -40,8 +44,12 @@ class NotificationHelper {
protected $cardMapper;
/** @var BoardMapper */
protected $boardMapper;
/** @var AssignedUsersMapper */
protected $assignedUsersMapper;
/** @var PermissionService */
protected $permissionService;
/** @var IConfig */
protected $config;
/** @var IManager */
protected $notificationManager;
/** @var IGroupManager */
@@ -54,14 +62,18 @@ class NotificationHelper {
public function __construct(
CardMapper $cardMapper,
BoardMapper $boardMapper,
AssignedUsersMapper $assignedUsersMapper,
PermissionService $permissionService,
IConfig $config,
IManager $notificationManager,
IGroupManager $groupManager,
$userId
) {
$this->cardMapper = $cardMapper;
$this->boardMapper = $boardMapper;
$this->assignedUsersMapper = $assignedUsersMapper;
$this->permissionService = $permissionService;
$this->config = $config;
$this->notificationManager = $notificationManager;
$this->groupManager = $groupManager;
$this->currentUser = $userId;
@@ -80,21 +92,38 @@ class NotificationHelper {
return;
}
// TODO: Once assigning users is possible, those should be notified instead of all users of the board
$boardId = $this->cardMapper->findBoardId($card->getId());
$board = $this->getBoard($boardId);
/** @var IUser $user */
$board = $this->getBoard($boardId, false, true);
/** @var User $user */
foreach ($this->permissionService->findUsers($boardId) as $user) {
$notification = $this->notificationManager->createNotification();
$notification
->setApp('deck')
->setUser((string) $user->getUID())
->setObject('card', $card->getId())
->setSubject('card-overdue', [
$card->getTitle(), $board->getTitle()
])
->setDateTime(new DateTime($card->getDuedate()));
$this->notificationManager->notify($notification);
$notificationSetting = $this->config->getUserValue($user->getUID(), Application::APP_ID, 'board:' . $boardId . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_DEFAULT);
if ($notificationSetting === ConfigService::SETTING_BOARD_NOTIFICATION_DUE_OFF) {
continue;
}
$shouldNotify = $notificationSetting === ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ALL;
if ($user->getUID() === $board->getOwner() && count($board->getAcl()) === 0) {
// Notify if all or assigned is configured for unshared boards
$shouldNotify = true;
} elseif ($notificationSetting === ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED && $this->assignedUsersMapper->isUserAssigned($card->getId(), $user->getUID())) {
// Notify if the user is assigned and has the assigned setting selected
$shouldNotify = true;
}
if ($shouldNotify) {
$notification = $this->notificationManager->createNotification();
$notification
->setApp('deck')
->setUser((string)$user->getUID())
->setObject('card', $card->getId())
->setSubject('card-overdue', [
$card->getTitle(), $board->getTitle()
])
->setDateTime(new DateTime($card->getDuedate()));
$this->notificationManager->notify($notification);
}
}
$this->cardMapper->markNotified($card);
}
@@ -169,9 +198,9 @@ class NotificationHelper {
* @return Board
* @throws \OCP\AppFramework\Db\DoesNotExistException
*/
private function getBoard($boardId) {
private function getBoard($boardId, bool $withLabels = false, bool $withAcl = false) {
if (!array_key_exists($boardId, $this->boards)) {
$this->boards[$boardId] = $this->boardMapper->find($boardId);
$this->boards[$boardId] = $this->boardMapper->find($boardId, $withLabels, $withAcl);
}
return $this->boards[$boardId];
}

View File

@@ -26,6 +26,7 @@ namespace OCA\Deck\Service;
use OCA\Deck\Activity\ActivityManager;
use OCA\Deck\Activity\ChangeSet;
use OCA\Deck\AppInfo\Application;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AclMapper;
use OCA\Deck\Db\AssignedUsersMapper;
@@ -37,6 +38,7 @@ use OCA\Deck\Db\StackMapper;
use OCA\Deck\NoPermissionException;
use OCA\Deck\Notification\NotificationHelper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IL10N;
use OCA\Deck\Db\Board;
@@ -52,6 +54,8 @@ class BoardService {
private $stackMapper;
private $labelMapper;
private $aclMapper;
/** @var IConfig */
private $config;
private $l10n;
private $permissionService;
private $notificationHelper;
@@ -66,9 +70,11 @@ class BoardService {
private $boardsCache = null;
public function __construct(
BoardMapper $boardMapper,
StackMapper $stackMapper,
IConfig $config,
IL10N $l10n,
LabelMapper $labelMapper,
AclMapper $aclMapper,
@@ -85,6 +91,7 @@ class BoardService {
$this->boardMapper = $boardMapper;
$this->stackMapper = $stackMapper;
$this->labelMapper = $labelMapper;
$this->config = $config;
$this->aclMapper = $aclMapper;
$this->l10n = $l10n;
$this->permissionService = $permissionService;
@@ -151,6 +158,7 @@ class BoardService {
'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE] ?? false,
'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false
]);
$this->enrichWithBoardSettings($item);
$result[$item->getId()] = $item;
}
$this->boardsCache = $result;
@@ -190,6 +198,7 @@ class BoardService {
'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ?? false
]);
$this->enrichWithUsers($board);
$this->enrichWithBoardSettings($board);
$this->boardsCache[$board->getId()] = $board;
return $board;
}
@@ -476,6 +485,15 @@ class BoardService {
return [$edit, $share, $manage];
}
public function enrichWithBoardSettings(Board $board) {
$globalCalendarConfig = (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'calendar', true);
$settings = [
'notify-due' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED),
'calendar' => $this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $board->getId() . ':calendar', $globalCalendarConfig),
];
$board->setSettings($settings);
}
/**
* @param $boardId
* @param $type

View File

@@ -0,0 +1,62 @@
<?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\Service;
use OCP\App\IAppManager;
/**
* Wrapper around circles app API since it is not in a public namespace so we need to make sure that
* having the app disabled is properly handled
*/
class CirclesService {
private $circlesEnabled;
public function __construct(IAppManager $appManager) {
$this->circlesEnabled = $appManager->isEnabledForUser('circles');
}
public function getCircle($circleId) {
if (!$this->circlesEnabled) {
return null;
}
return \OCA\Circles\Api\v1\Circles::detailsCircle($circleId, true);
}
public function isUserInCircle($circleId, $userId): bool {
if (!$this->circlesEnabled) {
return false;
}
try {
\OCA\Circles\Api\v1\Circles::getMember($circleId, $userId, 1, true);
return true;
} catch (\Exception $e) {
}
return false;
}
}

View File

@@ -27,12 +27,18 @@ declare(strict_types=1);
namespace OCA\Deck\Service;
use OCA\Deck\AppInfo\Application;
use OCA\Deck\BadRequestException;
use OCA\Deck\NoPermissionException;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
class ConfigService {
public const SETTING_BOARD_NOTIFICATION_DUE_OFF = 'off';
public const SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED = 'assigned';
public const SETTING_BOARD_NOTIFICATION_DUE_ALL = 'all';
public const SETTING_BOARD_NOTIFICATION_DUE_DEFAULT = self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED;
private $config;
private $userId;
private $groupManager;
@@ -49,19 +55,18 @@ class ConfigService {
public function getAll(): array {
$data = [
'calendar' => $this->get('calendar')
'calendar' => $this->isCalendarEnabled()
];
if ($this->groupManager->isAdmin($this->userId)) {
$data = [
'groupLimit' => $this->get('groupLimit'),
];
$data['groupLimit'] = $this->get('groupLimit');
}
return $data;
}
public function get($key) {
$result = null;
switch ($key) {
[$scope, $id] = explode(':', $key, 2);
switch ($scope) {
case 'groupLimit':
if (!$this->groupManager->isAdmin($this->userId)) {
throw new NoPermissionException('You must be admin to get the group limit');
@@ -75,9 +80,19 @@ class ConfigService {
return $result;
}
public function isCalendarEnabled(int $boardId = null): bool {
$defaultState = (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'calendar', true);
if ($boardId === null) {
return $defaultState;
}
return (bool)$this->config->getUserValue($this->userId, Application::APP_ID, 'board:' . $boardId . ':calendar', $defaultState);
}
public function set($key, $value) {
$result = null;
switch ($key) {
[$scope, $id] = explode(':', $key, 2);
switch ($scope) {
case 'groupLimit':
if (!$this->groupManager->isAdmin($this->userId)) {
throw new NoPermissionException('You must be admin to set the group limit');
@@ -88,6 +103,13 @@ class ConfigService {
$this->config->setUserValue($this->userId, Application::APP_ID, 'calendar', (int)$value);
$result = $value;
break;
case 'board':
[$boardId, $boardConfigKey] = explode(':', $key);
if ($boardConfigKey === 'notify-due' && !in_array($value, [self::SETTING_BOARD_NOTIFICATION_DUE_ALL, self::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED, self::SETTING_BOARD_NOTIFICATION_DUE_OFF], true)) {
throw new BadRequestException('Board notification option must be one of: off, assigned, all');
}
$this->config->setUserValue($this->userId, Application::APP_ID, $key, $value);
$result = $value;
}
return $result;
}

View File

@@ -34,38 +34,89 @@
style="opacity: 0.5" />
<template v-if="!deleted" slot="actions">
<ActionButton v-if="canManage && !board.archived"
icon="icon-rename"
:close-after-click="true"
@click="actionEdit">
{{ t('deck', 'Edit board') }}
<template v-if="!isDueSubmenuActive">
<ActionButton
icon="icon-more"
:close-after-click="true"
@click="actionDetails">
{{ t('deck', 'Board details') }}
</ActionButton>
<ActionButton v-if="canManage"
icon="icon-rename"
:close-after-click="true"
@click="actionEdit">
{{ t('deck', 'Edit board') }}
</ActionButton>
<ActionButton v-if="canManage"
icon="icon-clone"
:close-after-click="true"
@click="actionClone">
{{ t('deck', 'Clone board') }}
</ActionButton>
<ActionButton v-if="canManage"
icon="icon-archive"
:close-after-click="true"
@click="actionUnarchive">
{{ t('deck', 'Unarchive board') }}
</ActionButton>
<ActionButton v-if="canManage"
icon="icon-archive"
:close-after-click="true"
@click="actionArchive">
{{ t('deck', 'Archive board') }}
</ActionButton>
<ActionButton v-if="board.acl.length === 0" :icon="board.settings['notify-due'] === 'off' ? 'icon-sound' : 'icon-sound-off'" @click="board.settings['notify-due'] === 'off' ? updateSetting('notify-due', 'all') : updateSetting('notify-due', 'off')">
{{ board.settings['notify-due'] === 'off' ? t('deck', 'Turn on due date reminders') : t('deck', 'Turn off due date reminders') }}
</ActionButton>
</template>
<!-- Due date reminder settings -->
<template v-if="isDueSubmenuActive">
<ActionButton
:icon="updateDueSetting ? 'icon-loading-small' : 'icon-view-previous'"
:disabled="updateDueSetting"
@click="isDueSubmenuActive=false">
{{ t('deck', 'Due date reminders') }}
</ActionButton>
<ActionButton
name="notification"
icon="icon-sound"
:disabled="updateDueSetting"
:class="{ 'forced-active': board.settings['notify-due'] === 'all' }"
@click="updateSetting('notify-due', 'all')">
{{ t('deck', 'All cards') }}
</ActionButton>
<ActionButton
name="notification"
icon="icon-user"
:disabled="updateDueSetting"
:class="{ 'forced-active': board.settings['notify-due'] === 'assigned' }"
@click="updateSetting('notify-due', 'assigned')">
{{ t('deck', 'Assigned cards') }}
</ActionButton>
<ActionButton
name="notification"
icon="icon-sound-off"
:disabled="updateDueSetting"
:class="{ 'forced-active': board.settings['notify-due'] === 'off' }"
@click="updateSetting('notify-due', 'off')">
{{ t('deck', 'No notifications') }}
</ActionButton>
</template>
<ActionButton v-else-if="board.acl.length > 0"
:title="t('deck', 'Due date reminders')"
:icon="dueDateReminderIcon"
@click="isDueSubmenuActive=true">
{{ dueDateReminderText }}
</ActionButton>
<ActionButton v-if="canManage && !board.archived"
icon="icon-clone"
:close-after-click="true"
@click="actionClone">
{{ t('deck', 'Clone board ') }}
</ActionButton>
<ActionButton v-if="canManage && board.archived"
icon="icon-archive"
:close-after-click="true"
@click="actionUnarchive">
{{ t('deck', 'Unarchive board ') }}
</ActionButton>
<ActionButton v-if="canManage && !board.archived"
icon="icon-archive"
:close-after-click="true"
@click="actionArchive">
{{ t('deck', 'Archive board ') }}
</ActionButton>
<ActionButton v-if="canManage"
<ActionButton v-if="canManage && !isDueSubmenuActive"
icon="icon-delete"
:close-after-click="true"
@click="actionDelete">
{{ t('deck', 'Delete board ') }}
</ActionButton>
<ActionButton icon="icon-more" :close-after-click="true" @click="actionDetails">
{{ t('deck', 'Board details') }}
{{ t('deck', 'Delete board') }}
</ActionButton>
</template>
</AppNavigationItem>
@@ -114,6 +165,8 @@ export default {
undoTimeoutHandle: null,
editTitle: '',
editColor: '',
isDueSubmenuActive: false,
updateDueSetting: null,
}
},
computed: {
@@ -133,7 +186,27 @@ export default {
}
},
canManage() {
return this.board.permissions.PERMISSION_MANAGE
return this.board.permissions.PERMISSION_MANAGE && !this.board.archived
},
dueDateReminderIcon() {
if (this.board.settings['notify-due'] === 'all') {
return 'icon-sound'
} else if (this.board.settings['notify-due'] === 'assigned') {
return 'icon-user'
} else if (this.board.settings['notify-due'] === 'off') {
return 'icon-sound-off'
}
return ''
},
dueDateReminderText() {
if (this.board.settings['notify-due'] === 'all') {
return t('deck', 'All cards')
} else if (this.board.settings['notify-due'] === 'assigned') {
return t('deck', 'Only assigned cards')
} else if (this.board.settings['notify-due'] === 'off') {
return t('deck', 'No reminder')
}
return ''
},
},
watch: {},
@@ -230,6 +303,14 @@ export default {
route.name = 'board.details'
this.$router.push(route)
},
async updateSetting(key, value) {
this.updateDueSetting = value
const setting = {}
setting['board:' + this.board.id + ':' + key] = value
await this.$store.dispatch('setConfig', setting)
this.isDueSubmenuActive = false
this.updateDueSetting = null
},
},
inject: [
'boardApi',
@@ -266,4 +347,8 @@ export default {
background-size: 14px;
}
}
.forced-active {
box-shadow: inset 4px 0 var(--color-primary-element);
}
</style>

View File

@@ -138,7 +138,21 @@ export default new Vuex.Store({
},
mutations: {
SET_CONFIG(state, { key, value }) {
Vue.set(state.config, key, value)
const [scope, id, configKey] = key.split(':', 3)
let indexExisting = -1
switch (scope) {
case 'board':
indexExisting = state.boards.findIndex((b) => {
return id === '' + b.id
})
if (indexExisting > -1) {
Vue.set(state.boards[indexExisting].settings, configKey, value)
}
break
default:
Vue.set(state.config, key, value)
}
},
setSearchQuery(state, searchQuery) {
state.searchQuery = searchQuery

View File

@@ -23,6 +23,7 @@
namespace OCA\Deck\Db;
use OCA\Deck\NotFoundException;
use OCA\Deck\Service\AssignmentService;
use OCA\Deck\Service\BoardService;
use OCA\Deck\Service\StackService;
@@ -165,8 +166,8 @@ class AssignedUsersMapperTest extends \Test\TestCase {
$assignment->setCardId($this->cards[1]->getId());
$assignment->setParticipant('invalid-username');
$assignment->setType(AssignedUsers::TYPE_USER);
$actual = $this->assignedUsersMapper->insert($assignment);
$this->assertNull($actual);
$this->expectException(NotFoundException::class);
$this->assignedUsersMapper->insert($assignment);
}
/**
@@ -188,6 +189,44 @@ class AssignedUsersMapperTest extends \Test\TestCase {
$this->assertEquals('invalid-username', $assignment->resolveParticipant());
}
public function testIsUserAssigned() {
$assignment = new AssignedUsers();
$assignment->setCardId($this->cards[1]->getId());
$assignment->setParticipant(self::TEST_USER4);
$assignment->setType(AssignedUsers::TYPE_USER);
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4));
$assignment = $this->assignedUsersMapper->insert($assignment);
$actual = $this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4);
$this->assignedUsersMapper->delete($assignment);
$this->assertTrue($actual);
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4));
}
public function testIsUserAssignedGroup() {
$assignment = new AssignedUsers();
$assignment->setCardId($this->cards[1]->getId());
$assignment->setParticipant('group');
$assignment->setType(AssignedUsers::TYPE_GROUP);
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER1));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER2));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER3));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4));
$assignment = $this->assignedUsersMapper->insert($assignment);
$this->assertTrue($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER1));
$this->assertTrue($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER2));
$this->assertTrue($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER3));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4));
$this->assignedUsersMapper->delete($assignment);
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER1));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER2));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER3));
$this->assertFalse($this->assignedUsersMapper->isUserAssigned($this->cards[1]->getId(), self::TEST_USER4));
}
public function tearDown(): void {
$this->boardService->deleteForce($this->board->getId());
parent::tearDown();

View File

@@ -31,6 +31,7 @@ class BoardTest extends TestCase {
'acl' => [],
'archived' => false,
'users' => ['user1', 'user2'],
'settings' => [],
], $board->jsonSerialize());
}
@@ -50,6 +51,7 @@ class BoardTest extends TestCase {
'acl' => [],
'archived' => false,
'users' => [],
'settings' => [],
], $board->jsonSerialize());
}
public function testSetAcl() {
@@ -77,6 +79,7 @@ class BoardTest extends TestCase {
'archived' => false,
'shared' => 1,
'users' => [],
'settings' => [],
], $board->jsonSerialize());
}
}

View File

@@ -24,30 +24,50 @@
namespace OCA\Deck\Notification;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AssignedUsersMapper;
use OCA\Deck\Db\Board;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\Card;
use OCA\Deck\Db\CardMapper;
use OCA\Deck\Db\User;
use OCA\Deck\Service\ConfigService;
use OCA\Deck\Service\PermissionService;
use OCP\Comments\IComment;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\Notification\IManager;
use OCP\Notification\INotification;
use PHPUnit\Framework\MockObject\MockObject;
class DummyUser extends \OC\User\User {
private $uid;
public function __construct($uid) {
$this->uid = $uid;
}
public function getUID() {
return $this->uid;
}
}
class NotificationHelperTest extends \Test\TestCase {
/** @var CardMapper */
/** @var CardMapper|MockObject */
protected $cardMapper;
/** @var BoardMapper */
/** @var BoardMapper|MockObject */
protected $boardMapper;
/** @var PermissionService */
/** @var AssignedUsersMapper|MockObject */
protected $assignedUsersMapper;
/** @var PermissionService|MockObject */
protected $permissionService;
/** @var IManager */
/** @var IConfig|MockObject */
protected $config;
/** @var IManager|MockObject */
protected $notificationManager;
/** @var IGroupManager */
/** @var IGroupManager|MockObject */
protected $groupManager;
/** @var string */
protected $currentUser;
@@ -58,14 +78,18 @@ 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->permissionService = $this->createMock(PermissionService::class);
$this->config = $this->createMock(IConfig::class);
$this->notificationManager = $this->createMock(IManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->currentUser = 'admin';
$this->notificationHelper = new NotificationHelper(
$this->cardMapper,
$this->boardMapper,
$this->assignedUsersMapper,
$this->permissionService,
$this->config,
$this->notificationManager,
$this->groupManager,
$this->currentUser
@@ -90,34 +114,32 @@ class NotificationHelperTest extends \Test\TestCase {
}
public function testSendCardDuedate() {
$card = $this->createMock(Card::class);
$card->expects($this->at(0))
->method('__call')
->with('getNotified', [])
->willReturn(false);
$card->expects($this->at(1))
->method('__call')
->with('getId', [])
->willReturn(123);
for ($i=0; $i<3; $i++) {
$card->expects($this->at($i*3+2))
->method('__call')
->with('getId', [])
->willReturn(123);
$card->expects($this->at($i*3+3))
->method('__call', [])
->with('getTitle')
->willReturn('MyCardTitle');
}
$this->config->expects($this->at(0))
->method('getUserValue')
->with('foo', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ALL);
$this->config->expects($this->at(1))
->method('getUserValue')
->with('bar', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ALL);
$this->config->expects($this->at(2))
->method('getUserValue')
->with('asd', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ALL);
$card = Card::fromParams([
'notified' => false,
'id' => 123,
'title' => 'MyCardTitle'
]);
$this->cardMapper->expects($this->once())
->method('findBoardId')
->with(123)
->willReturn(234);
$board = $this->createMock(Board::class);
$board->expects($this->any())
->method('__call')
->with('getTitle', [])
->willReturn('MyBoardTitle');
$board = Board::fromParams([
'id' => 123,
'title' => 'MyBoardTitle'
]);
$this->boardMapper->expects($this->once())
->method('find')
->with(234)
@@ -183,6 +205,188 @@ class NotificationHelperTest extends \Test\TestCase {
$this->notificationHelper->sendCardDuedate($card);
}
public function testSendCardDuedateAssigned() {
$this->config->expects($this->at(0))
->method('getUserValue')
->with('foo', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED);
$this->config->expects($this->at(1))
->method('getUserValue')
->with('bar', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED);
$this->config->expects($this->at(2))
->method('getUserValue')
->with('asd', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED);
$users = [
new DummyUser('foo'), new DummyUser('bar'), new DummyUser('asd')
];
$card = Card::fromParams([
'notified' => false,
'id' => 123,
'title' => 'MyCardTitle'
]);
$card->setAssignedUsers([
new User($users[0])
]);
$this->cardMapper->expects($this->once())
->method('findBoardId')
->with(123)
->willReturn(234);
$board = Board::fromParams([
'id' => 123,
'title' => 'MyBoardTitle'
]);
$this->boardMapper->expects($this->once())
->method('find')
->with(234)
->willReturn($board);
$this->permissionService->expects($this->once())
->method('findUsers')
->with(234)
->willReturn($users);
$this->assignedUsersMapper->expects($this->exactly(3))
->method('isUserAssigned')
->willReturn(true);
$n1 = $this->createMock(INotification::class);
$n2 = $this->createMock(INotification::class);
$n3 = $this->createMock(INotification::class);
$n1->expects($this->once())->method('setApp')->with('deck')->willReturn($n1);
$n1->expects($this->once())->method('setUser')->with('foo')->willReturn($n1);
$n1->expects($this->once())->method('setObject')->with('card', 123)->willReturn($n1);
$n1->expects($this->once())->method('setSubject')->with('card-overdue', ['MyCardTitle', 'MyBoardTitle'])->willReturn($n1);
$n1->expects($this->once())->method('setDateTime')->willReturn($n1);
$n2->expects($this->once())->method('setApp')->with('deck')->willReturn($n2);
$n2->expects($this->once())->method('setUser')->with('bar')->willReturn($n2);
$n2->expects($this->once())->method('setObject')->with('card', 123)->willReturn($n2);
$n2->expects($this->once())->method('setSubject')->with('card-overdue', ['MyCardTitle', 'MyBoardTitle'])->willReturn($n2);
$n2->expects($this->once())->method('setDateTime')->willReturn($n2);
$n3->expects($this->once())->method('setApp')->with('deck')->willReturn($n3);
$n3->expects($this->once())->method('setUser')->with('asd')->willReturn($n3);
$n3->expects($this->once())->method('setObject')->with('card', 123)->willReturn($n3);
$n3->expects($this->once())->method('setSubject')->with('card-overdue', ['MyCardTitle', 'MyBoardTitle'])->willReturn($n3);
$n3->expects($this->once())->method('setDateTime')->willReturn($n3);
$this->notificationManager->expects($this->at(0))
->method('createNotification')
->willReturn($n1);
$this->notificationManager->expects($this->at(1))
->method('notify')
->with($n1);
$this->notificationManager->expects($this->at(2))
->method('createNotification')
->willReturn($n2);
$this->notificationManager->expects($this->at(3))
->method('notify')
->with($n2);
$this->notificationManager->expects($this->at(4))
->method('createNotification')
->willReturn($n3);
$this->notificationManager->expects($this->at(5))
->method('notify')
->with($n3);
$this->cardMapper->expects($this->once())
->method('markNotified')
->with($card);
$this->notificationHelper->sendCardDuedate($card);
}
public function testSendCardDuedateNever() {
$this->config->expects($this->at(0))
->method('getUserValue')
->with('foo', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED);
$this->config->expects($this->at(1))
->method('getUserValue')
->with('bar', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED);
$this->config->expects($this->at(2))
->method('getUserValue')
->with('asd', 'deck', 'board:234:notify-due', ConfigService::SETTING_BOARD_NOTIFICATION_DUE_ASSIGNED)
->willReturn(ConfigService::SETTING_BOARD_NOTIFICATION_DUE_OFF);
$users = [
new DummyUser('foo'), new DummyUser('bar'), new DummyUser('asd')
];
$card = Card::fromParams([
'notified' => false,
'id' => 123,
'title' => 'MyCardTitle'
]);
$card->setAssignedUsers([
new User($users[0])
]);
$this->cardMapper->expects($this->once())
->method('findBoardId')
->with(123)
->willReturn(234);
$board = Board::fromParams([
'id' => 123,
'title' => 'MyBoardTitle'
]);
$this->boardMapper->expects($this->once())
->method('find')
->with(234)
->willReturn($board);
$this->permissionService->expects($this->once())
->method('findUsers')
->with(234)
->willReturn($users);
$this->assignedUsersMapper->expects($this->exactly(2))
->method('isUserAssigned')
->willReturn(true);
$n1 = $this->createMock(INotification::class);
$n2 = $this->createMock(INotification::class);
$n1->expects($this->once())->method('setApp')->with('deck')->willReturn($n1);
$n1->expects($this->once())->method('setUser')->with('foo')->willReturn($n1);
$n1->expects($this->once())->method('setObject')->with('card', 123)->willReturn($n1);
$n1->expects($this->once())->method('setSubject')->with('card-overdue', ['MyCardTitle', 'MyBoardTitle'])->willReturn($n1);
$n1->expects($this->once())->method('setDateTime')->willReturn($n1);
$n2->expects($this->once())->method('setApp')->with('deck')->willReturn($n2);
$n2->expects($this->once())->method('setUser')->with('bar')->willReturn($n2);
$n2->expects($this->once())->method('setObject')->with('card', 123)->willReturn($n2);
$n2->expects($this->once())->method('setSubject')->with('card-overdue', ['MyCardTitle', 'MyBoardTitle'])->willReturn($n2);
$n2->expects($this->once())->method('setDateTime')->willReturn($n2);
$this->notificationManager->expects($this->at(0))
->method('createNotification')
->willReturn($n1);
$this->notificationManager->expects($this->at(1))
->method('notify')
->with($n1);
$this->notificationManager->expects($this->at(2))
->method('createNotification')
->willReturn($n2);
$this->notificationManager->expects($this->at(3))
->method('notify')
->with($n2);
$this->cardMapper->expects($this->once())
->method('markNotified')
->with($card);
$this->notificationHelper->sendCardDuedate($card);
}
public function testSendCardAssignedUser() {
$board = new Board();
$board->setId(123);

View File

@@ -36,6 +36,7 @@ use OCA\Deck\Db\LabelMapper;
use OCA\Deck\Db\StackMapper;
use OCA\Deck\NoPermissionException;
use OCA\Deck\Notification\NotificationHelper;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IGroupManager;
@@ -80,6 +81,7 @@ class BoardServiceTest extends TestCase {
$this->aclMapper = $this->createMock(AclMapper::class);
$this->boardMapper = $this->createMock(BoardMapper::class);
$this->stackMapper = $this->createMock(StackMapper::class);
$this->config = $this->createMock(IConfig::class);
$this->labelMapper = $this->createMock(LabelMapper::class);
$this->permissionService = $this->createMock(PermissionService::class);
$this->notificationHelper = $this->createMock(NotificationHelper::class);
@@ -93,6 +95,7 @@ class BoardServiceTest extends TestCase {
$this->service = new BoardService(
$this->boardMapper,
$this->stackMapper,
$this->config,
$this->l10n,
$this->labelMapper,
$this->aclMapper,