Feature/field comment frontend (#6530)

* Initial working version of comment frontend in edit view

* Make comment js text translatable

* Add comment icon

* Basic hardcoded comment adding widget

* Create widget object and register it with the comment app to subscribe to updates about related annotations and whether comments are shown

* Add data-contentpath attributes to field (and data-contentpath-disabled to fields which prevent a stable contentpath existing at this point - ie ListBlock block positions are not uniquely identified), and to ensure newly generated streamfield blocks also have a stable contentpath identifiable from the frontend, make streamfield uuids generate clientside

* Make comments detect new contentpaths, and move hardcoded comment widget on chooser template into js initialisation, also making new comment buttons init properly in new streamfield blocks

* Fix tests to expect contentpaths

* Remove two step comment widget initialisation, and replace with stored callbacks for widgets that try to initialise themselves before the comment app itself. Refactor widgets to receive the makeComment function directly from the commenting system via an onRegister method to accommodate this

* Use object argument instead of positional for FieldLevelCommentWidget constructor

* Use json_script to pass author to the comments system
pull/7050/head
Jacob Topp-Mugglestone 2020-12-04 11:13:45 +00:00 zatwierdzone przez Matt Westcott
rodzic ab3c8d7d3d
commit 2ab917bc92
25 zmienionych plików z 246 dodań i 6 usunięć

5
client/package-lock.json wygenerowano 100644
Wyświetl plik

@ -0,0 +1,5 @@
{
"name": "wagtail-client",
"version": "0.1.0",
"lockfileVersion": 1
}

Wyświetl plik

@ -0,0 +1,136 @@
import { initCommentsApp } from 'wagtail-comment-frontend';
import { STRINGS } from '../../config/wagtailConfig';
function initComments(initialComments) {
// in case any widgets try to initialise themselves before the comment app,
// store their initialisations as callbacks to be executed when the comment app
// itself is finished initialising.
const callbacks = [];
window.commentApp = {
registerWidget: (widget) => {
callbacks.push(() => { window.commentApp.registerWidget(widget); });
}
};
document.addEventListener('DOMContentLoaded', () => {
const commentsElement = document.getElementById('comments');
const author = JSON.parse(document.getElementById('comments-author').textContent);
if (commentsElement && author) {
window.commentApp = initCommentsApp(commentsElement, author, initialComments, STRINGS);
callbacks.forEach((callback) => { callback(); });
}
});
}
function getContentPath(fieldNode) {
// Return the total contentpath for an element as a string, in the form field.streamfield_uid.block...
if (fieldNode.closest('data-contentpath-disabled')) {
return '';
}
let element = fieldNode.closest('[data-contentpath]');
const contentPaths = [];
while (element !== null) {
contentPaths.push(element.dataset.contentpath);
element = element.parentElement.closest('[data-contentpath]');
}
contentPaths.reverse();
return contentPaths.join('.');
}
class BasicFieldLevelAnnotation {
constructor(fieldNode, node) {
this.node = node;
this.fieldNode = fieldNode;
this.position = '';
}
onDelete() {
this.node.remove();
}
onFocus() {
this.node.classList.remove('button-secondary');
this.node.ariaLabel = STRINGS.UNFOCUS_COMMENT;
}
onUnfocus() {
this.node.classList.add('button-secondary');
this.node.ariaLabel = STRINGS.UNFOCUS_COMMENT;
// TODO: ensure comment is focused accessibly when this is clicked,
// and that screenreader users can return to the annotation point when desired
}
show() {
this.node.classList.remove('u-hidden');
}
hide() {
this.node.classList.add('u-hidden');
}
setOnClickHandler(handler) {
this.node.addEventListener('click', handler);
}
getDesiredPosition() {
return (
this.fieldNode.getBoundingClientRect().top +
document.documentElement.scrollTop
);
}
}
class FieldLevelCommentWidget {
constructor({
fieldNode,
commentAdditionNode,
annotationTemplateNode
}) {
this.fieldNode = fieldNode;
this.contentPath = getContentPath(fieldNode);
this.commentAdditionNode = commentAdditionNode;
this.annotationTemplateNode = annotationTemplateNode;
this.commentNumber = 0;
this.commentsEnabled = false;
}
onRegister(makeComment) {
this.commentAdditionNode.addEventListener('click', () => {
makeComment(this.getAnnotationForComment(), this.contentPath);
});
}
setEnabled(enabled) {
// Update whether comments are enabled for the page
this.commentsEnabled = enabled;
this.updateVisibility();
}
onChangeComments(comments) {
// Receives a list of comments for the widget's contentpath
this.commentNumber = comments.length;
this.updateVisibility();
}
updateVisibility() {
// if comments are disabled, or the widget already has at least one associated comment,
// don't show the comment addition button
if (!this.commentsEnabled || this.commentNumber > 0) {
this.commentAdditionNode.classList.add('u-hidden');
} else {
this.commentAdditionNode.classList.remove('u-hidden');
}
}
getAnnotationForComment() {
const annotationNode = this.annotationTemplateNode.cloneNode(true);
annotationNode.id = '';
this.commentAdditionNode.insertAdjacentElement('afterend', annotationNode);
return new BasicFieldLevelAnnotation(this.fieldNode, annotationNode);
}
}
function initFieldLevelCommentWidget(fieldElement) {
const widget = new FieldLevelCommentWidget({
fieldNode: fieldElement,
commentAdditionNode: fieldElement.querySelector('[data-comment-add]'),
annotationTemplateNode: document.querySelector('#comment-icon')
});
if (widget.contentPath) {
window.commentApp.registerWidget(widget);
}
}
export default {
initComments,
FieldLevelCommentWidget,
initFieldLevelCommentWidget
};

Wyświetl plik

@ -0,0 +1,7 @@
import comments from '../../components/CommentApp/comments';
/**
* Entry point loaded when the comments system is in use.
*/
// Expose module as a global.
window.comments = comments;

Wyświetl plik

@ -0,0 +1,7 @@
require('./comments');
describe('comments', () => {
it('exposes module as global', () => {
expect(window.comments).toBeDefined();
});
});

Wyświetl plik

@ -94,6 +94,10 @@ function createPageChooser(id, openAtParentId, options) {
chooser.clear();
});
if (window.comments) {
window.comments.initFieldLevelCommentWidget(chooserElement[0]);
}
return chooser;
}
window.createPageChooser = createPageChooser;

