diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000000..51ac476ad5 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "wagtail-client", + "version": "0.1.0", + "lockfileVersion": 1 +} diff --git a/client/src/components/CommentApp/comments.js b/client/src/components/CommentApp/comments.js new file mode 100644 index 0000000000..fbc41a3cd5 --- /dev/null +++ b/client/src/components/CommentApp/comments.js @@ -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 +}; diff --git a/client/src/entrypoints/admin/comments.js b/client/src/entrypoints/admin/comments.js new file mode 100644 index 0000000000..996b895ab0 --- /dev/null +++ b/client/src/entrypoints/admin/comments.js @@ -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; diff --git a/client/src/entrypoints/admin/comments.test.js b/client/src/entrypoints/admin/comments.test.js new file mode 100644 index 0000000000..c6155d062d --- /dev/null +++ b/client/src/entrypoints/admin/comments.test.js @@ -0,0 +1,7 @@ +require('./comments'); + +describe('comments', () => { + it('exposes module as global', () => { + expect(window.comments).toBeDefined(); + }); +}); diff --git a/client/src/entrypoints/admin/page-chooser.js b/client/src/entrypoints/admin/page-chooser.js index 42085dd93d..1429316d00 100644 --- a/client/src/entrypoints/admin/page-chooser.js +++ b/client/src/entrypoints/admin/page-chooser.js @@ -94,6 +94,10 @@ function createPageChooser(id, openAtParentId, options) { chooser.clear(); }); + if (window.comments) { + window.comments.initFieldLevelCommentWidget(chooserElement[0]); + } + return chooser; } window.createPageChooser = createPageChooser; diff --git a/client/src/entrypoints/documents/document-chooser.js b/client/src/entrypoints/documents/document-chooser.js index 080ca00784..1e1745d942 100644 --- a/client/src/entrypoints/documents/document-chooser.js +++ b/client/src/entrypoints/documents/document-chooser.js @@ -80,6 +80,10 @@ function createDocumentChooser(id) { chooser.clear(); }); + if (window.comments) { + window.comments.initFieldLevelCommentWidget(chooserElement[0]); + } + return chooser; } window.createDocumentChooser = createDocumentChooser; diff --git a/client/src/entrypoints/images/image-chooser.js b/client/src/entrypoints/images/image-chooser.js index 7e0bb3ab90..466c30b334 100644 --- a/client/src/entrypoints/images/image-chooser.js +++ b/client/src/entrypoints/images/image-chooser.js @@ -93,6 +93,10 @@ function createImageChooser(id) { chooser.clear(); }); + if (window.comments) { + window.comments.initFieldLevelCommentWidget(chooserElement[0]); + } + return chooser; } window.createImageChooser = createImageChooser; diff --git a/client/src/entrypoints/snippets/snippet-chooser.js b/client/src/entrypoints/snippets/snippet-chooser.js index f3923ce32e..977327939a 100644 --- a/client/src/entrypoints/snippets/snippet-chooser.js +++ b/client/src/entrypoints/snippets/snippet-chooser.js @@ -80,6 +80,10 @@ function createSnippetChooser(id, modelString) { chooser.clear(); }); + if (window.comments) { + window.comments.initFieldLevelCommentWidget(chooserElement[0]); + } + return chooser; } window.createSnippetChooser = createSnippetChooser; diff --git a/client/tests/stubs.js b/client/tests/stubs.js index a41f2c555d..3cba5340a5 100644 --- a/client/tests/stubs.js +++ b/client/tests/stubs.js @@ -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: [ diff --git a/client/webpack.config.js b/client/webpack.config.js index 0234bdb2e7..d338ef6741 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -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 + } }, }, ], diff --git a/package-lock.json b/package-lock.json index b36b7ba151..b4d9e76651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d623b973f3..ebebd26ea0 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/wagtail/admin/localization.py b/wagtail/admin/localization.py index 0ca82ee5a3..d5a46583f0 100644 --- a/wagtail/admin/localization.py +++ b/wagtail/admin/localization.py @@ -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()], diff --git a/wagtail/admin/static_src/wagtailadmin/js/vendor/uuidv4.min.js b/wagtail/admin/static_src/wagtailadmin/js/vendor/uuidv4.min.js new file mode 100644 index 0000000000..53698eb692 --- /dev/null +++ b/wagtail/admin/static_src/wagtailadmin/js/vendor/uuidv4.min.js @@ -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)}})); \ No newline at end of file diff --git a/wagtail/admin/templates/wagtailadmin/edit_handlers/inline_panel_child.html b/wagtail/admin/templates/wagtailadmin/edit_handlers/inline_panel_child.html index d46563ae87..69c1186a4f 100644 --- a/wagtail/admin/templates/wagtailadmin/edit_handlers/inline_panel_child.html +++ b/wagtail/admin/templates/wagtailadmin/edit_handlers/inline_panel_child.html @@ -1,5 +1,5 @@ {% load i18n wagtailadmin_tags %} -
  • +
  • {% if child.form.non_field_errors %}