]> BookStack Code Mirror - bookstack/commitdiff
Updated some comment elements and standardised more JS
authorDan Brown <redacted>
Tue, 28 Jul 2020 17:19:18 +0000 (18:19 +0100)
committerDan Brown <redacted>
Tue, 28 Jul 2020 17:19:18 +0000 (18:19 +0100)
- Updated comment routes to be simpler.
- Updated comments JS to align better with updated component system.
- Documented available global JS functions/services.
- Removed redundant controller method.
- Added window.$events helpers for validation messages and
success/error.
- Updated JS events system to not be class based for simplicity.
- Added window.trans_plural method to handle pluralisation/replacements
where you already have the translation string itself.

Fixes #1836

app/Http/Controllers/Controller.php
dev/docs/components.md
resources/js/components/page-comments.js
resources/js/index.js
resources/js/services/events.js
resources/js/services/http.js
resources/js/services/translations.js
resources/views/comments/comments.blade.php
resources/views/comments/create.blade.php
routes/web.php
tests/Entity/CommentTest.php

index 2e8e8ed2ee12b005d58f9aab0492ef99dae0a56f..6a1dfcb0140062d0fcabcffe9174f226f1cacc48 100644 (file)
@@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\Exceptions\HttpResponseException;
 use Illuminate\Http\Request;
 use Illuminate\Routing\Controller as BaseController;
+use Illuminate\Validation\ValidationException;
 
 abstract class Controller extends BaseController
 {
@@ -132,23 +133,6 @@ abstract class Controller extends BaseController
         return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
     }
 
