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
This commit is contained in:
Dan Brown 2020-07-28 18:19:18 +01:00
parent 2c0fdf83c1
commit 7590ecd37c
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
11 changed files with 192 additions and 124 deletions

View File

@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use Illuminate\Validation\ValidationException;
abstract class Controller extends BaseController abstract class Controller extends BaseController
{ {
@ -132,23 +133,6 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode); 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. * Create a response that forces a download in the browser.
* @param string $content * @param string $content

View File

@ -59,4 +59,41 @@ Will result with `this.$opts` being:
"delay": "500", "delay": "500",
"show": "" "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);
``` ```

View File

@ -1,16 +1,31 @@
import {scrollAndHighlightElement} from "../services/util"; import {scrollAndHighlightElement} from "../services/util";
/**
* @extends {Component}
*/
class PageComments { class PageComments {
constructor(elem) { setup() {
this.elem = elem; this.elem = this.$el;
this.pageId = Number(elem.getAttribute('page-id')); 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.editingComment = null;
this.parentId = null; this.parentId = null;
this.container = elem.querySelector('[comment-container]');
this.formContainer = elem.querySelector('[comment-form-container]');
if (this.formContainer) { if (this.formContainer) {
this.form = this.formContainer.querySelector('form'); this.form = this.formContainer.querySelector('form');
this.formInput = this.form.querySelector('textarea'); this.formInput = this.form.querySelector('textarea');
@ -32,13 +47,14 @@ class PageComments {
if (actionElem === null) return; if (actionElem === null) return;
event.preventDefault(); event.preventDefault();
let action = actionElem.getAttribute('action'); const action = actionElem.getAttribute('action');
if (action === 'edit') this.editComment(actionElem.closest('[comment]')); const comment = actionElem.closest('[comment]');
if (action === 'edit') this.editComment(comment);
if (action === 'closeUpdateForm') this.closeUpdateForm(); 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 === 'addComment') this.showForm();
if (action === 'hideForm') this.hideForm(); 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(); if (action === 'remove-reply-to') this.removeReplyTo();
} }
@ -69,14 +85,15 @@ class PageComments {
}; };
this.showLoading(form); this.showLoading(form);
let commentId = this.editingComment.getAttribute('comment'); 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'); let newComment = document.createElement('div');
newComment.innerHTML = resp.data; newComment.innerHTML = resp.data;
this.editingComment.innerHTML = newComment.children[0].innerHTML; 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); window.components.init(this.editingComment);
this.closeUpdateForm(); this.closeUpdateForm();
this.editingComment = null; this.editingComment = null;
}).catch(window.$events.showValidationErrors).then(() => {
this.hideLoading(form); this.hideLoading(form);
}); });
} }
@ -84,9 +101,9 @@ class PageComments {
deleteComment(commentElem) { deleteComment(commentElem) {
let id = commentElem.getAttribute('comment'); let id = commentElem.getAttribute('comment');
this.showLoading(commentElem.querySelector('[comment-content]')); 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); commentElem.parentNode.removeChild(commentElem);
window.$events.emit('success', window.trans('entities.comment_deleted_success')); window.$events.success(this.deletedText);
this.updateCount(); this.updateCount();
this.hideForm(); this.hideForm();
}); });
@ -101,21 +118,24 @@ class PageComments {
parent_id: this.parentId || null, parent_id: this.parentId || null,
}; };
this.showLoading(this.form); 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'); let newComment = document.createElement('div');
newComment.innerHTML = resp.data; newComment.innerHTML = resp.data;
let newElem = newComment.children[0]; let newElem = newComment.children[0];
this.container.appendChild(newElem); this.container.appendChild(newElem);
window.components.init(newElem); window.components.init(newElem);
window.$events.emit('success', window.trans('entities.comment_created_success')); window.$events.success(this.createdText);
this.resetForm(); this.resetForm();
this.updateCount(); this.updateCount();
}).catch(err => {
window.$events.showValidationErrors(err);
this.hideLoading(this.form);
}); });
} }
updateCount() { updateCount() {
let count = this.container.children.length; 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() { resetForm() {
@ -129,7 +149,7 @@ class PageComments {
showForm() { showForm() {
this.formContainer.style.display = 'block'; this.formContainer.style.display = 'block';
this.formContainer.parentNode.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.focus();
this.formInput.scrollIntoView({behavior: "smooth"}); this.formInput.scrollIntoView({behavior: "smooth"});
} }
@ -137,14 +157,12 @@ class PageComments {
hideForm() { hideForm() {
this.formContainer.style.display = 'none'; this.formContainer.style.display = 'none';
this.formContainer.parentNode.style.display = 'none'; this.formContainer.parentNode.style.display = 'none';
const addButtonContainer = this.elem.querySelector('[comment-add-button-container]');
if (this.getCommentCount() > 0) { if (this.getCommentCount() > 0) {
this.elem.appendChild(addButtonContainer) this.elem.appendChild(this.addButtonContainer)
} else { } else {
const countBar = this.elem.querySelector('[comment-count-bar]'); this.commentCountBar.appendChild(this.addButtonContainer);
countBar.appendChild(addButtonContainer);
} }
addButtonContainer.style.display = 'block'; this.addButtonContainer.style.display = 'block';
} }
getCommentCount() { getCommentCount() {
@ -154,15 +172,15 @@ class PageComments {
setReply(commentElem) { setReply(commentElem) {
this.showForm(); this.showForm();
this.parentId = Number(commentElem.getAttribute('local-id')); this.parentId = Number(commentElem.getAttribute('local-id'));
this.elem.querySelector('[comment-form-reply-to]').style.display = 'block'; this.replyToRow.style.display = 'block';
let replyLink = this.elem.querySelector('[comment-form-reply-to] a'); const replyLink = this.replyToRow.querySelector('a');
replyLink.textContent = `#${this.parentId}`; replyLink.textContent = `#${this.parentId}`;
replyLink.href = `#comment${this.parentId}`; replyLink.href = `#comment${this.parentId}`;
} }
removeReplyTo() { removeReplyTo() {
this.parentId = null; this.parentId = null;
this.elem.querySelector('[comment-form-reply-to]').style.display = 'none'; this.replyToRow.style.display = 'none';
} }
showLoading(formElem) { showLoading(formElem) {

View File

@ -7,11 +7,10 @@ window.baseUrl = function(path) {
}; };
// Set events and http services on window // Set events and http services on window
import Events from "./services/events" import events from "./services/events"
import httpInstance from "./services/http" import httpInstance from "./services/http"
const eventManager = new Events();
window.$http = httpInstance; window.$http = httpInstance;
window.$events = eventManager; window.$events = events;
// Translation setup // Translation setup
// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system // 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(); const translator = new Translations();
window.trans = translator.get.bind(translator); window.trans = translator.get.bind(translator);
window.trans_choice = translator.getPlural.bind(translator); window.trans_choice = translator.getPlural.bind(translator);
window.trans_plural = translator.parsePlural.bind(translator);
// Load Components // Load Components
import components from "./components" import components from "./components"

View 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 { function emit(eventName, eventData) {
constructor() { stack.push({name: eventName, data: eventData});
this.listeners = {}; if (typeof listeners[eventName] === 'undefined') return this;
this.stack = []; 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}
*/
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);
} }
} }
export default Events; /**
* 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,
}

View File

@ -69,7 +69,10 @@ async function dataRequest(method, url, data = null) {
// Send data as JSON if a plain object // Send data as JSON if a plain object
if (typeof data === 'object' && !(data instanceof FormData)) { 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); options.body = JSON.stringify(data);
} }

View File

@ -47,7 +47,19 @@ class Translator {
*/ */
getPlural(key, count, replacements) { getPlural(key, count, replacements) {
const text = this.getTransText(key); 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 exactCountRegex = /^{([0-9]+)}/;
const rangeRegex = /^\[([0-9]+),([0-9*]+)]/; const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
let result = null; let result = null;

View 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([ <div refs="page-comments@commentCountBar" class="grid half left-focus v-center no-row-gap">
'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">
<h5 comments-title>{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}</h5> <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')) @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" <button type="button" action="addComment"
class="button outline">{{ trans('entities.comment_add') }}</button> class="button outline">{{ trans('entities.comment_add') }}</button>
</div> </div>
@endif @endif
</div> </div>
<div class="comment-container" comment-container> <div refs="page-comments@commentContainer" class="comment-container">
@foreach($page->comments as $comment) @foreach($page->comments as $comment)
@include('comments.comment', ['comment' => $comment]) @include('comments.comment', ['comment' => $comment])
@endforeach @endforeach
@ -27,7 +27,7 @@
@include('comments.create') @include('comments.create')
@if (count($page->comments) > 0) @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" <button type="button" action="addComment"
class="button outline">{{ trans('entities.comment_add') }}</button> class="button outline">{{ trans('entities.comment_add') }}</button>
</div> </div>

View 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 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 class="grid left-focus v-center">
<div> <div>
{!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!} {!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!}
@ -10,7 +11,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="content px-s" comment-form-container>
<div refs="page-comments@formContainer" class="content px-s">
<form novalidate> <form novalidate>
<div class="form-group description-input"> <div class="form-group description-input">
<textarea name="markdown" rows="3" <textarea name="markdown" rows="3"
@ -26,4 +28,5 @@
</div> </div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -135,9 +135,9 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax'); Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
// Comments // Comments
Route::post('/ajax/page/{pageId}/comment', 'CommentController@savePageComment'); Route::post('/comment/{pageId}', 'CommentController@savePageComment');
Route::put('/ajax/comment/{id}', 'CommentController@update'); Route::put('/comment/{id}', 'CommentController@update');
Route::delete('/ajax/comment/{id}', 'CommentController@destroy'); Route::delete('/comment/{id}', 'CommentController@destroy');
// Links // Links
Route::get('/link/{id}', 'PageController@redirectFromLink'); Route::get('/link/{id}', 'PageController@redirectFromLink');

View File

@ -13,7 +13,7 @@ class CommentTest extends TestCase
$page = Page::first(); $page = Page::first();
$comment = factory(Comment::class)->make(['parent_id' => 2]); $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->assertStatus(200);
$resp->assertSee($comment->text); $resp->assertSee($comment->text);
@ -36,11 +36,11 @@ class CommentTest extends TestCase
$page = Page::first(); $page = Page::first();
$comment = factory(Comment::class)->make(); $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(); $comment = $page->comments()->first();
$newText = 'updated text content'; $newText = 'updated text content';
$resp = $this->putJson("/ajax/comment/$comment->id", [ $resp = $this->putJson("/comment/$comment->id", [
'text' => $newText, 'text' => $newText,
]); ]);
@ -60,11 +60,11 @@ class CommentTest extends TestCase
$page = Page::first(); $page = Page::first();
$comment = factory(Comment::class)->make(); $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(); $comment = $page->comments()->first();
$resp = $this->delete("/ajax/comment/$comment->id"); $resp = $this->delete("/comment/$comment->id");
$resp->assertStatus(200); $resp->assertStatus(200);
$this->assertDatabaseMissing('comments', [ $this->assertDatabaseMissing('comments', [
@ -75,7 +75,7 @@ class CommentTest extends TestCase
public function test_comments_converts_markdown_input_to_html() public function test_comments_converts_markdown_input_to_html()
{ {
$page = Page::first(); $page = Page::first();
$this->asAdmin()->postJson("/ajax/page/$page->id/comment", [ $this->asAdmin()->postJson("/comment/$page->id", [
'text' => '# My Title', 'text' => '# My Title',
]); ]);
@ -96,7 +96,7 @@ class CommentTest extends TestCase
$page = Page::first(); $page = Page::first();
$script = '<script>const a = "script";</script>\n\n# sometextinthecomment'; $script = '<script>const a = "script";</script>\n\n# sometextinthecomment';
$this->postJson("/ajax/page/$page->id/comment", [ $this->postJson("/comment/$page->id", [
'text' => $script, 'text' => $script,
]); ]);
@ -105,7 +105,7 @@ class CommentTest extends TestCase
$pageView->assertSee('sometextinthecomment'); $pageView->assertSee('sometextinthecomment');
$comment = $page->comments()->first(); $comment = $page->comments()->first();
$this->putJson("/ajax/comment/$comment->id", [ $this->putJson("/comment/$comment->id", [
'text' => $script . 'updated', 'text' => $script . 'updated',
]); ]);