mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
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:
parent
2c0fdf83c1
commit
7590ecd37c
@ -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
|
||||
|
@ -60,3 +60,40 @@ Will result with `this.$opts` being:
|
||||
"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);
|
||||
```
|
@ -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) {
|
||||
|
@ -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"
|
||||
|
@ -1,55 +1,66 @@
|
||||
/**
|
||||
* Simple global events manager
|
||||
*/
|
||||
class Events {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
this.stack = [];
|
||||
}
|
||||
const listeners = {};
|
||||
const 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];
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
*/
|
||||
emitPublic(targetElement, eventName, 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 Events;
|
||||
export default {
|
||||
emit,
|
||||
emitPublic,
|
||||
listen,
|
||||
success: (msg) => emit('success', msg),
|
||||
error: (msg) => emit('error', msg),
|
||||
showValidationErrors,
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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');
|
||||
|
@ -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',
|
||||
]);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user