Wyświetl plik

@ -80,6 +80,10 @@ function createDocumentChooser(id) {
chooser.clear();
});
if (window.comments) {
window.comments.initFieldLevelCommentWidget(chooserElement[0]);
}
return chooser;
}
window.createDocumentChooser = createDocumentChooser;

Wyświetl plik

@ -93,6 +93,10 @@ function createImageChooser(id) {
chooser.clear();
});
if (window.comments) {
window.comments.initFieldLevelCommentWidget(chooserElement[0]);
}
return chooser;
}
window.createImageChooser = createImageChooser;

Wyświetl plik

@ -80,6 +80,10 @@ function createSnippetChooser(id, modelString) {
chooser.clear();
});
if (window.comments) {
window.comments.initFieldLevelCommentWidget(chooserElement[0]);
}
return chooser;
}
window.createSnippetChooser = createSnippetChooser;

Wyświetl plik

@ -47,6 +47,19 @@ global.wagtailConfig = {
EDIT_PAGE: 'Edit \'{title}\'',
VIEW_CHILD_PAGES_OF_PAGE: 'View child pages of \'{title}\'',
PAGE_EXPLORER: 'Page explorer',
SAVE: 'Save',
SAVING: 'Saving...',
CANCEL: 'Cancel',
DELETING: 'Deleting...',
SHOW_RESOLVED_COMMENTS: 'Show resolved comments',
SHOW_COMMENTS: 'Show comments',
REPLY: 'Reply',
RETRY: 'Retry',
DELETE_ERROR: 'Delete error',
CONFIRM_DELETE_COMMENT: 'Are you sure?',
SAVE_ERROR: 'Save error',
FOCUS_COMMENT: 'Focus comment',
UNFOCUS_COMMENT: 'Unfocus comment'
},
WAGTAIL_I18N_ENABLED: true,
LOCALES: [

Wyświetl plik

@ -26,6 +26,7 @@ const exposedDependencies = {
module.exports = function exports() {
const entrypoints = {
'admin': [
'comments',
'core',
'date-time-chooser',
'draftail',
@ -123,7 +124,10 @@ module.exports = function exports() {
{
loader: 'expose-loader',
options: {
exposes: globalName,
exposes: {
globalName,
override: true
}
},
},
],

13
package-lock.json wygenerowano
Wyświetl plik

@ -13120,6 +13120,11 @@
"integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=",
"dev": true
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
},
"resolve": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.18.1.tgz",
@ -15991,6 +15996,14 @@
"xml-name-validator": "^3.0.0"
}
},
"wagtail-comment-frontend": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/wagtail-comment-frontend/-/wagtail-comment-frontend-0.0.6.tgz",
"integrity": "sha512-qdkNNK9YtEP/JOZmB2TMCag/Fq4u66OZKRsgs7zGJozbShtT+a+CMtO7lpLjRnmez6+33KCzdkaOJ1dfTXeLsg==",
"requires": {
"reselect": "^4.0.0"
}
},
"walker": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",

Wyświetl plik

@ -100,6 +100,7 @@
"redux": "^4.0.0",
"redux-thunk": "^2.3.0",
"telepath-unpack": "^0.0.3",
"wagtail-comment-frontend": "0.0.6",
"whatwg-fetch": "^2.0.3"
},
"scripts": {

Wyświetl plik

@ -79,6 +79,19 @@ def get_js_translation_strings():
'EDIT_PAGE': _('Edit \'{title}\''),
'VIEW_CHILD_PAGES_OF_PAGE': _('View child pages of \'{title}\''),
'PAGE_EXPLORER': _('Page explorer'),
'SAVE': _('Save'),
'SAVING': _('Saving...'),
'CANCEL': _('Cancel'),
'DELETING': _('Deleting...'),
'SHOW_RESOLVED_COMMENTS': _('Show resolved comments'),
'SHOW_COMMENTS': _('Show comments'),
'REPLY': _('Reply'),
'RETRY': _('Retry'),
'DELETE_ERROR': _('Delete error'),
'CONFIRM_DELETE_COMMENT': _('Are you sure?'),
'SAVE_ERROR': _('Save error'),
'FOCUS_COMMENT': _('Focus comment'),
'UNFOCUS_COMMENT': _('Unfocus comment'),
'MONTHS': [str(m) for m in MONTHS.values()],

Wyświetl plik

@ -0,0 +1,3 @@
// uuid (https://github.com/uuidjs/uuid) @version 8.3.1
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).uuidv4=e()}(this,(function(){"use strict";var t="undefined"!=typeof crypto&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)||"undefined"!=typeof msCrypto&&"function"==typeof msCrypto.getRandomValues&&msCrypto.getRandomValues.bind(msCrypto),e=new Uint8Array(16);function o(){if(!t)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return t(e)}var n=/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;function r(t){return"string"==typeof t&&n.test(t)}for(var i=[],u=0;u<256;++u)i.push((u+256).toString(16).substr(1));return function(t,e,n){var u=(t=t||{}).random||(t.rng||o)();if(u[6]=15&u[6]|64,u[8]=63&u[8]|128,e){n=n||0;for(var f=0;f<16;++f)e[n+f]=u[f];return e}return function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,o=(i[t[e+0]]+i[t[e+1]]+i[t[e+2]]+i[t[e+3]]+"-"+i[t[e+4]]+i[t[e+5]]+"-"+i[t[e+6]]+i[t[e+7]]+"-"+i[t[e+8]]+i[t[e+9]]+"-"+i[t[e+10]]+i[t[e+11]]+i[t[e+12]]+i[t[e+13]]+i[t[e+14]]+i[t[e+15]]).toLowerCase();if(!r(o))throw TypeError("Stringified UUID is invalid");return o}(u)}}));

