From 7590ecd37c49b12fb6cf2ad251b1e65c1ea7d1ee Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 28 Jul 2020 18:19:18 +0100 Subject: [PATCH] Updated some comment elements and standardised more JS - 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 | 18 +--- dev/docs/components.md | 37 +++++++ resources/js/components/page-comments.js | 70 +++++++----- resources/js/index.js | 6 +- resources/js/services/events.js | 111 +++++++++++--------- resources/js/services/http.js | 5 +- resources/js/services/translations.js | 14 ++- resources/views/comments/comments.blade.php | 24 ++--- resources/views/comments/create.blade.php | 9 +- routes/web.php | 6 +- tests/Entity/CommentTest.php | 16 +-- 11 files changed, 192 insertions(+), 124 deletions(-) diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 2e8e8ed2e..6a1dfcb01 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -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 diff --git a/dev/docs/components.md b/dev/docs/components.md index ac0e929cd..832765dd6 100644 --- a/dev/docs/components.md +++ b/dev/docs/components.md @@ -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 diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 5d826cba1..c86eead1b 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -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) { diff --git a/resources/js/index.js b/resources/js/index.js index 913313603..ffdb54e19 100644 --- a/resources/js/index.js +++ b/resources/js/index.js @@ -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" diff --git a/resources/js/services/events.js b/resources/js/services/events.js index fa3ed7fdf..6668014e7 100644 --- a/resources/js/services/events.js +++ b/resources/js/services/events.js @@ -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 = []; - } - - /** - * 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} - */ - 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 - */ - emitPublic(targetElement, eventName, eventData) { - const event = new CustomEvent(eventName, { - detail: eventData, - bubbles: true - }); - targetElement.dispatchEvent(event); +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); } } -export default Events; \ No newline at end of file +/** + * 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); +} + +/** + * 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); +} + +/** + * 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 { + emit, + emitPublic, + listen, + success: (msg) => emit('success', msg), + error: (msg) => emit('error', msg), + showValidationErrors, +} \ No newline at end of file diff --git a/resources/js/services/http.js b/resources/js/services/http.js index 5b5e1c496..8ecd6c109 100644 --- a/resources/js/services/http.js +++ b/resources/js/services/http.js @@ -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); } diff --git a/resources/js/services/translations.js b/resources/js/services/translations.js index b595a05e6..62bb51f56 100644 --- a/resources/js/services/translations.js +++ b/resources/js/services/translations.js @@ -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; diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index fc81f13ee..140d0d027 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -1,23 +1,23 @@ -
+
- @exposeTranslations([ - 'entities.comment_updated_success', - 'entities.comment_deleted_success', - 'entities.comment_created_success', - 'entities.comment_count', - ]) - -
+
{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}
@if (count($page->comments) === 0 && userCan('comment-create-all')) -
+
@endif
-
+
@foreach($page->comments as $comment) @include('comments.comment', ['comment' => $comment]) @endforeach @@ -27,7 +27,7 @@ @include('comments.create') @if (count($page->comments) > 0) -
+
diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php index 61e41a354..12216b95b 100644 --- a/resources/views/comments/create.blade.php +++ b/resources/views/comments/create.blade.php @@ -1,6 +1,7 @@ -