Add save warning to page editor for comments and edits

pull/7050/head
Jacob Topp-Mugglestone 2021-04-06 14:33:23 +01:00 zatwierdzone przez Matt Westcott
rodzic c619261565
commit d64dad9739
15 zmienionych plików z 448 dodań i 71 usunięć

Wyświetl plik

@ -1,11 +1,7 @@
footer {
$border-curvature: 3px;
@include transition(bottom 0.5s ease 1s);
@include row();
border-radius: 3px 3px 0 0;
box-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
background: $color-grey-1;
color: $color-white;
margin-top: $mobile-nice-padding;
ul {
@include unlist();
@ -15,12 +11,48 @@ footer {
float: left;
margin-right: 1em;
li, // dropdown li
.dropdown li, // dropdown li
&:last-child {
margin-right: 0;
}
}
.footer__container {
border-radius: $border-curvature $border-curvature 0 0;
background: $color-grey-1;
color: $color-white;
margin-top: 0;
transition: transform 1s;
&:first-child {
margin-top: 0;
box-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
}
&.footer__container--hidden {
transform: translateY(100%);
}
}
.footer__save-warning {
font-size: 0.95em;
display: flex;
align-items: center;
.icon {
font-size: 1.2em;
margin-right: 0.5em;
}
p {
margin: -0.2em 0 0 0;
}
}
.footer__emphasise-span-tags span {
color: $color-orange;
}
.actions {
width: 250px;
@ -67,10 +99,13 @@ footer {
@include media-breakpoint-down(xs) {
.actions,
.preview,
.footer__container,
.preview .dropdown {
width: 100%;
}
margin-top: $mobile-nice-padding;
.meta {
p {
white-space: normal;
@ -81,6 +116,22 @@ footer {
left: auto;
}
}
.footer__container {
&:not(:first-child) {
border-radius: 0;
}
&--hidden {
display: none;
}
}
.footer__save-warning {
display: flex;
flex-direction: row;
justify-content: center;
}
}
@include media-breakpoint-up(sm) {
@ -89,6 +140,22 @@ footer {
width: auto;
position: fixed;
bottom: 0;
padding: 0.75em;
> ul {
display: flex;
}
.footer__container {
padding: 0.75em;
margin-right: 0;
&:not(:first-child) {
margin-left: -$border-curvature;
}
}
.footer__save-warning {
margin-right: 50px;
}
}
}

Wyświetl plik

@ -8,6 +8,7 @@ const remoteReply: CommentReply = {
author: { id: 1, name: 'test user' },
date: 0,
text: 'a reply',
originalText: 'a reply',
newText: '',
deleted: false,
};
@ -19,6 +20,7 @@ const localReply: CommentReply = {
author: { id: 1, name: 'test user' },
date: 0,
text: 'another reply',
originalText: 'another new reply',
newText: '',
deleted: false,
};
@ -34,6 +36,7 @@ const remoteComment: Comment = {
author: { id: 1, name: 'test user' },
date: 0,
text: 'test text',
originalText: 'test text',
newReply: '',
newText: '',
remoteReplyCount: 1,
@ -51,6 +54,7 @@ const localComment: Comment = {
author: { id: 1, name: 'test user' },
date: 0,
text: 'unsaved comment',
originalText: 'unsaved comment',
newReply: '',
newText: '',
replies: new Map(),

Wyświetl plik

@ -21,7 +21,8 @@ import {
selectCommentsForContentPathFactory,
selectCommentFactory,
selectEnabled,
selectFocused
selectFocused,
selectIsDirty
} from './selectors';
import CommentComponent from './components/Comment';
import { CommentFormSetComponent } from './components/Form';
@ -139,7 +140,8 @@ export class CommentApp {
selectors = {
selectComments,
selectEnabled,
selectFocused
selectFocused,
selectIsDirty
}
actions = commentActionFunctions;

Wyświetl plik

@ -4,6 +4,7 @@ import type { State } from '../state';
export const selectComments = (state: State) => state.comments.comments;
export const selectFocused = (state: State) => state.comments.focusedComment;
export const selectRemoteCommentCount = (state: State) => state.comments.remoteCommentCount;
export function selectCommentsForContentPathFactory(contentpath: string) {
return createSelector(selectComments, (comments) =>
@ -27,3 +28,21 @@ export function selectCommentFactory(localId: number) {
}
export const selectEnabled = (state: State) => state.settings.commentsEnabled;
export const selectIsDirty = createSelector(
selectComments,
selectRemoteCommentCount,
(comments, remoteCommentCount) => {
if (remoteCommentCount !== comments.size) {
return true;
}
return Array.from(comments.values()).some(comment => {
if (comment.deleted ||
comment.replies.size !== comment.remoteReplyCount ||
comment.originalText !== comment.text
) {
return true;
}
return Array.from(comment.replies.values()).some(reply => reply.deleted || reply.originalText !== reply.text);
});
});

Wyświetl plik

@ -1,14 +1,22 @@
import { basicCommentsState } from '../__fixtures__/state';
import { INITIAL_STATE } from '../state/settings';
import { INITIAL_STATE as INITIAL_SETTINGS_STATE } from '../state/settings';
import {
INITIAL_STATE as INITIAL_COMMENTS_STATE,
newComment,
newCommentReply,
} from '../state/comments';
import { reducer } from '../state';
import { selectCommentsForContentPathFactory } from './index';
import * as actions from '../actions/comments';
import { selectCommentsForContentPathFactory, selectIsDirty } from './index';
test('Select comments for contentpath', () => {
// test that the selectCommentsForContentPathFactory can generate selectors for the two
// contentpaths in basicCommentsState
const state = {
comments: basicCommentsState,
settings: INITIAL_STATE,
settings: INITIAL_SETTINGS_STATE,
};
const testContentPathSelector = selectCommentsForContentPathFactory(
'test_contentpath'
@ -23,3 +31,79 @@ test('Select comments for contentpath', () => {
expect(otherSelectedComments.length).toBe(1);
expect(otherSelectedComments[0].contentpath).toBe('test_contentpath_2');
});
test('Select is dirty', () => {
const state = {
comments: INITIAL_COMMENTS_STATE,
settings: INITIAL_SETTINGS_STATE
};
const stateWithUnsavedComment = reducer(state, actions.addComment(newComment(
'test_contentpath',
'test_position',
1,
null,
null,
0,
{
remoteId: null,
text: 'my new comment'
}
)));
expect(selectIsDirty(stateWithUnsavedComment)).toBe(true);
const stateWithSavedComment = reducer(state, actions.addComment(newComment(
'test_contentpath',
'test_position',
1,
null,
null,
0,
{
remoteId: 1,
text: 'my saved comment'
}
)));
expect(selectIsDirty(stateWithSavedComment)).toBe(false);
const stateWithDeletedComment = reducer(stateWithSavedComment, actions.deleteComment(1));
expect(selectIsDirty(stateWithDeletedComment)).toBe(true);
const stateWithEditedComment = reducer(stateWithSavedComment, actions.updateComment(1, { text: 'edited_text' }));
expect(selectIsDirty(stateWithEditedComment)).toBe(true);
const stateWithUnsavedReply = reducer(stateWithSavedComment, actions.addReply(1, newCommentReply(
2,
null,
0,
{
remoteId: null,
text: 'new reply'
}
)));
expect(selectIsDirty(stateWithUnsavedReply)).toBe(true);
const stateWithSavedReply = reducer(stateWithSavedComment, actions.addReply(1, newCommentReply(
2,
null,
0,
{
remoteId: 2,
text: 'new saved reply'
}
)));
expect(selectIsDirty(stateWithSavedReply)).toBe(false);
const stateWithDeletedReply = reducer(stateWithSavedReply, actions.deleteReply(1, 2));
expect(selectIsDirty(stateWithDeletedReply)).toBe(true);
const stateWithEditedReply = reducer(stateWithSavedReply, actions.updateReply(1, 2, { text: 'edited_text' }));
expect(selectIsDirty(stateWithEditedReply)).toBe(true);
});

Wyświetl plik

@ -30,6 +30,7 @@ test('New comment added to state', () => {
author: { id: 1, name: 'test user' },
date: 0,
text: 'new comment',
originalText: 'new comment',
newReply: '',
newText: '',
remoteReplyCount: 0,
@ -55,6 +56,7 @@ test('Remote comment added to state', () => {
author: { id: 1, name: 'test user' },
date: 0,
text: 'new comment',
originalText: 'new comment',
newReply: '',
newText: '',
remoteReplyCount: 0,
@ -70,7 +72,7 @@ test('Remote comment added to state', () => {
test('Existing comment updated', () => {
const commentUpdate: CommentUpdate = {
mode: 'editing',
mode: 'editing'
};
const updateAction = actions.updateComment(1, commentUpdate);
const newState = reducer(basicCommentsState, updateAction);
@ -125,6 +127,7 @@ test('Reply added', () => {
author: { id: 1, name: 'test user' },
date: 0,
text: 'a new reply',
originalText: 'a new reply',
newText: '',
deleted: false,
};
@ -149,6 +152,7 @@ test('Remote reply added', () => {
author: { id: 1, name: 'test user' },
date: 0,
text: 'a new reply',
originalText: 'a new reply',
newText: '',
deleted: false,
};

Wyświetl plik

@ -28,9 +28,15 @@ export interface CommentReply {
mode: CommentReplyMode;
author: Author | null;
date: number;
text: string;
newText: string;
deleted: boolean;
// There are three variables used for text
// text is the canonical text, that will be output to the form
// newText stores the edited version of the text until it is saved
// originalText stores the text upon reply creation, and is
// used to check whether existing replies have been edited
text: string;
originalText: string;
newText: string;
}
export interface NewReplyOptions {
@ -56,12 +62,13 @@ export function newCommentReply(
author,
date,
text,
originalText: text,
newText: '',
deleted: false,
};
}
export type CommentReplyUpdate = Partial<CommentReply>;
export type CommentReplyUpdate = Partial<Omit<CommentReply, 'originalText'>>;
export type CommentMode =
| 'default'
@ -84,11 +91,17 @@ export interface Comment {
deleted: boolean;
author: Author | null;
date: number;
text: string;
replies: Map<number, CommentReply>;
newReply: string;
newText: string;
remoteReplyCount: number;
// There are three variables used for text
// text is the canonical text, that will be output to the form
// newText stores the edited version of the text until it is saved
// originalText stores the text upon comment creation, and is
// used to check whether existing comments have been edited
text: string;
originalText: string;
newText: string;
}
export interface NewCommentOptions {
@ -122,6 +135,7 @@ export function newComment(
author,
date,
text,
originalText: text,
replies,
newReply: '',
newText: '',
@ -133,7 +147,7 @@ export function newComment(
};
}
export type CommentUpdate = Partial<Comment>;
export type CommentUpdate = Partial<Omit<Comment, 'originalText'>>;
export interface CommentsState {
comments: Map<number, Comment>;
@ -143,7 +157,7 @@ export interface CommentsState {
remoteCommentCount: number;
}
const INITIAL_STATE: CommentsState = {
export const INITIAL_STATE: CommentsState = {
comments: new Map(),
focusedComment: null,
pinnedComment: null,

Wyświetl plik

@ -44,7 +44,8 @@ window.initTagField = initTagField;
/*
* Enables a "dirty form check", prompting the user if they are navigating away
* from a page with unsaved changes.
* from a page with unsaved changes, as well as optionally controlling other
* behaviour via a callback
*
* It takes the following parameters:
*
@ -54,30 +55,139 @@ window.initTagField = initTagField;
* - confirmationMessage - The message to display in the prompt.
* - alwaysDirty - When set to true the form will always be considered dirty,
* prompting the user even when nothing has been changed.
* - commentApp - The CommentApp used by the commenting system, if the dirty check
* should include comments
* - callback - A function to be run when the dirty status of the form, or the comments
* system (if using) changes, taking formDirty, commentsDirty as arguments
*/
function enableDirtyFormCheck(formSelector, options) {
const $form = $(formSelector);
const confirmationMessage = options.confirmationMessage || ' ';
const alwaysDirty = options.alwaysDirty || false;
const commentApp = options.commentApp || null;
const callback = options.callback || null;
let initialData = null;
let formSubmitted = false;
const updateCallback = (formDirty, commentsDirty) => {
if (callback) {
callback(formDirty, commentsDirty);
}
};
$form.on('submit', () => {
formSubmitted = true;
});
let isDirty = alwaysDirty;
let isCommentsDirty = false;
let updateIsCommentsDirtyTimeout = -1;
if (commentApp) {
isCommentsDirty = commentApp.selectors.selectIsDirty(commentApp.store.getState());
commentApp.store.subscribe(() => {
// Update on a timeout to match the timings for responding to page form changes
clearTimeout(updateIsCommentsDirtyTimeout);
updateIsCommentsDirtyTimeout = setTimeout(() => {
const newIsCommentsDirty = commentApp.selectors.selectIsDirty(commentApp.store.getState());
if (newIsCommentsDirty !== isCommentsDirty) {
isCommentsDirty = newIsCommentsDirty;
updateCallback(isDirty, isCommentsDirty);
}
}, isCommentsDirty ? 3000 : 300);
});
}
updateCallback(isDirty, isCommentsDirty);
let updateIsDirtyTimeout = -1;
const isFormDirty = () => {
if (alwaysDirty) {
return true;
} else if (!initialData) {
return false;
}
const formData = new FormData($form[0]);
const keys = Array.from(formData.keys()).filter((key) => !key.startsWith('comments-'));
if (keys.length !== initialData.size) {
return true;
}
return keys.some((key) => {
const newValue = formData.getAll(key);
const oldValue = initialData.get(key);
if (newValue === oldValue) {
return false;
} else if (Array.isArray(newValue) && Array.isArray(oldValue)) {
return newValue.length !== oldValue.length || newValue.some((value, index) => value !== oldValue[index]);
}
return false;
});
};
const updateIsDirty = () => {
const previousIsDirty = isDirty;
isDirty = isFormDirty();
if (previousIsDirty !== isDirty) {
updateCallback(isDirty, isCommentsDirty);
}
};
// Delay snapshotting the forms data to avoid race conditions with form widgets that might process the values.
// User interaction with the form within that delay also wont trigger the confirmation message.
setTimeout(() => {
initialData = $form.serialize();
}, 1000 * 10);
if (!alwaysDirty) {
setTimeout(() => {
const initialFormData = new FormData($form[0]);
initialData = new Map();
Array.from(initialFormData.keys())
.filter(key => !key.startsWith('comments-'))
.forEach(key => initialData.set(key, initialFormData.getAll(key)));
const updateDirtyCheck = () => {
clearTimeout(updateIsDirtyTimeout);
// If the form is dirty, it is relatively unlikely to become clean again, so
// run the dirty check on a relatively long timer that we reset on any form update
// otherwise, use a short timer both for nicer UX and to ensure widgets
// like Draftail have time to serialize their data
updateIsDirtyTimeout = setTimeout(updateIsDirty, isDirty ? 3000 : 300);
};
$form.on('change keyup', updateDirtyCheck).trigger('change');
const validInputNodeInList = (nodeList) => {
for (const node of nodeList) {
if (node.nodeType === node.ELEMENT_NODE && ['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName)) {
return true;
}
}
return false;
};
const observer = new MutationObserver((mutationList) => {
for (const mutation of mutationList) {
if (validInputNodeInList(mutation.addedNodes) || validInputNodeInList(mutation.removedNodes)) {
updateDirtyCheck();
return;
}
}
});
observer.observe($form[0], {
childList: true,
attributes: false,
subtree: true
});
}, 1000 * 10);
}
// eslint-disable-next-line consistent-return
window.addEventListener('beforeunload', (event) => {
const isDirty = initialData && $form.serialize() !== initialData;
clearTimeout(updateIsDirtyTimeout);
updateIsDirty();
const displayConfirmation = (
!formSubmitted && (alwaysDirty || isDirty)
!formSubmitted && (isDirty || isCommentsDirty)
);
if (displayConfirmation) {

Wyświetl plik

@ -379,6 +379,42 @@ $(() => {
});
});
let updateFooterTextTimeout = -1;
window.updateFooterSaveWarning = (formDirty, commentsDirty) => {
const warningContainer = $('[data-unsaved-warning]');
const warnings = warningContainer.find('[data-unsaved-type]');
const anyDirty = formDirty || commentsDirty;
const typeVisibility = {
all: formDirty && commentsDirty,
any: anyDirty,
comments: commentsDirty && !formDirty,
edits: formDirty && !commentsDirty
};
let hiding = false;
if (anyDirty) {
warningContainer.removeClass('footer__container--hidden');
} else {
if (!warningContainer.hasClass('footer__container--hidden')) {
hiding = true;
}
warningContainer.addClass('footer__container--hidden');
}
clearTimeout(updateFooterTextTimeout);
const updateWarnings = () => {
for (const warning of warnings) {
const visible = typeVisibility[warning.dataset.unsavedType];
warning.hidden = !visible;
}
};
if (hiding) {
// If hiding, we want to keep the text as-is before it disappears
updateFooterTextTimeout = setTimeout(updateWarnings, 1050);
} else {
updateWarnings();
}
};
if (typeof module !== 'undefined' && module.exports) {
module.exports.cleanForSlug = cleanForSlug;
}

Wyświetl plik

@ -8,3 +8,5 @@ import 'core-js/shim';
import 'whatwg-fetch';
// IE11.
import 'element-closest';
// IE11
import 'formdata-polyfill';

5
package-lock.json wygenerowano
Wyświetl plik

@ -5987,6 +5987,11 @@
"mime-types": "^2.1.12"
}
},
"formdata-polyfill": {
"version": "3.0.20",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-3.0.20.tgz",
"integrity": "sha512-TAaxIEwTBdoH1TWndtUH1T0/GisUHwmOKcV5hjkR/iTatHBJSOHb563FP86Lra5nXo3iNdhK7HPwMl5Ihg71pg=="
},
"fragment-cache": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",

Wyświetl plik

@ -96,6 +96,7 @@
"element-closest": "^2.0.2",
"focus-trap-react": "^3.1.0",
"immer": "^9.0.1",
"formdata-polyfill": "^3.0.20",
"postcss-calc": "^7.0.5",
"prop-types": "^15.6.2",
"react": "^16.14.0",

Wyświetl plik

@ -0,0 +1,14 @@
{% load wagtailadmin_tags i18n %}
<li class="footer__container footer__container--hidden footer__save-warning" data-unsaved-warning>
<div hidden data-unsaved-type="any" class="footer__save-warning footer__emphasise-span-tags">
<span>
{% icon name="warning" class_name="default footer__emphasis" %}
</span>
<div>
<b hidden data-unsaved-type="edits">{% blocktrans %}You have <span>unsaved edits</span>{% endblocktrans %}</b>
<b hidden data-unsaved-type="comments">{% blocktrans %}You have <span>unsaved comments</span>{% endblocktrans %}</b>
<b hidden data-unsaved-type="all">{% blocktrans %}You have <span>unsaved edits</span> and <span>comments</span>{% endblocktrans %}</b>
<p>{% trans 'Save the page before leaving' %}</p>
</div>
</div>
</li>

Wyświetl plik

@ -6,7 +6,7 @@
{% block bodyclass %}page-editor create model-{{ content_type.model }}{% endblock %}
{% block content %}
<div id="comments"></div>
<header class="merged tab-merged">
{% explorer_breadcrumb parent_page include_self=1 trailing_arrow=True %}
@ -38,38 +38,43 @@
{{ edit_handler.render_form_content }}
<footer>
<nav aria-label="{% trans 'Actions' %}">
<ul>
<li class="actions actions--primary">
<div class="dropdown dropup dropdown-button dropdown-button--white match-width {% if is_revision %}warning{% endif %}">
{{ action_menu.render_html }}
</div>
</li>
{% if preview_modes %}
<li class="preview">
{% trans 'Preview' as preview_label %}
{% if preview_modes|length > 1 %}
<div class="dropdown dropup dropdown-button match-width">
{% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %}
<div class="dropdown-toggle">{% icon name="arrow-up" %}</div>
<ul>
{% for mode_name, mode_display_name in preview_modes %}
<li>
{% include "wagtailadmin/pages/_preview_button_on_create.html" with mode=mode_name label=mode_display_name %}
</li>
{% endfor %}
</ul>
<ul>
<li class="footer__container">
<nav aria-label="{% trans 'Actions' %}">
<ul>
<li class="actions actions--primary">
<div class="dropdown dropup dropdown-button dropdown-button--white match-width {% if is_revision %}warning{% endif %}">
{{ action_menu.render_html }}
</div>
{% else %}
{% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %}
</li>
{% if preview_modes %}
<li class="preview">
{% trans 'Preview' as preview_label %}
{% if preview_modes|length > 1 %}
<div class="dropdown dropup dropdown-button match-width">
{% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %}
<div class="dropdown-toggle">{% icon name="arrow-up" %}</div>
<ul>
{% for mode_name, mode_display_name in preview_modes %}
<li>
{% include "wagtailadmin/pages/_preview_button_on_create.html" with mode=mode_name label=mode_display_name %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
{% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %}
{% endif %}
</li>
{% endif %}
</li>
{% endif %}
{% block extra_footer_actions %}
{% endblock %}
</ul>
</nav>
{% block extra_footer_actions %}
{% endblock %}
</ul>
</nav>
</li>
{% include "wagtailadmin/pages/_unsaved_changes_warning.html" %}
</ul>
</footer>
</form>
@ -111,6 +116,8 @@
{% if has_unsaved_changes %}
alwaysDirty: true,
{% endif %}
commentApp: window.commentApp,
callback: window.updateFooterSaveWarning
}
);
});

Wyświetl plik

@ -54,16 +54,18 @@
{% endif %}
<footer>
<nav aria-label="{% trans 'Actions' %}">
<ul>
<li class="actions actions--primary">
<div class="dropdown dropup dropdown-button match-width {% if is_revision %}warning{% endif %}">
<ul>
<li class="footer__container">
<nav aria-label="{% trans 'Actions' %}">
<ul>
<li class="actions actions--primary">
<div class="dropdown dropup dropdown-button match-width {% if is_revision %}warning{% endif %}">
{{ action_menu.render_html }}
</div>
</li>
</div>
</li>
{% if preview_modes %}
<li class="preview">
{% if preview_modes %}
<li class="preview">
{% trans 'Preview' as preview_label %}
{% if preview_modes|length > 1 %}
<div class="dropdown dropup dropdown-button match-width">
@ -80,13 +82,16 @@
{% else %}
{% include "wagtailadmin/pages/_preview_button_on_edit.html" with label=preview_label icon=1 %}
{% endif %}
</li>
{% endif %}
</li>
{% endif %}
{% block extra_footer_actions %}
{% endblock %}
</ul>
</nav>
{% block extra_footer_actions %}
{% endblock %}
</ul>
</nav>
</li>
{% include "wagtailadmin/pages/_unsaved_changes_warning.html" %}
</ul>
</footer>
</form>
{% endblock %}
@ -158,6 +163,9 @@
{% if has_unsaved_changes %}
alwaysDirty: true,
{% endif %}
commentApp: window.commentApp,
callback: window.updateFooterSaveWarning
}
);