Merge pull request #4137 from TehThanos/master

This commit is contained in:
Julius Härtl
2023-11-08 15:40:43 +01:00
committed by GitHub
25 changed files with 585 additions and 62 deletions

View File

@@ -16,7 +16,7 @@
- 🚀 Get your project organized - 🚀 Get your project organized
</description> </description>
<version>1.11.0-dev.1</version> <version>1.11.0-dev.2</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>Julius Härtl</author> <author>Julius Härtl</author>
<documentation> <documentation>

View File

@@ -61,6 +61,8 @@ return [
['name' => 'card#reorder', 'url' => '/cards/{cardId}/reorder', 'verb' => 'PUT'], ['name' => 'card#reorder', 'url' => '/cards/{cardId}/reorder', 'verb' => 'PUT'],
['name' => 'card#archive', 'url' => '/cards/{cardId}/archive', 'verb' => 'PUT'], ['name' => 'card#archive', 'url' => '/cards/{cardId}/archive', 'verb' => 'PUT'],
['name' => 'card#unarchive', 'url' => '/cards/{cardId}/unarchive', 'verb' => 'PUT'], ['name' => 'card#unarchive', 'url' => '/cards/{cardId}/unarchive', 'verb' => 'PUT'],
['name' => 'card#done', 'url' => '/cards/{cardId}/done', 'verb' => 'PUT'],
['name' => 'card#undone', 'url' => '/cards/{cardId}/undone', 'verb' => 'PUT'],
['name' => 'card#assignLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'POST'], ['name' => 'card#assignLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'POST'],
['name' => 'card#removeLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card#removeLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'],
['name' => 'card#assignUser', 'url' => '/cards/{cardId}/assign', 'verb' => 'POST'], ['name' => 'card#assignUser', 'url' => '/cards/{cardId}/assign', 'verb' => 'POST'],

View File

@@ -117,6 +117,7 @@ This API version has become available with **Deck 1.3.0**.
- [GET /boards/import/getSystems - Import a board](#get-boardsimportgetsystems-import-a-board) - [GET /boards/import/getSystems - Import a board](#get-boardsimportgetsystems-import-a-board)
- [GET /boards/import/config/system/{schema} - Import a board](#get-boardsimportconfigsystemschema-import-a-board) - [GET /boards/import/config/system/{schema} - Import a board](#get-boardsimportconfigsystemschema-import-a-board)
- [POST /boards/import - Import a board](#post-boardsimport-import-a-board) - [POST /boards/import - Import a board](#post-boardsimport-import-a-board)
- The `done` property was added to cards
# Endpoints # Endpoints
@@ -601,6 +602,7 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit
"owner":"admin", "owner":"admin",
"order":999, "order":999,
"archived":false, "archived":false,
"done":null,
"duedate": "2019-12-24T19:29:30+00:00", "duedate": "2019-12-24T19:29:30+00:00",
"deletedAt":0, "deletedAt":0,
"commentsUnread":0, "commentsUnread":0,
@@ -624,12 +626,15 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit
#### Request data #### Request data
| Parameter | Type | Description | | Parameter | Type | Description |
|-------------|-----------|------------------------------------------------------| |-------------|-----------------|-----------------------------------------------------------------------------------------------------|
| title | String | The title of the card, maximum length is limited to 255 characters | | title | String | The title of the card, maximum length is limited to 255 characters |
| description | String | The markdown description of the card | | description | String | The markdown description of the card |
| type | String | Type of the card (for later use) use 'plain' for now | | type | String | Type of the card (for later use) use 'plain' for now |
| owner | String | The user that owns the card |
| order | Integer | Order for sorting the stacks | | order | Integer | Order for sorting the stacks |
| duedate | timestamp | The ISO-8601 formatted duedate of the card or null | | duedate | timestamp | The ISO-8601 formatted duedate of the card or null |
| archived | bool | Whether the card is archived or not |
| done | timestamp\|null | The ISO-8601 formatted date when the card is marked as done (optional, null indicates undone state) |
``` ```
@@ -637,8 +642,11 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit
"title": "Test card", "title": "Test card",
"description": "A card description", "description": "A card description",
"type": "plain", "type": "plain",
"owner": "admin",
"order": 999, "order": 999,
"duedate": "2019-12-24T19:29:30+00:00", "duedate": "2019-12-24T19:29:30+00:00",
"archived": false,
"done": null,
} }
``` ```

View File

@@ -12,11 +12,12 @@ Overall, Deck is easy to use. You can create boards, add users, share the Deck,
1. [Create my first board](#1-create-my-first-board) 1. [Create my first board](#1-create-my-first-board)
2. [Create stacks and cards](#2-create-stacks-and-cards) 2. [Create stacks and cards](#2-create-stacks-and-cards)
3. [Handle cards options](#3-handle-cards-options) 3. [Handle cards options](#3-handle-cards-options)
4. [Archive old tasks](#4-archive-old-tasks) 4. [Mark task as done](#4-mark-as-done)
5. [Manage your board](#5-manage-your-board) 5. [Archive old tasks](#5-archive-old-tasks)
6. [Import boards](#6-import-boards) 6. [Manage your board](#6-manage-your-board)
7. [Search](#7-search) 7. [Import boards](#7-import-boards)
8. [New owner for the deck entities](#8-new-owner-for-the-deck-entities) 8. [Search](#8-search)
9. [New owner for the deck entities](#9-new-owner-for-the-deck-entities)
### 1. Create my first board ### 1. Create my first board
In this example, we're going to create a board and share it with an other nextcloud user. In this example, we're going to create a board and share it with an other nextcloud user.
@@ -53,12 +54,18 @@ And even :
![Gif for puting infos on tasks 2](resources/gifs/EN_put_infos_2.gif) ![Gif for puting infos on tasks 2](resources/gifs/EN_put_infos_2.gif)
### 4. Archive old tasks ### 4. Mark as done
Once finished or obsolete, a task could be archived. The tasks is not deleted, it's just archived, and you can retrieve it later Once a task has been completed, you can mark it as done. This will prevent it from becoming overdue and hide it from the upcoming cards.
You can mark it as not done at any time.
![Gif for puting infos on tasks 2](resources/gifs/EN_archive.gif) ![Gif for marking a card as done](resources/gifs/EN_done.gif)
### 5. Manage your board ### 5. Archive old tasks
Once obsolete, a task could be archived. The task is not deleted, it's just archived, and you can retrieve it later
![Gif for archiving a task](resources/gifs/EN_archive.gif)
### 6. Manage your board
You can manage the settings of your Deck once you are inside it, by clicking on the small wheel at the top right. You can manage the settings of your Deck once you are inside it, by clicking on the small wheel at the top right.
Once in this menu, you have access to several things: Once in this menu, you have access to several things:
@@ -72,7 +79,7 @@ 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. **Deleted objects** allows you to return previously deleted stacks or cards.
The **Timeline** allows you to see everything that happened in your boards. Everything! The **Timeline** allows you to see everything that happened in your boards. Everything!
### 6. Import boards ### 7. Import boards
Importing can be done using the API or the `occ` `deck:import` command. Importing can be done using the API or the `occ` `deck:import` command.
@@ -138,7 +145,7 @@ Example configuration file:
} }
``` ```
### 7. Search ### 8. 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. 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. This search allows advanced filtering of cards across all board of the logged in user.
@@ -161,7 +168,7 @@ Other text tokens will be used to perform a case-insensitive search on the card
In addition, quotes can be used to pass a query with spaces, e.g. `"Exact match with spaces"` or `title:"My card"`. In addition, quotes can be used to pass a query with spaces, e.g. `"Exact match with spaces"` or `title:"My card"`.
### 8. New owner for the deck entities ### 9. New owner for the deck entities
You can transfer ownership of boards, cards, etc to a new user, using `occ` command `deck:transfer-ownership` You can transfer ownership of boards, cards, etc to a new user, using `occ` command `deck:transfer-ownership`
```bash ```bash

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -91,6 +91,8 @@ class ActivityManager {
public const SUBJECT_CARD_UPDATE_DUEDATE = 'card_update_duedate'; public const SUBJECT_CARD_UPDATE_DUEDATE = 'card_update_duedate';
public const SUBJECT_CARD_UPDATE_ARCHIVE = 'card_update_archive'; public const SUBJECT_CARD_UPDATE_ARCHIVE = 'card_update_archive';
public const SUBJECT_CARD_UPDATE_UNARCHIVE = 'card_update_unarchive'; public const SUBJECT_CARD_UPDATE_UNARCHIVE = 'card_update_unarchive';
public const SUBJECT_CARD_UPDATE_DONE = 'card_update_done';
public const SUBJECT_CARD_UPDATE_UNDONE = 'card_update_undone';
public const SUBJECT_CARD_UPDATE_STACKID = 'card_update_stackId'; public const SUBJECT_CARD_UPDATE_STACKID = 'card_update_stackId';
public const SUBJECT_CARD_USER_ASSIGN = 'card_user_assign'; public const SUBJECT_CARD_USER_ASSIGN = 'card_user_assign';
public const SUBJECT_CARD_USER_UNASSIGN = 'card_user_unassign'; public const SUBJECT_CARD_USER_UNASSIGN = 'card_user_unassign';
@@ -198,6 +200,12 @@ class ActivityManager {
case self::SUBJECT_CARD_UPDATE_UNARCHIVE: case self::SUBJECT_CARD_UPDATE_UNARCHIVE:
$subject = $ownActivity ? $l->t('You have unarchived card {card} in list {stack} on board {board}') : $l->t('{user} has unarchived card {card} in list {stack} on board {board}'); $subject = $ownActivity ? $l->t('You have unarchived card {card} in list {stack} on board {board}') : $l->t('{user} has unarchived card {card} in list {stack} on board {board}');
break; break;
case self::SUBJECT_CARD_UPDATE_DONE:
$subject = $ownActivity ? $l->t('You have marked the card {card} as done in list {stack} on board {board}') : $l->t('{user} has marked card {card} as done in list {stack} on board {board}');
break;
case self::SUBJECT_CARD_UPDATE_UNDONE:
$subject = $ownActivity ? $l->t('You have marked the card {card} as undone in list {stack} on board {board}') : $l->t('{user} has marked the card {card} as undone in list {stack} on board {board}');
break;
case self::SUBJECT_CARD_UPDATE_DUEDATE: case self::SUBJECT_CARD_UPDATE_DUEDATE:
if (!isset($subjectParams['after'])) { if (!isset($subjectParams['after'])) {
$subject = $ownActivity ? $l->t('You have removed the due date of card {card}') : $l->t('{user} has removed the due date of card {card}'); $subject = $ownActivity ? $l->t('You have removed the due date of card {card}') : $l->t('{user} has removed the due date of card {card}');
@@ -357,6 +365,8 @@ class ActivityManager {
case self::SUBJECT_CARD_DELETE: case self::SUBJECT_CARD_DELETE:
case self::SUBJECT_CARD_UPDATE_ARCHIVE: case self::SUBJECT_CARD_UPDATE_ARCHIVE:
case self::SUBJECT_CARD_UPDATE_UNARCHIVE: case self::SUBJECT_CARD_UPDATE_UNARCHIVE:
case self::SUBJECT_CARD_UPDATE_DONE:
case self::SUBJECT_CARD_UPDATE_UNDONE:
case self::SUBJECT_CARD_UPDATE_TITLE: case self::SUBJECT_CARD_UPDATE_TITLE:
case self::SUBJECT_CARD_UPDATE_DESCRIPTION: case self::SUBJECT_CARD_UPDATE_DESCRIPTION:
case self::SUBJECT_CARD_UPDATE_DUEDATE: case self::SUBJECT_CARD_UPDATE_DUEDATE:

View File

@@ -25,6 +25,7 @@
namespace OCA\Deck\Controller; namespace OCA\Deck\Controller;
use OCA\Deck\Model\OptionalNullableValue;
use OCA\Deck\Service\AssignmentService; use OCA\Deck\Service\AssignmentService;
use OCA\Deck\Service\CardService; use OCA\Deck\Service\CardService;
use OCP\AppFramework\ApiController; use OCP\AppFramework\ApiController;
@@ -105,7 +106,8 @@ class CardApiController extends ApiController {
* Update a card * Update a card
*/ */
public function update($title, $type, $owner, $description = '', $order = 0, $duedate = null, $archived = null) { public function update($title, $type, $owner, $description = '', $order = 0, $duedate = null, $archived = null) {
$card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived); $done = array_key_exists('done', $this->request->getParams()) ? new OptionalNullableValue($this->request->getParam('done', null)) : null;
$card = $this->cardService->update($this->request->getParam('cardId'), $title, $this->request->getParam('stackId'), $type, $owner, $description, $order, $duedate, 0, $archived, $done);
return new DataResponse($card, HTTP::STATUS_OK); return new DataResponse($card, HTTP::STATUS_OK);
} }

View File

@@ -143,6 +143,24 @@ class CardController extends Controller {
return $this->cardService->unarchive($cardId); return $this->cardService->unarchive($cardId);
} }
/**
* @NoAdminRequired
* @param $cardId
* @return \OCP\AppFramework\Db\Entity
*/
public function done(int $cardId) {
return $this->cardService->done($cardId);
}
/**
* @NoAdminRequired
* @param $cardId
* @return \OCP\AppFramework\Db\Entity
*/
public function undone(int $cardId) {
return $this->cardService->undone($cardId);
}
/** /**
* @NoAdminRequired * @NoAdminRequired
* @param $cardId * @param $cardId

View File

@@ -1,4 +1,7 @@
<?php <?php
declare(strict_types=1);
/** /**
* @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net> * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
* *
@@ -37,6 +40,8 @@ use Sabre\VObject\Component\VCalendar;
* @method int getCreatedAt() * @method int getCreatedAt()
* @method bool getArchived() * @method bool getArchived()
* @method bool getNotified() * @method bool getNotified()
* @method ?DateTime getDone()
* @method void setDone(?DateTime $done)
* *
* @method void setLabels(Label[] $labels) * @method void setLabels(Label[] $labels)
* @method null|Label[] getLabels() * @method null|Label[] getLabels()
@@ -83,6 +88,7 @@ class Card extends RelationalEntity {
protected $owner; protected $owner;
protected $order; protected $order;
protected $archived = false; protected $archived = false;
protected $done = null;
protected $duedate; protected $duedate;
protected $notified = false; protected $notified = false;
protected $deletedAt = 0; protected $deletedAt = 0;
@@ -106,6 +112,7 @@ class Card extends RelationalEntity {
$this->addType('lastModified', 'integer'); $this->addType('lastModified', 'integer');
$this->addType('createdAt', 'integer'); $this->addType('createdAt', 'integer');
$this->addType('archived', 'boolean'); $this->addType('archived', 'boolean');
$this->addType('done', 'datetime');
$this->addType('notified', 'boolean'); $this->addType('notified', 'boolean');
$this->addType('deletedAt', 'integer'); $this->addType('deletedAt', 'integer');
$this->addType('duedate', 'datetime'); $this->addType('duedate', 'datetime');
@@ -139,18 +146,21 @@ class Card extends RelationalEntity {
$event->add('RELATED-TO', 'deck-stack-' . $this->getStackId()); $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId());
// FIXME: For write support: CANCELLED / IN-PROCESS handling // FIXME: For write support: CANCELLED / IN-PROCESS handling
$event->STATUS = $this->getArchived() ? "COMPLETED" : "NEEDS-ACTION"; if ($this->getDone() || $this->getArchived()) {
if ($this->getArchived()) {
$date = new DateTime(); $date = new DateTime();
$date->setTimestamp($this->getLastModified()); $date->setTimestamp($this->getLastModified());
$event->COMPLETED = $date; $event->STATUS = 'COMPLETED';
$event->COMPLETED = $this->getDone() ? $this->$this->getDone() : $this->getArchived();
} else {
$event->STATUS = 'NEEDS-ACTION';
}
// $event->add('PERCENT-COMPLETE', 100); // $event->add('PERCENT-COMPLETE', 100);
}
if (count($this->getLabels()) > 0) { $labels = $this->getLabels() ?? [];
$event->CATEGORIES = array_map(function ($label) { $event->CATEGORIES = array_map(function ($label): string {
return $label->getTitle(); return $label->getTitle();
}, $this->getLabels()); }, $labels);
}
$event->SUMMARY = $this->getTitle(); $event->SUMMARY = $this->getTitle();
$event->DESCRIPTION = $this->getDescription(); $event->DESCRIPTION = $this->getDescription();
@@ -177,7 +187,7 @@ class Card extends RelationalEntity {
return 'card'; return 'card';
} }
public function getETag() { public function getETag(): string {
return md5((string)$this->getLastModified()); return md5((string)$this->getLastModified());
} }
} }

View File

@@ -263,6 +263,7 @@ class CardMapper extends QBMapper implements IPermissionMapper {
->where($qb->expr()->in('s.board_id', $qb->createNamedParameter($boardIds, IQueryBuilder::PARAM_INT_ARRAY))) ->where($qb->expr()->in('s.board_id', $qb->createNamedParameter($boardIds, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->isNotNull('c.duedate')) ->andWhere($qb->expr()->isNotNull('c.duedate'))
->andWhere($qb->expr()->eq('c.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('c.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('done'))
->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
@@ -284,6 +285,7 @@ class CardMapper extends QBMapper implements IPermissionMapper {
) )
// Filter out archived/deleted cards and board // Filter out archived/deleted cards and board
->andWhere($qb->expr()->eq('c.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('c.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('done'))
->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('c.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
@@ -298,6 +300,7 @@ class CardMapper extends QBMapper implements IPermissionMapper {
->where($qb->expr()->lt('duedate', $qb->createFunction('NOW()'))) ->where($qb->expr()->lt('duedate', $qb->createFunction('NOW()')))
->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('notified', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) ->andWhere($qb->expr()->eq('archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('done'))
->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)));
return $this->findEntities($qb); return $this->findEntities($qb);
} }

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Thanos kamber <thanos.kamber@gmail.com>
*
* @author Thanos kamber <thanos.kamber@gmail.com>
*
* @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/>.
*
*/
namespace OCA\Deck\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version1011Date20230901010840 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('deck_cards');
if (!$table->hasColumn('done')) {
$table->addColumn('done', Types::DATETIME, [
'default' => null,
'notnull' => false,
]);
return $schema;
}
return null;
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* @copyright Copyright (c) 2023 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/>.
*/
namespace OCA\Deck\Model;
/**
* This is a helper abstraction to allow usage of optional parameters
* which hold a nullable value. The actual null value of the parameter
* is used to indicate if it has been set or not. The containing value
* will then still allow having null as a value
*
* Example use case: Have a nullable database column,
* but only update it if it is passed
*
* @template T
*/
class OptionalNullableValue {
/** @var ?T */
private mixed $value;
/** @param ?T $value */
public function __construct(mixed $value) {
$this->value = $value;
}
/** @return ?T */
public function getValue(): mixed {
return $this->value;
}
}

View File

@@ -43,6 +43,7 @@ use OCA\Deck\Event\CardCreatedEvent;
use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardDeletedEvent;
use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Event\CardUpdatedEvent;
use OCA\Deck\Model\CardDetails; use OCA\Deck\Model\CardDetails;
use OCA\Deck\Model\OptionalNullableValue;
use OCA\Deck\NoPermissionException; use OCA\Deck\NoPermissionException;
use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\Notification\NotificationHelper;
use OCA\Deck\StatusException; use OCA\Deck\StatusException;
@@ -284,6 +285,9 @@ class CardService {
* @param $description * @param $description
* @param $order * @param $order
* @param $duedate * @param $duedate
* @param $deletedAt
* @param $archived
* @param $done
* @return \OCP\AppFramework\Db\Entity * @return \OCP\AppFramework\Db\Entity
* @throws StatusException * @throws StatusException
* @throws \OCA\Deck\NoPermissionException * @throws \OCA\Deck\NoPermissionException
@@ -291,7 +295,7 @@ class CardService {
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws BadRequestException * @throws BadRequestException
*/ */
public function update($id, $title, $stackId, $type, $owner, $description = '', $order = 0, $duedate = null, $deletedAt = null, $archived = null) { public function update($id, $title, $stackId, $type, $owner, $description = '', $order = 0, $duedate = null, $deletedAt = null, $archived = null, ?OptionalNullableValue $done = null) {
$this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order')); $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order'));
$this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT); $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT);
@@ -341,6 +345,9 @@ class CardService {
if ($archived !== null) { if ($archived !== null) {
$card->setArchived($archived); $card->setArchived($archived);
} }
if ($done !== null) {
$card->setDone($done->getValue());
}
// Trigger update events before setting description as it is handled separately // Trigger update events before setting description as it is handled separately
@@ -511,6 +518,57 @@ class CardService {
return $newCard; return $newCard;
} }
/**
* @param $id
* @return \OCA\Deck\Db\Card
* @throws StatusException
* @throws \OCA\Deck\NoPermissionException
* @throws \OCP\AppFramework\Db\DoesNotExistException
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws BadRequestException
*/
public function done(int $id): Card {
$this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT);
if ($this->boardService->isArchived($this->cardMapper, $id)) {
throw new StatusException('Operation not allowed. This board is archived.');
}
$card = $this->cardMapper->find($id);
$card->setDone(new \DateTime());
$newCard = $this->cardMapper->update($card);
$this->notificationHelper->markDuedateAsRead($card);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_DONE);
$this->changeHelper->cardChanged($id, false);
$this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card));
return $newCard;
}
/**
* @param $id
* @return \OCA\Deck\Db\Card
* @throws StatusException
* @throws \OCA\Deck\NoPermissionException
* @throws \OCP\AppFramework\Db\DoesNotExistException
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws BadRequestException
*/
public function undone(int $id): Card {
$this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT);
if ($this->boardService->isArchived($this->cardMapper, $id)) {
throw new StatusException('Operation not allowed. This board is archived.');
}
$card = $this->cardMapper->find($id);
$card->setDone(null);
$newCard = $this->cardMapper->update($card);
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_UNDONE);
$this->changeHelper->cardChanged($id, false);
$this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card));
return $newCard;
}
/** /**
* @param $cardId * @param $cardId
* @param $labelId * @param $labelId

View File

@@ -0,0 +1,27 @@
<template>
<div class="selector-wrapper" :aria-label="label">
<div class="selector-wrapper--icon">
<slot name="icon" />
</div>
<div class="selector-wrapper--content">
<slot />
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'CardDetailEntry',
props: {
label: {
type: String,
required: true,
},
},
})
</script>
<style lang="scss">
@import '../../css/selector';
</style>

View File

@@ -35,7 +35,9 @@
@select="assignUserToCard" @select="assignUserToCard"
@remove="removeUserFromCard" /> @remove="removeUserFromCard" />
<DueDateSelector :card="card" :can-edit="canEdit" @change="updateCardDue" /> <DueDateSelector :card="card"
:can-edit="canEdit"
@change="updateCardDue" />
<div v-if="projectsEnabled" class="section-wrapper"> <div v-if="projectsEnabled" class="section-wrapper">
<CollectionList v-if="card.id" <CollectionList v-if="card.id"
@@ -218,12 +220,36 @@ export default {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
button.action-item--single { .remove-due-button{
margin-top: -3px; margin-top: -2px;
margin-left: 6px;
} }
} }
} }
.button-group {
width: 100%;
display: flex;
button {
width: 100%;
}
}
.done {
display: flex;
}
.tag {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
padding: 0px 5px;
border-radius: 15px;
font-size: 85%;
margin-right: 3px;
}
.avatarLabel { .avatarLabel {
padding: 6px padding: 6px
} }

View File

@@ -1,9 +1,8 @@
<template> <template>
<div class="selector-wrapper" :aria-label="t('deck', 'Assign a due date to this card')"> <CardDetailEntry :label="t('deck', 'Assign a due date to this card…')">
<div class="selector-wrapper--icon"> <Calendar v-if="!card.done" slot="icon" :size="20" />
<Calendar :size="20" /> <CalendarCheck v-else slot="icon" :size="20" />
</div> <template v-if="!card.done && !card.archived">
<div class="duedate-selector">
<NcDateTimePickerNative v-if="duedate" <NcDateTimePickerNative v-if="duedate"
id="card-duedate-picker" id="card-duedate-picker"
v-model="duedate" v-model="duedate"
@@ -43,28 +42,86 @@
{{ t('deck', 'Remove due date') }} {{ t('deck', 'Remove due date') }}
</NcActionButton> </NcActionButton>
</NcActions> </NcActions>
<NcButton v-if="!card.done"
type="secondary"
class="completed-button"
@click="changeCardDoneStatus()">
<template #icon>
<CheckIcon :size="20" />
</template>
{{ t('deck', 'Completed') }}
</NcButton>
</template>
<template v-else>
<div class="done-info">
<span v-if="card.done" class="done-info--done">
{{ formatReadableDate(card.done) }}
</span>
<span v-if="duedate" class="done-info--duedate" :class="{ 'dimmed': card.done }">
{{ t('deck', 'Due at:') }}
{{ formatReadableDate(duedate) }}
</span>
</div> </div>
<div class="due-actions">
<NcButton v-if="!card.archived"
type="tertiary"
:title="t('deck', 'Not completed')"
@click="changeCardDoneStatus()">
<template #icon>
<ClearIcon :size="20" />
</template>
</NcButton>
<NcButton type="secondary" @click="archiveUnarchiveCard()">
<template #icon>
<ArchiveIcon :size="20" />
</template>
{{ card.archived ? t('deck', 'Unarchive card') : t('deck', 'Archive card') }}
</NcButton>
</div> </div>
</template> </template>
</CardDetailEntry>
</template>
<script> <script>
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { NcActionButton, NcActions, NcActionSeparator, NcDateTimePickerNative } from '@nextcloud/vue' import {
NcActionButton,
NcActions,
NcActionSeparator,
NcButton,
NcDateTimePickerNative,
} from '@nextcloud/vue'
import readableDate from '../../mixins/readableDate.js'
import { getDayNamesMin, getFirstDay, getMonthNamesShort } from '@nextcloud/l10n' import { getDayNamesMin, getFirstDay, getMonthNamesShort } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import ArchiveIcon from 'vue-material-design-icons/Archive.vue'
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from 'vue-material-design-icons/Plus.vue'
import Calendar from 'vue-material-design-icons/Calendar.vue' import Calendar from 'vue-material-design-icons/Calendar.vue'
import moment from '@nextcloud/moment' import CalendarCheck from 'vue-material-design-icons/CalendarCheck.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
import ClearIcon from 'vue-material-design-icons/Close.vue'
import CardDetailEntry from './CardDetailEntry.vue'
export default defineComponent({ export default defineComponent({
name: 'DueDateSelector', name: 'DueDateSelector',
components: { components: {
NcButton,
ArchiveIcon,
ClearIcon,
CardDetailEntry,
Plus, Plus,
Calendar, Calendar,
CalendarCheck,
CheckIcon,
NcActions, NcActions,
NcActionButton, NcActionButton,
NcActionSeparator, NcActionSeparator,
NcDateTimePickerNative, NcDateTimePickerNative,
}, },
mixins: [
readableDate,
],
props: { props: {
card: { card: {
type: Object, type: Object,
@@ -166,13 +223,35 @@ export default defineComponent({
getTimestamp(momentObject) { getTimestamp(momentObject) {
return momentObject?.minute(0).second(0).millisecond(0).toDate() || null return momentObject?.minute(0).second(0).millisecond(0).toDate() || null
}, },
changeCardDoneStatus() {
this.$store.dispatch('changeCardDoneStatus', { ...this.card, done: !this.card.done })
},
archiveUnarchiveCard() {
this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived })
},
}, },
}) })
</script> </script>
<style lang="scss"> <style scoped lang="scss">
@import '../../css/selector'; .done-info {
flex-grow: 1;
}
.duedate-selector { .done-info--duedate,
.done-info--done {
display: flex; display: flex;
padding-top: 10px;
&.dimmed {
color: var(--color-text-maxcontrast);
}
}
.completed-button {
margin-left: auto;
}
.due-actions {
display: flex;
align-items: flex-start;
} }
</style> </style>

View File

@@ -64,7 +64,8 @@
<input type="submit" value="" class="icon-confirm"> <input type="submit" value="" class="icon-confirm">
</form> </form>
<DueDate v-if="!editing" :card="card" /> <DueDate v-if="!editing && !card.done" :card="card" />
<Done v-else-if="!editing && card.done" :card="card" />
<CardMenu v-if="!editing && compactMode" :card="card" class="right" /> <CardMenu v-if="!editing && compactMode" :card="card" class="right" />
</div> </div>
@@ -94,12 +95,13 @@ import Color from '../../mixins/color.js'
import labelStyle from '../../mixins/labelStyle.js' import labelStyle from '../../mixins/labelStyle.js'
import AttachmentDragAndDrop from '../AttachmentDragAndDrop.vue' import AttachmentDragAndDrop from '../AttachmentDragAndDrop.vue'
import CardMenu from './CardMenu.vue' import CardMenu from './CardMenu.vue'
import Done from './badges/Done.vue'
import DueDate from './badges/DueDate.vue' import DueDate from './badges/DueDate.vue'
import CardCover from './CardCover.vue' import CardCover from './CardCover.vue'
export default { export default {
name: 'CardItem', name: 'CardItem',
components: { CardBadges, AttachmentDragAndDrop, CardMenu, DueDate, CardCover }, components: { CardBadges, AttachmentDragAndDrop, CardMenu, DueDate, CardCover, Done },
directives: { directives: {
ClickOutside, ClickOutside,
}, },

View File

@@ -36,6 +36,9 @@
@click="unassignCardFromMe()"> @click="unassignCardFromMe()">
{{ t('deck', 'Unassign myself') }} {{ t('deck', 'Unassign myself') }}
</NcActionButton> </NcActionButton>
<NcActionButton icon="icon-checkmark" :close-after-click="true" @click="changeCardDoneStatus()">
{{ card.done ? t('deck', 'Mark as not done') : t('deck', 'Mark as done') }}
</NcActionButton>
<NcActionButton icon="icon-external" :close-after-click="true" @click="modalShow=true"> <NcActionButton icon="icon-external" :close-after-click="true" @click="modalShow=true">
{{ t('deck', 'Move card') }} {{ t('deck', 'Move card') }}
</NcActionButton> </NcActionButton>
@@ -157,6 +160,9 @@ export default {
this.$store.dispatch('deleteCard', this.card) this.$store.dispatch('deleteCard', this.card)
showUndo(t('deck', 'Card deleted'), () => this.$store.dispatch('cardUndoDelete', this.card)) showUndo(t('deck', 'Card deleted'), () => this.$store.dispatch('cardUndoDelete', this.card))
}, },
changeCardDoneStatus() {
this.$store.dispatch('changeCardDoneStatus', { ...this.card, done: !this.card.done })
},
archiveUnarchiveCard() { archiveUnarchiveCard() {
this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived }) this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived })
}, },

View File

@@ -0,0 +1,72 @@
<!--
- @copyright Copyright (c) 2022 Thanos Kamber <thanos.kamber@gmail.com>
-
- @author Thanos Kamber <thanos.kamber@gmail.com>
-
- @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="card" class="done">
<transition name="zoom">
<div class="icon-check-circle">
<CheckCircle :size="20" :title="formatReadableDate(card.done)" />
</div>
</transition>
</div>
</template>
<script>
import CheckCircle from 'vue-material-design-icons/CheckCircle.vue'
import readableDate from '../../../mixins/readableDate.js'
export default {
name: 'Done',
components: {
CheckCircle,
},
mixins: [
readableDate,
],
props: {
card: {
type: Object,
default: null,
},
},
}
</script>
<style lang="scss" scoped>
.icon-check-circle {
color: var(--color-success);
margin: 14px;
}
@media print {
.icon-check-circle {
span {
display: none;
}
&::before {
color: var(--color-text-lighter);
content: 'Done';
}
}
}
</style>

View File

@@ -33,4 +33,9 @@
&--selector { &--selector {
width: 100%; width: 100%;
} }
&--content {
display: flex;
flex-grow: 1;
}
} }

View File

@@ -0,0 +1,33 @@
/*
* @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/>.
*
*/
import moment from '@nextcloud/moment'
export default {
computed: {
formatReadableDate() {
return (timestamp) => {
return moment(timestamp).format('lll')
}
},
},
}

View File

@@ -165,6 +165,36 @@ export class CardApi {
}) })
} }
markCardAsDone(card) {
return axios.put(this.url(`/cards/${card.id}/done`))
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
},
)
.catch((err) => {
return Promise.reject(err)
})
}
markCardAsUndone(card) {
return axios.put(this.url(`/cards/${card.id}/undone`))
.then(
(response) => {
return Promise.resolve(response.data)
},
(err) => {
return Promise.reject(err)
},
)
.catch((err) => {
return Promise.reject(err)
})
}
assignLabelToCard(data) { assignLabelToCard(data) {
return axios.post(this.url(`/cards/${data.card.id}/label/${data.labelId}`)) return axios.post(this.url(`/cards/${data.card.id}/label/${data.labelId}`))
.then( .then(

View File

@@ -334,6 +334,15 @@ export default {
const updatedCard = await apiClient[call](card) const updatedCard = await apiClient[call](card)
commit('updateCard', updatedCard) commit('updateCard', updatedCard)
}, },
async changeCardDoneStatus({ commit }, card) {
let call = 'markCardAsDone'
if (card.done === false) {
call = 'markCardAsUndone'
}
const updatedCard = await apiClient[call](card)
commit('updateCardProperty', { property: 'done', card: updatedCard })
},
async assignCardToUser({ commit }, { card, assignee }) { async assignCardToUser({ commit }, { card, assignee }) {
const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type) const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type)
commit('assignCardToUser', user) commit('assignCardToUser', user)

View File

@@ -133,7 +133,7 @@ class ImportExportTest extends \Test\TestCase {
); );
} }
public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version']): string { public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done']): string {
$output = ''; $output = '';
$arrayIsList = array_keys($array) === range(0, count($array) - 1); $arrayIsList = array_keys($array) === range(0, count($array) - 1);
foreach ($array as $key => $value) { foreach ($array as $key => $value) {

View File

@@ -41,6 +41,7 @@ class CardTest extends TestCase {
$card->setOwner("admin"); $card->setOwner("admin");
$card->setOrder(12); $card->setOrder(12);
$card->setArchived(false); $card->setArchived(false);
$card->setDone(null);
// TODO: relation shared labels acl // TODO: relation shared labels acl
return $card; return $card;
} }
@@ -87,6 +88,7 @@ class CardTest extends TestCase {
'commentsCount' => 0, 'commentsCount' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => null,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
public function testJsonSerializeLabels() { public function testJsonSerializeLabels() {
@@ -114,6 +116,7 @@ class CardTest extends TestCase {
'commentsCount' => 0, 'commentsCount' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => false,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
@@ -143,6 +146,7 @@ class CardTest extends TestCase {
'commentsCount' => 0, 'commentsCount' => 0,
'lastEditor' => null, 'lastEditor' => null,
'ETag' => $card->getETag(), 'ETag' => $card->getETag(),
'done' => false,
], (new CardDetails($card))->jsonSerialize()); ], (new CardDetails($card))->jsonSerialize());
} }
} }