From 4b9f6beb376fc6b0c31af77d96ff0842bcdd53bc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 7 Jun 2023 13:24:49 +0100 Subject: [PATCH 1/5] Comments: Updated to show as nested threads Initial functional implementation, a lot of tweaking and adapting to be done. --- app/Activity/CommentRepo.php | 24 +---- app/Activity/Tools/CommentTree.php | 102 ++++++++++++++++++ app/Entities/Controllers/PageController.php | 12 +-- resources/sass/_components.scss | 7 ++ .../views/comments/comment-branch.blade.php | 17 +++ resources/views/comments/comment.blade.php | 2 +- resources/views/comments/comments.blade.php | 10 +- resources/views/pages/show.blade.php | 4 +- 8 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 app/Activity/Tools/CommentTree.php create mode 100644 resources/views/comments/comment-branch.blade.php diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index f16767fcf..2aabab79d 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -7,27 +7,14 @@ use BookStack\Entities\Models\Entity; use BookStack\Facades\Activity as ActivityService; use League\CommonMark\CommonMarkConverter; -/** - * Class CommentRepo. - */ class CommentRepo { - /** - * @var Comment - */ - protected $comment; - - public function __construct(Comment $comment) - { - $this->comment = $comment; - } - /** * Get a comment by ID. */ public function getById(int $id): Comment { - return $this->comment->newQuery()->findOrFail($id); + return Comment::query()->findOrFail($id); } /** @@ -36,7 +23,7 @@ class CommentRepo public function create(Entity $entity, string $text, ?int $parent_id): Comment { $userId = user()->id; - $comment = $this->comment->newInstance(); + $comment = new Comment(); $comment->text = $text; $comment->html = $this->commentToHtml($text); @@ -83,7 +70,7 @@ class CommentRepo 'allow_unsafe_links' => false, ]); - return $converter->convertToHtml($commentText); + return $converter->convert($commentText); } /** @@ -91,9 +78,8 @@ class CommentRepo */ protected function getNextLocalId(Entity $entity): int { - /** @var Comment $comment */ - $comment = $entity->comments(false)->orderBy('local_id', 'desc')->first(); + $currentMaxId = $entity->comments()->max('local_id'); - return ($comment->local_id ?? 0) + 1; + return $currentMaxId + 1; } } diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php new file mode 100644 index 000000000..559edccf3 --- /dev/null +++ b/app/Activity/Tools/CommentTree.php @@ -0,0 +1,102 @@ +comments = $this->loadComments(); + $this->tree = $this->createTree($this->comments); + } + + public function enabled(): bool + { + return !setting('app-disable-comments'); + } + + public function empty(): bool + { + return count($this->tree) === 0; + } + + public function count(): int + { + return count($this->comments); + } + + public function get(): array + { + return $this->tree; + } + + /** + * @param Comment[] $comments + */ + protected function createTree(array $comments): array + { + $byId = []; + foreach ($comments as $comment) { + $byId[$comment->local_id] = $comment; + } + + $childMap = []; + foreach ($comments as $comment) { + $parent = $comment->parent_id; + if (is_null($parent) || !isset($byId[$parent])) { + $parent = 0; + } + + if (!isset($childMap[$parent])) { + $childMap[$parent] = []; + } + $childMap[$parent][] = $comment->local_id; + } + + $tree = []; + foreach ($childMap[0] as $childId) { + $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap); + } + + return $tree; + } + + protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array + { + $childIds = $childMap[$id] ?? []; + $children = []; + + foreach ($childIds as $childId) { + $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap); + } + + return [ + 'comment' => $byId[$id], + 'depth' => $depth, + 'children' => $children, + ]; + } + + protected function loadComments(): array + { + if (!$this->enabled()) { + return []; + } + + return $this->page->comments() + ->with('createdBy') + ->get() + ->all(); + } +} diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index a6ef68dd7..e0444ecd2 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\Models\View; +use BookStack\Activity\Tools\CommentTree; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\BookContents; @@ -140,15 +141,10 @@ class PageController extends Controller $pageContent = (new PageContent($page)); $page->html = $pageContent->render(); - $sidebarTree = (new BookContents($page->book))->getTree(); $pageNav = $pageContent->getNavigation($page->html); - // Check if page comments are enabled - $commentsEnabled = !setting('app-disable-comments'); - if ($commentsEnabled) { - $page->load(['comments.createdBy']); - } - + $sidebarTree = (new BookContents($page->book))->getTree(); + $commentTree = (new CommentTree($page)); $nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree); View::incrementFor($page); @@ -159,7 +155,7 @@ class PageController extends Controller 'book' => $page->book, 'current' => $page, 'sidebarTree' => $sidebarTree, - 'commentsEnabled' => $commentsEnabled, + 'commentTree' => $commentTree, 'pageNav' => $pageNav, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 1521e6eaa..bd85bb99f 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -704,6 +704,13 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } +.comment-thread-indicator { + border-inline-start: 3px dotted #DDD; + @include lightDark(border-color, #DDD, #444); + margin-inline-start: $-xs; + width: $-l; +} + #tag-manager .drag-card { max-width: 500px; } diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php new file mode 100644 index 000000000..d64dd4ade --- /dev/null +++ b/resources/views/comments/comment-branch.blade.php @@ -0,0 +1,17 @@ +
+
+ @include('comments.comment', ['comment' => $branch['comment']]) +
+ @if(count($branch['children']) > 0) +
+
+
+
+
+ @foreach($branch['children'] as $childBranch) + @include('comments.comment-branch', ['branch' => $childBranch]) + @endforeach +
+
+ @endif +
\ No newline at end of file diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 6189c65d4..093e5a899 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 140d0d027..f50e3a218 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -8,8 +8,8 @@ aria-label="{{ trans('entities.comments') }}">
-
{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}
- @if (count($page->comments) === 0 && userCan('comment-create-all')) +
{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}
+ @if ($commentTree->empty() && userCan('comment-create-all'))
@@ -18,15 +18,15 @@
- @foreach($page->comments as $comment) - @include('comments.comment', ['comment' => $comment]) + @foreach($commentTree->get() as $branch) + @include('comments.comment-branch', ['branch' => $branch]) @endforeach
@if(userCan('comment-create-all')) @include('comments.create') - @if (count($page->comments) > 0) + @if (!$commentTree->empty())
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 2cbc7fe47..fa6b1a2cd 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -27,7 +27,7 @@ @include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous]) - @if ($commentsEnabled) + @if ($commentTree->enabled()) @if(($previous || $next))

