Comments: Started archive display, created mode for tree node
Some checks failed
test-js / build (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-migrations / build (8.4) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
test-php / build (8.4) (push) Has been cancelled

This commit is contained in:
Dan Brown
2025-04-28 20:09:18 +01:00
parent 8bdf948743
commit 099f6104d0
10 changed files with 110 additions and 28 deletions

View File

@ -4,6 +4,8 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService; use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter; use BookStack\Util\HtmlDescriptionFilter;
@ -59,6 +61,10 @@ class CommentRepo
*/ */
public function archive(Comment $comment): Comment public function archive(Comment $comment): Comment
{ {
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be archived.');
}
$comment->archived = true; $comment->archived = true;
$comment->save(); $comment->save();
@ -72,6 +78,10 @@ class CommentRepo
*/ */
public function unarchive(Comment $comment): Comment public function unarchive(Comment $comment): Comment
{ {
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be un-archived.');
}
$comment->archived = false; $comment->archived = false;
$comment->save(); $comment->save();

View File

@ -3,6 +3,8 @@
namespace BookStack\Activity\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo; use BookStack\Activity\CommentRepo;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -45,10 +47,7 @@ class CommentController extends Controller
return view('comments.comment-branch', [ return view('comments.comment-branch', [
'readOnly' => false, 'readOnly' => false,
'branch' => [ 'branch' => new CommentTreeNode($comment, 0, []),
'comment' => $comment,
'children' => [],
]
]); ]);
} }
@ -81,15 +80,17 @@ class CommentController extends Controller
public function archive(int $id) public function archive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
$this->commentRepo->archive($comment); $this->commentRepo->archive($comment);
return view('comments.comment', [ $tree = new CommentTree($comment->entity);
'comment' => $comment, return view('comments.comment-branch', [
'readOnly' => false, 'readOnly' => false,
'branch' => $tree->getCommentNodeForId($id),
]); ]);
} }
@ -99,15 +100,17 @@ class CommentController extends Controller
public function unarchive(int $id) public function unarchive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
$this->commentRepo->unarchive($comment); $this->commentRepo->unarchive($comment);
return view('comments.comment', [ $tree = new CommentTree($comment->entity);
'comment' => $comment, return view('comments.comment-branch', [
'readOnly' => false, 'readOnly' => false,
'branch' => $tree->getCommentNodeForId($id),
]); ]);
} }

View File

@ -9,7 +9,7 @@ class CommentTree
{ {
/** /**
* The built nested tree structure array. * The built nested tree structure array.
* @var array{comment: Comment, depth: int, children: array}[] * @var CommentTreeNode[]
*/ */
protected array $tree; protected array $tree;
protected array $comments; protected array $comments;
@ -36,9 +36,25 @@ class CommentTree
return count($this->comments); return count($this->comments);
} }
public function get(): array public function getActive(): array
{ {
return $this->tree; return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
}
public function getArchived(): array
{
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
}
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
{
foreach ($this->tree as $node) {
if ($node->comment->id === $commentId) {
return $node;
}
}
return null;
} }
public function canUpdateAny(): bool public function canUpdateAny(): bool
@ -54,6 +70,7 @@ class CommentTree
/** /**
* @param Comment[] $comments * @param Comment[] $comments
* @return CommentTreeNode[]
*/ */
protected function createTree(array $comments): array protected function createTree(array $comments): array
{ {
@ -77,26 +94,22 @@ class CommentTree
$tree = []; $tree = [];
foreach ($childMap[0] ?? [] as $childId) { foreach ($childMap[0] ?? [] as $childId) {
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap); $tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
} }
return $tree; return $tree;
} }
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
{ {
$childIds = $childMap[$id] ?? []; $childIds = $childMap[$id] ?? [];
$children = []; $children = [];
foreach ($childIds as $childId) { foreach ($childIds as $childId) {
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap); $children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
} }
return [ return new CommentTreeNode($byId[$id], $depth, $children);
'comment' => $byId[$id],
'depth' => $depth,
'children' => $children,
];
} }
protected function loadComments(): array protected function loadComments(): array

View File

@ -0,0 +1,23 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
class CommentTreeNode
{
public Comment $comment;
public int $depth;
/**
* @var CommentTreeNode[]
*/
public array $children;
public function __construct(Comment $comment, int $depth, array $children)
{
$this->comment = $comment;
$this->depth = $depth;
$this->children = $children;
}
}

View File