-    /**
-     * Create the response for when a request fails validation.
-     * @param  \Illuminate\Http\Request  $request
-     * @param  array  $errors
-     * @return \Symfony\Component\HttpFoundation\Response
-     */
-    protected function buildFailedValidationResponse(Request $request, array $errors)
-    {
-        if ($request->expectsJson()) {
-            return response()->json(['validation' => $errors], 422);
-        }
-
-        return redirect()->to($this->getRedirectUrl())
-            ->withInput($request->input())
-            ->withErrors($errors, $this->errorBag());
-    }
-
     /**
      * Create a response that forces a download in the browser.
      * @param string $content
index ac0e929cdfe416a4454abf6ffa02929232a15c79..832765dd6a3ef9919d97bb355a488c5fb73d0970 100644 (file)
@@ -59,4 +59,41 @@ Will result with `this.$opts` being:
     "delay": "500",
     "show": ""  
 }
+```
+
+#### Global Helpers
+
+There are various global helper libraries which can be used in components:
+
+```js
+// HTTP service
+window.$http.get(url, params);
+window.$http.post(url, data);
+window.$http.put(url, data);
+window.$http.delete(url, data);
+window.$http.patch(url, data);
+
+// Global event system
+// Emit a global event
+window.$events.emit(eventName, eventData);
+// Listen to a global event
+window.$events.listen(eventName, callback);
+// Show a success message
+window.$events.success(message);
+// Show an error message
+window.$events.error(message);
+// Show validation errors, if existing, as an error notification
+window.$events.showValidationErrors(error);
+
+// Translator
+// Take the given plural text and count to decide on what plural option
+// to use, Similar to laravel's trans_choice function but instead
+// takes the direction directly instead of a translation key.
+window.trans_plural(translationString, count, replacements);
+
+// Component System
+// Parse and initialise any components from the given root el down.
+window.components.init(rootEl);
+// Get the first active component of the given name
+window.components.first(name);
 ```
\ No newline at end of file
index 5d826cba13d1bb12c0e95e4ba2020db66d487985..c86eead1b865bd8bdaa8184f44bd4ab55a961d7b 100644 (file)
@@ -1,16 +1,31 @@
 import {scrollAndHighlightElement} from "../services/util";
 
+/**
+ * @extends {Component}
+ */
 class PageComments {
 
-    constructor(elem) {
-        this.elem = elem;
-        this.pageId = Number(elem.getAttribute('page-id'));
+    setup() {
+        this.elem = this.$el;
+        this.pageId = Number(this.$opts.pageId);
+
+        // Element references
+        this.container = this.$refs.commentContainer;
+        this.formContainer = this.$refs.formContainer;
+        this.commentCountBar = this.$refs.commentCountBar;
+        this.addButtonContainer = this.$refs.addButtonContainer;
+        this.replyToRow = this.$refs.replyToRow;
+
+        // 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;
 
-        this.container = elem.querySelector('[comment-container]');
-        this.formContainer = elem.querySelector('[comment-form-container]');
-
         if (this.formContainer) {
             this.form = this.formContainer.querySelector('form');
             this.formInput = this.form.querySelector('textarea');
@@ -32,13 +47,14 @@ class PageComments {
         if (actionElem === null) return;
         event.preventDefault();
 
-        let action = actionElem.getAttribute('action');
-        if (action === 'edit') this.editComment(actionElem.closest('[comment]'));
+        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(actionElem.closest('[comment]'));
+        if (action === 'delete') this.deleteComment(comment);
         if (action === 'addComment') this.showForm();
         if (action === 'hideForm') this.hideForm();
-        if (action === 'reply') this.setReply(actionElem.closest('[comment]'));
+        if (action === 'reply') this.setReply(comment);
         if (action === 'remove-reply-to') this.removeReplyTo();
     }
 
@@ -69,14 +85,15 @@ class PageComments {
         };
         this.showLoading(form);
         let commentId = this.editingComment.getAttribute('comment');
-        window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
+        window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
             let newComment = document.createElement('div');
             newComment.innerHTML = resp.data;
             this.editingComment.innerHTML = newComment.children[0].innerHTML;
-            window.$events.emit('success', window.trans('entities.comment_updated_success'));
+            window.$events.success(this.updatedText);
             window.components.init(this.editingComment);
             this.closeUpdateForm();
             this.editingComment = null;
+        }).catch(window.$events.showValidationErrors).then(() => {
             this.hideLoading(form);
         });
     }
@@ -84,9 +101,9 @@ class PageComments {
     deleteComment(commentElem) {
         let id = commentElem.getAttribute('comment');
         this.showLoading(commentElem.querySelector('[comment-content]'));
-        window.$http.delete(`/ajax/comment/${id}`).then(resp => {
+        window.$http.delete(`/comment/${id}`).then(resp => {
             commentElem.parentNode.removeChild(commentElem);
-            window.$events.emit('success', window.trans('entities.comment_deleted_success'));
+            window.$events.success(this.deletedText);
             this.updateCount();
             this.hideForm();
         });
@@ -101,21 +118,24 @@ class PageComments {
             parent_id: this.parentId || null,
         };
         this.showLoading(this.form);
-        window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
+        window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
             let newComment = document.createElement('div');
             newComment.innerHTML = resp.data;
             let newElem = newComment.children[0];
             this.container.appendChild(newElem);
             window.components.init(newElem);
-            window.$events.emit('success', window.trans('entities.comment_created_success'));
+            window.$events.success(this.createdText);
             this.resetForm();
             this.updateCount();
+        }).catch(err => {
+            window.$events.showValidationErrors(err);
+            this.hideLoading(this.form);
         });
     }
 
     updateCount() {
         let count = this.container.children.length;
-        this.elem.querySelector('[comments-title]').textContent = window.trans_choice('entities.comment_count', count, {count});
+        this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
     }
 
     resetForm() {
@@ -129,7 +149,7 @@ class PageComments {
     showForm() {
         this.formContainer.style.display = 'block';
         this.formContainer.parentNode.style.display = 'block';
-        this.elem.querySelector('[comment-add-button-container]').style.display = 'none';
+        this.addButtonContainer.style.display = 'none';
         this.formInput.focus();
         this.formInput.scrollIntoView({behavior: "smooth"});
     }
@@ -137,14 +157,12 @@ class PageComments {
     hideForm() {
         this.formContainer.style.display = 'none';
         this.formContainer.parentNode.style.display = 'none';
-        const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
         if (this.getCommentCount() > 0) {
-            this.elem.appendChild(addButtonContainer)
+            this.elem.appendChild(this.addButtonContainer)
         } else {
-            const countBar = this.elem.querySelector('[comment-count-bar]');
-            countBar.appendChild(addButtonContainer);
+            this.commentCountBar.appendChild(this.addButtonContainer);
         }
-        addButtonContainer.style.display = 'block';
+        this.addButtonContainer.style.display = 'block';
     }
 
     getCommentCount() {
@@ -154,15 +172,15 @@ class PageComments {
     setReply(commentElem) {
         this.showForm();
         this.parentId = Number(commentElem.getAttribute('local-id'));
-        this.elem.querySelector('[comment-form-reply-to]').style.display = 'block';
-        let replyLink = this.elem.querySelector('[comment-form-reply-to] a');
+        this.replyToRow.style.display = 'block';
+        const replyLink = this.replyToRow.querySelector('a');
         replyLink.textContent = `#${this.parentId}`;
         replyLink.href = `#comment${this.parentId}`;
     }
 
     removeReplyTo() {
         this.parentId = null;
-        this.elem.querySelector('[comment-form-reply-to]').style.display = 'none';
+        this.replyToRow.style.display = 'none';
     }
 
     showLoading(formElem) {
index 91331360398458a27e65352f7157a11d9406fa5e..ffdb54e191e1557b7c3cbb6e9f6b4402913c40b9 100644 (file)
@@ -7,11 +7,10 @@ window.baseUrl = function(path) {
 };
 
 // Set events and http services on window
-import Events from "./services/events"
+import events from "./services/events"
 import httpInstance from "./services/http"
-const eventManager = new Events();
 window.$http = httpInstance;
-window.$events = eventManager;
+window.$events = events;
 
 // Translation setup
 // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
@@ -19,6 +18,7 @@ import Translations from "./services/translations"
 const translator = new Translations();
 window.trans = translator.get.bind(translator);
 window.trans_choice = translator.getPlural.bind(translator);
+window.trans_plural = translator.parsePlural.bind(translator);
 
 // Load Components
 import components from "./components"
index fa3ed7fdfcb55ebd341ee44543eafd58b697d14d..6668014e7b6913ca4fbee93fe11f69307ada3349 100644 (file)
@@ -1,55 +1,66 @@
+const listeners = {};
+const stack = [];
+
 /**
- * Simple global events manager
+ * Emit a custom event for any handlers to pick-up.
+ * @param {String} eventName
+ * @param {*} eventData
  */
-class Events {
-    constructor() {
-        this.listeners = {};
-        this.stack = [];
+function emit(eventName, eventData) {
+    stack.push({name: eventName, data: eventData});
+    if (typeof listeners[eventName] === 'undefined') return this;
+    let eventsToStart = listeners[eventName];
+    for (let i = 0; i < eventsToStart.length; i++) {
+        let event = eventsToStart[i];
+        event(eventData);
     }
+}
 
-    /**
-     * Emit a custom event for any handlers to pick-up.
-     * @param {String} eventName
-     * @param {*} eventData
-     * @returns {Events}
-     */
-    emit(eventName, eventData) {
-        this.stack.push({name: eventName, data: eventData});
-        if (typeof this.listeners[eventName] === 'undefined') return this;
-        let eventsToStart = this.listeners[eventName];
-        for (let i = 0; i < eventsToStart.length; i++) {
-            let event = eventsToStart[i];
-            event(eventData);
-        }
-        return this;
-    }
+/**
+ * Listen to a custom event and run the given callback when that event occurs.
+ * @param {String} eventName
+ * @param {Function} callback
+ * @returns {Events}
+ */
+function listen(eventName, callback) {
+    if (typeof listeners[eventName] === 'undefined') listeners[eventName] = [];
+    listeners[eventName].push(callback);
+}
 
-    /**
-     * Listen to a custom event and run the given callback when that event occurs.
-     * @param {String} eventName
-     * @param {Function} callback
-     * @returns {Events}
-     */
-    listen(eventName, callback) {
-        if (typeof this.listeners[eventName] === 'undefined') this.listeners[eventName] = [];
-        this.listeners[eventName].push(callback);
-        return this;
-    }
+/**
+ * Emit an event for public use.
+ * Sends the event via the native DOM event handling system.
+ * @param {Element} targetElement
+ * @param {String} eventName
+ * @param {Object} eventData
+ */
+function emitPublic(targetElement, eventName, eventData) {
+    const event = new CustomEvent(eventName, {
+        detail: eventData,
+        bubbles: true
+    });
+    targetElement.dispatchEvent(event);
+}
 
-    /**
-     * Emit an event for public use.
-     * Sends the event via the native DOM event handling system.
-     * @param {Element} targetElement
-     * @param {String} eventName
-     * @param {Object} eventData
-     */
-    emitPublic(targetElement, eventName, eventData) {
-        const event = new CustomEvent(eventName, {
-            detail: eventData,
-            bubbles: true
-        });
-        targetElement.dispatchEvent(event);
+/**
+ * Notify of a http error.
+ * Check for standard scenarios such as validation errors and
+ * formats an error notification accordingly.
+ * @param {Error} error
+ */
+function showValidationErrors(error) {
+    if (!error.status) return;
+    if (error.status === 422 && error.data) {
+        const message = Object.values(error.data).flat().join('\n');
+        emit('error', message);
     }
 }
 
-export default Events;
\ No newline at end of file
+export default {
+    emit,
+    emitPublic,
+    listen,
+    success: (msg) => emit('success', msg),
+    error: (msg) => emit('error', msg),
+    showValidationErrors,
+}
\ No newline at end of file
index 5b5e1c4960bd197e9095be6d4346fe2d22256bfd..8ecd6c109168d26a48024c3dbb847b130e3d1592 100644 (file)
@@ -69,7 +69,10 @@ async function dataRequest(method, url, data = null) {
 
     // Send data as JSON if a plain object
     if (typeof data === 'object' && !(data instanceof FormData)) {
-        options.headers = {'Content-Type': 'application/json'};
+        options.headers = {
+            'Content-Type': 'application/json',
+            'X-Requested-With': 'XMLHttpRequest',
+        };
         options.body = JSON.stringify(data);
     }
 
index b595a05e6f95b8e9a2d7862adbc5b5d003f6e163..62bb51f56aacb5f0216e0e4621ffdfcae0d34481 100644 (file)
@@ -47,7 +47,19 @@ class Translator {
      */
     getPlural(key, count, replacements) {
         const text = this.getTransText(key);
-        const splitText = text.split('|');
+        return this.parsePlural(text, count, replacements);
+    }
+
+    /**
+     * Parse the given translation and find the correct plural option
+     * to use. Similar format at laravel's 'trans_choice' helper.
+     * @param {String} translation
+     * @param {Number} count
+     * @param {Object} replacements
+     * @returns {String}
+     */
+    parsePlural(translation, count, replacements) {
+        const splitText = translation.split('|');
         const exactCountRegex = /^{([0-9]+)}/;
         const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
         let result = null;
index fc81f13ee73053e4c4e208f64cc64e9eb8183826..140d0d027d7ad1996a6795bec9cb61a9621769d9 100644 (file)
@@ -1,23 +1,23 @@
-<section page-comments page-id="{{ $page->id }}" class="comments-list" aria-label="{{ trans('entities.comments') }}">
+<section component="page-comments"
+         option:page-comments:page-id="{{ $page->id }}"
+         option:page-comments:updated-text="{{ trans('entities.comment_updated_success') }}"
+         option:page-comments:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+         option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
+         option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+         class="comments-list"
+         aria-label="{{ trans('entities.comments') }}">
 
-    @exposeTranslations([
-        'entities.comment_updated_success',
-        'entities.comment_deleted_success',
-        'entities.comment_created_success',
-        'entities.comment_count',
-    ])
-
-    <div comment-count-bar class="grid half left-focus v-center no-row-gap">
+    <div refs="page-comments@commentCountBar" class="grid half left-focus v-center no-row-gap">
         <h5 comments-title>{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}</h5>
         @if (count($page->comments) === 0 && userCan('comment-create-all'))
-            <div class="text-m-right" comment-add-button-container>
+            <div class="text-m-right" refs="page-comments@addButtonContainer">
                 <button type="button" action="addComment"
                         class="button outline">{{ trans('entities.comment_add') }}</button>
             </div>
         @endif
     </div>
 
-    <div class="comment-container" comment-container>
+    <div refs="page-comments@commentContainer" class="comment-container">
         @foreach($page->comments as $comment)
             @include('comments.comment', ['comment' => $comment])
         @endforeach
@@ -27,7 +27,7 @@
         @include('comments.create')
 
         @if (count($page->comments) > 0)
-            <div class="text-right" comment-add-button-container>
+            <div refs="page-comments@addButtonContainer" class="text-right">
                 <button type="button" action="addComment"
                         class="button outline">{{ trans('entities.comment_add') }}</button>
             </div>
index 61e41a354fab3214883296238e9ee55ac7c9a130..12216b95b9bdb1f5ef33bfc3403094773b5aa73b 100644 (file)
@@ -1,6 +1,7 @@
-<div class="comment-box" comment-box style="display:none;">
+<div class="comment-box" style="display:none;">
+
     <div class="header p-s">{{ trans('entities.comment_new') }}</div>
-    <div comment-form-reply-to class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
+    <div refs="page-comments@replyToRow" class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
         <div class="grid left-focus v-center">
             <div>
                 {!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!}
@@ -10,7 +11,8 @@
             </div>
         </div>
     </div>
-    <div class="content px-s" comment-form-container>
+
+    <div refs="page-comments@formContainer" class="content px-s">
         <form novalidate>
             <div class="form-group description-input">
                         <textarea name="markdown" rows="3"
@@ -26,4 +28,5 @@
             </div>
         </form>
     </div>
+
 </div>
\ No newline at end of file
index fb586c1cbc81b4d2e2f0a7653eb6aa8fadaef4b4..d962c432406e5d07eee6f80b6a3ce4f28b7e8293 100644 (file)
@@ -135,9 +135,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
 
     // Comments
-    Route::post('/ajax/page/{pageId}/comment', 'CommentController@savePageComment');
-    Route::put('/ajax/comment/{id}', 'CommentController@update');
-    Route::delete('/ajax/comment/{id}', 'CommentController@destroy');
+    Route::post('/comment/{pageId}', 'CommentController@savePageComment');
+    Route::put('/comment/{id}', 'CommentController@update');
+    Route::delete('/comment/{id}', 'CommentController@destroy');
 
     // Links
     Route::get('/link/{id}', 'PageController@redirectFromLink');
index 2562f7e7de9e7aabfe5add97647b1dcee117064e..2198b2dd2c72decb348bd6421d8f664b52c3e793 100644 (file)
@@ -13,7 +13,7 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $comment = factory(Comment::class)->make(['parent_id' => 2]);
-        $resp = $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $resp->assertStatus(200);
         $resp->assertSee($comment->text);
@@ -36,11 +36,11 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $comment = factory(Comment::class)->make();
-        $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
         $newText = 'updated text content';
-        $resp = $this->putJson("/ajax/comment/$comment->id", [
+        $resp = $this->putJson("/comment/$comment->id", [
             'text' => $newText,
         ]);
 
@@ -60,11 +60,11 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $comment = factory(Comment::class)->make();
-        $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes());
+        $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
 
-        $resp = $this->delete("/ajax/comment/$comment->id");
+        $resp = $this->delete("/comment/$comment->id");
         $resp->assertStatus(200);
 
         $this->assertDatabaseMissing('comments', [
@@ -75,7 +75,7 @@ class CommentTest extends TestCase
     public function test_comments_converts_markdown_input_to_html()
     {
         $page = Page::first();
-        $this->asAdmin()->postJson("/ajax/page/$page->id/comment", [
+        $this->asAdmin()->postJson("/comment/$page->id", [
             'text' => '# My Title',
         ]);
 
@@ -96,7 +96,7 @@ class CommentTest extends TestCase
         $page = Page::first();
 
         $script = '<script>const a = "script";</script>\n\n# sometextinthecomment';
-        $this->postJson("/ajax/page/$page->id/comment", [
+        $this->postJson("/comment/$page->id", [
             'text' => $script,
         ]);
 
@@ -105,7 +105,7 @@ class CommentTest extends TestCase
         $pageView->assertSee('sometextinthecomment');
 
         $comment = $page->comments()->first();
-        $this->putJson("/ajax/comment/$comment->id", [
+        $this->putJson("/comment/$comment->id", [
             'text' => $script . 'updated',
         ]);