@@ -35,7 +35,7 @@ @endif @endif From 154924cc0c19546599bbe1953744f9ab1162b263 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 7 Jun 2023 17:47:37 +0100 Subject: [PATCH 2/5] Comments: updated component and split out code Split out comment component code so single-comment actions (delete, edit) are handled within their own compontent. Modernised existing component code. --- lang/en/entities.php | 2 - resources/js/components/index.js | 1 + resources/js/components/page-comment.js | 85 ++++++++++ resources/js/components/page-comments.js | 159 +++++------------- .../views/comments/comment-branch.blade.php | 2 +- resources/views/comments/comment.blade.php | 43 +++-- resources/views/comments/comments.blade.php | 14 +- resources/views/comments/create.blade.php | 20 +-- 8 files changed, 167 insertions(+), 159 deletions(-) create mode 100644 resources/js/components/page-comment.js diff --git a/lang/en/entities.php b/lang/en/entities.php index 501fc9f2a..8499bb30f 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -362,8 +362,6 @@ return [ 'comment_placeholder' => 'Leave a comment here', 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', 'comment_save' => 'Save Comment', - 'comment_saving' => 'Saving comment...', - 'comment_deleting' => 'Deleting comment...', 'comment_new' => 'New Comment', 'comment_created' => 'commented :createDiff', 'comment_updated' => 'Updated :updateDiff by :username', diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 803714e62..a56f18a5a 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -34,6 +34,7 @@ export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; export {OptionalInput} from './optional-input'; +export {PageComment} from './page-comment'; export {PageComments} from './page-comments'; export {PageDisplay} from './page-display'; export {PageEditor} from './page-editor'; diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js new file mode 100644 index 000000000..ff86297e2 --- /dev/null +++ b/resources/js/components/page-comment.js @@ -0,0 +1,85 @@ +import {Component} from './component'; +import {getLoading, htmlToDom} from '../services/dom'; + +export class PageComment extends Component { + + setup() { + // Options + this.commentId = this.$opts.commentId; + this.commentLocalId = this.$opts.commentLocalId; + this.commentParentId = this.$opts.commentParentId; + this.deletedText = this.$opts.deletedText; + this.updatedText = this.$opts.updatedText; + + // Element References + this.container = this.$el; + this.contentContainer = this.$refs.contentContainer; + this.form = this.$refs.form; + this.formCancel = this.$refs.formCancel; + this.editButton = this.$refs.editButton; + this.deleteButton = this.$refs.deleteButton; + this.replyButton = this.$refs.replyButton; + this.input = this.$refs.input; + + this.setupListeners(); + } + + setupListeners() { + this.replyButton.addEventListener('click', () => this.$emit('reply', {id: this.commentLocalId})); + this.editButton.addEventListener('click', this.startEdit.bind(this)); + this.deleteButton.addEventListener('click', this.delete.bind(this)); + this.form.addEventListener('submit', this.update.bind(this)); + this.formCancel.addEventListener('click', () => this.toggleEditMode(false)); + } + + toggleEditMode(show) { + this.contentContainer.toggleAttribute('hidden', show); + this.form.toggleAttribute('hidden', !show); + } + + startEdit() { + this.toggleEditMode(true); + const lineCount = this.$refs.input.value.split('\n').length; + this.$refs.input.style.height = `${(lineCount * 20) + 40}px`; + } + + async update(event) { + event.preventDefault(); + const loading = this.showLoading(); + this.form.toggleAttribute('hidden', true); + + const reqData = { + text: this.input.value, + parent_id: this.parentId || null, + }; + + try { + const resp = await window.$http.put(`/comment/${this.commentId}`, reqData); + const newComment = htmlToDom(resp.data); + this.container.replaceWith(newComment); + window.$events.success(this.updatedText); + } catch (err) { + console.error(err); + window.$events.showValidationErrors(err); + this.form.toggleAttribute('hidden', false); + loading.remove(); + } + } + + async delete() { + this.showLoading(); + + await window.$http.delete(`/comment/${this.commentId}`); + this.container.closest('.comment-branch').remove(); + window.$events.success(this.deletedText); + this.$emit('delete'); + } + + showLoading() { + const loading = getLoading(); + loading.classList.add('px-l'); + this.container.append(loading); + return loading; + } + +} diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 0ac9d0572..9dc529963 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,6 +1,5 @@ -import {scrollAndHighlightElement} from '../services/util'; import {Component} from './component'; -import {htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom'; export class PageComments extends Component { @@ -10,166 +9,110 @@ export class PageComments extends Component { // Element references this.container = this.$refs.commentContainer; - this.formContainer = this.$refs.formContainer; this.commentCountBar = this.$refs.commentCountBar; + this.commentsTitle = this.$refs.commentsTitle; this.addButtonContainer = this.$refs.addButtonContainer; this.replyToRow = this.$refs.replyToRow; + this.formContainer = this.$refs.formContainer; + this.form = this.$refs.form; + this.formInput = this.$refs.formInput; + this.addCommentButton = this.$refs.addCommentButton; + this.hideFormButton = this.$refs.hideFormButton; + this.removeReplyToButton = this.$refs.removeReplyToButton; // Translations - this.updatedText = this.$opts.updatedText; - this.deletedText = this.$opts.deletedText; this.createdText = this.$opts.createdText; this.countText = this.$opts.countText; // Internal State - this.editingComment = null; this.parentId = null; - if (this.formContainer) { - this.form = this.formContainer.querySelector('form'); - this.formInput = this.form.querySelector('textarea'); - this.form.addEventListener('submit', this.saveComment.bind(this)); - } - - this.elem.addEventListener('click', this.handleAction.bind(this)); - this.elem.addEventListener('submit', this.updateComment.bind(this)); + this.setupListeners(); } - handleAction(event) { - const actionElem = event.target.closest('[action]'); + setupListeners() { + this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this)); + this.hideFormButton.addEventListener('click', this.hideForm.bind(this)); + this.addCommentButton.addEventListener('click', this.showForm.bind(this)); - if (event.target.matches('a[href^="#"]')) { - const id = event.target.href.split('#')[1]; - scrollAndHighlightElement(document.querySelector(`#${id}`)); - } - - if (actionElem === null) return; - event.preventDefault(); - - const action = actionElem.getAttribute('action'); - const comment = actionElem.closest('[comment]'); - if (action === 'edit') this.editComment(comment); - if (action === 'closeUpdateForm') this.closeUpdateForm(); - if (action === 'delete') this.deleteComment(comment); - if (action === 'addComment') this.showForm(); - if (action === 'hideForm') this.hideForm(); - if (action === 'reply') this.setReply(comment); - if (action === 'remove-reply-to') this.removeReplyTo(); - } - - closeUpdateForm() { - if (!this.editingComment) return; - this.editingComment.querySelector('[comment-content]').style.display = 'block'; - this.editingComment.querySelector('[comment-edit-container]').style.display = 'none'; - } - - editComment(commentElem) { - this.hideForm(); - if (this.editingComment) this.closeUpdateForm(); - commentElem.querySelector('[comment-content]').style.display = 'none'; - commentElem.querySelector('[comment-edit-container]').style.display = 'block'; - const textArea = commentElem.querySelector('[comment-edit-container] textarea'); - const lineCount = textArea.value.split('\n').length; - textArea.style.height = `${(lineCount * 20) + 40}px`; - this.editingComment = commentElem; - } - - updateComment(event) { - const form = event.target; - event.preventDefault(); - const text = form.querySelector('textarea').value; - const reqData = { - text, - parent_id: this.parentId || null, - }; - this.showLoading(form); - const commentId = this.editingComment.getAttribute('comment'); - window.$http.put(`/comment/${commentId}`, reqData).then(resp => { - const newComment = document.createElement('div'); - newComment.innerHTML = resp.data; - this.editingComment.innerHTML = newComment.children[0].innerHTML; - window.$events.success(this.updatedText); - window.$components.init(this.editingComment); - this.closeUpdateForm(); - this.editingComment = null; - }).catch(window.$events.showValidationErrors).then(() => { - this.hideLoading(form); - }); - } - - deleteComment(commentElem) { - const id = commentElem.getAttribute('comment'); - this.showLoading(commentElem.querySelector('[comment-content]')); - window.$http.delete(`/comment/${id}`).then(() => { - commentElem.parentNode.removeChild(commentElem); - window.$events.success(this.deletedText); + this.elem.addEventListener('page-comment-delete', () => { this.updateCount(); this.hideForm(); }); + + this.elem.addEventListener('page-comment-reply', event => { + this.setReply(event.detail.id); + }); + + if (this.form) { + this.form.addEventListener('submit', this.saveComment.bind(this)); + } } saveComment(event) { event.preventDefault(); event.stopPropagation(); + + const loading = getLoading(); + loading.classList.add('px-l'); + this.form.after(loading); + this.form.toggleAttribute('hidden', true); + const text = this.formInput.value; const reqData = { text, parent_id: this.parentId || null, }; - this.showLoading(this.form); + window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => { const newElem = htmlToDom(resp.data); this.container.appendChild(newElem); - window.$components.init(newElem); window.$events.success(this.createdText); this.resetForm(); this.updateCount(); }).catch(err => { + this.form.toggleAttribute('hidden', false); window.$events.showValidationErrors(err); - this.hideLoading(this.form); }); + + loading.remove(); } updateCount() { - const count = this.container.children.length; - this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count}); + const count = this.getCommentCount(); + this.commentsTitle.textContent = window.trans_plural(this.countText, count, {count}); } resetForm() { this.formInput.value = ''; - this.formContainer.appendChild(this.form); this.hideForm(); this.removeReplyTo(); - this.hideLoading(this.form); } showForm() { - this.formContainer.style.display = 'block'; - this.formContainer.parentNode.style.display = 'block'; - this.addButtonContainer.style.display = 'none'; + this.formContainer.toggleAttribute('hidden', false); + this.addButtonContainer.toggleAttribute('hidden', true); this.formInput.focus(); - this.formInput.scrollIntoView({behavior: 'smooth'}); } hideForm() { - this.formContainer.style.display = 'none'; - this.formContainer.parentNode.style.display = 'none'; + this.formContainer.toggleAttribute('hidden', true); if (this.getCommentCount() > 0) { this.elem.appendChild(this.addButtonContainer); } else { this.commentCountBar.appendChild(this.addButtonContainer); } - this.addButtonContainer.style.display = 'block'; + this.addButtonContainer.toggleAttribute('hidden', false); } getCommentCount() { - return this.elem.querySelectorAll('.comment-box[comment]').length; + return this.container.querySelectorAll('[compontent="page-comment"]').length; } - setReply(commentElem) { + setReply(commentLocalId) { this.showForm(); - this.parentId = Number(commentElem.getAttribute('local-id')); - this.replyToRow.style.display = 'block'; + this.parentId = commentLocalId; + this.replyToRow.toggleAttribute('hidden', false); const replyLink = this.replyToRow.querySelector('a'); replyLink.textContent = `#${this.parentId}`; replyLink.href = `#comment${this.parentId}`; @@ -177,23 +120,7 @@ export class PageComments extends Component { removeReplyTo() { this.parentId = null; - this.replyToRow.style.display = 'none'; - } - - showLoading(formElem) { - const groups = formElem.querySelectorAll('.form-group'); - for (const group of groups) { - group.style.display = 'none'; - } - formElem.querySelector('.form-group.loading').style.display = 'block'; - } - - hideLoading(formElem) { - const groups = formElem.querySelectorAll('.form-group'); - for (const group of groups) { - group.style.display = 'block'; - } - formElem.querySelector('.form-group.loading').style.display = 'none'; + this.replyToRow.toggleAttribute('hidden', true); } } diff --git a/resources/views/comments/comment-branch.blade.php b/resources/views/comments/comment-branch.blade.php index d64dd4ade..69c967cd2 100644 --- a/resources/views/comments/comment-branch.blade.php +++ b/resources/views/comments/comment-branch.blade.php @@ -1,4 +1,4 @@ -
+
@include('comments.comment', ['comment' => $branch['comment']])
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 093e5a899..ce0d59473 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,4 +1,11 @@ -
+
@@ -21,10 +28,10 @@
@if(userCan('comment-update', $comment)) - + @endif @if(userCan('comment-create-all')) - + @endif @if(userCan('comment-delete', $comment)) @endif -
- +
{!! $comment->html !!}
@if(userCan('comment-update', $comment)) - + @endif
\ No newline at end of file diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index f50e3a218..b79f0fd45 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,17 +1,16 @@
-
-
{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}
+
+
{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}
@if ($commentTree->empty() && userCan('comment-create-all')) -
-
@endif @@ -28,7 +27,8 @@ @if (!$commentTree->empty())
-
@endif diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php index a5a84b004..5f9f6d449 100644 --- a/resources/views/comments/create.blade.php +++ b/resources/views/comments/create.blade.php @@ -1,31 +1,29 @@ -