]> BookStack Code Mirror - bookstack/commitdiff
Comments: Added archive endpoints, messages, Js actions and tests
authorDan Brown <redacted>
Mon, 28 Apr 2025 14:37:09 +0000 (15:37 +0100)
committerDan Brown <redacted>
Mon, 28 Apr 2025 14:37:09 +0000 (15:37 +0100)
app/Activity/CommentRepo.php
app/Activity/Controllers/CommentController.php
lang/en/common.php
lang/en/entities.php
resources/icons/archive.svg [new file with mode: 0644]
resources/js/components/page-comment.ts
resources/views/comments/comment.blade.php
routes/web.php
tests/Entity/CommentTest.php

index c488350ca24aadf13e6e006961d1953089b92793..866368ee64963a0f5a78ffd1d29931885d58c898 100644 (file)
@@ -53,6 +53,33 @@ class CommentRepo
         return $comment;
     }
 
+
+    /**
+     * Archive an existing comment.
+     */
+    public function archive(Comment $comment): Comment
+    {
+        $comment->archived = true;
+        $comment->save();
+
+        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+        return $comment;
+    }
+
+    /**
+     * Un-archive an existing comment.
+     */
+    public function unarchive(Comment $comment): Comment
+    {
+        $comment->archived = false;
+        $comment->save();
+
+        ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
+
+        return $comment;
+    }
+
     /**
      * Delete a comment from the system.
      */
index 2620800678406b5b0607a3cd551849cee1b74d8a..7a290ebabc526969ae38232be72982c8af953f30 100644 (file)
@@ -75,6 +75,42 @@ class CommentController extends Controller
         ]);
     }
 
+    /**
+     * Mark a comment as archived.
+     */
+    public function archive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->archive($comment);
+
+        return view('comments.comment', [
+            'comment' => $comment,
+            'readOnly' => false,
+        ]);
+    }
+
+    /**
+     * Unmark a comment as archived.
+     */
+    public function unarchive(int $id)
+    {
+        $comment = $this->commentRepo->getById($id);
+        if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
+            $this->showPermissionError();
+        }
+
+        $this->commentRepo->unarchive($comment);
+
+        return view('comments.comment', [
+            'comment' => $comment,
+            'readOnly' => false,
+        ]);
+    }
+
     /**
      * Delete a comment from the system.
      */
