- 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
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
+use Illuminate\Validation\ValidationException;
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
"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
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');
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();
}
};
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);
});
}
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();
});
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() {
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"});
}
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() {
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) {
};
// 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
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"
+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
// 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);
}
*/
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;
-<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
@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>
-<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>']) !!}
</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"
</div>
</form>
</div>
+
</div>
\ No newline at end of file
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');
$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);
$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,
]);
$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', [
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',
]);
$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,
]);
$pageView->assertSee('sometextinthecomment');
$comment = $page->comments()->first();
- $this->putJson("/ajax/comment/$comment->id", [
+ $this->putJson("/comment/$comment->id", [
'text' => $script . 'updated',
]);