diff --git a/appinfo/routes.php b/appinfo/routes.php index 6d019b3a3..2b04971c5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -123,7 +123,9 @@ return [ ['name' => 'attachment_api#restore', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/attachments/{attachmentId}/restore', 'verb' => 'PUT'], ['name' => 'comments_api#list', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/comments', 'verb' => 'GET'], - + ['name' => 'comments_api#create', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/comments', 'verb' => 'POST'], + ['name' => 'comments_api#update', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/comments/{commentId}', 'verb' => 'PUT'], + ['name' => 'comments_api#delete', 'url' => '/api/v1.0/boards/{boardId}/stacks/{stackId}/cards/{cardId}/comments/{commentId}', 'verb' => 'DELETE'], ['name' => 'board_api#preflighted_cors', 'url' => '/api/v1.0/{path}','verb' => 'OPTIONS', 'requirements' => ['path' => '.+']], ] diff --git a/css/icons.scss b/css/icons.scss index 09cb77090..f0e68d979 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -61,6 +61,7 @@ @include icon-black-white('clone', 'deck', 1); @include icon-black-white('filter', 'deck', 1); @include icon-black-white('attach', 'deck', 1); + @include icon-black-white('reply', 'deck', 1); .icon-toggle-compact-collapsed { @include icon-color('toggle-view-expand', 'deck', $color-black); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 935fa7599..3d2885c56 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -56,6 +56,8 @@ class Application extends App { public const APP_ID = 'deck'; + public const COMMENT_ENTITY_TYPE = 'deckCard'; + /** @var IServerContainer */ private $server; @@ -149,7 +151,7 @@ class Application extends App { public function registerCommentsEntity(): void { $this->server->getEventDispatcher()->addListener(CommentsEntityEvent::EVENT_ENTITY, function(CommentsEntityEvent $event) { - $event->addEntityCollection('deckCard', function($name) { + $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function($name) { /** @var CardMapper */ $cardMapper = $this->getContainer()->query(CardMapper::class); $permissionService = $this->getContainer()->query(PermissionService::class); diff --git a/lib/Controller/CommentsApiController.php b/lib/Controller/CommentsApiController.php index 058f660d7..6a16629c3 100644 --- a/lib/Controller/CommentsApiController.php +++ b/lib/Controller/CommentsApiController.php @@ -23,87 +23,59 @@ namespace OCA\Deck\Controller; - +use OCA\Deck\Service\CommentService; +use OCA\Deck\StatusException; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\JSONResponse; -use OCP\Comments\IComment; -use OCP\Comments\ICommentsManager; -use OCP\Comments\NotFoundException; -use OCP\ILogger; + use OCP\IRequest; -use OCP\IUserManager; class CommentsApiController extends ApiController { - /** @var ICommentsManager */ - private $commentsManager; - /** @var IUserManager */ - private $userManager; - /** @var ILogger */ - private $logger; + /** @var CommentService */ + private $commentService; public function __construct( $appName, IRequest $request, $corsMethods = 'PUT, POST, GET, DELETE, PATCH', $corsAllowedHeaders = 'Authorization, Content-Type, Accept', $corsMaxAge = 1728000, - ICommentsManager $commentsManager, - IUserManager $userManager, - ILogger $logger + CommentService $commentService ) { parent::__construct($appName, $request, $corsMethods, $corsAllowedHeaders, $corsMaxAge); - - $this->commentsManager = $commentsManager; - $this->userManager = $userManager; - $this->logger = $logger; + $this->commentService = $commentService; } /** * @NoAdminRequired - * @CORS * @NoCSRFRequired + * @throws StatusException */ - public function list(int $cardId, $limit = 20, $offset = 0): JSONResponse { - $comments = $this->commentsManager->getForObject('deckCard', $cardId, $limit, $offset); - $result = []; - foreach ($comments as $comment) { - $formattedComment = $this->formatComment($comment); - try { - if ($comment->getParentId() !== '0' && $replyTo = $this->commentsManager->get($comment->getParentId())) { - $formattedComment['replyTo'] = $this->formatComment($replyTo); - } - } catch (NotFoundException $e) { - } - $result[] = $formattedComment; - } - return new JSONResponse($result); + public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse { + return $this->commentService->list($cardId, $limit, $offset); } - private function formatComment(IComment $comment): array { - $user = $this->userManager->get($comment->getActorId()); - $actorDisplayName = $user !== null ? $user->getDisplayName() : $comment->getActorId(); - - return [ - 'id' => $comment->getId(), - 'message' => $comment->getMessage(), - 'actorId' => $comment->getActorId(), - 'actorType' => $comment->getActorType(), - 'actorDisplayName' => $actorDisplayName, - 'mentions' => array_map(function($mention) { - try { - $displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']); - } catch (\OutOfBoundsException $e) { - $this->logger->logException($e); - // No displayname, upon client's discretion what to display. - $displayName = ''; - } - - return [ - 'mentionId' => $mention['id'], - 'mentionType' => $mention['type'], - 'mentionDisplayName' => $displayName - ]; - }, $comment->getMentions()), - ]; + /** + * @NoAdminRequired + * @NoCSRFRequired + * @throws StatusException + */ + public function create(string $cardId, string $message, string $parentId = '0'): DataResponse { + return $this->commentService->create($cardId, $message, $parentId); } + /** + * @NoAdminRequired + * @NoCSRFRequired + * @throws StatusException + */ + public function update(string $cardId, string $commentId, string $message): DataResponse { + return $this->commentService->update($cardId, $commentId, $message); + } + /** + * @NoAdminRequired + * @NoCSRFRequired + * @throws StatusException + */ + public function delete(string $cardId, string $commentId): DataResponse { + return $this->commentService->delete($cardId, $commentId); + } } diff --git a/lib/Service/CommentService.php b/lib/Service/CommentService.php new file mode 100644 index 000000000..03b0532b5 --- /dev/null +++ b/lib/Service/CommentService.php @@ -0,0 +1,172 @@ + + * + * @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\Service; + + +use OCA\Deck\AppInfo\Application; +use OCA\Deck\BadRequestException; +use OCA\Deck\NoPermissionException; +use OCA\Deck\NotFoundException; +use OCA\Deck\StatusException; +use OCP\AppFramework\Http\DataResponse; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Comments\MessageTooLongException; +use OCP\Comments\NotFoundException as CommentNotFoundException; +use OCP\ILogger; +use OCP\IUserManager; +use OutOfBoundsException; +use Sabre\DAV\Exception\Forbidden; +use function is_numeric; + +class CommentService { + + /** + * @var ICommentsManager + */ + private $commentsManager; + /** + * @var IUserManager + */ + private $userManager; + /** @var ILogger */ + private $logger; + private $userId; + + public function __construct(ICommentsManager $commentsManager, IUserManager $userManager, ILogger $logger, $userId) { + $this->commentsManager = $commentsManager; + $this->userManager = $userManager; + $this->logger = $logger; + $this->userId = $userId; + } + + public function list(string $cardId, int $limit = 20, int $offset = 0): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + $comments = $this->commentsManager->getForObject(Application::COMMENT_ENTITY_TYPE, $cardId, $limit, $offset); + $result = []; + foreach ($comments as $comment) { + $formattedComment = $this->formatComment($comment); + try { + if ($comment->getParentId() !== '0' && $replyTo = $this->commentsManager->get($comment->getParentId())) { + $formattedComment['replyTo'] = $this->formatComment($replyTo); + } + } catch (CommentNotFoundException $e) { + } + $result[] = $formattedComment; + } + return new DataResponse($result); + } + + /** + * @param string $cardId + * @param string $message + * @param string $replyTo + * @return DataResponse + * @throws BadRequestException + * @throws NotFoundException + */ + public function create(string $cardId, string $message, string $replyTo = '0'): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + try { + $comment = $this->commentsManager->create('users', $this->userId, Application::COMMENT_ENTITY_TYPE, $cardId); + $comment->setMessage($message); + $comment->setVerb('comment'); + $comment->setParentId($replyTo); + $this->commentsManager->save($comment); + return new DataResponse($this->formatComment($comment)); + } catch (\InvalidArgumentException $e) { + throw new BadRequestException('Invalid input values'); + } catch (MessageTooLongException $e) { + $msg = 'Message exceeds allowed character limit of '; + throw new BadRequestException($msg . IComment::MAX_MESSAGE_LENGTH); + } catch (CommentNotFoundException $e) { + throw new NotFoundException('Could not create comment.'); + } + } + + public function update(string $cardId, string $commentId, string $message): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + if (!is_numeric($commentId)) { + throw new BadRequestException('A valid comment id must be provided'); + } + try { + $comment = $this->commentsManager->get($commentId); + } catch (CommentNotFoundException $e) { + throw new NotFoundException('No comment found.'); + } + if ($comment->getActorType() !== 'users' || $comment->getActorId() !== $this->userId) { + throw new NoPermissionException('Only authors are allowed to edit their comment.'); + } + $comment->setMessage($message); + $this->commentsManager->save($comment); + return new DataResponse($this->formatComment($comment)); + } + + public function delete(string $cardId, string $commentId): DataResponse { + if (!is_numeric($cardId)) { + throw new BadRequestException('A valid card id must be provided'); + } + if (!is_numeric($commentId)) { + throw new BadRequestException('A valid comment id must be provided'); + } + $this->commentsManager->delete($commentId); + return new DataResponse([]); + } + + private function formatComment(IComment $comment): array { + $user = $this->userManager->get($comment->getActorId()); + $actorDisplayName = $user !== null ? $user->getDisplayName() : $comment->getActorId(); + + return [ + 'id' => $comment->getId(), + 'objectId' => $comment->getObjectId(), + 'message' => $comment->getMessage(), + 'actorId' => $comment->getActorId(), + 'actorType' => $comment->getActorType(), + 'actorDisplayName' => $actorDisplayName, + 'mentions' => array_map(function($mention) { + try { + $displayName = $this->commentsManager->resolveDisplayName($mention['type'], $mention['id']); + } catch (OutOfBoundsException $e) { + $this->logger->logException($e); + // No displayname, upon client's discretion what to display. + $displayName = ''; + } + + return [ + 'mentionId' => $mention['id'], + 'mentionType' => $mention['type'], + 'mentionDisplayName' => $displayName + ]; + }, $comment->getMentions()), + ]; + } + +} diff --git a/src/components/card/CardSidebarTabComments.vue b/src/components/card/CardSidebarTabComments.vue index 4235e2c61..da438562b 100644 --- a/src/components/card/CardSidebarTabComments.vue +++ b/src/components/card/CardSidebarTabComments.vue @@ -7,6 +7,7 @@ +
    @@ -58,6 +59,7 @@ export default { computed: { ...mapState({ currentBoard: state => state.currentBoard, + replyTo: state => state.comment.replyTo, }), ...mapGetters([ 'getCommentsForCard', @@ -98,6 +100,7 @@ export default { comment: content, } await this.$store.dispatch('createComment', commentObj) + this.$store.dispatch('setReplyTo', null) this.newComment = '' await this.loadComments() }, diff --git a/src/components/card/CommentForm.vue b/src/components/card/CommentForm.vue index e20eb44c8..430e54925 100644 --- a/src/components/card/CommentForm.vue +++ b/src/components/card/CommentForm.vue @@ -45,7 +45,7 @@ @keydown.enter="handleKeydown" @paste="onPaste" @blur="error = null" - @input="validate" /> + @input="validate()" /> 1000) { @@ -111,14 +111,13 @@ export default { return this.error === null ? content : null }, submit() { - const content = this.validate() + const content = this.validate(true) if (content) { this.$emit('input', content) this.$emit('submit', content) } }, - /** - * All credits for this go to the talk app + /* All credits for this go to the talk app * https://github.com/nextcloud/spreed/blob/e69740b372e17eec4541337b47baa262a5766510/src/components/NewMessageForm/NewMessageForm.vue#L100-L143 */ contentEditableToParsed() { diff --git a/src/components/card/CommentItem.vue b/src/components/card/CommentItem.vue index 6b82d6ad3..1c9f87b7e 100644 --- a/src/components/card/CommentItem.vue +++ b/src/components/card/CommentItem.vue @@ -1,5 +1,12 @@