Wyświetl plik

@ -1,5 +1,5 @@
{% load i18n wagtailadmin_tags %}
<li data-inline-panel-child id="inline_child_{{ child.form.prefix }}">
<li data-inline-panel-child id="inline_child_{{ child.form.prefix }}" data-contentpath-disabled>
{% if child.form.non_field_errors %}
<ul>
{% for error in child.form.non_field_errors %}

Wyświetl plik

@ -0,0 +1,3 @@
<symbol id="icon-comment" viewBox="0 0 512 512">
<path d="M448 0H64C28.7 0 0 28.7 0 64v288c0 35.3 28.7 64 64 64h96v84c0 9.8 11.2 15.5 19.1 9.7L304 416h144c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64z"/>
</symbol>

Wyświetl plik

@ -27,5 +27,6 @@
<script src="{% versioned_static 'wagtailadmin/js/workflow-status.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/lock-unlock-action.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/vendor/bootstrap-tooltip.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/comments.js' %}"></script>
{% hook_output 'insert_editor_js' %}

Wyświetl plik

@ -7,6 +7,9 @@
{% block content %}
{% page_permissions page as page_perms %}
<div id="comments">
</div>
<header class="merged tab-merged">
{% explorer_breadcrumb page %}
@ -113,6 +116,8 @@
Additional HTML code that edit handlers define through 'html_declarations'. (Technically this isn't JavaScript, but it will generally be data that exists for JavaScript to work with...)
{% endcomment %}
{{ edit_handler.html_declarations }}
{{ comments_author|json_script:"comments-author" }}
<button type="button" id="comment-icon" data-annotation class="button button-secondary button-small u-hidden">{% icon name='comment' class_name='initial' %}</button>
<script>
$(function() {
@ -174,4 +179,7 @@
LockUnlockAction('{{ csrf_token|escapejs }}', '{% url 'wagtailadmin_pages:edit' page.id %}');
});
</script>
<script>
window.comments.initComments([]);
</script>
{% endblock %}

Wyświetl plik

@ -1,5 +1,5 @@
{% load wagtailadmin_tags %}
<div class="field {{ field|fieldtype }} {{ field|widgettype }} {{ field_classes }}">
<div class="field {{ field|fieldtype }} {{ field|widgettype }} {{ field_classes }}" data-contentpath="{{ field.name }}">
{% if show_label|default_if_none:True %}{{ field.label_tag }}{% endif %}
<div class="field-content">
<div class="input {{ input_classes }} ">

Wyświetl plik

@ -1,3 +1,5 @@
{% load wagtailadmin_tags i18n %}
{% comment %}
Either the chosen or unchosen div will be shown, depending on the presence
of the 'blank' class on the container.
@ -33,6 +35,7 @@
<button type="button" class="button action-choose button-small button-secondary">{{ widget.choose_one_text }}</button>
</div>
<button type="button" data-comment-add class="button button-secondary button-small u-hidden" aria-label="{% trans 'Add comment' %}">{% icon name='comment' class_name='initial' %}</button>
</div>
{{ original_field_html }}

Wyświetl plik

@ -14,6 +14,7 @@ from django.views.generic.base import ContextMixin, TemplateResponseMixin, View
from wagtail.admin import messages
from wagtail.admin.action_menu import PageActionMenu
from wagtail.admin.templatetags.wagtailadmin_tags import user_display_name
from wagtail.admin.views.generic import HookResponseMixin
from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
from wagtail.core.exceptions import PageClassNotFoundError
@ -532,6 +533,10 @@ class EditView(TemplateResponseMixin, ContextMixin, HookResponseMixin, View):
'publishing_will_cancel_workflow': self.workflow_tasks and getattr(settings, 'WAGTAIL_WORKFLOW_CANCEL_ON_PUBLISH', True),
'locale': None,
'translations': [],
'comments_author': {
'id': self.request.user.pk, # TODO: move into the comments widget/edit handler when created
'name': user_display_name(self.request.user)
},
})
if getattr(settings, 'WAGTAIL_I18N_ENABLED', False):