index b05169bb2c46211f2341d2f13284f2f74459080a..06a9e855ce3989f3b7aef22fb75c9a9be6e7fff6 100644 (file)
@@ -30,6 +30,8 @@ return [
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
+    'archive' => 'Archive',
+    'unarchive' => 'Un-Archive',
     'sort' => 'Sort',
     'move' => 'Move',
     'copy' => 'Copy',
index f9fab8ebfd3286edd5d6d71a910e28742f378265..141e75b5f3568a0e325a2435d9ce523474b4905b 100644 (file)
@@ -402,6 +402,8 @@ return [
     'comment_deleted_success' => 'Comment deleted',
     'comment_created_success' => 'Comment added',
     'comment_updated_success' => 'Comment updated',
+    'comment_archive_success' => 'Comment archived',
+    'comment_unarchive_success' => 'Comment un-archived',
     'comment_view' => 'View comment',
     'comment_jump_to_thread' => 'Jump to thread',
     'comment_delete_confirm' => 'Are you sure you want to delete this comment?',
diff --git a/resources/icons/archive.svg b/resources/icons/archive.svg
new file mode 100644 (file)
index 0000000..90a4f35
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m480-240 160-160-56-56-64 64v-168h-80v168l-64-64-56 56 160 160ZM200-640v440h560v-440H200Zm0 520q-33 0-56.5-23.5T120-200v-499q0-14 4.5-27t13.5-24l50-61q11-14 27.5-21.5T250-840h460q18 0 34.5 7.5T772-811l50 61q9 11 13.5 24t4.5 27v499q0 33-23.5 56.5T760-120H200Zm16-600h528l-34-40H250l-34 40Zm264 300Z"/></svg>
\ No newline at end of file
index 11ad769b1d04040d7a9bae6a19984326dba777e1..d2cbd21d1db89810fbd95255b8efd78b6ee3f9fa 100644 (file)
@@ -8,6 +8,7 @@ export class PageComment extends Component {
     protected commentLocalId: string;
     protected deletedText: string;
     protected updatedText: string;
+    protected archiveText: string;
 
     protected wysiwygEditor: any = null;
     protected wysiwygLanguage: string;
@@ -20,6 +21,7 @@ export class PageComment extends Component {
     protected editButton: HTMLElement;
     protected deleteButton: HTMLElement;
     protected replyButton: HTMLElement;
+    protected archiveButton: HTMLElement;
     protected input: HTMLInputElement;
 
     setup() {
@@ -27,7 +29,8 @@ export class PageComment extends Component {
         this.commentId = this.$opts.commentId;
         this.commentLocalId = this.$opts.commentLocalId;
         this.deletedText = this.$opts.deletedText;
-        this.updatedText = this.$opts.updatedText;
+        this.deletedText = this.$opts.deletedText;
+        this.archiveText = this.$opts.archiveText;
 
         // Editor reference and text options
         this.wysiwygLanguage = this.$opts.wysiwygLanguage;
@@ -41,6 +44,7 @@ export class PageComment extends Component {
         this.editButton = this.$refs.editButton;
         this.deleteButton = this.$refs.deleteButton;
         this.replyButton = this.$refs.replyButton;
+        this.archiveButton = this.$refs.archiveButton;
         this.input = this.$refs.input as HTMLInputElement;
 
         this.setupListeners();
@@ -63,6 +67,10 @@ export class PageComment extends Component {
         if (this.deleteButton) {
             this.deleteButton.addEventListener('click', this.delete.bind(this));
         }
+
+        if (this.archiveButton) {
+            this.archiveButton.addEventListener('click', this.archive.bind(this));
+        }
     }
 
     protected toggleEditMode(show: boolean) : void {
@@ -126,6 +134,15 @@ export class PageComment extends Component {
         window.$events.success(this.deletedText);
     }
 
+    protected async archive(): Promise<void> {
+        this.showLoading();
+        const isArchived = this.archiveButton.dataset.isArchived === 'true';
+
+        await window.$http.put(`/comment/${this.commentId}/${isArchived ? 'unarchive' : 'archive'}`);
+        this.$emit('archive');
+        window.$events.success(this.archiveText);
+    }
+
     protected showLoading(): HTMLElement {
         const loading = getLoading();
         loading.classList.add('px-l');
index 5310b2fe4c59eb83e1477248103b9de12734ddae..58e057140a0e07f13e84cd5fc41c765a3bc0a06e 100644 (file)
@@ -6,6 +6,7 @@
      option:page-comment:comment-local-id="{{ $comment->local_id }}"
      option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
      option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+     option:page-comment:archive-text="{{ $comment->archived ? trans('entities.comment_unarchive_success') : trans('entities.comment_archive_success') }}"
      option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
      option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
      id="comment{{$comment->local_id}}"
                     @if(userCan('comment-create-all'))
                         <button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('reply') {{ trans('common.reply') }}</button>
                     @endif
+                    @if(userCan('comment-update', $comment) || userCan('comment-delete', $comment))
+                        <button refs="page-comment@archive-button"
+                                type="button"
+                                data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"
+                                class="text-button text-muted hover-underline text-small p-xs">@icon('archive') {{ trans('common.' . ($comment->archived ? 'unarchive' : 'archive')) }}</button>
+                    @endif
                     @if(userCan('comment-update', $comment))
                         <button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline text-small p-xs">@icon('edit') {{ trans('common.edit') }}</button>
                     @endif
index 8184725834caae44f3dae98942d2bc981ec84406..ea3efe1ac776cea1d6ebf16349531d3958b6cd8b 100644 (file)
@@ -179,6 +179,8 @@ Route::middleware('auth')->group(function () {
 
     // Comments
     Route::post('/comment/{pageId}', [ActivityControllers\CommentController::class, 'savePageComment']);
+    Route::put('/comment/{id}/archive', [ActivityControllers\CommentController::class, 'archive']);
+    Route::put('/comment/{id}/unarchive', [ActivityControllers\CommentController::class, 'unarchive']);
     Route::put('/comment/{id}', [ActivityControllers\CommentController::class, 'update']);
     Route::delete('/comment/{id}', [ActivityControllers\CommentController::class, 'destroy']);
 
index 973b2b81d8d301ecf840770e22d7ff8f22a8da86..baf0d392bebda78f94508d13f359a7ceee30ebe4 100644 (file)
@@ -106,6 +106,66 @@ class CommentTest extends TestCase
         $this->assertActivityExists(ActivityType::COMMENT_DELETE);
     }
 
+    public function test_comment_archive_and_unarchive()
+    {
+        $this->asAdmin();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $this->put("/comment/$comment->id/archive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => true,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+
+        $this->put("/comment/$comment->id/unarchive");
+
+        $this->assertDatabaseHas('comments', [
+            'id' => $comment->id,
+            'archived' => false,
+        ]);
+
+        $this->assertActivityExists(ActivityType::COMMENT_UPDATE);
+    }
+
+    public function test_archive_endpoints_require_delete_or_edit_permissions()
+    {
+        $viewer = $this->users->viewer();
+        $page = $this->entities->page();
+
+        $comment = Comment::factory()->make();
+        $page->comments()->save($comment);
+        $comment->refresh();
+
+        $endpoints = ["/comment/$comment->id/archive", "/comment/$comment->id/unarchive"];
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $this->assertPermissionError($resp);
+        }
+
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-delete-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+
+        $this->permissions->removeUserRolePermissions($viewer, ['comment-delete-all']);
+        $this->permissions->grantUserRolePermissions($viewer, ['comment-update-all']);
+
+        foreach ($endpoints as $endpoint) {
+            $resp = $this->actingAs($viewer)->put($endpoint);
+            $resp->assertOk();
+        }
+    }
+
     public function test_scripts_cannot_be_injected_via_comment_html()
     {
         $page = $this->entities->page();
Morty Proxy This is a proxified and sanitized view of the page, visit original site.