@ -392,6 +392,7 @@ return [
'comment' => 'Comment', 'comment' => 'Comment',
'comments' => 'Comments', 'comments' => 'Comments',
'comment_add' => 'Add Comment', 'comment_add' => 'Add Comment',
'comment_archived' => ':count Archived Comment|:count Archived Comments',
'comment_placeholder' => 'Leave a comment here', 'comment_placeholder' => 'Leave a comment here',
'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
'comment_save' => 'Save Comment', 'comment_save' => 'Save Comment',

View File

@ -137,10 +137,12 @@ export class PageComment extends Component {
protected async archive(): Promise<void> { protected async archive(): Promise<void> {
this.showLoading(); this.showLoading();
const isArchived = this.archiveButton.dataset.isArchived === 'true'; const isArchived = this.archiveButton.dataset.isArchived === 'true';
const action = isArchived ? 'unarchive' : 'archive';
await window.$http.put(`/comment/${this.commentId}/${isArchived ? 'unarchive' : 'archive'}`); const response = await window.$http.put(`/comment/${this.commentId}/${action}`);
this.$emit('archive'); this.$emit(action, {new_thread_dom: htmlToDom(response.data as string)});
window.$events.success(this.archiveText); window.$events.success(this.archiveText);
this.container.closest('.comment-branch')?.remove();
} }
protected showLoading(): HTMLElement { protected showLoading(): HTMLElement {

View File

@ -9,6 +9,12 @@ export interface CommentReplyEvent extends Event {
} }
} }
export interface ArchiveEvent extends Event {
detail: {
new_thread_dom: HTMLElement;
}
}
export class PageComments extends Component { export class PageComments extends Component {
private elem: HTMLElement; private elem: HTMLElement;
@ -17,6 +23,7 @@ export class PageComments extends Component {
private commentCountBar: HTMLElement; private commentCountBar: HTMLElement;
private commentsTitle: HTMLElement; private commentsTitle: HTMLElement;
private addButtonContainer: HTMLElement; private addButtonContainer: HTMLElement;
private archiveContainer: HTMLElement;
private replyToRow: HTMLElement; private replyToRow: HTMLElement;
private formContainer: HTMLElement; private formContainer: HTMLElement;
private form: HTMLFormElement; private form: HTMLFormElement;
@ -43,6 +50,7 @@ export class PageComments extends Component {
this.commentCountBar = this.$refs.commentCountBar; this.commentCountBar = this.$refs.commentCountBar;
this.commentsTitle = this.$refs.commentsTitle; this.commentsTitle = this.$refs.commentsTitle;
this.addButtonContainer = this.$refs.addButtonContainer; this.addButtonContainer = this.$refs.addButtonContainer;
this.archiveContainer = this.$refs.archiveContainer;
this.replyToRow = this.$refs.replyToRow; this.replyToRow = this.$refs.replyToRow;
this.formContainer = this.$refs.formContainer; this.formContainer = this.$refs.formContainer;
this.form = this.$refs.form as HTMLFormElement; this.form = this.$refs.form as HTMLFormElement;
@ -75,6 +83,14 @@ export class PageComments extends Component {
this.setReply(event.detail.id, event.detail.element); this.setReply(event.detail.id, event.detail.element);
}); });
this.elem.addEventListener('page-comment-archive', (event: ArchiveEvent) => {
this.archiveContainer.append(event.detail.new_thread_dom);
});
this.elem.addEventListener('page-comment-unarchive', (event: ArchiveEvent) => {
this.container.append(event.detail.new_thread_dom)
});
if (this.form) { if (this.form) {
this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); this.hideFormButton.addEventListener('click', this.hideForm.bind(this));

View File

@ -1,13 +1,16 @@
{{--
$branch CommentTreeNode
--}}
<div class="comment-branch"> <div class="comment-branch">
<div> <div>
@include('comments.comment', ['comment' => $branch['comment']]) @include('comments.comment', ['comment' => $branch->comment])
</div> </div>
<div class="flex-container-row"> <div class="flex-container-row">
<div class="comment-thread-indicator-parent"> <div class="comment-thread-indicator-parent">
<div class="comment-thread-indicator"></div> <div class="comment-thread-indicator"></div>
</div> </div>
<div class="comment-branch-children flex"> <div class="comment-branch-children flex">
@foreach($branch['children'] as $childBranch) @foreach($branch->children as $childBranch)
@include('comments.comment-branch', ['branch' => $childBranch]) @include('comments.comment-branch', ['branch' => $childBranch])
@endforeach @endforeach
</div> </div>

View File

@ -38,7 +38,7 @@
@if(userCan('comment-create-all')) @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> <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 @endif
@if(userCan('comment-update', $comment) || userCan('comment-delete', $comment)) @if(!$comment->parent_id && (userCan('comment-update', $comment) || userCan('comment-delete', $comment)))
<button refs="page-comment@archive-button" <button refs="page-comment@archive-button"
type="button" type="button"
data-is-archived="{{ $comment->archived ? 'true' : 'false' }}" data-is-archived="{{ $comment->archived ? 'true' : 'false' }}"

View File

@ -18,8 +18,8 @@
@endif @endif
</div> </div>
<div refs="page-comments@commentContainer" class="comment-container"> <div refs="page-comments@comment-container" class="comment-container">
@foreach($commentTree->get() as $branch) @foreach($commentTree->getActive() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false]) @include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
@endforeach @endforeach
</div> </div>
@ -27,14 +27,25 @@
@if(userCan('comment-create-all')) @if(userCan('comment-create-all'))
@include('comments.create') @include('comments.create')
@if (!$commentTree->empty()) @if (!$commentTree->empty())
<div refs="page-comments@addButtonContainer" class="text-right"> <div refs="page-comments@addButtonContainer" class="flex-container-row">
<button type="button"
refs="page-comments@show-archived-button"
class="text-button hover-underline">{{ trans_choice('entities.comment_archived', count($commentTree->getArchived())) }}</button>
<button type="button" <button type="button"
refs="page-comments@add-comment-button" refs="page-comments@add-comment-button"
class="button outline">{{ trans('entities.comment_add') }}</button> class="button outline ml-auto">{{ trans('entities.comment_add') }}</button>
</div> </div>
@endif @endif
@endif @endif
<div refs="page-comments@archive-container" class="comment-container">
@foreach($commentTree->getArchived() as $branch)
@include('comments.comment-branch', ['branch' => $branch, 'readOnly' => false])
@endforeach
</div>
@if(userCan('comment-create-all') || $commentTree->canUpdateAny()) @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
@push('body-end') @push('body-end')
<script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script> <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}" defer></script>