diff --git a/appinfo/info.xml b/appinfo/info.xml index 089f26b69..63972500f 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -16,7 +16,7 @@ - 🚀 Get your project organized - 1.11.0-dev.1 + 1.11.0-dev.2 agpl Julius Härtl diff --git a/appinfo/routes.php b/appinfo/routes.php index 9105bf8ef..467a3ddc6 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -61,6 +61,8 @@ ['name' => 'card#reorder', 'url' => '/cards/{cardId}/reorder', 'verb' => 'PUT'], ['name' => 'card#archive', 'url' => '/cards/{cardId}/archive', '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#removeLabel', 'url' => '/cards/{cardId}/label/{labelId}', 'verb' => 'DELETE'], ['name' => 'card#assignUser', 'url' => '/cards/{cardId}/assign', 'verb' => 'POST'], diff --git a/docs/API.md b/docs/API.md index 5e95da1db..fe07635b4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -80,7 +80,7 @@ An ETag header is returned in order to determine if further child elements have - Fetch a single card of a board `GET /api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}` - Fetch attachments of a card `GET /api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments` -If a `If-None-Match` header is provided and the requested element has not changed a `304` Not Modified response will be returned. +If a `If-None-Match` header is provided and the requested element has not changed a `304` Not Modified response will be returned. Changes of child elements will propagate to their parents and also cause an update of the ETag which will be useful for determining if a sync is necessary on any client integration side. As an example, if a label is added to a card, the ETag of all related entities (the card, stack and board) will change. @@ -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/config/system/{schema} - Import a board](#get-boardsimportconfigsystemschema-import-a-board) - [POST /boards/import - Import a board](#post-boardsimport-import-a-board) +- The `done` property was added to cards # Endpoints @@ -587,7 +588,7 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit #### Response ```json -{ +{ "title":"Test", "description":null, "stackId":6, @@ -601,6 +602,7 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit "owner":"admin", "order":999, "archived":false, + "done":null, "duedate": "2019-12-24T19:29:30+00:00", "deletedAt":0, "commentsUnread":0, @@ -623,22 +625,28 @@ The board list endpoint supports setting an `If-Modified-Since` header to limit #### Request data -| Parameter | Type | Description | -|-------------|-----------|------------------------------------------------------| -| title | String | The title of the card, maximum length is limited to 255 characters | -| description | String | The markdown description of the card | -| type | String | Type of the card (for later use) use 'plain' for now | -| order | Integer | Order for sorting the stacks | -| duedate | timestamp | The ISO-8601 formatted duedate of the card or null | +| Parameter | Type | Description | +|-------------|-----------------|-----------------------------------------------------------------------------------------------------| +| title | String | The title of the card, maximum length is limited to 255 characters | +| description | String | The markdown description of the card | +| 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 | +| 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) | ``` -{ +{ "title": "Test card", "description": "A card description", "type": "plain", + "owner": "admin", "order": 999, "duedate": "2019-12-24T19:29:30+00:00", + "archived": false, + "done": null, } ``` @@ -977,7 +985,7 @@ For now only `deck_file` is supported as an attachment type. ### DELETE /boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId} - Delete an attachment - + #### Request parameters | Parameter | Type | Description | @@ -1051,12 +1059,12 @@ Make a request to see the json schema of system # OCS API -The following endpoints are available through the Nextcloud OCS endpoint, which is available at `/ocs/v2.php/apps/deck/api/v1.0/`. +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. +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 @@ -1064,10 +1072,10 @@ Deck stores user and app configuration values globally and per board. The GET en | Config key | Description | | --- | --- | -| calendar | Determines if the calendar/tasks integration through the CalDAV backend is enabled for the user (boolean) | -| cardDetailsInModal | Determines if the bigger view is used (boolean) | -| cardIdBadge | Determines if the ID badges are displayed on cards (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)| +| calendar | Determines if the calendar/tasks integration through the CalDAV backend is enabled for the user (boolean) | +| cardDetailsInModal | Determines if the bigger view is used (boolean) | +| cardIdBadge | Determines if the ID badges are displayed on cards (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)| ``` { @@ -1112,7 +1120,7 @@ Deck stores user and app configuration values globally and per board. The GET en | calendar | Boolean | | cardDetailsInModal | Boolean | | cardIdBadge | Boolean | - + #### Example request ``` @@ -1186,7 +1194,7 @@ A list of comments will be provided under the `ocs.data` key. If no or no more c } ``` -In case a comment is marked as a reply to another comment object, the parent comment will be added as `replyTo` entry to the response. Only the next parent node is added, nested replies are not exposed directly. +In case a comment is marked as a reply to another comment object, the parent comment will be added as `replyTo` entry to the response. Only the next parent node is added, nested replies are not exposed directly. ```json [ diff --git a/docs/User_documentation_en.md b/docs/User_documentation_en.md index 36f2dbbad..125b14afa 100644 --- a/docs/User_documentation_en.md +++ b/docs/User_documentation_en.md @@ -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) 2. [Create stacks and cards](#2-create-stacks-and-cards) 3. [Handle cards options](#3-handle-cards-options) -4. [Archive old tasks](#4-archive-old-tasks) -5. [Manage your board](#5-manage-your-board) -6. [Import boards](#6-import-boards) -7. [Search](#7-search) -8. [New owner for the deck entities](#8-new-owner-for-the-deck-entities) +4. [Mark task as done](#4-mark-as-done) +5. [Archive old tasks](#5-archive-old-tasks) +6. [Manage your board](#6-manage-your-board) +7. [Import boards](#7-import-boards) +8. [Search](#8-search) +9. [New owner for the deck entities](#9-new-owner-for-the-deck-entities) ### 1. Create my first board 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) -### 4. Archive old tasks -Once finished or obsolete, a task could be archived. The tasks is not deleted, it's just archived, and you can retrieve it later +### 4. Mark as done +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. 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. 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. @@ -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. 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"`. -### 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` ```bash diff --git a/docs/resources/gifs/EN_done.gif b/docs/resources/gifs/EN_done.gif new file mode 100644 index 000000000..e65cef47e Binary files /dev/null and b/docs/resources/gifs/EN_done.gif differ diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 15d4bd59d..010820c43 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -91,6 +91,8 @@ class ActivityManager { public const SUBJECT_CARD_UPDATE_DUEDATE = 'card_update_duedate'; public const SUBJECT_CARD_UPDATE_ARCHIVE = 'card_update_archive'; 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_USER_ASSIGN = 'card_user_assign'; public const SUBJECT_CARD_USER_UNASSIGN = 'card_user_unassign'; @@ -198,6 +200,12 @@ public function getActivityFormat($language, $subjectIdentifier, $subjectParams 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}'); 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: 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}'); @@ -357,6 +365,8 @@ private function createEvent($objectType, $entity, $subject, $additionalParams = case self::SUBJECT_CARD_DELETE: case self::SUBJECT_CARD_UPDATE_ARCHIVE: 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_DESCRIPTION: case self::SUBJECT_CARD_UPDATE_DUEDATE: diff --git a/lib/Controller/CardApiController.php b/lib/Controller/CardApiController.php index bb1c8a880..95eea6f54 100644 --- a/lib/Controller/CardApiController.php +++ b/lib/Controller/CardApiController.php @@ -25,6 +25,7 @@ namespace OCA\Deck\Controller; +use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\Service\AssignmentService; use OCA\Deck\Service\CardService; use OCP\AppFramework\ApiController; @@ -105,7 +106,8 @@ public function create($title, $type = 'plain', $order = 999, $description = '', * Update a card */ 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); } diff --git a/lib/Controller/CardController.php b/lib/Controller/CardController.php index 2260d0dac..7eebb021e 100644 --- a/lib/Controller/CardController.php +++ b/lib/Controller/CardController.php @@ -143,6 +143,24 @@ public function 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 * @param $cardId diff --git a/lib/Db/Card.php b/lib/Db/Card.php index e36a9fb0c..746ea465c 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -1,4 +1,7 @@ * @@ -37,6 +40,8 @@ * @method int getCreatedAt() * @method bool getArchived() * @method bool getNotified() + * @method ?DateTime getDone() + * @method void setDone(?DateTime $done) * * @method void setLabels(Label[] $labels) * @method null|Label[] getLabels() @@ -83,6 +88,7 @@ class Card extends RelationalEntity { protected $owner; protected $order; protected $archived = false; + protected $done = null; protected $duedate; protected $notified = false; protected $deletedAt = 0; @@ -106,6 +112,7 @@ public function __construct() { $this->addType('lastModified', 'integer'); $this->addType('createdAt', 'integer'); $this->addType('archived', 'boolean'); + $this->addType('done', 'datetime'); $this->addType('notified', 'boolean'); $this->addType('deletedAt', 'integer'); $this->addType('duedate', 'datetime'); @@ -139,19 +146,22 @@ public function getCalendarObject(): VCalendar { $event->add('RELATED-TO', 'deck-stack-' . $this->getStackId()); // FIXME: For write support: CANCELLED / IN-PROCESS handling - $event->STATUS = $this->getArchived() ? "COMPLETED" : "NEEDS-ACTION"; - if ($this->getArchived()) { + if ($this->getDone() || $this->getArchived()) { $date = new DateTime(); $date->setTimestamp($this->getLastModified()); - $event->COMPLETED = $date; - //$event->add('PERCENT-COMPLETE', 100); - } - if (count($this->getLabels()) > 0) { - $event->CATEGORIES = array_map(function ($label) { - return $label->getTitle(); - }, $this->getLabels()); + $event->STATUS = 'COMPLETED'; + $event->COMPLETED = $this->getDone() ? $this->$this->getDone() : $this->getArchived(); + } else { + $event->STATUS = 'NEEDS-ACTION'; } + // $event->add('PERCENT-COMPLETE', 100); + + $labels = $this->getLabels() ?? []; + $event->CATEGORIES = array_map(function ($label): string { + return $label->getTitle(); + }, $labels); + $event->SUMMARY = $this->getTitle(); $event->DESCRIPTION = $this->getDescription(); $calendar->add($event); @@ -177,7 +187,7 @@ public function getCalendarPrefix(): string { return 'card'; } - public function getETag() { + public function getETag(): string { return md5((string)$this->getLastModified()); } } diff --git a/lib/Db/CardMapper.php b/lib/Db/CardMapper.php index 60938002c..9e8c3bfd0 100644 --- a/lib/Db/CardMapper.php +++ b/lib/Db/CardMapper.php @@ -263,6 +263,7 @@ public function findAllWithDue(array $boardIds) { ->where($qb->expr()->in('s.board_id', $qb->createNamedParameter($boardIds, IQueryBuilder::PARAM_INT_ARRAY))) ->andWhere($qb->expr()->isNotNull('c.duedate')) ->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('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) @@ -284,6 +285,7 @@ public function findToMeOrNotAssignedCards(array $boardIds, string $username) { ) // Filter out archived/deleted cards and board ->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('s.deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))) ->andWhere($qb->expr()->eq('b.archived', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) @@ -298,6 +300,7 @@ public function findOverdue() { ->where($qb->expr()->lt('duedate', $qb->createFunction('NOW()'))) ->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()->isNull('done')) ->andWhere($qb->expr()->eq('deleted_at', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); return $this->findEntities($qb); } diff --git a/lib/Migration/Version1011Date20230901010840.php b/lib/Migration/Version1011Date20230901010840.php new file mode 100644 index 000000000..d91cdb084 --- /dev/null +++ b/lib/Migration/Version1011Date20230901010840.php @@ -0,0 +1,61 @@ + + * + * @author Thanos kamber + * + * @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 . + * + */ + +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; + } +} diff --git a/lib/Model/OptionalNullableValue.php b/lib/Model/OptionalNullableValue.php new file mode 100644 index 000000000..0d3240621 --- /dev/null +++ b/lib/Model/OptionalNullableValue.php @@ -0,0 +1,51 @@ + + * + * @author Julius Härtl + * + * @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 . + */ + +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; + } + +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 45559b87f..abf8ee378 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -43,6 +43,7 @@ use OCA\Deck\Event\CardDeletedEvent; use OCA\Deck\Event\CardUpdatedEvent; use OCA\Deck\Model\CardDetails; +use OCA\Deck\Model\OptionalNullableValue; use OCA\Deck\NoPermissionException; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; @@ -284,6 +285,9 @@ public function delete($id) { * @param $description * @param $order * @param $duedate + * @param $deletedAt + * @param $archived + * @param $done * @return \OCP\AppFramework\Db\Entity * @throws StatusException * @throws \OCA\Deck\NoPermissionException @@ -291,7 +295,7 @@ public function delete($id) { * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @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->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT); @@ -341,6 +345,9 @@ public function update($id, $title, $stackId, $type, $owner, $description = '', if ($archived !== null) { $card->setArchived($archived); } + if ($done !== null) { + $card->setDone($done->getValue()); + } // Trigger update events before setting description as it is handled separately @@ -511,6 +518,57 @@ public function unarchive($id) { 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 $labelId diff --git a/src/components/card/CardDetailEntry.vue b/src/components/card/CardDetailEntry.vue new file mode 100644 index 000000000..e65e28583 --- /dev/null +++ b/src/components/card/CardDetailEntry.vue @@ -0,0 +1,27 @@ + + + + diff --git a/src/components/card/CardSidebarTabDetails.vue b/src/components/card/CardSidebarTabDetails.vue index b3c4afd6d..c34eab09d 100644 --- a/src/components/card/CardSidebarTabDetails.vue +++ b/src/components/card/CardSidebarTabDetails.vue @@ -35,7 +35,9 @@ @select="assignUserToCard" @remove="removeUserFromCard" /> - +
-
-
- -
-
+ + + + + + - diff --git a/src/components/cards/CardItem.vue b/src/components/cards/CardItem.vue index f1fcfde51..9751abb45 100644 --- a/src/components/cards/CardItem.vue +++ b/src/components/cards/CardItem.vue @@ -64,7 +64,8 @@ - + +
@@ -94,12 +95,13 @@ import Color from '../../mixins/color.js' import labelStyle from '../../mixins/labelStyle.js' import AttachmentDragAndDrop from '../AttachmentDragAndDrop.vue' import CardMenu from './CardMenu.vue' +import Done from './badges/Done.vue' import DueDate from './badges/DueDate.vue' import CardCover from './CardCover.vue' export default { name: 'CardItem', - components: { CardBadges, AttachmentDragAndDrop, CardMenu, DueDate, CardCover }, + components: { CardBadges, AttachmentDragAndDrop, CardMenu, DueDate, CardCover, Done }, directives: { ClickOutside, }, diff --git a/src/components/cards/CardMenu.vue b/src/components/cards/CardMenu.vue index b3220f714..91acc65bd 100644 --- a/src/components/cards/CardMenu.vue +++ b/src/components/cards/CardMenu.vue @@ -36,6 +36,9 @@ @click="unassignCardFromMe()"> {{ t('deck', 'Unassign myself') }} + + {{ card.done ? t('deck', 'Mark as not done') : t('deck', 'Mark as done') }} + {{ t('deck', 'Move card') }} @@ -157,6 +160,9 @@ export default { this.$store.dispatch('deleteCard', 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() { this.$store.dispatch('archiveUnarchiveCard', { ...this.card, archived: !this.card.archived }) }, diff --git a/src/components/cards/badges/Done.vue b/src/components/cards/badges/Done.vue new file mode 100644 index 000000000..74679cc30 --- /dev/null +++ b/src/components/cards/badges/Done.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/src/css/selector.scss b/src/css/selector.scss index ac07023b8..e829a4f64 100644 --- a/src/css/selector.scss +++ b/src/css/selector.scss @@ -33,4 +33,9 @@ &--selector { width: 100%; } + + &--content { + display: flex; + flex-grow: 1; + } } diff --git a/src/mixins/readableDate.js b/src/mixins/readableDate.js new file mode 100644 index 000000000..3205431f5 --- /dev/null +++ b/src/mixins/readableDate.js @@ -0,0 +1,33 @@ +/* + * @copyright Copyright (c) 2020 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ + +import moment from '@nextcloud/moment' + +export default { + computed: { + formatReadableDate() { + return (timestamp) => { + return moment(timestamp).format('lll') + } + }, + }, +} diff --git a/src/services/CardApi.js b/src/services/CardApi.js index 3bd28114b..50022e086 100644 --- a/src/services/CardApi.js +++ b/src/services/CardApi.js @@ -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) { return axios.post(this.url(`/cards/${data.card.id}/label/${data.labelId}`)) .then( diff --git a/src/store/card.js b/src/store/card.js index 21c0e299a..0a2a2edcc 100644 --- a/src/store/card.js +++ b/src/store/card.js @@ -334,6 +334,15 @@ export default { const updatedCard = await apiClient[call](card) 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 }) { const user = await apiClient.assignUser(card.id, assignee.userId, assignee.type) commit('assignCardToUser', user) diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index 7b159b7e9..e7d9cbdf1 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -133,7 +133,7 @@ public function testReimportOcc() { ); } - 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 = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index a32fa3c0e..794255a46 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -41,6 +41,7 @@ private function createCard() { $card->setOwner("admin"); $card->setOrder(12); $card->setArchived(false); + $card->setDone(null); // TODO: relation shared labels acl return $card; } @@ -87,6 +88,7 @@ public function testJsonSerialize() { 'commentsCount' => 0, 'lastEditor' => null, 'ETag' => $card->getETag(), + 'done' => null, ], (new CardDetails($card))->jsonSerialize()); } public function testJsonSerializeLabels() { @@ -114,6 +116,7 @@ public function testJsonSerializeLabels() { 'commentsCount' => 0, 'lastEditor' => null, 'ETag' => $card->getETag(), + 'done' => false, ], (new CardDetails($card))->jsonSerialize()); } @@ -143,6 +146,7 @@ public function testJsonSerializeAsignedUsers() { 'commentsCount' => 0, 'lastEditor' => null, 'ETag' => $card->getETag(), + 'done' => false, ], (new CardDetails($card))->jsonSerialize()); } }