Wyświetl plik

@ -724,6 +724,7 @@ def register_icons(icons):
'cogs.svg',
'collapse-down.svg',
'collapse-up.svg',
'comment.svg',
'cross.svg',
'date.svg',
'doc-empty-inverse.svg',

Wyświetl plik

@ -876,6 +876,7 @@
<li>{% icon 'no-view' %} no-view</li>
<li>{% icon 'collapse-up' %} collapse-up</li>
<li>{% icon 'collapse-down' %} collapse-down</li>
<li>{% icon 'comment' %} comment</li>
<li>{% icon 'help' %} help</li>
<li>{% icon 'warning' %} warning</li>
<li>{% icon 'error' %} error</li>

Wyświetl plik

@ -434,7 +434,7 @@ class TestTableBlockPageEdit(TestCase, WagtailTestUtils):
"""
response = self.client.get(reverse('wagtailadmin_pages:edit', args=(self.table_block_page.id,)))
# check page + field renders
self.assertContains(response, '<div class="field char_field widget-table_input fieldname-table">')
self.assertContains(response, '<div data-contentpath="table" class="field char_field widget-table_input fieldname-table">')
# check data
self.assertContains(response, 'Battlestar')
self.assertContains(response, 'Galactica')

Wyświetl plik

@ -468,7 +468,7 @@ class TestSnippetCreateView(TestCase, WagtailTestUtils):
response = self.get()
self.assertContains(response, '<button type="submit" name="test" value="Test" class="button action-secondary"><svg class="icon icon-undo icon" aria-hidden="true" focusable="false"><use href="#icon-undo"></use></svg>Test</button>', html=True)
self.assertNotContains(response, 'Save')
self.assertNotContains(response, "<em>'Save'</em>")
@override_settings(WAGTAIL_I18N_ENABLED=True)
@ -684,7 +684,7 @@ class TestSnippetEditView(BaseTestSnippetEditView):
with self.register_hook('construct_snippet_action_menu', hook_func):
response = self.get()
self.assertNotContains(response, 'Save')
self.assertNotContains(response, '<em>Save</em>')
class TestEditTabbedSnippet(BaseTestSnippetEditView):