Update Composer, update everything

This commit is contained in:
Oliver Davies 2018-11-23 12:29:20 +00:00
parent ea3e94409f
commit dda5c284b6
19527 changed files with 1135420 additions and 351004 deletions

View file

@ -48,7 +48,7 @@
font-size: 1em;
}
.quickedit .icon-pencil {
margin-left: .5em;
margin-left: 0.5em;
padding-left: 1.5em;
}

View file

@ -86,7 +86,6 @@
* Entity toolbar.
*/
.quickedit-toolbar-container {
max-width: 100%;
position: absolute;
max-width: 320px;
width: 320px;

View file

@ -26,7 +26,7 @@
}
.quickedit-editing.quickedit-validation-error,
.quickedit-form.quickedit-validation-error {
box-shadow: 0 0 0px 1px #ee8b74, 0 0 0 2px #fa2209;
box-shadow: 0 0 0 1px #ee8b74, 0 0 0 2px #fa2209;
}
.quickedit-editing.quickedit-editor-is-popup {
box-shadow: none;
@ -45,7 +45,7 @@
margin: 0;
}
.quickedit-form .form-wrapper {
margin: .5em;
margin: 0.5em;
}
/**
@ -55,35 +55,35 @@
opacity: 0;
}
.quickedit-animate-default {
-webkit-transition: all .4s ease;
transition: all .4s ease;
-webkit-transition: all 0.4s ease;
transition: all 0.4s ease;
}
.quickedit-animate-slow {
-webkit-transition: all .6s ease;
transition: all .6s ease;
-webkit-transition: all 0.6s ease;
transition: all 0.6s ease;
}
.quickedit-animate-delay-veryfast {
-webkit-transition-delay: .05s;
transition-delay: .05s;
-webkit-transition-delay: 0.05s;
transition-delay: 0.05s;
}
.quickedit-animate-delay-fast {
-webkit-transition-delay: .2s;
transition-delay: .2s;
-webkit-transition-delay: 0.2s;
transition-delay: 0.2s;
}
.quickedit-animate-disable-width {
-webkit-transition: width 0s;
transition: width 0s;
}
.quickedit-animate-only-visibility {
-webkit-transition: opacity .2s ease;
transition: opacity .2s ease;
-webkit-transition: opacity 0.2s ease;
transition: opacity 0.2s ease;
}
/**
* In-place editors that don't use a popup.
*/
.quickedit-validation-errors .messages.error {
box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5);
box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, 0.5);
background-color: white;
}
@ -99,7 +99,7 @@
* Toolbars.
*/
.quickedit-toolbar-container {
font-family: 'Source Sans Pro','Lucida Grande', sans-serif;
font-family: 'Source Sans Pro', 'Lucida Grande', sans-serif;
padding-bottom: 7px;
padding-top: 7px;
-webkit-transition: all 1s;
@ -107,7 +107,7 @@
}
.quickedit-toolbar-container > .quickedit-toolbar-content {
background-image: -webkit-linear-gradient(top, #fff, #e4e4e4);
background-image: linear-gradient(to bottom, #fff, #e4e4e4);
background-image: linear-gradient(to bottom, #fff, #e4e4e4);
box-sizing: border-box;
color: black;
padding: 0.1667em;
@ -121,7 +121,7 @@
.quickedit-toolbar-container > .quickedit-toolbar-pointer {
background-color: #e4e4e4;
bottom: 2px;
box-shadow: 0 0 0 1px #818181, 0px 0px 0 4px rgba(150, 150, 150, 0.5);
box-shadow: 0 0 0 1px #818181, 0 0 0 4px rgba(150, 150, 150, 0.5);
display: block;
height: 16px;
left: 18px; /* LTR */
@ -142,7 +142,7 @@
}
.quickedit-toolbar-container > .quickedit-toolbar-lining {
bottom: 7px;
box-shadow: 0 0 0 1px #818181, 0px 3px 0px 1px rgba(150, 150, 150, 0.5);
box-shadow: 0 0 0 1px #818181, 0 3px 0 1px rgba(150, 150, 150, 0.5);
display: block;
left: 0;
position: absolute;
@ -174,9 +174,9 @@
padding: 0.1667em 0.2em;
}
/**
* Info toolgroup.
*/
/**
* Info toolgroup.
*/
.quickedit-toolbar-fullwidth {
width: 100%;
}
@ -208,8 +208,8 @@
margin: 0;
opacity: 1;
padding: 0.345em;
-webkit-transition: opacity .1s ease;
transition: opacity .1s ease;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
}
.quickedit-button[aria-hidden="true"] {
visibility: hidden;
@ -237,7 +237,7 @@
color: white;
background-color: #50a0e9;
background-image: -webkit-linear-gradient(top, #50a0e9, #4481dc);
background-image: linear-gradient(to bottom, #50a0e9, #4481dc);
background-image: linear-gradient(to bottom, #50a0e9, #4481dc);
border: 1px solid transparent;
}
.quickedit-button.action-save:hover,

View file

@ -0,0 +1,279 @@
/**
* @file
* Form-based in-place editor. Works for any field type.
*/
(function($, Drupal, _) {
/**
* @constructor
*
* @augments Drupal.quickedit.EditorView
*/
Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(
/** @lends Drupal.quickedit.editors.form# */ {
/**
* Tracks form container DOM element that is used while in-place editing.
*
* @type {jQuery}
*/
$formContainer: null,
/**
* Holds the {@link Drupal.Ajax} object.
*
* @type {Drupal.Ajax}
*/
formSaveAjax: null,
/**
* @inheritdoc
*
* @param {object} fieldModel
* The field model that holds the state.
* @param {string} state
* The state to change to.
*/
stateChange(fieldModel, state) {
const from = fieldModel.previous('state');
const to = state;
switch (to) {
case 'inactive':
break;
case 'candidate':
if (from !== 'inactive') {
this.removeForm();
}
break;
case 'highlighted':
break;
case 'activating':
// If coming from an invalid state, then the form is already loaded.
if (from !== 'invalid') {
this.loadForm();
}
break;
case 'active':
break;
case 'changed':
break;
case 'saving':
this.save();
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
break;
}
},
/**
* @inheritdoc
*
* @return {object}
* A settings object for the quick edit UI.
*/
getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: true,
fullWidthToolbar: true,
popup: true,
};
},
/**
* Loads the form for this field, displays it on top of the actual field.
*/
loadForm() {
const fieldModel = this.fieldModel;
// Generate a DOM-compatible ID for the form container DOM element.
const id = `quickedit-form-for-${fieldModel.id.replace(
/[/[\]]/g,
'_',
)}`;
// Render form container.
const $formContainer = $(
Drupal.theme('quickeditFormContainer', {
id,
loadingMsg: Drupal.t('Loading…'),
}),
);
this.$formContainer = $formContainer;
$formContainer
.find('.quickedit-form')
.addClass(
'quickedit-editable quickedit-highlighted quickedit-editing',
)
.attr('role', 'dialog');
// Insert form container in DOM.
if (this.$el.css('display') === 'inline') {
$formContainer.prependTo(this.$el.offsetParent());
// Position the form container to render on top of the field's element.
const pos = this.$el.position();
$formContainer.css('left', pos.left).css('top', pos.top);
} else {
$formContainer.insertBefore(this.$el);
}
// Load form, insert it into the form container and attach event handlers.
const formOptions = {
fieldID: fieldModel.get('fieldID'),
$el: this.$el,
nocssjs: false,
// Reset an existing entry for this entity in the PrivateTempStore (if
// any) when loading the field. Logically speaking, this should happen
// in a separate request because this is an entity-level operation, not
// a field-level operation. But that would require an additional
// request, that might not even be necessary: it is only when a user
// loads a first changed field for an entity that this needs to happen:
// precisely now!
reset: !fieldModel.get('entity').get('inTempStore'),
};
Drupal.quickedit.util.form.load(formOptions, (form, ajax) => {
Drupal.AjaxCommands.prototype.insert(ajax, {
data: form,
selector: `#${id} .placeholder`,
});
$formContainer
.on('formUpdated.quickedit', ':input', event => {
const state = fieldModel.get('state');
// If the form is in an invalid state, it will persist on the page.
// Set the field to activating so that the user can correct the
// invalid value.
if (state === 'invalid') {
fieldModel.set('state', 'activating');
}
// Otherwise assume that the fieldModel is in a candidate state and
// set it to changed on formUpdate.
else {
fieldModel.set('state', 'changed');
}
})
.on('keypress.quickedit', 'input', event => {
if (event.keyCode === 13) {
return false;
}
});
// The in-place editor has loaded; change state to 'active'.
fieldModel.set('state', 'active');
});
},
/**
* Removes the form for this field, detaches behaviors and event handlers.
*/
removeForm() {
if (this.$formContainer === null) {
return;
}
delete this.formSaveAjax;
// Allow form widgets to detach properly.
Drupal.detachBehaviors(this.$formContainer.get(0), null, 'unload');
this.$formContainer
.off('change.quickedit', ':input')
.off('keypress.quickedit', 'input')
.remove();
this.$formContainer = null;
},
/**
* @inheritdoc
*/
save() {
const $formContainer = this.$formContainer;
const $submit = $formContainer.find('.quickedit-form-submit');
const editorModel = this.model;
const fieldModel = this.fieldModel;
// Create an AJAX object for the form associated with the field.
let formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(
{
nocssjs: false,
other_view_modes: fieldModel.findOtherViewModes(),
},
$submit,
);
function cleanUpAjax() {
Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax);
formSaveAjax = null;
}
// Successfully saved.
formSaveAjax.commands.quickeditFieldFormSaved = function(
ajax,
response,
status,
) {
cleanUpAjax();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Second, set the 'htmlForOtherViewModes' attribute, so that when this
// field is rerendered, the change can be propagated to other instances
// of this field, which may be displayed in different view modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will cause
// the field to be rerendered.
_.defer(() => {
fieldModel.set('html', response.data);
});
};
// Unsuccessfully saved; validation errors.
formSaveAjax.commands.quickeditFieldFormValidationErrors = function(
ajax,
response,
status,
) {
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
// The quickeditFieldForm AJAX command is called upon attempting to save
// the form; Form API will mark which form items have errors, if any. This
// command is invoked only if validation errors exist and then it runs
// before editFieldFormValidationErrors().
formSaveAjax.commands.quickeditFieldForm = function(
ajax,
response,
status,
) {
Drupal.AjaxCommands.prototype.insert(ajax, {
data: response.data,
selector: `#${$formContainer.attr('id')} form`,
});
};
// Click the form's submit button; the scoped AJAX commands above will
// handle the server's response.
$submit.trigger('click.quickedit');
},
/**
* @inheritdoc
*/
showValidationErrors() {
this.$formContainer
.find('.quickedit-form')
.addClass('quickedit-validation-error')
.find('form')
.prepend(this.model.get('validationErrors'));
},
},
);
})(jQuery, Drupal, _);

View file

@ -1,42 +1,17 @@
/**
* @file
* Form-based in-place editor. Works for any field type.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal, _) {
'use strict';
/**
* @constructor
*
* @augments Drupal.quickedit.EditorView
*/
Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.form# */{
/**
* Tracks form container DOM element that is used while in-place editing.
*
* @type {jQuery}
*/
Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend({
$formContainer: null,
/**
* Holds the {@link Drupal.Ajax} object.
*
* @type {Drupal.Ajax}
*/
formSaveAjax: null,
/**
* @inheritdoc
*
* @param {object} fieldModel
* The field model that holds the state.
* @param {string} state
* The state to change to.
*/
stateChange: function (fieldModel, state) {
stateChange: function stateChange(fieldModel, state) {
var from = fieldModel.previous('state');
var to = state;
switch (to) {
@ -53,7 +28,6 @@
break;
case 'activating':
// If coming from an invalid state, then the form is already loaded.
if (from !== 'invalid') {
this.loadForm();
}
@ -77,59 +51,40 @@
break;
}
},
/**
* @inheritdoc
*
* @return {object}
* A settings object for the quick edit UI.
*/
getQuickEditUISettings: function () {
return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true};
getQuickEditUISettings: function getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: true,
fullWidthToolbar: true,
popup: true
};
},
/**
* Loads the form for this field, displays it on top of the actual field.
*/
loadForm: function () {
loadForm: function loadForm() {
var fieldModel = this.fieldModel;
// Generate a DOM-compatible ID for the form container DOM element.
var id = 'quickedit-form-for-' + fieldModel.id.replace(/[\/\[\]]/g, '_');
var id = 'quickedit-form-for-' + fieldModel.id.replace(/[/[\]]/g, '_');
// Render form container.
var $formContainer = this.$formContainer = $(Drupal.theme('quickeditFormContainer', {
var $formContainer = $(Drupal.theme('quickeditFormContainer', {
id: id,
loadingMsg: Drupal.t('Loading…')
}));
$formContainer
.find('.quickedit-form')
.addClass('quickedit-editable quickedit-highlighted quickedit-editing')
.attr('role', 'dialog');
this.$formContainer = $formContainer;
$formContainer.find('.quickedit-form').addClass('quickedit-editable quickedit-highlighted quickedit-editing').attr('role', 'dialog');
// Insert form container in DOM.
if (this.$el.css('display') === 'inline') {
$formContainer.prependTo(this.$el.offsetParent());
// Position the form container to render on top of the field's element.
var pos = this.$el.position();
$formContainer.css('left', pos.left).css('top', pos.top);
}
else {
} else {
$formContainer.insertBefore(this.$el);
}
// Load form, insert it into the form container and attach event handlers.
var formOptions = {
fieldID: fieldModel.get('fieldID'),
$el: this.$el,
nocssjs: false,
// Reset an existing entry for this entity in the PrivateTempStore (if
// any) when loading the field. Logically speaking, this should happen
// in a separate request because this is an entity-level operation, not
// a field-level operation. But that would require an additional
// request, that might not even be necessary: it is only when a user
// loads a first changed field for an entity that this needs to happen:
// precisely now!
reset: !fieldModel.get('entity').get('inTempStore')
};
Drupal.quickedit.util.form.load(formOptions, function (form, ajax) {
@ -138,96 +93,67 @@
selector: '#' + id + ' .placeholder'
});
$formContainer
.on('formUpdated.quickedit', ':input', function (event) {
var state = fieldModel.get('state');
// If the form is in an invalid state, it will persist on the page.
// Set the field to activating so that the user can correct the
// invalid value.
if (state === 'invalid') {
fieldModel.set('state', 'activating');
}
// Otherwise assume that the fieldModel is in a candidate state and
// set it to changed on formUpdate.
else {
$formContainer.on('formUpdated.quickedit', ':input', function (event) {
var state = fieldModel.get('state');
if (state === 'invalid') {
fieldModel.set('state', 'activating');
} else {
fieldModel.set('state', 'changed');
}
})
.on('keypress.quickedit', 'input', function (event) {
if (event.keyCode === 13) {
return false;
}
});
}).on('keypress.quickedit', 'input', function (event) {
if (event.keyCode === 13) {
return false;
}
});
// The in-place editor has loaded; change state to 'active'.
fieldModel.set('state', 'active');
});
},
/**
* Removes the form for this field, detaches behaviors and event handlers.
*/
removeForm: function () {
removeForm: function removeForm() {
if (this.$formContainer === null) {
return;
}
delete this.formSaveAjax;
// Allow form widgets to detach properly.
Drupal.detachBehaviors(this.$formContainer.get(0), null, 'unload');
this.$formContainer
.off('change.quickedit', ':input')
.off('keypress.quickedit', 'input')
.remove();
this.$formContainer.off('change.quickedit', ':input').off('keypress.quickedit', 'input').remove();
this.$formContainer = null;
},
/**
* @inheritdoc
*/
save: function () {
save: function save() {
var $formContainer = this.$formContainer;
var $submit = $formContainer.find('.quickedit-form-submit');
var editorModel = this.model;
var fieldModel = this.fieldModel;
var formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving({
nocssjs: false,
other_view_modes: fieldModel.findOtherViewModes()
}, $submit);
function cleanUpAjax() {
Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax);
formSaveAjax = null;
}
// Create an AJAX object for the form associated with the field.
var formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving({
nocssjs: false,
other_view_modes: fieldModel.findOtherViewModes()
}, $submit);
// Successfully saved.
formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) {
cleanUpAjax();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Second, set the 'htmlForOtherViewModes' attribute, so that when this
// field is rerendered, the change can be propagated to other instances
// of this field, which may be displayed in different view modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will cause
// the field to be rerendered.
_.defer(function () {
fieldModel.set('html', response.data);
});
};
// Unsuccessfully saved; validation errors.
formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) {
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
// The quickeditFieldForm AJAX command is called upon attempting to save
// the form; Form API will mark which form items have errors, if any. This
// command is invoked only if validation errors exist and then it runs
// before editFieldFormValidationErrors().
formSaveAjax.commands.quickeditFieldForm = function (ajax, response, status) {
Drupal.AjaxCommands.prototype.insert(ajax, {
data: response.data,
@ -235,21 +161,10 @@
});
};
// Click the form's submit button; the scoped AJAX commands above will
// handle the server's response.
$submit.trigger('click.quickedit');
},
/**
* @inheritdoc
*/
showValidationErrors: function () {
this.$formContainer
.find('.quickedit-form')
.addClass('quickedit-validation-error')
.find('form')
.prepend(this.model.get('validationErrors'));
showValidationErrors: function showValidationErrors() {
this.$formContainer.find('.quickedit-form').addClass('quickedit-validation-error').find('form').prepend(this.model.get('validationErrors'));
}
});
})(jQuery, Drupal, _);
})(jQuery, Drupal, _);

View file

@ -0,0 +1,140 @@
/**
* @file
* ContentEditable-based in-place editor for plain text content.
*/
(function($, _, Drupal) {
Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend(
/** @lends Drupal.quickedit.editors.plain_text# */ {
/**
* Stores the textual DOM element that is being in-place edited.
*/
$textElement: null,
/**
* @constructs
*
* @augments Drupal.quickedit.EditorView
*
* @param {object} options
* Options for the plain text editor.
*/
initialize(options) {
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
const editorModel = this.model;
const fieldModel = this.fieldModel;
// Store the original value of this field. Necessary for reverting
// changes.
const $fieldItems = this.$el.find('.quickedit-field');
const $textElement = $fieldItems.length ? $fieldItems.eq(0) : this.$el;
this.$textElement = $textElement;
editorModel.set('originalValue', $.trim(this.$textElement.text()));
// Sets the state to 'changed' whenever the value changes.
let previousText = editorModel.get('originalValue');
$textElement.on('keyup paste', event => {
const currentText = $.trim($textElement.text());
if (previousText !== currentText) {
previousText = currentText;
editorModel.set('currentValue', currentText);
fieldModel.set('state', 'changed');
}
});
},
/**
* @inheritdoc
*
* @return {jQuery}
* The text element for the plain text editor.
*/
getEditedElement() {
return this.$textElement;
},
/**
* @inheritdoc
*
* @param {object} fieldModel
* The field model that holds the state.
* @param {string} state
* The state to change to.
* @param {object} options
* State options, if needed by the state change.
*/
stateChange(fieldModel, state, options) {
const from = fieldModel.previous('state');
const to = state;
switch (to) {
case 'inactive':
break;
case 'candidate':
if (from !== 'inactive') {
this.$textElement.removeAttr('contenteditable');
}
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
break;
case 'activating':
// Defer updating the field model until the current state change has
// propagated, to not trigger a nested state change event.
_.defer(() => {
fieldModel.set('state', 'active');
});
break;
case 'active':
this.$textElement.attr('contenteditable', 'true');
break;
case 'changed':
break;
case 'saving':
if (from === 'invalid') {
this.removeValidationErrors();
}
this.save(options);
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
break;
}
},
/**
* @inheritdoc
*
* @return {object}
* A settings object for the quick edit UI.
*/
getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false,
};
},
/**
* @inheritdoc
*/
revert() {
this.$textElement.html(this.model.get('originalValue'));
},
},
);
})(jQuery, _, Drupal);

View file

@ -1,46 +1,25 @@
/**
* @file
* ContentEditable-based in-place editor for plain text content.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Drupal) {
'use strict';
Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.plain_text# */{
/**
* Stores the textual DOM element that is being in-place edited.
*/
Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend({
$textElement: null,
/**
* @constructs
*
* @augments Drupal.quickedit.EditorView
*
* @param {object} options
* Options for the plain text editor.
*/
initialize: function (options) {
initialize: function initialize(options) {
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
var editorModel = this.model;
var fieldModel = this.fieldModel;
// Store the original value of this field. Necessary for reverting
// changes.
var $textElement;
var $fieldItems = this.$el.find('.quickedit-field');
if ($fieldItems.length) {
$textElement = this.$textElement = $fieldItems.eq(0);
}
else {
$textElement = this.$textElement = this.$el;
}
var $textElement = $fieldItems.length ? $fieldItems.eq(0) : this.$el;
this.$textElement = $textElement;
editorModel.set('originalValue', $.trim(this.$textElement.text()));
// Sets the state to 'changed' whenever the value changes.
var previousText = editorModel.get('originalValue');
$textElement.on('keyup paste', function (event) {
var currentText = $.trim($textElement.text());
@ -51,28 +30,10 @@
}
});
},
/**
* @inheritdoc
*
* @return {jQuery}
* The text element for the plain text editor.
*/
getEditedElement: function () {
getEditedElement: function getEditedElement() {
return this.$textElement;
},
/**
* @inheritdoc
*
* @param {object} fieldModel
* The field model that holds the state.
* @param {string} state
* The state to change to.
* @param {object} options
* State options, if needed by the state change.
*/
stateChange: function (fieldModel, state, options) {
stateChange: function stateChange(fieldModel, state, options) {
var from = fieldModel.previous('state');
var to = state;
switch (to) {
@ -92,8 +53,6 @@
break;
case 'activating':
// Defer updating the field model until the current state change has
// propagated, to not trigger a nested state change event.
_.defer(function () {
fieldModel.set('state', 'active');
});
@ -121,24 +80,16 @@
break;
}
},
/**
* @inheritdoc
*
* @return {object}
* A settings object for the quick edit UI.
*/
getQuickEditUISettings: function () {
return {padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false};
getQuickEditUISettings: function getQuickEditUISettings() {
return {
padding: true,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false
};
},
/**
* @inheritdoc
*/
revert: function () {
revert: function revert() {
this.$textElement.html(this.model.get('originalValue'));
}
});
})(jQuery, _, Drupal);
})(jQuery, _, Drupal);

View file

@ -0,0 +1,52 @@
/**
* @file
* A Backbone Model for the state of the in-place editing application.
*
* @see Drupal.quickedit.AppView
*/
(function(Backbone, Drupal) {
/**
* @constructor
*
* @augments Backbone.Model
*/
Drupal.quickedit.AppModel = Backbone.Model.extend(
/** @lends Drupal.quickedit.AppModel# */ {
/**
* @type {object}
*
* @prop {Drupal.quickedit.FieldModel} highlightedField
* @prop {Drupal.quickedit.FieldModel} activeField
* @prop {Drupal.dialog~dialogDefinition} activeModal
*/
defaults: /** @lends Drupal.quickedit.AppModel# */ {
/**
* The currently state='highlighted' Drupal.quickedit.FieldModel, if any.
*
* @type {Drupal.quickedit.FieldModel}
*
* @see Drupal.quickedit.FieldModel.states
*/
highlightedField: null,
/**
* The currently state = 'active' Drupal.quickedit.FieldModel, if any.
*
* @type {Drupal.quickedit.FieldModel}
*
* @see Drupal.quickedit.FieldModel.states
*/
activeField: null,
/**
* Reference to a {@link Drupal.dialog} instance if a state change
* requires confirmation.
*
* @type {Drupal.dialog~dialogDefinition}
*/
activeModal: null,
},
},
);
})(Backbone, Drupal);

View file

@ -1,57 +1,18 @@
/**
* @file
* A Backbone Model for the state of the in-place editing application.
*
* @see Drupal.quickedit.AppView
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Backbone, Drupal) {
'use strict';
/**
* @constructor
*
* @augments Backbone.Model
*/
Drupal.quickedit.AppModel = Backbone.Model.extend(/** @lends Drupal.quickedit.AppModel# */{
/**
* @type {object}
*
* @prop {Drupal.quickedit.FieldModel} highlightedField
* @prop {Drupal.quickedit.FieldModel} activeField
* @prop {Drupal.dialog~dialogDefinition} activeModal
*/
defaults: /** @lends Drupal.quickedit.AppModel# */{
/**
* The currently state='highlighted' Drupal.quickedit.FieldModel, if any.
*
* @type {Drupal.quickedit.FieldModel}
*
* @see Drupal.quickedit.FieldModel.states
*/
Drupal.quickedit.AppModel = Backbone.Model.extend({
defaults: {
highlightedField: null,
/**
* The currently state = 'active' Drupal.quickedit.FieldModel, if any.
*
* @type {Drupal.quickedit.FieldModel}
*
* @see Drupal.quickedit.FieldModel.states
*/
activeField: null,
/**
* Reference to a {@link Drupal.dialog} instance if a state change
* requires confirmation.
*
* @type {Drupal.dialog~dialogDefinition}
*/
activeModal: null
}
});
}(Backbone, Drupal));
})(Backbone, Drupal);

View file

@ -0,0 +1,55 @@
/**
* @file
* A Backbone Model subclass that enforces validation when calling set().
*/
(function(Drupal, Backbone) {
Drupal.quickedit.BaseModel = Backbone.Model.extend(
/** @lends Drupal.quickedit.BaseModel# */ {
/**
* @constructs
*
* @augments Backbone.Model
*
* @param {object} options
* Options for the base model-
*
* @return {Drupal.quickedit.BaseModel}
* A quickedit base model.
*/
initialize(options) {
this.__initialized = true;
return Backbone.Model.prototype.initialize.call(this, options);
},
/**
* Set a value on the model
*
* @param {object|string} key
* The key to set a value for.
* @param {*} val
* The value to set.
* @param {object} [options]
* Options for the model.
*
* @return {*}
* The result of `Backbone.Model.prototype.set` with the specified
* parameters.
*/
set(key, val, options) {
if (this.__initialized) {
// Deal with both the "key", value and {key:value}-style arguments.
if (typeof key === 'object') {
key.validate = true;
} else {
if (!options) {
options = {};
}
options.validate = true;
}
}
return Backbone.Model.prototype.set.call(this, key, val, options);
},
},
);
})(Drupal, Backbone);

View file

@ -1,51 +1,22 @@
/**
* @file
* A Backbone Model subclass that enforces validation when calling set().
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
(function (Drupal, Backbone) {
'use strict';
Drupal.quickedit.BaseModel = Backbone.Model.extend(/** @lends Drupal.quickedit.BaseModel# */{
/**
* @constructs
*
* @augments Backbone.Model
*
* @param {object} options
* Options for the base model-
*
* @return {Drupal.quickedit.BaseModel}
* A quickedit base model.
*/
initialize: function (options) {
Drupal.quickedit.BaseModel = Backbone.Model.extend({
initialize: function initialize(options) {
this.__initialized = true;
return Backbone.Model.prototype.initialize.call(this, options);
},
/**
* Set a value on the model
*
* @param {object|string} key
* The key to set a value for.
* @param {*} val
* The value to set.
* @param {object} [options]
* Options for the model.
*
* @return {*}
* The result of `Backbone.Model.prototype.set` with the specified
* parameters.
*/
set: function (key, val, options) {
set: function set(key, val, options) {
if (this.__initialized) {
// Deal with both the "key", value and {key:value}-style arguments.
if (typeof key === 'object') {
if ((typeof key === 'undefined' ? 'undefined' : _typeof(key)) === 'object') {
key.validate = true;
}
else {
} else {
if (!options) {
options = {};
}
@ -54,7 +25,5 @@
}
return Backbone.Model.prototype.set.call(this, key, val, options);
}
});
}(Drupal, Backbone));
})(Drupal, Backbone);

View file

@ -0,0 +1,49 @@
/**
* @file
* A Backbone Model for the state of an in-place editor.
*
* @see Drupal.quickedit.EditorView
*/
(function(Backbone, Drupal) {
/**
* @constructor
*
* @augments Backbone.Model
*/
Drupal.quickedit.EditorModel = Backbone.Model.extend(
/** @lends Drupal.quickedit.EditorModel# */ {
/**
* @type {object}
*
* @prop {string} originalValue
* @prop {string} currentValue
* @prop {Array} validationErrors
*/
defaults: /** @lends Drupal.quickedit.EditorModel# */ {
/**
* Not the full HTML representation of this field, but the "actual"
* original value of the field, stored by the used in-place editor, and
* in a representation that can be chosen by the in-place editor.
*
* @type {string}
*/
originalValue: null,
/**
* Analogous to originalValue, but the current value.
*
* @type {string}
*/
currentValue: null,
/**
* Stores any validation errors to be rendered.
*
* @type {Array}
*/
validationErrors: null,
},
},
);
})(Backbone, Drupal);

View file

@ -1,54 +1,18 @@
/**
* @file
* A Backbone Model for the state of an in-place editor.
*
* @see Drupal.quickedit.EditorView
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Backbone, Drupal) {
'use strict';
/**
* @constructor
*
* @augments Backbone.Model
*/
Drupal.quickedit.EditorModel = Backbone.Model.extend(/** @lends Drupal.quickedit.EditorModel# */{
/**
* @type {object}
*
* @prop {string} originalValue
* @prop {string} currentValue
* @prop {Array} validationErrors
*/
defaults: /** @lends Drupal.quickedit.EditorModel# */{
/**
* Not the full HTML representation of this field, but the "actual"
* original value of the field, stored by the used in-place editor, and
* in a representation that can be chosen by the in-place editor.
*
* @type {string}
*/
Drupal.quickedit.EditorModel = Backbone.Model.extend({
defaults: {
originalValue: null,
/**
* Analogous to originalValue, but the current value.
*
* @type {string}
*/
currentValue: null,
/**
* Stores any validation errors to be rendered.
*
* @type {Array}
*/
validationErrors: null
}
});
}(Backbone, Drupal));
})(Backbone, Drupal);

View file

@ -0,0 +1,798 @@
/**
* @file
* A Backbone Model for the state of an in-place editable entity in the DOM.
*/
(function(_, $, Backbone, Drupal) {
Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(
/** @lends Drupal.quickedit.EntityModel# */ {
/**
* @type {object}
*/
defaults: /** @lends Drupal.quickedit.EntityModel# */ {
/**
* The DOM element that represents this entity.
*
* It may seem bizarre to have a DOM element in a Backbone Model, but we
* need to be able to map entities in the DOM to EntityModels in memory.
*
* @type {HTMLElement}
*/
el: null,
/**
* An entity ID, of the form `<entity type>/<entity ID>`
*
* @example
* "node/1"
*
* @type {string}
*/
entityID: null,
/**
* An entity instance ID.
*
* The first instance of a specific entity (i.e. with a given entity ID)
* is assigned 0, the second 1, and so on.
*
* @type {number}
*/
entityInstanceID: null,
/**
* The unique ID of this entity instance on the page, of the form
* `<entity type>/<entity ID>[entity instance ID]`
*
* @example
* "node/1[0]"
*
* @type {string}
*/
id: null,
/**
* The label of the entity.
*
* @type {string}
*/
label: null,
/**
* A FieldCollection for all fields of the entity.
*
* @type {Drupal.quickedit.FieldCollection}
*
* @see Drupal.quickedit.FieldCollection
*/
fields: null,
// The attributes below are stateful. The ones above will never change
// during the life of a EntityModel instance.
/**
* Indicates whether this entity is currently being edited in-place.
*
* @type {bool}
*/
isActive: false,
/**
* Whether one or more fields are already been stored in PrivateTempStore.
*
* @type {bool}
*/
inTempStore: false,
/**
* Indicates whether a "Save" button is necessary or not.
*
* Whether one or more fields have already been stored in PrivateTempStore
* *or* the field that's currently being edited is in the 'changed' or a
* later state.
*
* @type {bool}
*/
isDirty: false,
/**
* Whether the request to the server has been made to commit this entity.
*
* Used to prevent multiple such requests.
*
* @type {bool}
*/
isCommitting: false,
/**
* The current processing state of an entity.
*
* @type {string}
*/
state: 'closed',
/**
* IDs of fields whose new values have been stored in PrivateTempStore.
*
* We must store this on the EntityModel as well (even though it already
* is on the FieldModel) because when a field is rerendered, its
* FieldModel is destroyed and this allows us to transition it back to
* the proper state.
*
* @type {Array.<string>}
*/
fieldsInTempStore: [],
/**
* A flag the tells the application that this EntityModel must be reloaded
* in order to restore the original values to its fields in the client.
*
* @type {bool}
*/
reload: false,
},
/**
* @constructs
*
* @augments Drupal.quickedit.BaseModel
*/
initialize() {
this.set('fields', new Drupal.quickedit.FieldCollection());
// Respond to entity state changes.
this.listenTo(this, 'change:state', this.stateChange);
// The state of the entity is largely dependent on the state of its
// fields.
this.listenTo(
this.get('fields'),
'change:state',
this.fieldStateChange,
);
// Call Drupal.quickedit.BaseModel's initialize() method.
Drupal.quickedit.BaseModel.prototype.initialize.call(this);
},
/**
* Updates FieldModels' states when an EntityModel change occurs.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The entity model
* @param {string} state
* The state of the associated entity. One of
* {@link Drupal.quickedit.EntityModel.states}.
* @param {object} options
* Options for the entity model.
*/
stateChange(entityModel, state, options) {
const to = state;
switch (to) {
case 'closed':
this.set({
isActive: false,
inTempStore: false,
isDirty: false,
});
break;
case 'launching':
break;
case 'opening':
// Set the fields to candidate state.
entityModel.get('fields').each(fieldModel => {
fieldModel.set('state', 'candidate', options);
});
break;
case 'opened':
// The entity is now ready for editing!
this.set('isActive', true);
break;
case 'committing': {
// The user indicated they want to save the entity.
const fields = this.get('fields');
// For fields that are in an active state, transition them to
// candidate.
fields
.chain()
.filter(
fieldModel =>
_.intersection([fieldModel.get('state')], ['active']).length,
)
.each(fieldModel => {
fieldModel.set('state', 'candidate');
});
// For fields that are in a changed state, field values must first be
// stored in PrivateTempStore.
fields
.chain()
.filter(
fieldModel =>
_.intersection(
[fieldModel.get('state')],
Drupal.quickedit.app.changedFieldStates,
).length,
)
.each(fieldModel => {
fieldModel.set('state', 'saving');
});
break;
}
case 'deactivating': {
const changedFields = this.get('fields').filter(
fieldModel =>
_.intersection(
[fieldModel.get('state')],
['changed', 'invalid'],
).length,
);
// If the entity contains unconfirmed or unsaved changes, return the
// entity to an opened state and ask the user if they would like to
// save the changes or discard the changes.
// 1. One of the fields is in a changed state. The changed field
// might just be a change in the client or it might have been saved
// to tempstore.
// 2. The saved flag is empty and the confirmed flag is empty. If
// the entity has been saved to the server, the fields changed in
// the client are irrelevant. If the changes are confirmed, then
// proceed to set the fields to candidate state.
if (
(changedFields.length || this.get('fieldsInTempStore').length) &&
(!options.saved && !options.confirmed)
) {
// Cancel deactivation until the user confirms save or discard.
this.set('state', 'opened', { confirming: true });
// An action in reaction to state change must be deferred.
_.defer(() => {
Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
});
} else {
const invalidFields = this.get('fields').filter(
fieldModel =>
_.intersection([fieldModel.get('state')], ['invalid']).length,
);
// Indicate if this EntityModel needs to be reloaded in order to
// restore the original values of its fields.
entityModel.set(
'reload',
this.get('fieldsInTempStore').length || invalidFields.length,
);
// Set all fields to the 'candidate' state. A changed field may have
// to go through confirmation first.
entityModel.get('fields').each(fieldModel => {
// If the field is already in the candidate state, trigger a
// change event so that the entityModel can move to the next state
// in deactivation.
if (
_.intersection(
[fieldModel.get('state')],
['candidate', 'highlighted'],
).length
) {
fieldModel.trigger(
'change:state',
fieldModel,
fieldModel.get('state'),
options,
);
} else {
fieldModel.set('state', 'candidate', options);
}
});
}
break;
}
case 'closing':
// Set all fields to the 'inactive' state.
options.reason = 'stop';
this.get('fields').each(fieldModel => {
fieldModel.set(
{
inTempStore: false,
state: 'inactive',
},
options,
);
});
break;
}
},
/**
* Updates a Field and Entity model's "inTempStore" when appropriate.
*
* Helper function.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The model of the entity for which a field's state attribute has
* changed.
* @param {Drupal.quickedit.FieldModel} fieldModel
* The model of the field whose state attribute has changed.
*
* @see Drupal.quickedit.EntityModel#fieldStateChange
*/
_updateInTempStoreAttributes(entityModel, fieldModel) {
const current = fieldModel.get('state');
const previous = fieldModel.previous('state');
let fieldsInTempStore = entityModel.get('fieldsInTempStore');
// If the fieldModel changed to the 'saved' state: remember that this
// field was saved to PrivateTempStore.
if (current === 'saved') {
// Mark the entity as saved in PrivateTempStore, so that we can pass the
// proper "reset PrivateTempStore" boolean value when communicating with
// the server.
entityModel.set('inTempStore', true);
// Mark the field as saved in PrivateTempStore, so that visual
// indicators signifying just that may be rendered.
fieldModel.set('inTempStore', true);
// Remember that this field is in PrivateTempStore, restore when
// rerendered.
fieldsInTempStore.push(fieldModel.get('fieldID'));
fieldsInTempStore = _.uniq(fieldsInTempStore);
entityModel.set('fieldsInTempStore', fieldsInTempStore);
}
// If the fieldModel changed to the 'candidate' state from the
// 'inactive' state, then this is a field for this entity that got
// rerendered. Restore its previous 'inTempStore' attribute value.
else if (current === 'candidate' && previous === 'inactive') {
fieldModel.set(
'inTempStore',
_.intersection([fieldModel.get('fieldID')], fieldsInTempStore)
.length > 0,
);
}
},
/**
* Reacts to state changes in this entity's fields.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The model of the field whose state attribute changed.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
fieldStateChange(fieldModel, state) {
const entityModel = this;
const fieldState = state;
// Switch on the entityModel state.
// The EntityModel responds to FieldModel state changes as a function of
// its state. For example, a field switching back to 'candidate' state
// when its entity is in the 'opened' state has no effect on the entity.
// But that same switch back to 'candidate' state of a field when the
// entity is in the 'committing' state might allow the entity to proceed
// with the commit flow.
switch (this.get('state')) {
case 'closed':
case 'launching':
// It should be impossible to reach these: fields can't change state
// while the entity is closed or still launching.
break;
case 'opening':
// We must change the entity to the 'opened' state, but it must first
// be confirmed that all of its fieldModels have transitioned to the
// 'candidate' state.
// We do this here, because this is called every time a fieldModel
// changes state, hence each time this is called, we get closer to the
// goal of having all fieldModels in the 'candidate' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(() => {
entityModel.set('state', 'opened', {
'accept-field-states': Drupal.quickedit.app.readyFieldStates,
});
});
break;
case 'opened':
// Set the isDirty attribute when appropriate so that it is known when
// to display the "Save" button in the entity toolbar.
// Note that once a field has been changed, there's no way to discard
// that change, hence it will have to be saved into PrivateTempStore,
// or the in-place editing of this field will have to be stopped
// completely. In other words: once any field enters the 'changed'
// field, then for the remainder of the in-place editing session, the
// entity is by definition dirty.
if (fieldState === 'changed') {
entityModel.set('isDirty', true);
} else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
break;
case 'committing': {
// If the field save returned a validation error, set the state of the
// entity back to 'opened'.
if (fieldState === 'invalid') {
// A state change in reaction to another state change must be
// deferred.
_.defer(() => {
entityModel.set('state', 'opened', { reason: 'invalid' });
});
} else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
// Attempt to save the entity. If the entity's fields are not yet all
// in a ready state, the save will not be processed.
const options = {
'accept-field-states': Drupal.quickedit.app.readyFieldStates,
};
if (entityModel.set('isCommitting', true, options)) {
entityModel.save({
success() {
entityModel.set(
{
state: 'deactivating',
isCommitting: false,
},
{ saved: true },
);
},
error() {
// Reset the "isCommitting" mutex.
entityModel.set('isCommitting', false);
// Change the state back to "opened", to allow the user to hit
// the "Save" button again.
entityModel.set('state', 'opened', {
reason: 'networkerror',
});
// Show a modal to inform the user of the network error.
const message = Drupal.t(
'Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.',
{ '@entity-title': entityModel.get('label') },
);
Drupal.quickedit.util.networkErrorModal(
Drupal.t('Network problem!'),
message,
);
},
});
}
break;
}
case 'deactivating':
// When setting the entity to 'closing', require that all fieldModels
// are in either the 'candidate' or 'highlighted' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(() => {
entityModel.set('state', 'closing', {
'accept-field-states': Drupal.quickedit.app.readyFieldStates,
});
});
break;
case 'closing':
// When setting the entity to 'closed', require that all fieldModels
// are in the 'inactive' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(() => {
entityModel.set('state', 'closed', {
'accept-field-states': ['inactive'],
});
});
break;
}
},
/**
* Fires an AJAX request to the REST save URL for an entity.
*
* @param {object} options
* An object of options that contains:
* @param {function} [options.success]
* A function to invoke if the entity is successfully saved.
*/
save(options) {
const entityModel = this;
// Create a Drupal.ajax instance to save the entity.
const entitySaverAjax = Drupal.ajax({
url: Drupal.url(`quickedit/entity/${entityModel.get('entityID')}`),
error() {
// Let the Drupal.quickedit.EntityModel Backbone model's error()
// method handle errors.
options.error.call(entityModel);
},
});
// Entity saved successfully.
entitySaverAjax.commands.quickeditEntitySaved = function(
ajax,
response,
status,
) {
// All fields have been moved from PrivateTempStore to permanent
// storage, update the "inTempStore" attribute on FieldModels, on the
// EntityModel and clear EntityModel's "fieldInTempStore" attribute.
entityModel.get('fields').each(fieldModel => {
fieldModel.set('inTempStore', false);
});
entityModel.set('inTempStore', false);
entityModel.set('fieldsInTempStore', []);
// Invoke the optional success callback.
if (options.success) {
options.success.call(entityModel);
}
};
// Trigger the AJAX request, which will will return the
// quickeditEntitySaved AJAX command to which we then react.
entitySaverAjax.execute();
},
/**
* Validate the entity model.
*
* @param {object} attrs
* The attributes changes in the save or set call.
* @param {object} options
* An object with the following option:
* @param {string} [options.reason]
* A string that conveys a particular reason to allow for an exceptional
* state change.
* @param {Array} options.accept-field-states
* An array of strings that represent field states that the entities must
* be in to validate. For example, if `accept-field-states` is
* `['candidate', 'highlighted']`, then all the fields of the entity must
* be in either of these two states for the save or set call to
* validate and proceed.
*
* @return {string}
* A string to say something about the state of the entity model.
*/
validate(attrs, options) {
const acceptedFieldStates = options['accept-field-states'] || [];
// Validate state change.
const currentState = this.get('state');
const nextState = attrs.state;
if (currentState !== nextState) {
// Ensure it's a valid state.
if (_.indexOf(this.constructor.states, nextState) === -1) {
return `"${nextState}" is an invalid state`;
}
// Ensure it's a state change that is allowed.
// Check if the acceptStateChange function accepts it.
if (!this._acceptStateChange(currentState, nextState, options)) {
return 'state change not accepted';
}
// If that function accepts it, then ensure all fields are also in an
// acceptable state.
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'state change not accepted because fields are not in acceptable state';
}
}
// Validate setting isCommitting = true.
const currentIsCommitting = this.get('isCommitting');
const nextIsCommitting = attrs.isCommitting;
if (currentIsCommitting === false && nextIsCommitting === true) {
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'isCommitting change not accepted because fields are not in acceptable state';
}
} else if (currentIsCommitting === true && nextIsCommitting === true) {
return 'isCommitting is a mutex, hence only changes are allowed';
}
},
/**
* Checks if a state change can be accepted.
*
* @param {string} from
* From state.
* @param {string} to
* To state.
* @param {object} context
* Context for the check.
* @param {string} context.reason
* The reason for the state change.
* @param {bool} context.confirming
* Whether context is confirming or not.
*
* @return {bool}
* Whether the state change is accepted or not.
*
* @see Drupal.quickedit.AppView#acceptEditorStateChange
*/
_acceptStateChange(from, to, context) {
let accept = true;
// In general, enforce the states sequence. Disallow going back from a
// "later" state to an "earlier" state, except in explicitly allowed
// cases.
if (!this.constructor.followsStateSequence(from, to)) {
accept = false;
// Allow: closing -> closed.
// Necessary to stop editing an entity.
if (from === 'closing' && to === 'closed') {
accept = true;
}
// Allow: committing -> opened.
// Necessary to be able to correct an invalid field, or to hit the
// "Save" button again after a server/network error.
else if (
from === 'committing' &&
to === 'opened' &&
context.reason &&
(context.reason === 'invalid' || context.reason === 'networkerror')
) {
accept = true;
}
// Allow: deactivating -> opened.
// Necessary to be able to confirm changes with the user.
else if (
from === 'deactivating' &&
to === 'opened' &&
context.confirming
) {
accept = true;
}
// Allow: opened -> deactivating.
// Necessary to be able to stop editing.
else if (
from === 'opened' &&
to === 'deactivating' &&
context.confirmed
) {
accept = true;
}
}
return accept;
},
/**
* Checks if fields have acceptable states.
*
* @param {Array} acceptedFieldStates
* An array of acceptable field states to check for.
*
* @return {bool}
* Whether the fields have an acceptable state.
*
* @see Drupal.quickedit.EntityModel#validate
*/
_fieldsHaveAcceptableStates(acceptedFieldStates) {
let accept = true;
// If no acceptable field states are provided, assume all field states are
// acceptable. We want to let validation pass as a default and only
// check validity on calls to set that explicitly request it.
if (acceptedFieldStates.length > 0) {
const fieldStates = this.get('fields').pluck('state') || [];
// If not all fields are in one of the accepted field states, then we
// still can't allow this state change.
if (_.difference(fieldStates, acceptedFieldStates).length) {
accept = false;
}
}
return accept;
},
/**
* Destroys the entity model.
*
* @param {object} options
* Options for the entity model.
*/
destroy(options) {
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
this.stopListening();
// Destroy all fields of this entity.
this.get('fields').reset();
},
/**
* @inheritdoc
*/
sync() {
// We don't use REST updates to sync.
},
},
/** @lends Drupal.quickedit.EntityModel */ {
/**
* Sequence of all possible states an entity can be in during quickediting.
*
* @type {Array.<string>}
*/
states: [
// Initial state, like field's 'inactive' OR the user has just finished
// in-place editing this entity.
// - Trigger: none (initial) or EntityModel (finished).
// - Expected behavior: (when not initial state): tear down
// EntityToolbarView, in-place editors and related views.
'closed',
// User has activated in-place editing of this entity.
// - Trigger: user.
// - Expected behavior: the EntityToolbarView is gets set up, in-place
// editors (EditorViews) and related views for this entity's fields are
// set up. Upon completion of those, the state is changed to 'opening'.
'launching',
// Launching has finished.
// - Trigger: application.
// - Guarantees: in-place editors ready for use, all entity and field
// views have been set up, all fields are in the 'inactive' state.
// - Expected behavior: all fields are changed to the 'candidate' state
// and once this is completed, the entity state will be changed to
// 'opened'.
'opening',
// Opening has finished.
// - Trigger: EntityModel.
// - Guarantees: see 'opening', all fields are in the 'candidate' state.
// - Expected behavior: the user is able to actually use in-place editing.
'opened',
// User has clicked the 'Save' button (and has thus changed at least one
// field).
// - Trigger: user.
// - Guarantees: see 'opened', plus: either a changed field is in
// PrivateTempStore, or the user has just modified a field without
// activating (switching to) another field.
// - Expected behavior: 1) if any of the fields are not yet in
// PrivateTempStore, save them to PrivateTempStore, 2) if then any of
// the fields has the 'invalid' state, then change the entity state back
// to 'opened', otherwise: save the entity by committing it from
// PrivateTempStore into permanent storage.
'committing',
// User has clicked the 'Close' button, or has clicked the 'Save' button
// and that was successfully completed.
// - Trigger: user or EntityModel.
// - Guarantees: when having clicked 'Close' hardly any: fields may be in
// a variety of states; when having clicked 'Save': all fields are in
// the 'candidate' state.
// - Expected behavior: transition all fields to the 'candidate' state,
// possibly requiring confirmation in the case of having clicked
// 'Close'.
'deactivating',
// Deactivation has been completed.
// - Trigger: EntityModel.
// - Guarantees: all fields are in the 'candidate' state.
// - Expected behavior: change all fields to the 'inactive' state.
'closing',
],
/**
* Indicates whether the 'from' state comes before the 'to' state.
*
* @param {string} from
* One of {@link Drupal.quickedit.EntityModel.states}.
* @param {string} to
* One of {@link Drupal.quickedit.EntityModel.states}.
*
* @return {bool}
* Whether the 'from' state comes before the 'to' state.
*/
followsStateSequence(from, to) {
return _.indexOf(this.states, from) < _.indexOf(this.states, to);
},
},
);
/**
* @constructor
*
* @augments Backbone.Collection
*/
Drupal.quickedit.EntityCollection = Backbone.Collection.extend(
/** @lends Drupal.quickedit.EntityCollection# */ {
/**
* @type {Drupal.quickedit.EntityModel}
*/
model: Drupal.quickedit.EntityModel,
},
);
})(_, jQuery, Backbone, Drupal);

View file

@ -1,172 +1,50 @@
/**
* @file
* A Backbone Model for the state of an in-place editable entity in the DOM.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (_, $, Backbone, Drupal) {
'use strict';
Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{
/**
* @type {object}
*/
defaults: /** @lends Drupal.quickedit.EntityModel# */{
/**
* The DOM element that represents this entity.
*
* It may seem bizarre to have a DOM element in a Backbone Model, but we
* need to be able to map entities in the DOM to EntityModels in memory.
*
* @type {HTMLElement}
*/
Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend({
defaults: {
el: null,
/**
* An entity ID, of the form `<entity type>/<entity ID>`
*
* @example
* "node/1"
*
* @type {string}
*/
entityID: null,
/**
* An entity instance ID.
*
* The first instance of a specific entity (i.e. with a given entity ID)
* is assigned 0, the second 1, and so on.
*
* @type {number}
*/
entityInstanceID: null,
/**
* The unique ID of this entity instance on the page, of the form
* `<entity type>/<entity ID>[entity instance ID]`
*
* @example
* "node/1[0]"
*
* @type {string}
*/
id: null,
/**
* The label of the entity.
*
* @type {string}
*/
label: null,
/**
* A FieldCollection for all fields of the entity.
*
* @type {Drupal.quickedit.FieldCollection}
*
* @see Drupal.quickedit.FieldCollection
*/
fields: null,
// The attributes below are stateful. The ones above will never change
// during the life of a EntityModel instance.
/**
* Indicates whether this entity is currently being edited in-place.
*
* @type {bool}
*/
isActive: false,
/**
* Whether one or more fields are already been stored in PrivateTempStore.
*
* @type {bool}
*/
inTempStore: false,
/**
* Indicates whether a "Save" button is necessary or not.
*
* Whether one or more fields have already been stored in PrivateTempStore
* *or* the field that's currently being edited is in the 'changed' or a
* later state.
*
* @type {bool}
*/
isDirty: false,
/**
* Whether the request to the server has been made to commit this entity.
*
* Used to prevent multiple such requests.
*
* @type {bool}
*/
isCommitting: false,
/**
* The current processing state of an entity.
*
* @type {string}
*/
state: 'closed',
/**
* IDs of fields whose new values have been stored in PrivateTempStore.
*
* We must store this on the EntityModel as well (even though it already
* is on the FieldModel) because when a field is rerendered, its
* FieldModel is destroyed and this allows us to transition it back to
* the proper state.
*
* @type {Array.<string>}
*/
fieldsInTempStore: [],
/**
* A flag the tells the application that this EntityModel must be reloaded
* in order to restore the original values to its fields in the client.
*
* @type {bool}
*/
reload: false
},
/**
* @constructs
*
* @augments Drupal.quickedit.BaseModel
*/
initialize: function () {
initialize: function initialize() {
this.set('fields', new Drupal.quickedit.FieldCollection());
// Respond to entity state changes.
this.listenTo(this, 'change:state', this.stateChange);
// The state of the entity is largely dependent on the state of its
// fields.
this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange);
// Call Drupal.quickedit.BaseModel's initialize() method.
Drupal.quickedit.BaseModel.prototype.initialize.call(this);
},
/**
* Updates FieldModels' states when an EntityModel change occurs.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The entity model
* @param {string} state
* The state of the associated entity. One of
* {@link Drupal.quickedit.EntityModel.states}.
* @param {object} options
* Options for the entity model.
*/
stateChange: function (entityModel, state, options) {
stateChange: function stateChange(entityModel, state, options) {
var to = state;
switch (to) {
case 'closed':
@ -181,89 +59,64 @@
break;
case 'opening':
// Set the fields to candidate state.
entityModel.get('fields').each(function (fieldModel) {
fieldModel.set('state', 'candidate', options);
});
break;
case 'opened':
// The entity is now ready for editing!
this.set('isActive', true);
break;
case 'committing':
// The user indicated they want to save the entity.
var fields = this.get('fields');
// For fields that are in an active state, transition them to
// candidate.
fields.chain()
.filter(function (fieldModel) {
{
var fields = this.get('fields');
fields.chain().filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], ['active']).length;
})
.each(function (fieldModel) {
}).each(function (fieldModel) {
fieldModel.set('state', 'candidate');
});
// For fields that are in a changed state, field values must first be
// stored in PrivateTempStore.
fields.chain()
.filter(function (fieldModel) {
fields.chain().filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length;
})
.each(function (fieldModel) {
}).each(function (fieldModel) {
fieldModel.set('state', 'saving');
});
break;
break;
}
case 'deactivating':
var changedFields = this.get('fields')
.filter(function (fieldModel) {
{
var changedFields = this.get('fields').filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length;
});
// If the entity contains unconfirmed or unsaved changes, return the
// entity to an opened state and ask the user if they would like to
// save the changes or discard the changes.
// 1. One of the fields is in a changed state. The changed field
// might just be a change in the client or it might have been saved
// to tempstore.
// 2. The saved flag is empty and the confirmed flag is empty. If
// the entity has been saved to the server, the fields changed in
// the client are irrelevant. If the changes are confirmed, then
// proceed to set the fields to candidate state.
if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) {
// Cancel deactivation until the user confirms save or discard.
this.set('state', 'opened', {confirming: true});
// An action in reaction to state change must be deferred.
_.defer(function () {
Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
});
}
else {
var invalidFields = this.get('fields')
.filter(function (fieldModel) {
if ((changedFields.length || this.get('fieldsInTempStore').length) && !options.saved && !options.confirmed) {
this.set('state', 'opened', { confirming: true });
_.defer(function () {
Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
});
} else {
var invalidFields = this.get('fields').filter(function (fieldModel) {
return _.intersection([fieldModel.get('state')], ['invalid']).length;
});
// Indicate if this EntityModel needs to be reloaded in order to
// restore the original values of its fields.
entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length));
// Set all fields to the 'candidate' state. A changed field may have
// to go through confirmation first.
entityModel.get('fields').each(function (fieldModel) {
// If the field is already in the candidate state, trigger a
// change event so that the entityModel can move to the next state
// in deactivation.
if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) {
fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
}
else {
fieldModel.set('state', 'candidate', options);
}
});
entityModel.set('reload', this.get('fieldsInTempStore').length || invalidFields.length);
entityModel.get('fields').each(function (fieldModel) {
if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) {
fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
} else {
fieldModel.set('state', 'candidate', options);
}
});
}
break;
}
break;
case 'closing':
// Set all fields to the 'inactive' state.
options.reason = 'stop';
this.get('fields').each(function (fieldModel) {
fieldModel.set({
@ -274,83 +127,33 @@
break;
}
},
/**
* Updates a Field and Entity model's "inTempStore" when appropriate.
*
* Helper function.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The model of the entity for which a field's state attribute has
* changed.
* @param {Drupal.quickedit.FieldModel} fieldModel
* The model of the field whose state attribute has changed.
*
* @see Drupal.quickedit.EntityModel#fieldStateChange
*/
_updateInTempStoreAttributes: function (entityModel, fieldModel) {
_updateInTempStoreAttributes: function _updateInTempStoreAttributes(entityModel, fieldModel) {
var current = fieldModel.get('state');
var previous = fieldModel.previous('state');
var fieldsInTempStore = entityModel.get('fieldsInTempStore');
// If the fieldModel changed to the 'saved' state: remember that this
// field was saved to PrivateTempStore.
if (current === 'saved') {
// Mark the entity as saved in PrivateTempStore, so that we can pass the
// proper "reset PrivateTempStore" boolean value when communicating with
// the server.
entityModel.set('inTempStore', true);
// Mark the field as saved in PrivateTempStore, so that visual
// indicators signifying just that may be rendered.
fieldModel.set('inTempStore', true);
// Remember that this field is in PrivateTempStore, restore when
// rerendered.
fieldsInTempStore.push(fieldModel.get('fieldID'));
fieldsInTempStore = _.uniq(fieldsInTempStore);
entityModel.set('fieldsInTempStore', fieldsInTempStore);
}
// If the fieldModel changed to the 'candidate' state from the
// 'inactive' state, then this is a field for this entity that got
// rerendered. Restore its previous 'inTempStore' attribute value.
else if (current === 'candidate' && previous === 'inactive') {
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
}
} else if (current === 'candidate' && previous === 'inactive') {
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
}
},
/**
* Reacts to state changes in this entity's fields.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The model of the field whose state attribute changed.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
fieldStateChange: function (fieldModel, state) {
fieldStateChange: function fieldStateChange(fieldModel, state) {
var entityModel = this;
var fieldState = state;
// Switch on the entityModel state.
// The EntityModel responds to FieldModel state changes as a function of
// its state. For example, a field switching back to 'candidate' state
// when its entity is in the 'opened' state has no effect on the entity.
// But that same switch back to 'candidate' state of a field when the
// entity is in the 'committing' state might allow the entity to proceed
// with the commit flow.
switch (this.get('state')) {
case 'closed':
case 'launching':
// It should be impossible to reach these: fields can't change state
// while the entity is closed or still launching.
break;
case 'opening':
// We must change the entity to the 'opened' state, but it must first
// be confirmed that all of its fieldModels have transitioned to the
// 'candidate' state.
// We do this here, because this is called every time a fieldModel
// changes state, hence each time this is called, we get closer to the
// goal of having all fieldModels in the 'candidate' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(function () {
entityModel.set('state', 'opened', {
'accept-field-states': Drupal.quickedit.app.readyFieldStates
@ -359,68 +162,50 @@
break;
case 'opened':
// Set the isDirty attribute when appropriate so that it is known when
// to display the "Save" button in the entity toolbar.
// Note that once a field has been changed, there's no way to discard
// that change, hence it will have to be saved into PrivateTempStore,
// or the in-place editing of this field will have to be stopped
// completely. In other words: once any field enters the 'changed'
// field, then for the remainder of the in-place editing session, the
// entity is by definition dirty.
if (fieldState === 'changed') {
entityModel.set('isDirty', true);
}
else {
} else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
break;
case 'committing':
// If the field save returned a validation error, set the state of the
// entity back to 'opened'.
if (fieldState === 'invalid') {
// A state change in reaction to another state change must be
// deferred.
_.defer(function () {
entityModel.set('state', 'opened', {reason: 'invalid'});
});
}
else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
{
if (fieldState === 'invalid') {
_.defer(function () {
entityModel.set('state', 'opened', { reason: 'invalid' });
});
} else {
this._updateInTempStoreAttributes(entityModel, fieldModel);
}
// Attempt to save the entity. If the entity's fields are not yet all
// in a ready state, the save will not be processed.
var options = {
'accept-field-states': Drupal.quickedit.app.readyFieldStates
};
if (entityModel.set('isCommitting', true, options)) {
entityModel.save({
success: function () {
entityModel.set({
state: 'deactivating',
isCommitting: false
}, {saved: true});
},
error: function () {
// Reset the "isCommitting" mutex.
entityModel.set('isCommitting', false);
// Change the state back to "opened", to allow the user to hit
// the "Save" button again.
entityModel.set('state', 'opened', {reason: 'networkerror'});
// Show a modal to inform the user of the network error.
var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', {'@entity-title': entityModel.get('label')});
Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message);
}
});
var options = {
'accept-field-states': Drupal.quickedit.app.readyFieldStates
};
if (entityModel.set('isCommitting', true, options)) {
entityModel.save({
success: function success() {
entityModel.set({
state: 'deactivating',
isCommitting: false
}, { saved: true });
},
error: function error() {
entityModel.set('isCommitting', false);
entityModel.set('state', 'opened', {
reason: 'networkerror'
});
var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', { '@entity-title': entityModel.get('label') });
Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message);
}
});
}
break;
}
break;
case 'deactivating':
// When setting the entity to 'closing', require that all fieldModels
// are in either the 'candidate' or 'highlighted' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(function () {
entityModel.set('state', 'closing', {
'accept-field-states': Drupal.quickedit.app.readyFieldStates
@ -429,10 +214,6 @@
break;
case 'closing':
// When setting the entity to 'closed', require that all fieldModels
// are in the 'inactive' state.
// A state change in reaction to another state change must be
// deferred.
_.defer(function () {
entityModel.set('state', 'closed', {
'accept-field-states': ['inactive']
@ -441,180 +222,84 @@
break;
}
},
/**
* Fires an AJAX request to the REST save URL for an entity.
*
* @param {object} options
* An object of options that contains:
* @param {function} [options.success]
* A function to invoke if the entity is successfully saved.
*/
save: function (options) {
save: function save(options) {
var entityModel = this;
// Create a Drupal.ajax instance to save the entity.
var entitySaverAjax = Drupal.ajax({
url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')),
error: function () {
// Let the Drupal.quickedit.EntityModel Backbone model's error()
// method handle errors.
error: function error() {
options.error.call(entityModel);
}
});
// Entity saved successfully.
entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) {
// All fields have been moved from PrivateTempStore to permanent
// storage, update the "inTempStore" attribute on FieldModels, on the
// EntityModel and clear EntityModel's "fieldInTempStore" attribute.
entityModel.get('fields').each(function (fieldModel) {
fieldModel.set('inTempStore', false);
});
entityModel.set('inTempStore', false);
entityModel.set('fieldsInTempStore', []);
// Invoke the optional success callback.
if (options.success) {
options.success.call(entityModel);
}
};
// Trigger the AJAX request, which will will return the
// quickeditEntitySaved AJAX command to which we then react.
entitySaverAjax.execute();
},
/**
* Validate the entity model.
*
* @param {object} attrs
* The attributes changes in the save or set call.
* @param {object} options
* An object with the following option:
* @param {string} [options.reason]
* A string that conveys a particular reason to allow for an exceptional
* state change.
* @param {Array} options.accept-field-states
* An array of strings that represent field states that the entities must
* be in to validate. For example, if `accept-field-states` is
* `['candidate', 'highlighted']`, then all the fields of the entity must
* be in either of these two states for the save or set call to
* validate and proceed.
*
* @return {string}
* A string to say something about the state of the entity model.
*/
validate: function (attrs, options) {
validate: function validate(attrs, options) {
var acceptedFieldStates = options['accept-field-states'] || [];
// Validate state change.
var currentState = this.get('state');
var nextState = attrs.state;
if (currentState !== nextState) {
// Ensure it's a valid state.
if (_.indexOf(this.constructor.states, nextState) === -1) {
return '"' + nextState + '" is an invalid state';
}
// Ensure it's a state change that is allowed.
// Check if the acceptStateChange function accepts it.
if (!this._acceptStateChange(currentState, nextState, options)) {
return 'state change not accepted';
}
// If that function accepts it, then ensure all fields are also in an
// acceptable state.
else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'state change not accepted because fields are not in acceptable state';
}
}
// Validate setting isCommitting = true.
var currentIsCommitting = this.get('isCommitting');
var nextIsCommitting = attrs.isCommitting;
if (currentIsCommitting === false && nextIsCommitting === true) {
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'isCommitting change not accepted because fields are not in acceptable state';
}
}
else if (currentIsCommitting === true && nextIsCommitting === true) {
} else if (currentIsCommitting === true && nextIsCommitting === true) {
return 'isCommitting is a mutex, hence only changes are allowed';
}
},
/**
* Checks if a state change can be accepted.
*
* @param {string} from
* From state.
* @param {string} to
* To state.
* @param {object} context
* Context for the check.
* @param {string} context.reason
* The reason for the state change.
* @param {bool} context.confirming
* Whether context is confirming or not.
*
* @return {bool}
* Whether the state change is accepted or not.
*
* @see Drupal.quickedit.AppView#acceptEditorStateChange
*/
_acceptStateChange: function (from, to, context) {
_acceptStateChange: function _acceptStateChange(from, to, context) {
var accept = true;
// In general, enforce the states sequence. Disallow going back from a
// "later" state to an "earlier" state, except in explicitly allowed
// cases.
if (!this.constructor.followsStateSequence(from, to)) {
accept = false;
// Allow: closing -> closed.
// Necessary to stop editing an entity.
if (from === 'closing' && to === 'closed') {
accept = true;
}
// Allow: committing -> opened.
// Necessary to be able to correct an invalid field, or to hit the
// "Save" button again after a server/network error.
else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) {
accept = true;
}
// Allow: deactivating -> opened.
// Necessary to be able to confirm changes with the user.
else if (from === 'deactivating' && to === 'opened' && context.confirming) {
accept = true;
}
// Allow: opened -> deactivating.
// Necessary to be able to stop editing.
else if (from === 'opened' && to === 'deactivating' && context.confirmed) {
accept = true;
}
} else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) {
accept = true;
} else if (from === 'deactivating' && to === 'opened' && context.confirming) {
accept = true;
} else if (from === 'opened' && to === 'deactivating' && context.confirmed) {
accept = true;
}
}
return accept;
},
/**
* Checks if fields have acceptable states.
*
* @param {Array} acceptedFieldStates
* An array of acceptable field states to check for.
*
* @return {bool}
* Whether the fields have an acceptable state.
*
* @see Drupal.quickedit.EntityModel#validate
*/
_fieldsHaveAcceptableStates: function (acceptedFieldStates) {
_fieldsHaveAcceptableStates: function _fieldsHaveAcceptableStates(acceptedFieldStates) {
var accept = true;
// If no acceptable field states are provided, assume all field states are
// acceptable. We want to let validation pass as a default and only
// check validity on calls to set that explicitly request it.
if (acceptedFieldStates.length > 0) {
var fieldStates = this.get('fields').pluck('state') || [];
// If not all fields are in one of the accepted field states, then we
// still can't allow this state change.
if (_.difference(fieldStates, acceptedFieldStates).length) {
accept = false;
}
@ -622,120 +307,23 @@
return accept;
},
/**
* Destroys the entity model.
*
* @param {object} options
* Options for the entity model.
*/
destroy: function (options) {
destroy: function destroy(options) {
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
this.stopListening();
// Destroy all fields of this entity.
this.get('fields').reset();
},
sync: function sync() {}
}, {
states: ['closed', 'launching', 'opening', 'opened', 'committing', 'deactivating', 'closing'],
/**
* @inheritdoc
*/
sync: function () {
// We don't use REST updates to sync.
return;
}
}, /** @lends Drupal.quickedit.EntityModel */{
/**
* Sequence of all possible states an entity can be in during quickediting.
*
* @type {Array.<string>}
*/
states: [
// Initial state, like field's 'inactive' OR the user has just finished
// in-place editing this entity.
// - Trigger: none (initial) or EntityModel (finished).
// - Expected behavior: (when not initial state): tear down
// EntityToolbarView, in-place editors and related views.
'closed',
// User has activated in-place editing of this entity.
// - Trigger: user.
// - Expected behavior: the EntityToolbarView is gets set up, in-place
// editors (EditorViews) and related views for this entity's fields are
// set up. Upon completion of those, the state is changed to 'opening'.
'launching',
// Launching has finished.
// - Trigger: application.
// - Guarantees: in-place editors ready for use, all entity and field
// views have been set up, all fields are in the 'inactive' state.
// - Expected behavior: all fields are changed to the 'candidate' state
// and once this is completed, the entity state will be changed to
// 'opened'.
'opening',
// Opening has finished.
// - Trigger: EntityModel.
// - Guarantees: see 'opening', all fields are in the 'candidate' state.
// - Expected behavior: the user is able to actually use in-place editing.
'opened',
// User has clicked the 'Save' button (and has thus changed at least one
// field).
// - Trigger: user.
// - Guarantees: see 'opened', plus: either a changed field is in
// PrivateTempStore, or the user has just modified a field without
// activating (switching to) another field.
// - Expected behavior: 1) if any of the fields are not yet in
// PrivateTempStore, save them to PrivateTempStore, 2) if then any of
// the fields has the 'invalid' state, then change the entity state back
// to 'opened', otherwise: save the entity by committing it from
// PrivateTempStore into permanent storage.
'committing',
// User has clicked the 'Close' button, or has clicked the 'Save' button
// and that was successfully completed.
// - Trigger: user or EntityModel.
// - Guarantees: when having clicked 'Close' hardly any: fields may be in
// a variety of states; when having clicked 'Save': all fields are in
// the 'candidate' state.
// - Expected behavior: transition all fields to the 'candidate' state,
// possibly requiring confirmation in the case of having clicked
// 'Close'.
'deactivating',
// Deactivation has been completed.
// - Trigger: EntityModel.
// - Guarantees: all fields are in the 'candidate' state.
// - Expected behavior: change all fields to the 'inactive' state.
'closing'
],
/**
* Indicates whether the 'from' state comes before the 'to' state.
*
* @param {string} from
* One of {@link Drupal.quickedit.EntityModel.states}.
* @param {string} to
* One of {@link Drupal.quickedit.EntityModel.states}.
*
* @return {bool}
* Whether the 'from' state comes before the 'to' state.
*/
followsStateSequence: function (from, to) {
followsStateSequence: function followsStateSequence(from, to) {
return _.indexOf(this.states, from) < _.indexOf(this.states, to);
}
});
/**
* @constructor
*
* @augments Backbone.Collection
*/
Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{
/**
* @type {Drupal.quickedit.EntityModel}
*/
Drupal.quickedit.EntityCollection = Backbone.Collection.extend({
model: Drupal.quickedit.EntityModel
});
}(_, jQuery, Backbone, Drupal));
})(_, jQuery, Backbone, Drupal);

View file

@ -0,0 +1,353 @@
/**
* @file
* A Backbone Model for the state of an in-place editable field in the DOM.
*/
(function(_, Backbone, Drupal) {
Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(
/** @lends Drupal.quickedit.FieldModel# */ {
/**
* @type {object}
*/
defaults: /** @lends Drupal.quickedit.FieldModel# */ {
/**
* The DOM element that represents this field. It may seem bizarre to have
* a DOM element in a Backbone Model, but we need to be able to map fields
* in the DOM to FieldModels in memory.
*/
el: null,
/**
* A field ID, of the form
* `<entity type>/<id>/<field name>/<language>/<view mode>`
*
* @example
* "node/1/field_tags/und/full"
*/
fieldID: null,
/**
* The unique ID of this field within its entity instance on the page, of
* the form `<entity type>/<id>/<field name>/<language>/<view
* mode>[entity instance ID]`.
*
* @example
* "node/1/field_tags/und/full[0]"
*/
id: null,
/**
* A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which
* is a FieldCollection, is automatically updated to include this
* FieldModel.
*/
entity: null,
/**
* This field's metadata as returned by the
* QuickEditController::metadata().
*/
metadata: null,
/**
* Callback function for validating changes between states. Receives the
* previous state, new state, context, and a callback.
*/
acceptStateChange: null,
/**
* A logical field ID, of the form
* `<entity type>/<id>/<field name>/<language>`, i.e. the fieldID without
* the view mode, to be able to identify other instances of the same
* field on the page but rendered in a different view mode.
*
* @example
* "node/1/field_tags/und".
*/
logicalFieldID: null,
// The attributes below are stateful. The ones above will never change
// during the life of a FieldModel instance.
/**
* In-place editing state of this field. Defaults to the initial state.
* Possible values: {@link Drupal.quickedit.FieldModel.states}.
*/
state: 'inactive',
/**
* The field is currently in the 'changed' state or one of the following
* states in which the field is still changed.
*/
isChanged: false,
/**
* Is tracked by the EntityModel, is mirrored here solely for decorative
* purposes: so that FieldDecorationView.renderChanged() can react to it.
*/
inTempStore: false,
/**
* The full HTML representation of this field (with the element that has
* the data-quickedit-field-id as the outer element). Used to propagate
* changes from this field to other instances of the same field storage.
*/
html: null,
/**
* An object containing the full HTML representations (values) of other
* view modes (keys) of this field, for other instances of this field
* displayed in a different view mode.
*/
htmlForOtherViewModes: null,
},
/**
* State of an in-place editable field in the DOM.
*
* @constructs
*
* @augments Drupal.quickedit.BaseModel
*
* @param {object} options
* Options for the field model.
*/
initialize(options) {
// Store the original full HTML representation of this field.
this.set('html', options.el.outerHTML);
// Enlist field automatically in the associated entity's field collection.
this.get('entity')
.get('fields')
.add(this);
// Automatically generate the logical field ID.
this.set(
'logicalFieldID',
this.get('fieldID')
.split('/')
.slice(0, 4)
.join('/'),
);
// Call Drupal.quickedit.BaseModel's initialize() method.
Drupal.quickedit.BaseModel.prototype.initialize.call(this, options);
},
/**
* Destroys the field model.
*
* @param {object} options
* Options for the field model.
*/
destroy(options) {
if (this.get('state') !== 'inactive') {
throw new Error(
'FieldModel cannot be destroyed if it is not inactive state.',
);
}
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
},
/**
* @inheritdoc
*/
sync() {
// We don't use REST updates to sync.
},
/**
* Validate function for the field model.
*
* @param {object} attrs
* The attributes changes in the save or set call.
* @param {object} options
* An object with the following option:
* @param {string} [options.reason]
* A string that conveys a particular reason to allow for an exceptional
* state change.
* @param {Array} options.accept-field-states
* An array of strings that represent field states that the entities must
* be in to validate. For example, if `accept-field-states` is
* `['candidate', 'highlighted']`, then all the fields of the entity must
* be in either of these two states for the save or set call to
* validate and proceed.
*
* @return {string}
* A string to say something about the state of the field model.
*/
validate(attrs, options) {
const current = this.get('state');
const next = attrs.state;
if (current !== next) {
// Ensure it's a valid state.
if (_.indexOf(this.constructor.states, next) === -1) {
return `"${next}" is an invalid state`;
}
// Check if the acceptStateChange callback accepts it.
if (!this.get('acceptStateChange')(current, next, options, this)) {
return 'state change not accepted';
}
}
},
/**
* Extracts the entity ID from this field's ID.
*
* @return {string}
* An entity ID: a string of the format `<entity type>/<id>`.
*/
getEntityID() {
return this.get('fieldID')
.split('/')
.slice(0, 2)
.join('/');
},
/**
* Extracts the view mode ID from this field's ID.
*
* @return {string}
* A view mode ID.
*/
getViewMode() {
return this.get('fieldID')
.split('/')
.pop();
},
/**
* Find other instances of this field with different view modes.
*
* @return {Array}
* An array containing view mode IDs.
*/
findOtherViewModes() {
const currentField = this;
const otherViewModes = [];
Drupal.quickedit.collections.fields
// Find all instances of fields that display the same logical field
// (same entity, same field, just a different instance and maybe a
// different view mode).
.where({ logicalFieldID: currentField.get('logicalFieldID') })
.forEach(field => {
// Ignore the current field and other fields with the same view mode.
if (
field !== currentField &&
field.get('fieldID') !== currentField.get('fieldID')
) {
otherViewModes.push(field.getViewMode());
}
});
return otherViewModes;
},
},
/** @lends Drupal.quickedit.FieldModel */ {
/**
* Sequence of all possible states a field can be in during quickediting.
*
* @type {Array.<string>}
*/
states: [
// The field associated with this FieldModel is linked to an EntityModel;
// the user can choose to start in-place editing that entity (and
// consequently this field). No in-place editor (EditorView) is associated
// with this field, because this field is not being in-place edited.
// This is both the initial (not yet in-place editing) and the end state
// (finished in-place editing).
'inactive',
// The user is in-place editing this entity, and this field is a
// candidate
// for in-place editing. In-place editor should not
// - Trigger: user.
// - Guarantees: entity is ready, in-place editor (EditorView) is
// associated with the field.
// - Expected behavior: visual indicators
// around the field indicate it is available for in-place editing, no
// in-place editor presented yet.
'candidate',
// User is highlighting this field.
// - Trigger: user.
// - Guarantees: see 'candidate'.
// - Expected behavior: visual indicators to convey highlighting, in-place
// editing toolbar shows field's label.
'highlighted',
// User has activated the in-place editing of this field; in-place editor
// is activating.
// - Trigger: user.
// - Guarantees: see 'candidate'.
// - Expected behavior: loading indicator, in-place editor is loading
// remote data (e.g. retrieve form from back-end). Upon retrieval of
// remote data, the in-place editor transitions the field's state to
// 'active'.
'activating',
// In-place editor has finished loading remote data; ready for use.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate'.
// - Expected behavior: in-place editor for the field is ready for use.
'active',
// User has modified values in the in-place editor.
// - Trigger: user.
// - Guarantees: see 'candidate', plus in-place editor is ready for use.
// - Expected behavior: visual indicator of change.
'changed',
// User is saving changed field data in in-place editor to
// PrivateTempStore. The save mechanism of the in-place editor is called.
// - Trigger: user.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: saving indicator, in-place editor is saving field
// data into PrivateTempStore. Upon successful saving (without
// validation errors), the in-place editor transitions the field's state
// to 'saved', but to 'invalid' upon failed saving (with validation
// errors).
'saving',
// In-place editor has successfully saved the changed field.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: transition back to 'candidate' state because the
// deed is done. Then: 1) transition to 'inactive' to allow the field
// to be rerendered, 2) destroy the FieldModel (which also destroys
// attached views like the EditorView), 3) replace the existing field
// HTML with the existing HTML and 4) attach behaviors again so that the
// field becomes available again for in-place editing.
'saved',
// In-place editor has failed to saved the changed field: there were
// validation errors.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: remain in 'invalid' state, let the user make more
// changes so that he can save it again, without validation errors.
'invalid',
],
/**
* Indicates whether the 'from' state comes before the 'to' state.
*
* @param {string} from
* One of {@link Drupal.quickedit.FieldModel.states}.
* @param {string} to
* One of {@link Drupal.quickedit.FieldModel.states}.
*
* @return {bool}
* Whether the 'from' state comes before the 'to' state.
*/
followsStateSequence(from, to) {
return _.indexOf(this.states, from) < _.indexOf(this.states, to);
},
},
);
/**
* @constructor
*
* @augments Backbone.Collection
*/
Drupal.quickedit.FieldCollection = Backbone.Collection.extend(
/** @lends Drupal.quickedit.FieldCollection */ {
/**
* @type {Drupal.quickedit.FieldModel}
*/
model: Drupal.quickedit.FieldModel,
},
);
})(_, Backbone, Drupal);

View file

@ -1,348 +1,92 @@
/**
* @file
* A Backbone Model for the state of an in-place editable field in the DOM.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (_, Backbone, Drupal) {
'use strict';
Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.FieldModel# */{
/**
* @type {object}
*/
defaults: /** @lends Drupal.quickedit.FieldModel# */{
/**
* The DOM element that represents this field. It may seem bizarre to have
* a DOM element in a Backbone Model, but we need to be able to map fields
* in the DOM to FieldModels in memory.
*/
Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend({
defaults: {
el: null,
/**
* A field ID, of the form
* `<entity type>/<id>/<field name>/<language>/<view mode>`
*
* @example
* "node/1/field_tags/und/full"
*/
fieldID: null,
/**
* The unique ID of this field within its entity instance on the page, of
* the form `<entity type>/<id>/<field name>/<language>/<view
* mode>[entity instance ID]`.
*
* @example
* "node/1/field_tags/und/full[0]"
*/
id: null,
/**
* A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which
* is a FieldCollection, is automatically updated to include this
* FieldModel.
*/
entity: null,
/**
* This field's metadata as returned by the
* QuickEditController::metadata().
*/
metadata: null,
/**
* Callback function for validating changes between states. Receives the
* previous state, new state, context, and a callback.
*/
acceptStateChange: null,
/**
* A logical field ID, of the form
* `<entity type>/<id>/<field name>/<language>`, i.e. the fieldID without
* the view mode, to be able to identify other instances of the same
* field on the page but rendered in a different view mode.
*
* @example
* "node/1/field_tags/und".
*/
logicalFieldID: null,
// The attributes below are stateful. The ones above will never change
// during the life of a FieldModel instance.
/**
* In-place editing state of this field. Defaults to the initial state.
* Possible values: {@link Drupal.quickedit.FieldModel.states}.
*/
state: 'inactive',
/**
* The field is currently in the 'changed' state or one of the following
* states in which the field is still changed.
*/
isChanged: false,
/**
* Is tracked by the EntityModel, is mirrored here solely for decorative
* purposes: so that FieldDecorationView.renderChanged() can react to it.
*/
inTempStore: false,
/**
* The full HTML representation of this field (with the element that has
* the data-quickedit-field-id as the outer element). Used to propagate
* changes from this field to other instances of the same field storage.
*/
html: null,
/**
* An object containing the full HTML representations (values) of other
* view modes (keys) of this field, for other instances of this field
* displayed in a different view mode.
*/
htmlForOtherViewModes: null
},
/**
* State of an in-place editable field in the DOM.
*
* @constructs
*
* @augments Drupal.quickedit.BaseModel
*
* @param {object} options
* Options for the field model.
*/
initialize: function (options) {
// Store the original full HTML representation of this field.
initialize: function initialize(options) {
this.set('html', options.el.outerHTML);
// Enlist field automatically in the associated entity's field collection.
this.get('entity').get('fields').add(this);
// Automatically generate the logical field ID.
this.set('logicalFieldID', this.get('fieldID').split('/').slice(0, 4).join('/'));
// Call Drupal.quickedit.BaseModel's initialize() method.
Drupal.quickedit.BaseModel.prototype.initialize.call(this, options);
},
/**
* Destroys the field model.
*
* @param {object} options
* Options for the field model.
*/
destroy: function (options) {
destroy: function destroy(options) {
if (this.get('state') !== 'inactive') {
throw new Error('FieldModel cannot be destroyed if it is not inactive state.');
}
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
},
/**
* @inheritdoc
*/
sync: function () {
// We don't use REST updates to sync.
return;
},
/**
* Validate function for the field model.
*
* @param {object} attrs
* The attributes changes in the save or set call.
* @param {object} options
* An object with the following option:
* @param {string} [options.reason]
* A string that conveys a particular reason to allow for an exceptional
* state change.
* @param {Array} options.accept-field-states
* An array of strings that represent field states that the entities must
* be in to validate. For example, if `accept-field-states` is
* `['candidate', 'highlighted']`, then all the fields of the entity must
* be in either of these two states for the save or set call to
* validate and proceed.
*
* @return {string}
* A string to say something about the state of the field model.
*/
validate: function (attrs, options) {
sync: function sync() {},
validate: function validate(attrs, options) {
var current = this.get('state');
var next = attrs.state;
if (current !== next) {
// Ensure it's a valid state.
if (_.indexOf(this.constructor.states, next) === -1) {
return '"' + next + '" is an invalid state';
}
// Check if the acceptStateChange callback accepts it.
if (!this.get('acceptStateChange')(current, next, options, this)) {
return 'state change not accepted';
}
}
},
/**
* Extracts the entity ID from this field's ID.
*
* @return {string}
* An entity ID: a string of the format `<entity type>/<id>`.
*/
getEntityID: function () {
getEntityID: function getEntityID() {
return this.get('fieldID').split('/').slice(0, 2).join('/');
},
/**
* Extracts the view mode ID from this field's ID.
*
* @return {string}
* A view mode ID.
*/
getViewMode: function () {
getViewMode: function getViewMode() {
return this.get('fieldID').split('/').pop();
},
/**
* Find other instances of this field with different view modes.
*
* @return {Array}
* An array containing view mode IDs.
*/
findOtherViewModes: function () {
findOtherViewModes: function findOtherViewModes() {
var currentField = this;
var otherViewModes = [];
Drupal.quickedit.collections.fields
// Find all instances of fields that display the same logical field
// (same entity, same field, just a different instance and maybe a
// different view mode).
.where({logicalFieldID: currentField.get('logicalFieldID')})
.forEach(function (field) {
// Ignore the current field.
if (field === currentField) {
return;
}
// Also ignore other fields with the same view mode.
else if (field.get('fieldID') === currentField.get('fieldID')) {
return;
}
else {
otherViewModes.push(field.getViewMode());
}
});
Drupal.quickedit.collections.fields.where({ logicalFieldID: currentField.get('logicalFieldID') }).forEach(function (field) {
if (field !== currentField && field.get('fieldID') !== currentField.get('fieldID')) {
otherViewModes.push(field.getViewMode());
}
});
return otherViewModes;
}
}, {
states: ['inactive', 'candidate', 'highlighted', 'activating', 'active', 'changed', 'saving', 'saved', 'invalid'],
}, /** @lends Drupal.quickedit.FieldModel */{
/**
* Sequence of all possible states a field can be in during quickediting.
*
* @type {Array.<string>}
*/
states: [
// The field associated with this FieldModel is linked to an EntityModel;
// the user can choose to start in-place editing that entity (and
// consequently this field). No in-place editor (EditorView) is associated
// with this field, because this field is not being in-place edited.
// This is both the initial (not yet in-place editing) and the end state
// (finished in-place editing).
'inactive',
// The user is in-place editing this entity, and this field is a
// candidate
// for in-place editing. In-place editor should not
// - Trigger: user.
// - Guarantees: entity is ready, in-place editor (EditorView) is
// associated with the field.
// - Expected behavior: visual indicators
// around the field indicate it is available for in-place editing, no
// in-place editor presented yet.
'candidate',
// User is highlighting this field.
// - Trigger: user.
// - Guarantees: see 'candidate'.
// - Expected behavior: visual indicators to convey highlighting, in-place
// editing toolbar shows field's label.
'highlighted',
// User has activated the in-place editing of this field; in-place editor
// is activating.
// - Trigger: user.
// - Guarantees: see 'candidate'.
// - Expected behavior: loading indicator, in-place editor is loading
// remote data (e.g. retrieve form from back-end). Upon retrieval of
// remote data, the in-place editor transitions the field's state to
// 'active'.
'activating',
// In-place editor has finished loading remote data; ready for use.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate'.
// - Expected behavior: in-place editor for the field is ready for use.
'active',
// User has modified values in the in-place editor.
// - Trigger: user.
// - Guarantees: see 'candidate', plus in-place editor is ready for use.
// - Expected behavior: visual indicator of change.
'changed',
// User is saving changed field data in in-place editor to
// PrivateTempStore. The save mechanism of the in-place editor is called.
// - Trigger: user.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: saving indicator, in-place editor is saving field
// data into PrivateTempStore. Upon successful saving (without
// validation errors), the in-place editor transitions the field's state
// to 'saved', but to 'invalid' upon failed saving (with validation
// errors).
'saving',
// In-place editor has successfully saved the changed field.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: transition back to 'candidate' state because the
// deed is done. Then: 1) transition to 'inactive' to allow the field
// to be rerendered, 2) destroy the FieldModel (which also destroys
// attached views like the EditorView), 3) replace the existing field
// HTML with the existing HTML and 4) attach behaviors again so that the
// field becomes available again for in-place editing.
'saved',
// In-place editor has failed to saved the changed field: there were
// validation errors.
// - Trigger: in-place editor.
// - Guarantees: see 'candidate' and 'active'.
// - Expected behavior: remain in 'invalid' state, let the user make more
// changes so that he can save it again, without validation errors.
'invalid'
],
/**
* Indicates whether the 'from' state comes before the 'to' state.
*
* @param {string} from
* One of {@link Drupal.quickedit.FieldModel.states}.
* @param {string} to
* One of {@link Drupal.quickedit.FieldModel.states}.
*
* @return {bool}
* Whether the 'from' state comes before the 'to' state.
*/
followsStateSequence: function (from, to) {
followsStateSequence: function followsStateSequence(from, to) {
return _.indexOf(this.states, from) < _.indexOf(this.states, to);
}
});
/**
* @constructor
*
* @augments Backbone.Collection
*/
Drupal.quickedit.FieldCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.FieldCollection */{
/**
* @type {Drupal.quickedit.FieldModel}
*/
Drupal.quickedit.FieldCollection = Backbone.Collection.extend({
model: Drupal.quickedit.FieldModel
});
}(_, Backbone, Drupal));
})(_, Backbone, Drupal);

View file

@ -0,0 +1,770 @@
/**
* @file
* Attaches behavior for the Quick Edit module.
*
* Everything happens asynchronously, to allow for:
* - dynamically rendered contextual links
* - asynchronously retrieved (and cached) per-field in-place editing metadata
* - asynchronous setup of in-place editable field and "Quick edit" link.
*
* To achieve this, there are several queues:
* - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
* - fieldsAvailableQueue: queue of fields whose metadata is known, and for
* which it has been confirmed that the user has permission to edit them.
* However, FieldModels will only be created for them once there's a
* contextual link for their entity: when it's possible to initiate editing.
* - contextualLinksQueue: queue of contextual links on entities for which it
* is not yet known whether the user has permission to edit at >=1 of them.
*/
(function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
const options = $.extend(
drupalSettings.quickedit,
// Merge strings on top of drupalSettings so that they are not mutable.
{
strings: {
quickEdit: Drupal.t('Quick edit'),
},
},
);
/**
* Tracks fields without metadata. Contains objects with the following keys:
* - DOM el
* - String fieldID
* - String entityID
*/
let fieldsMetadataQueue = [];
/**
* Tracks fields ready for use. Contains objects with the following keys:
* - DOM el
* - String fieldID
* - String entityID
*/
let fieldsAvailableQueue = [];
/**
* Tracks contextual links on entities. Contains objects with the following
* keys:
* - String entityID
* - DOM el
* - DOM region
*/
let contextualLinksQueue = [];
/**
* Tracks how many instances exist for each unique entity. Contains key-value
* pairs:
* - String entityID
* - Number count
*/
const entityInstancesTracker = {};
/**
* Initialize the Quick Edit app.
*
* @param {HTMLElement} bodyElement
* This document's body element.
*/
function initQuickEdit(bodyElement) {
Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
// Instantiate AppModel (application state) and AppView, which is the
// controller of the whole in-place editing experience.
Drupal.quickedit.app = new Drupal.quickedit.AppView({
el: bodyElement,
model: new Drupal.quickedit.AppModel(),
entitiesCollection: Drupal.quickedit.collections.entities,
fieldsCollection: Drupal.quickedit.collections.fields,
});
}
/**
* Assigns the entity an instance ID.
*
* @param {HTMLElement} entityElement
* A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
* attribute.
*/
function processEntity(entityElement) {
const entityID = entityElement.getAttribute('data-quickedit-entity-id');
if (!entityInstancesTracker.hasOwnProperty(entityID)) {
entityInstancesTracker[entityID] = 0;
} else {
entityInstancesTracker[entityID]++;
}
// Set the calculated entity instance ID for this element.
const entityInstanceID = entityInstancesTracker[entityID];
entityElement.setAttribute(
'data-quickedit-entity-instance-id',
entityInstanceID,
);
}
/**
* Initialize a field; create FieldModel.
*
* @param {HTMLElement} fieldElement
* The field's DOM element.
* @param {string} fieldID
* The field's ID.
* @param {string} entityID
* The field's entity's ID.
* @param {string} entityInstanceID
* The field's entity's instance ID.
*/
function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
const entity = Drupal.quickedit.collections.entities.findWhere({
entityID,
entityInstanceID,
});
$(fieldElement).addClass('quickedit-field');
// The FieldModel stores the state of an in-place editable entity field.
const field = new Drupal.quickedit.FieldModel({
el: fieldElement,
fieldID,
id: `${fieldID}[${entity.get('entityInstanceID')}]`,
entity,
metadata: Drupal.quickedit.metadata.get(fieldID),
acceptStateChange: _.bind(
Drupal.quickedit.app.acceptEditorStateChange,
Drupal.quickedit.app,
),
});
// Track all fields on the page.
Drupal.quickedit.collections.fields.add(field);
}
/**
* Loads missing in-place editor's attachments (JavaScript and CSS files).
*
* Missing in-place editors are those whose fields are actively being used on
* the page but don't have.
*
* @param {function} callback
* Callback function to be called when the missing in-place editors (if any)
* have been inserted into the DOM. i.e. they may still be loading.
*/
function loadMissingEditors(callback) {
const loadedEditors = _.keys(Drupal.quickedit.editors);
let missingEditors = [];
Drupal.quickedit.collections.fields.each(fieldModel => {
const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
missingEditors.push(metadata.editor);
// Set a stub, to prevent subsequent calls to loadMissingEditors() from
// loading the same in-place editor again. Loading an in-place editor
// requires talking to a server, to download its JavaScript, then
// executing its JavaScript, and only then its Drupal.quickedit.editors
// entry will be set.
Drupal.quickedit.editors[metadata.editor] = false;
}
});
missingEditors = _.uniq(missingEditors);
if (missingEditors.length === 0) {
callback();
return;
}
// @see https://www.drupal.org/node/2029999.
// Create a Drupal.Ajax instance to load the form.
const loadEditorsAjax = Drupal.ajax({
url: Drupal.url('quickedit/attachments'),
submit: { 'editors[]': missingEditors },
});
// Implement a scoped insert AJAX command: calls the callback after all AJAX
// command functions have been executed (hence the deferred calling).
const realInsert = Drupal.AjaxCommands.prototype.insert;
loadEditorsAjax.commands.insert = function(ajax, response, status) {
_.defer(callback);
realInsert(ajax, response, status);
};
// Trigger the AJAX request, which will should return AJAX commands to
// insert any missing attachments.
loadEditorsAjax.execute();
}
/**
* Attempts to set up a "Quick edit" link and corresponding EntityModel.
*
* @param {object} contextualLink
* An object with the following properties:
* - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
* "block_content/5".
* - String entityInstanceID: a Quick Edit entity instance identifier,
* e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
* instance of this entity).
* - DOM el: element pointing to the contextual links placeholder for this
* entity.
* - DOM region: element pointing to the contextual region of this entity.
*
* @return {bool}
* Returns true when a contextual the given contextual link metadata can be
* removed from the queue (either because the contextual link has been set
* up or because it is certain that in-place editing is not allowed for any
* of its fields). Returns false otherwise.
*/
function initializeEntityContextualLink(contextualLink) {
const metadata = Drupal.quickedit.metadata;
// Check if the user has permission to edit at least one of them.
function hasFieldWithPermission(fieldIDs) {
for (let i = 0; i < fieldIDs.length; i++) {
const fieldID = fieldIDs[i];
if (metadata.get(fieldID, 'access') === true) {
return true;
}
}
return false;
}
// Checks if the metadata for all given field IDs exists.
function allMetadataExists(fieldIDs) {
return fieldIDs.length === metadata.intersection(fieldIDs).length;
}
// Find all fields for this entity instance and collect their field IDs.
const fields = _.where(fieldsAvailableQueue, {
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID,
});
const fieldIDs = _.pluck(fields, 'fieldID');
// No fields found yet.
if (fieldIDs.length === 0) {
return false;
}
// The entity for the given contextual link contains at least one field that
// the current user may edit in-place; instantiate EntityModel,
// EntityDecorationView and ContextualLinkView.
if (hasFieldWithPermission(fieldIDs)) {
const entityModel = new Drupal.quickedit.EntityModel({
el: contextualLink.region,
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID,
id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
});
Drupal.quickedit.collections.entities.add(entityModel);
// Create an EntityDecorationView associated with the root DOM node of the
// entity.
const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
el: contextualLink.region,
model: entityModel,
});
entityModel.set('entityDecorationView', entityDecorationView);
// Initialize all queued fields within this entity (creates FieldModels).
_.each(fields, field => {
initializeField(
field.el,
field.fieldID,
contextualLink.entityID,
contextualLink.entityInstanceID,
);
});
fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
// Initialization should only be called once. Use Underscore's once method
// to get a one-time use version of the function.
const initContextualLink = _.once(() => {
const $links = $(contextualLink.el).find('.contextual-links');
const contextualLinkView = new Drupal.quickedit.ContextualLinkView(
$.extend(
{
el: $(
'<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>',
).prependTo($links),
model: entityModel,
appModel: Drupal.quickedit.app.model,
},
options,
),
);
entityModel.set('contextualLinkView', contextualLinkView);
});
// Set up ContextualLinkView after loading any missing in-place editors.
loadMissingEditors(initContextualLink);
return true;
}
// There was not at least one field that the current user may edit in-place,
// even though the metadata for all fields within this entity is available.
if (allMetadataExists(fieldIDs)) {
return true;
}
return false;
}
/**
* Extracts the entity ID from a field ID.
*
* @param {string} fieldID
* A field ID: a string of the format
* `<entity type>/<id>/<field name>/<language>/<view mode>`.
*
* @return {string}
* An entity ID: a string of the format `<entity type>/<id>`.
*/
function extractEntityID(fieldID) {
return fieldID
.split('/')
.slice(0, 2)
.join('/');
}
/**
* Fetch the field's metadata; queue or initialize it (if EntityModel exists).
*
* @param {HTMLElement} fieldElement
* A Drupal Field API field's DOM element with a data-quickedit-field-id
* attribute.
*/
function processField(fieldElement) {
const metadata = Drupal.quickedit.metadata;
const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
const entityID = extractEntityID(fieldID);
// Figure out the instance ID by looking at the ancestor
// [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
// attribute.
const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
const $entityElement = $(entityElementSelector);
// If there are no elements returned from `entityElementSelector`
// throw an error. Check the browser console for this message.
if (!$entityElement.length) {
throw new Error(
`Quick Edit could not associate the rendered entity field markup (with [data-quickedit-field-id="${fieldID}"]) with the corresponding rendered entity markup: no parent DOM node found with [data-quickedit-entity-id="${entityID}"]. This is typically caused by the theme's template for this entity type forgetting to print the attributes.`,
);
}
let entityElement = $(fieldElement).closest($entityElement);
// In the case of a full entity view page, the entity title is rendered
// outside of "the entity DOM node": it's rendered as the page title. So in
// this case, we find the lowest common parent element (deepest in the tree)
// and consider that the entity element.
if (entityElement.length === 0) {
const $lowestCommonParent = $entityElement
.parents()
.has(fieldElement)
.first();
entityElement = $lowestCommonParent.find($entityElement);
}
const entityInstanceID = entityElement
.get(0)
.getAttribute('data-quickedit-entity-instance-id');
// Early-return if metadata for this field is missing.
if (!metadata.has(fieldID)) {
fieldsMetadataQueue.push({
el: fieldElement,
fieldID,
entityID,
entityInstanceID,
});
return;
}
// Early-return if the user is not allowed to in-place edit this field.
if (metadata.get(fieldID, 'access') !== true) {
return;
}
// If an EntityModel for this field already exists (and hence also a "Quick
// edit" contextual link), then initialize it immediately.
if (
Drupal.quickedit.collections.entities.findWhere({
entityID,
entityInstanceID,
})
) {
initializeField(fieldElement, fieldID, entityID, entityInstanceID);
}
// Otherwise: queue the field. It is now available to be set up when its
// corresponding entity becomes in-place editable.
else {
fieldsAvailableQueue.push({
el: fieldElement,
fieldID,
entityID,
entityInstanceID,
});
}
}
/**
* Delete models and queue items that are contained within a given context.
*
* Deletes any contained EntityModels (plus their associated FieldModels and
* ContextualLinkView) and FieldModels, as well as the corresponding queues.
*
* After EntityModels, FieldModels must also be deleted, because it is
* possible in Drupal for a field DOM element to exist outside of the entity
* DOM element, e.g. when viewing the full node, the title of the node is not
* rendered within the node (the entity) but as the page title.
*
* Note: this will not delete an entity that is actively being in-place
* edited.
*
* @param {jQuery} $context
* The context within which to delete.
*/
function deleteContainedModelsAndQueues($context) {
$context
.find('[data-quickedit-entity-id]')
.addBack('[data-quickedit-entity-id]')
.each((index, entityElement) => {
// Delete entity model.
const entityModel = Drupal.quickedit.collections.entities.findWhere({
el: entityElement,
});
if (entityModel) {
const contextualLinkView = entityModel.get('contextualLinkView');
contextualLinkView.undelegateEvents();
contextualLinkView.remove();
// Remove the EntityDecorationView.
entityModel.get('entityDecorationView').remove();
// Destroy the EntityModel; this will also destroy its FieldModels.
entityModel.destroy();
}
// Filter queue.
function hasOtherRegion(contextualLink) {
return contextualLink.region !== entityElement;
}
contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
});
$context
.find('[data-quickedit-field-id]')
.addBack('[data-quickedit-field-id]')
.each((index, fieldElement) => {
// Delete field models.
Drupal.quickedit.collections.fields
.chain()
.filter(fieldModel => fieldModel.get('el') === fieldElement)
.invoke('destroy');
// Filter queues.
function hasOtherFieldElement(field) {
return field.el !== fieldElement;
}
fieldsMetadataQueue = _.filter(
fieldsMetadataQueue,
hasOtherFieldElement,
);
fieldsAvailableQueue = _.filter(
fieldsAvailableQueue,
hasOtherFieldElement,
);
});
}
/**
* Fetches metadata for fields whose metadata is missing.
*
* Fields whose metadata is missing are tracked at fieldsMetadataQueue.
*
* @param {function} callback
* A callback function that receives field elements whose metadata will just
* have been fetched.
*/
function fetchMissingMetadata(callback) {
if (fieldsMetadataQueue.length) {
const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
// Ensure we only request entityIDs for which we don't have metadata yet.
entityIDs = _.difference(
entityIDs,
Drupal.quickedit.metadata.intersection(entityIDs),
);
fieldsMetadataQueue = [];
$.ajax({
url: Drupal.url('quickedit/metadata'),
type: 'POST',
data: {
'fields[]': fieldIDs,
'entities[]': entityIDs,
},
dataType: 'json',
success(results) {
// Store the metadata.
_.each(results, (fieldMetadata, fieldID) => {
Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
});
callback(fieldElementsWithoutMetadata);
},
});
}
}
/**
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.quickedit = {
attach(context) {
// Initialize the Quick Edit app once per page load.
$('body')
.once('quickedit-init')
.each(initQuickEdit);
// Find all in-place editable fields, if any.
const $fields = $(context)
.find('[data-quickedit-field-id]')
.once('quickedit');
if ($fields.length === 0) {
return;
}
// Process each entity element: identical entities that appear multiple
// times will get a numeric identifier, starting at 0.
$(context)
.find('[data-quickedit-entity-id]')
.once('quickedit')
.each((index, entityElement) => {
processEntity(entityElement);
});
// Process each field element: queue to be used or to fetch metadata.
// When a field is being rerendered after editing, it will be processed
// immediately. New fields will be unable to be processed immediately,
// but will instead be queued to have their metadata fetched, which occurs
// below in fetchMissingMetaData().
$fields.each((index, fieldElement) => {
processField(fieldElement);
});
// Entities and fields on the page have been detected, try to set up the
// contextual links for those entities that already have the necessary
// meta- data in the client-side cache.
contextualLinksQueue = _.filter(
contextualLinksQueue,
contextualLink => !initializeEntityContextualLink(contextualLink),
);
// Fetch metadata for any fields that are queued to retrieve it.
fetchMissingMetadata(fieldElementsWithFreshMetadata => {
// Metadata has been fetched, reprocess fields whose metadata was
// missing.
_.each(fieldElementsWithFreshMetadata, processField);
// Metadata has been fetched, try to set up more contextual links now.
contextualLinksQueue = _.filter(
contextualLinksQueue,
contextualLink => !initializeEntityContextualLink(contextualLink),
);
});
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
deleteContainedModelsAndQueues($(context));
}
},
};
/**
*
* @namespace
*/
Drupal.quickedit = {
/**
* A {@link Drupal.quickedit.AppView} instance.
*/
app: null,
/**
* @type {object}
*
* @prop {Array.<Drupal.quickedit.EntityModel>} entities
* @prop {Array.<Drupal.quickedit.FieldModel>} fields
*/
collections: {
// All in-place editable entities (Drupal.quickedit.EntityModel) on the
// page.
entities: null,
// All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
fields: null,
},
/**
* In-place editors will register themselves in this object.
*
* @namespace
*/
editors: {},
/**
* Per-field metadata that indicates whether in-place editing is allowed,
* which in-place editor should be used, etc.
*
* @namespace
*/
metadata: {
/**
* Check if a field exists in storage.
*
* @param {string} fieldID
* The field id to check.
*
* @return {bool}
* Whether it was found or not.
*/
has(fieldID) {
return storage.getItem(this._prefixFieldID(fieldID)) !== null;
},
/**
* Add metadata to a field id.
*
* @param {string} fieldID
* The field ID to add data to.
* @param {object} metadata
* Metadata to add.
*/
add(fieldID, metadata) {
storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
},
/**
* Get a key from a field id.
*
* @param {string} fieldID
* The field ID to check.
* @param {string} [key]
* The key to check. If empty, will return all metadata.
*
* @return {object|*}
* The value for the key, if defined. Otherwise will return all metadata
* for the specified field id.
*
*/
get(fieldID, key) {
const metadata = JSON.parse(
storage.getItem(this._prefixFieldID(fieldID)),
);
return typeof key === 'undefined' ? metadata : metadata[key];
},
/**
* Prefix the field id.
*
* @param {string} fieldID
* The field id to prefix.
*
* @return {string}
* A prefixed field id.
*/
_prefixFieldID(fieldID) {
return `Drupal.quickedit.metadata.${fieldID}`;
},
/**
* Unprefix the field id.
*
* @param {string} fieldID
* The field id to unprefix.
*
* @return {string}
* An unprefixed field id.
*/
_unprefixFieldID(fieldID) {
// Strip "Drupal.quickedit.metadata.", which is 26 characters long.
return fieldID.substring(26);
},
/**
* Intersection calculation.
*
* @param {Array} fieldIDs
* An array of field ids to compare to prefix field id.
*
* @return {Array}
* The intersection found.
*/
intersection(fieldIDs) {
const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
const intersection = _.intersection(
prefixedFieldIDs,
_.keys(sessionStorage),
);
return _.map(intersection, this._unprefixFieldID);
},
},
};
// Clear the Quick Edit metadata cache whenever the current user's set of
// permissions changes.
const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID(
'permissionsHash',
);
const permissionsHashValue = storage.getItem(permissionsHashKey);
const permissionsHash = drupalSettings.user.permissionsHash;
if (permissionsHashValue !== permissionsHash) {
if (typeof permissionsHash === 'string') {
_.chain(storage)
.keys()
.each(key => {
if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
storage.removeItem(key);
}
});
}
storage.setItem(permissionsHashKey, permissionsHash);
}
/**
* Detect contextual links on entities annotated by quickedit.
*
* Queue contextual links to be processed.
*
* @param {jQuery.Event} event
* The `drupalContextualLinkAdded` event.
* @param {object} data
* An object containing the data relevant to the event.
*
* @listens event:drupalContextualLinkAdded
*/
$(document).on('drupalContextualLinkAdded', (event, data) => {
if (data.$region.is('[data-quickedit-entity-id]')) {
// If the contextual link is cached on the client side, an entity instance
// will not yet have been assigned. So assign one.
if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
data.$region.once('quickedit');
processEntity(data.$region.get(0));
}
const contextualLink = {
entityID: data.$region.attr('data-quickedit-entity-id'),
entityInstanceID: data.$region.attr(
'data-quickedit-entity-instance-id',
),
el: data.$el[0],
region: data.$region[0],
};
// Set up contextual links for this, otherwise queue it to be set up
// later.
if (!initializeEntityContextualLink(contextualLink)) {
contextualLinksQueue.push(contextualLink);
}
}
});
})(
jQuery,
_,
Backbone,
Drupal,
drupalSettings,
window.JSON,
window.sessionStorage,
);

View file

@ -1,244 +1,351 @@
/**
* @file
* Attaches behavior for the Quick Edit module.
*
* Everything happens asynchronously, to allow for:
* - dynamically rendered contextual links
* - asynchronously retrieved (and cached) per-field in-place editing metadata
* - asynchronous setup of in-place editable field and "Quick edit" link.
*
* To achieve this, there are several queues:
* - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
* - fieldsAvailableQueue: queue of fields whose metadata is known, and for
* which it has been confirmed that the user has permission to edit them.
* However, FieldModels will only be created for them once there's a
* contextual link for their entity: when it's possible to initiate editing.
* - contextualLinksQueue: queue of contextual links on entities for which it
* is not yet known whether the user has permission to edit at >=1 of them.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
'use strict';
var options = $.extend(drupalSettings.quickedit,
// Merge strings on top of drupalSettings so that they are not mutable.
{
strings: {
quickEdit: Drupal.t('Quick edit')
}
var options = $.extend(drupalSettings.quickedit, {
strings: {
quickEdit: Drupal.t('Quick edit')
}
);
});
/**
* Tracks fields without metadata. Contains objects with the following keys:
* - DOM el
* - String fieldID
* - String entityID
*/
var fieldsMetadataQueue = [];
/**
* Tracks fields ready for use. Contains objects with the following keys:
* - DOM el
* - String fieldID
* - String entityID
*/
var fieldsAvailableQueue = [];
/**
* Tracks contextual links on entities. Contains objects with the following
* keys:
* - String entityID
* - DOM el
* - DOM region
*/
var contextualLinksQueue = [];
/**
* Tracks how many instances exist for each unique entity. Contains key-value
* pairs:
* - String entityID
* - Number count
*/
var entityInstancesTracker = {};
/**
*
* @type {Drupal~behavior}
*/
function initQuickEdit(bodyElement) {
Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
Drupal.quickedit.app = new Drupal.quickedit.AppView({
el: bodyElement,
model: new Drupal.quickedit.AppModel(),
entitiesCollection: Drupal.quickedit.collections.entities,
fieldsCollection: Drupal.quickedit.collections.fields
});
}
function processEntity(entityElement) {
var entityID = entityElement.getAttribute('data-quickedit-entity-id');
if (!entityInstancesTracker.hasOwnProperty(entityID)) {
entityInstancesTracker[entityID] = 0;
} else {
entityInstancesTracker[entityID]++;
}
var entityInstanceID = entityInstancesTracker[entityID];
entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
}
function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
var entity = Drupal.quickedit.collections.entities.findWhere({
entityID: entityID,
entityInstanceID: entityInstanceID
});
$(fieldElement).addClass('quickedit-field');
var field = new Drupal.quickedit.FieldModel({
el: fieldElement,
fieldID: fieldID,
id: fieldID + '[' + entity.get('entityInstanceID') + ']',
entity: entity,
metadata: Drupal.quickedit.metadata.get(fieldID),
acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app)
});
Drupal.quickedit.collections.fields.add(field);
}
function loadMissingEditors(callback) {
var loadedEditors = _.keys(Drupal.quickedit.editors);
var missingEditors = [];
Drupal.quickedit.collections.fields.each(function (fieldModel) {
var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
missingEditors.push(metadata.editor);
Drupal.quickedit.editors[metadata.editor] = false;
}
});
missingEditors = _.uniq(missingEditors);
if (missingEditors.length === 0) {
callback();
return;
}
var loadEditorsAjax = Drupal.ajax({
url: Drupal.url('quickedit/attachments'),
submit: { 'editors[]': missingEditors }
});
var realInsert = Drupal.AjaxCommands.prototype.insert;
loadEditorsAjax.commands.insert = function (ajax, response, status) {
_.defer(callback);
realInsert(ajax, response, status);
};
loadEditorsAjax.execute();
}
function initializeEntityContextualLink(contextualLink) {
var metadata = Drupal.quickedit.metadata;
function hasFieldWithPermission(fieldIDs) {
for (var i = 0; i < fieldIDs.length; i++) {
var fieldID = fieldIDs[i];
if (metadata.get(fieldID, 'access') === true) {
return true;
}
}
return false;
}
function allMetadataExists(fieldIDs) {
return fieldIDs.length === metadata.intersection(fieldIDs).length;
}
var fields = _.where(fieldsAvailableQueue, {
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID
});
var fieldIDs = _.pluck(fields, 'fieldID');
if (fieldIDs.length === 0) {
return false;
}
if (hasFieldWithPermission(fieldIDs)) {
var entityModel = new Drupal.quickedit.EntityModel({
el: contextualLink.region,
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID,
id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']',
label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label')
});
Drupal.quickedit.collections.entities.add(entityModel);
var entityDecorationView = new Drupal.quickedit.EntityDecorationView({
el: contextualLink.region,
model: entityModel
});
entityModel.set('entityDecorationView', entityDecorationView);
_.each(fields, function (field) {
initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
});
fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
var initContextualLink = _.once(function () {
var $links = $(contextualLink.el).find('.contextual-links');
var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
model: entityModel,
appModel: Drupal.quickedit.app.model
}, options));
entityModel.set('contextualLinkView', contextualLinkView);
});
loadMissingEditors(initContextualLink);
return true;
}
if (allMetadataExists(fieldIDs)) {
return true;
}
return false;
}
function extractEntityID(fieldID) {
return fieldID.split('/').slice(0, 2).join('/');
}
function processField(fieldElement) {
var metadata = Drupal.quickedit.metadata;
var fieldID = fieldElement.getAttribute('data-quickedit-field-id');
var entityID = extractEntityID(fieldID);
var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]';
var $entityElement = $(entityElementSelector);
if (!$entityElement.length) {
throw new Error('Quick Edit could not associate the rendered entity field markup (with [data-quickedit-field-id="' + fieldID + '"]) with the corresponding rendered entity markup: no parent DOM node found with [data-quickedit-entity-id="' + entityID + '"]. This is typically caused by the theme\'s template for this entity type forgetting to print the attributes.');
}
var entityElement = $(fieldElement).closest($entityElement);
if (entityElement.length === 0) {
var $lowestCommonParent = $entityElement.parents().has(fieldElement).first();
entityElement = $lowestCommonParent.find($entityElement);
}
var entityInstanceID = entityElement.get(0).getAttribute('data-quickedit-entity-instance-id');
if (!metadata.has(fieldID)) {
fieldsMetadataQueue.push({
el: fieldElement,
fieldID: fieldID,
entityID: entityID,
entityInstanceID: entityInstanceID
});
return;
}
if (metadata.get(fieldID, 'access') !== true) {
return;
}
if (Drupal.quickedit.collections.entities.findWhere({
entityID: entityID,
entityInstanceID: entityInstanceID
})) {
initializeField(fieldElement, fieldID, entityID, entityInstanceID);
} else {
fieldsAvailableQueue.push({
el: fieldElement,
fieldID: fieldID,
entityID: entityID,
entityInstanceID: entityInstanceID
});
}
}
function deleteContainedModelsAndQueues($context) {
$context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) {
var entityModel = Drupal.quickedit.collections.entities.findWhere({
el: entityElement
});
if (entityModel) {
var contextualLinkView = entityModel.get('contextualLinkView');
contextualLinkView.undelegateEvents();
contextualLinkView.remove();
entityModel.get('entityDecorationView').remove();
entityModel.destroy();
}
function hasOtherRegion(contextualLink) {
return contextualLink.region !== entityElement;
}
contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
});
$context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) {
Drupal.quickedit.collections.fields.chain().filter(function (fieldModel) {
return fieldModel.get('el') === fieldElement;
}).invoke('destroy');
function hasOtherFieldElement(field) {
return field.el !== fieldElement;
}
fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
});
}
function fetchMissingMetadata(callback) {
if (fieldsMetadataQueue.length) {
var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
fieldsMetadataQueue = [];
$.ajax({
url: Drupal.url('quickedit/metadata'),
type: 'POST',
data: {
'fields[]': fieldIDs,
'entities[]': entityIDs
},
dataType: 'json',
success: function success(results) {
_.each(results, function (fieldMetadata, fieldID) {
Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
});
callback(fieldElementsWithoutMetadata);
}
});
}
}
Drupal.behaviors.quickedit = {
attach: function (context) {
// Initialize the Quick Edit app once per page load.
attach: function attach(context) {
$('body').once('quickedit-init').each(initQuickEdit);
// Find all in-place editable fields, if any.
var $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
if ($fields.length === 0) {
return;
}
// Process each entity element: identical entities that appear multiple
// times will get a numeric identifier, starting at 0.
$(context).find('[data-quickedit-entity-id]').once('quickedit').each(function (index, entityElement) {
processEntity(entityElement);
});
// Process each field element: queue to be used or to fetch metadata.
// When a field is being rerendered after editing, it will be processed
// immediately. New fields will be unable to be processed immediately,
// but will instead be queued to have their metadata fetched, which occurs
// below in fetchMissingMetaData().
$fields.each(function (index, fieldElement) {
processField(fieldElement);
});
// Entities and fields on the page have been detected, try to set up the
// contextual links for those entities that already have the necessary
// meta- data in the client-side cache.
contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
return !initializeEntityContextualLink(contextualLink);
});
// Fetch metadata for any fields that are queued to retrieve it.
fetchMissingMetadata(function (fieldElementsWithFreshMetadata) {
// Metadata has been fetched, reprocess fields whose metadata was
// missing.
_.each(fieldElementsWithFreshMetadata, processField);
// Metadata has been fetched, try to set up more contextual links now.
contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
return !initializeEntityContextualLink(contextualLink);
});
});
},
detach: function (context, settings, trigger) {
detach: function detach(context, settings, trigger) {
if (trigger === 'unload') {
deleteContainedModelsAndQueues($(context));
}
}
};
/**
*
* @namespace
*/
Drupal.quickedit = {
/**
* A {@link Drupal.quickedit.AppView} instance.
*/
app: null,
/**
* @type {object}
*
* @prop {Array.<Drupal.quickedit.EntityModel>} entities
* @prop {Array.<Drupal.quickedit.FieldModel>} fields
*/
collections: {
// All in-place editable entities (Drupal.quickedit.EntityModel) on the
// page.
entities: null,
// All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
fields: null
},
/**
* In-place editors will register themselves in this object.
*
* @namespace
*/
editors: {},
/**
* Per-field metadata that indicates whether in-place editing is allowed,
* which in-place editor should be used, etc.
*
* @namespace
*/
metadata: {
/**
* Check if a field exists in storage.
*
* @param {string} fieldID
* The field id to check.
*
* @return {bool}
* Whether it was found or not.
*/
has: function (fieldID) {
has: function has(fieldID) {
return storage.getItem(this._prefixFieldID(fieldID)) !== null;
},
/**
* Add metadata to a field id.
*
* @param {string} fieldID
* The field ID to add data to.
* @param {object} metadata
* Metadata to add.
*/
add: function (fieldID, metadata) {
add: function add(fieldID, metadata) {
storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
},
/**
* Get a key from a field id.
*
* @param {string} fieldID
* The field ID to check.
* @param {string} [key]
* The key to check. If empty, will return all metadata.
*
* @return {object|*}
* The value for the key, if defined. Otherwise will return all metadata
* for the specified field id.
*
*/
get: function (fieldID, key) {
get: function get(fieldID, key) {
var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
return (typeof key === 'undefined') ? metadata : metadata[key];
return typeof key === 'undefined' ? metadata : metadata[key];
},
/**
* Prefix the field id.
*
* @param {string} fieldID
* The field id to prefix.
*
* @return {string}
* A prefixed field id.
*/
_prefixFieldID: function (fieldID) {
_prefixFieldID: function _prefixFieldID(fieldID) {
return 'Drupal.quickedit.metadata.' + fieldID;
},
/**
* Unprefix the field id.
*
* @param {string} fieldID
* The field id to unprefix.
*
* @return {string}
* An unprefixed field id.
*/
_unprefixFieldID: function (fieldID) {
// Strip "Drupal.quickedit.metadata.", which is 26 characters long.
_unprefixFieldID: function _unprefixFieldID(fieldID) {
return fieldID.substring(26);
},
/**
* Intersection calculation.
*
* @param {Array} fieldIDs
* An array of field ids to compare to prefix field id.
*
* @return {Array}
* The intersection found.
*/
intersection: function (fieldIDs) {
intersection: function intersection(fieldIDs) {
var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
var intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
return _.map(intersection, this._unprefixFieldID);
@ -246,8 +353,6 @@
}
};
// Clear the Quick Edit metadata cache whenever the current user's set of
// permissions changes.
var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
var permissionsHashValue = storage.getItem(permissionsHashKey);
var permissionsHash = drupalSettings.user.permissionsHash;
@ -262,22 +367,8 @@
storage.setItem(permissionsHashKey, permissionsHash);
}
/**
* Detect contextual links on entities annotated by quickedit.
*
* Queue contextual links to be processed.
*
* @param {jQuery.Event} event
* The `drupalContextualLinkAdded` event.
* @param {object} data
* An object containing the data relevant to the event.
*
* @listens event:drupalContextualLinkAdded
*/
$(document).on('drupalContextualLinkAdded', function (event, data) {
if (data.$region.is('[data-quickedit-entity-id]')) {
// If the contextual link is cached on the client side, an entity instance
// will not yet have been assigned. So assign one.
if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
data.$region.once('quickedit');
processEntity(data.$region.get(0));
@ -288,399 +379,10 @@
el: data.$el[0],
region: data.$region[0]
};
// Set up contextual links for this, otherwise queue it to be set up
// later.
if (!initializeEntityContextualLink(contextualLink)) {
contextualLinksQueue.push(contextualLink);
}
}
});
/**
* Extracts the entity ID from a field ID.
*
* @param {string} fieldID
* A field ID: a string of the format
* `<entity type>/<id>/<field name>/<language>/<view mode>`.
*
* @return {string}
* An entity ID: a string of the format `<entity type>/<id>`.
*/
function extractEntityID(fieldID) {
return fieldID.split('/').slice(0, 2).join('/');
}
/**
* Initialize the Quick Edit app.
*
* @param {HTMLElement} bodyElement
* This document's body element.
*/
function initQuickEdit(bodyElement) {
Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
// Instantiate AppModel (application state) and AppView, which is the
// controller of the whole in-place editing experience.
Drupal.quickedit.app = new Drupal.quickedit.AppView({
el: bodyElement,
model: new Drupal.quickedit.AppModel(),
entitiesCollection: Drupal.quickedit.collections.entities,
fieldsCollection: Drupal.quickedit.collections.fields
});
}
/**
* Assigns the entity an instance ID.
*
* @param {HTMLElement} entityElement
* A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
* attribute.
*/
function processEntity(entityElement) {
var entityID = entityElement.getAttribute('data-quickedit-entity-id');
if (!entityInstancesTracker.hasOwnProperty(entityID)) {
entityInstancesTracker[entityID] = 0;
}
else {
entityInstancesTracker[entityID]++;
}
// Set the calculated entity instance ID for this element.
var entityInstanceID = entityInstancesTracker[entityID];
entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
}
/**
* Fetch the field's metadata; queue or initialize it (if EntityModel exists).
*
* @param {HTMLElement} fieldElement
* A Drupal Field API field's DOM element with a data-quickedit-field-id
* attribute.
*/
function processField(fieldElement) {
var metadata = Drupal.quickedit.metadata;
var fieldID = fieldElement.getAttribute('data-quickedit-field-id');
var entityID = extractEntityID(fieldID);
// Figure out the instance ID by looking at the ancestor
// [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
// attribute.
var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]';
var entityElement = $(fieldElement).closest(entityElementSelector);
// In the case of a full entity view page, the entity title is rendered
// outside of "the entity DOM node": it's rendered as the page title. So in
// this case, we find the lowest common parent element (deepest in the tree)
// and consider that the entity element.
if (entityElement.length === 0) {
var $lowestCommonParent = $(entityElementSelector).parents().has(fieldElement).first();
entityElement = $lowestCommonParent.find(entityElementSelector);
}
var entityInstanceID = entityElement
.get(0)
.getAttribute('data-quickedit-entity-instance-id');
// Early-return if metadata for this field is missing.
if (!metadata.has(fieldID)) {
fieldsMetadataQueue.push({
el: fieldElement,
fieldID: fieldID,
entityID: entityID,
entityInstanceID: entityInstanceID
});
return;
}
// Early-return if the user is not allowed to in-place edit this field.
if (metadata.get(fieldID, 'access') !== true) {
return;
}
// If an EntityModel for this field already exists (and hence also a "Quick
// edit" contextual link), then initialize it immediately.
if (Drupal.quickedit.collections.entities.findWhere({entityID: entityID, entityInstanceID: entityInstanceID})) {
initializeField(fieldElement, fieldID, entityID, entityInstanceID);
}
// Otherwise: queue the field. It is now available to be set up when its
// corresponding entity becomes in-place editable.
else {
fieldsAvailableQueue.push({el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID});
}
}
/**
* Initialize a field; create FieldModel.
*
* @param {HTMLElement} fieldElement
* The field's DOM element.
* @param {string} fieldID
* The field's ID.
* @param {string} entityID
* The field's entity's ID.
* @param {string} entityInstanceID
* The field's entity's instance ID.
*/
function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
var entity = Drupal.quickedit.collections.entities.findWhere({
entityID: entityID,
entityInstanceID: entityInstanceID
});
$(fieldElement).addClass('quickedit-field');
// The FieldModel stores the state of an in-place editable entity field.
var field = new Drupal.quickedit.FieldModel({
el: fieldElement,
fieldID: fieldID,
id: fieldID + '[' + entity.get('entityInstanceID') + ']',
entity: entity,
metadata: Drupal.quickedit.metadata.get(fieldID),
acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app)
});
// Track all fields on the page.
Drupal.quickedit.collections.fields.add(field);
}
/**
* Fetches metadata for fields whose metadata is missing.
*
* Fields whose metadata is missing are tracked at fieldsMetadataQueue.
*
* @param {function} callback
* A callback function that receives field elements whose metadata will just
* have been fetched.
*/
function fetchMissingMetadata(callback) {
if (fieldsMetadataQueue.length) {
var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
// Ensure we only request entityIDs for which we don't have metadata yet.
entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
fieldsMetadataQueue = [];
$.ajax({
url: Drupal.url('quickedit/metadata'),
type: 'POST',
data: {
'fields[]': fieldIDs,
'entities[]': entityIDs
},
dataType: 'json',
success: function (results) {
// Store the metadata.
_.each(results, function (fieldMetadata, fieldID) {
Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
});
callback(fieldElementsWithoutMetadata);
}
});
}
}
/**
* Loads missing in-place editor's attachments (JavaScript and CSS files).
*
* Missing in-place editors are those whose fields are actively being used on
* the page but don't have.
*
* @param {function} callback
* Callback function to be called when the missing in-place editors (if any)
* have been inserted into the DOM. i.e. they may still be loading.
*/
function loadMissingEditors(callback) {
var loadedEditors = _.keys(Drupal.quickedit.editors);
var missingEditors = [];
Drupal.quickedit.collections.fields.each(function (fieldModel) {
var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
missingEditors.push(metadata.editor);
// Set a stub, to prevent subsequent calls to loadMissingEditors() from
// loading the same in-place editor again. Loading an in-place editor
// requires talking to a server, to download its JavaScript, then
// executing its JavaScript, and only then its Drupal.quickedit.editors
// entry will be set.
Drupal.quickedit.editors[metadata.editor] = false;
}
});
missingEditors = _.uniq(missingEditors);
if (missingEditors.length === 0) {
callback();
return;
}
// @see https://www.drupal.org/node/2029999.
// Create a Drupal.Ajax instance to load the form.
var loadEditorsAjax = Drupal.ajax({
url: Drupal.url('quickedit/attachments'),
submit: {'editors[]': missingEditors}
});
// Implement a scoped insert AJAX command: calls the callback after all AJAX
// command functions have been executed (hence the deferred calling).
var realInsert = Drupal.AjaxCommands.prototype.insert;
loadEditorsAjax.commands.insert = function (ajax, response, status) {
_.defer(callback);
realInsert(ajax, response, status);
};
// Trigger the AJAX request, which will should return AJAX commands to
// insert any missing attachments.
loadEditorsAjax.execute();
}
/**
* Attempts to set up a "Quick edit" link and corresponding EntityModel.
*
* @param {object} contextualLink
* An object with the following properties:
* - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
* "block_content/5".
* - String entityInstanceID: a Quick Edit entity instance identifier,
* e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
* instance of this entity).
* - DOM el: element pointing to the contextual links placeholder for this
* entity.
* - DOM region: element pointing to the contextual region of this entity.
*
* @return {bool}
* Returns true when a contextual the given contextual link metadata can be
* removed from the queue (either because the contextual link has been set
* up or because it is certain that in-place editing is not allowed for any
* of its fields). Returns false otherwise.
*/
function initializeEntityContextualLink(contextualLink) {
var metadata = Drupal.quickedit.metadata;
// Check if the user has permission to edit at least one of them.
function hasFieldWithPermission(fieldIDs) {
for (var i = 0; i < fieldIDs.length; i++) {
var fieldID = fieldIDs[i];
if (metadata.get(fieldID, 'access') === true) {
return true;
}
}
return false;
}
// Checks if the metadata for all given field IDs exists.
function allMetadataExists(fieldIDs) {
return fieldIDs.length === metadata.intersection(fieldIDs).length;
}
// Find all fields for this entity instance and collect their field IDs.
var fields = _.where(fieldsAvailableQueue, {
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID
});
var fieldIDs = _.pluck(fields, 'fieldID');
// No fields found yet.
if (fieldIDs.length === 0) {
return false;
}
// The entity for the given contextual link contains at least one field that
// the current user may edit in-place; instantiate EntityModel,
// EntityDecorationView and ContextualLinkView.
else if (hasFieldWithPermission(fieldIDs)) {
var entityModel = new Drupal.quickedit.EntityModel({
el: contextualLink.region,
entityID: contextualLink.entityID,
entityInstanceID: contextualLink.entityInstanceID,
id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']',
label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label')
});
Drupal.quickedit.collections.entities.add(entityModel);
// Create an EntityDecorationView associated with the root DOM node of the
// entity.
var entityDecorationView = new Drupal.quickedit.EntityDecorationView({
el: contextualLink.region,
model: entityModel
});
entityModel.set('entityDecorationView', entityDecorationView);
// Initialize all queued fields within this entity (creates FieldModels).
_.each(fields, function (field) {
initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
});
fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
// Initialization should only be called once. Use Underscore's once method
// to get a one-time use version of the function.
var initContextualLink = _.once(function () {
var $links = $(contextualLink.el).find('.contextual-links');
var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
model: entityModel,
appModel: Drupal.quickedit.app.model
}, options));
entityModel.set('contextualLinkView', contextualLinkView);
});
// Set up ContextualLinkView after loading any missing in-place editors.
loadMissingEditors(initContextualLink);
return true;
}
// There was not at least one field that the current user may edit in-place,
// even though the metadata for all fields within this entity is available.
else if (allMetadataExists(fieldIDs)) {
return true;
}
return false;
}
/**
* Delete models and queue items that are contained within a given context.
*
* Deletes any contained EntityModels (plus their associated FieldModels and
* ContextualLinkView) and FieldModels, as well as the corresponding queues.
*
* After EntityModels, FieldModels must also be deleted, because it is
* possible in Drupal for a field DOM element to exist outside of the entity
* DOM element, e.g. when viewing the full node, the title of the node is not
* rendered within the node (the entity) but as the page title.
*
* Note: this will not delete an entity that is actively being in-place
* edited.
*
* @param {jQuery} $context
* The context within which to delete.
*/
function deleteContainedModelsAndQueues($context) {
$context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) {
// Delete entity model.
var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement});
if (entityModel) {
var contextualLinkView = entityModel.get('contextualLinkView');
contextualLinkView.undelegateEvents();
contextualLinkView.remove();
// Remove the EntityDecorationView.
entityModel.get('entityDecorationView').remove();
// Destroy the EntityModel; this will also destroy its FieldModels.
entityModel.destroy();
}
// Filter queue.
function hasOtherRegion(contextualLink) {
return contextualLink.region !== entityElement;
}
contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
});
$context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) {
// Delete field models.
Drupal.quickedit.collections.fields.chain()
.filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; })
.invoke('destroy');
// Filter queues.
function hasOtherFieldElement(field) {
return field.el !== fieldElement;
}
fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
});
}
})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);

View file

@ -0,0 +1,187 @@
/**
* @file
* Provides theme functions for all of Quick Edit's client-side HTML.
*/
(function($, Drupal) {
/**
* Theme function for a "backstage" for the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the backstage.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditBackstage = function(settings) {
let html = '';
html += `<div id="${settings.id}" />`;
return html;
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* the id to apply to the backstage.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbar = function(settings) {
let html = '';
html += `<div id="${
settings.id
}" class="quickedit quickedit-toolbar-container clearfix">`;
html += '<i class="quickedit-toolbar-pointer"></i>';
html += '<div class="quickedit-toolbar-content">';
html +=
'<div class="quickedit-toolbar quickedit-toolbar-entity clearfix icon icon-pencil">';
html += '<div class="quickedit-toolbar-label" />';
html += '</div>';
html +=
'<div class="quickedit-toolbar quickedit-toolbar-field clearfix" />';
html += '</div><div class="quickedit-toolbar-lining"></div></div>';
return html;
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.entityLabel
* The title of the active entity.
* @param {string} settings.fieldLabel
* The label of the highlighted or active field.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarLabel = function(settings) {
// @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
return `<span class="field">${Drupal.checkPlain(
settings.fieldLabel,
)}</span>${Drupal.checkPlain(settings.entityLabel)}`;
};
/**
* Element defining a containing box for the placement of the entity toolbar.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarFence = function() {
return '<div id="quickedit-toolbar-fence" />';
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the toolbar container.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFieldToolbar = function(settings) {
return `<div id="${settings.id}" />`;
};
/**
* Theme function for a toolbar toolgroup of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} [settings.id]
* The id of the toolgroup.
* @param {string} settings.classes
* The class of the toolgroup.
* @param {Array} settings.buttons
* See {@link Drupal.theme.quickeditButtons}.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditToolgroup = function(settings) {
// Classes.
const classes = settings.classes || [];
classes.unshift('quickedit-toolgroup');
let html = '';
html += `<div class="${classes.join(' ')}"`;
if (settings.id) {
html += ` id="${settings.id}"`;
}
html += '>';
html += Drupal.theme('quickeditButtons', { buttons: settings.buttons });
html += '</div>';
return html;
};
/**
* Theme function for buttons of the Quick Edit module.
*
* Can be used for the buttons both in the toolbar toolgroups and in the
* modal.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {Array} settings.buttons
* - String type: the type of the button (defaults to 'button')
* - Array classes: the classes of the button.
* - String label: the label of the button.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditButtons = function(settings) {
let html = '';
for (let i = 0; i < settings.buttons.length; i++) {
const button = settings.buttons[i];
if (!button.hasOwnProperty('type')) {
button.type = 'button';
}
// Attributes.
const attributes = [];
const attrMap = settings.buttons[i].attributes || {};
Object.keys(attrMap).forEach(attr => {
attributes.push(attr + (attrMap[attr] ? `="${attrMap[attr]}"` : ''));
});
html += `<button type="${button.type}" class="${
button.classes
}" ${attributes.join(' ')}>${button.label}</button>`;
}
return html;
};
/**
* Theme function for a form container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the toolbar container.
* @param {string} settings.loadingMsg
* The message to show while loading.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFormContainer = function(settings) {
let html = '';
html += `<div id="${settings.id}" class="quickedit-form-container">`;
html += ' <div class="quickedit-form">';
html += ' <div class="placeholder">';
html += settings.loadingMsg;
html += ' </div>';
html += ' </div>';
html += '</div>';
return html;
};
})(jQuery, Drupal);

View file

@ -1,40 +1,17 @@
/**
* @file
* Provides theme functions for all of Quick Edit's client-side HTML.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal) {
'use strict';
/**
* Theme function for a "backstage" for the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the backstage.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditBackstage = function (settings) {
var html = '';
html += '<div id="' + settings.id + '" />';
return html;
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* the id to apply to the backstage.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbar = function (settings) {
var html = '';
html += '<div id="' + settings.id + '" class="quickedit quickedit-toolbar-container clearfix">';
@ -48,67 +25,20 @@
return html;
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.entityLabel
* The title of the active entity.
* @param {string} settings.fieldLabel
* The label of the highlighted or active field.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarLabel = function (settings) {
// @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
return '<span class="field">' + Drupal.checkPlain(settings.fieldLabel) + '</span>' + Drupal.checkPlain(settings.entityLabel);
};
/**
* Element defining a containing box for the placement of the entity toolbar.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarFence = function () {
return '<div id="quickedit-toolbar-fence" />';
};
/**
* Theme function for a toolbar container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the toolbar container.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFieldToolbar = function (settings) {
return '<div id="' + settings.id + '" />';
};
/**
* Theme function for a toolbar toolgroup of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} [settings.id]
* The id of the toolgroup.
* @param {string} settings.classes
* The class of the toolgroup.
* @param {Array} settings.buttons
* See {@link Drupal.theme.quickeditButtons}.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditToolgroup = function (settings) {
// Classes.
var classes = (settings.classes || []);
var classes = settings.classes || [];
classes.unshift('quickedit-toolgroup');
var html = '';
html += '<div class="' + classes.join(' ') + '"';
@ -116,62 +46,34 @@
html += ' id="' + settings.id + '"';
}
html += '>';
html += Drupal.theme('quickeditButtons', {buttons: settings.buttons});
html += Drupal.theme('quickeditButtons', { buttons: settings.buttons });
html += '</div>';
return html;
};
/**
* Theme function for buttons of the Quick Edit module.
*
* Can be used for the buttons both in the toolbar toolgroups and in the
* modal.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {Array} settings.buttons
* - String type: the type of the button (defaults to 'button')
* - Array classes: the classes of the button.
* - String label: the label of the button.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditButtons = function (settings) {
var html = '';
for (var i = 0; i < settings.buttons.length; i++) {
var _loop = function _loop(i) {
var button = settings.buttons[i];
if (!button.hasOwnProperty('type')) {
button.type = 'button';
}
// Attributes.
var attributes = [];
var attrMap = settings.buttons[i].attributes || {};
for (var attr in attrMap) {
if (attrMap.hasOwnProperty(attr)) {
attributes.push(attr + ((attrMap[attr]) ? '="' + attrMap[attr] + '"' : ''));
}
}
html += '<button type="' + button.type + '" class="' + button.classes + '"' + ' ' + attributes.join(' ') + '>';
html += button.label;
html += '</button>';
Object.keys(attrMap).forEach(function (attr) {
attributes.push(attr + (attrMap[attr] ? '="' + attrMap[attr] + '"' : ''));
});
html += '<button type="' + button.type + '" class="' + button.classes + '" ' + attributes.join(' ') + '>' + button.label + '</button>';
};
for (var i = 0; i < settings.buttons.length; i++) {
_loop(i);
}
return html;
};
/**
* Theme function for a form container of the Quick Edit module.
*
* @param {object} settings
* Settings object used to construct the markup.
* @param {string} settings.id
* The id to apply to the toolbar container.
* @param {string} settings.loadingMsg
* The message to show while loading.
*
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFormContainer = function (settings) {
var html = '';
html += '<div id="' + settings.id + '" class="quickedit-form-container">';
@ -183,5 +85,4 @@
html += '</div>';
return html;
};
})(jQuery, Drupal);
})(jQuery, Drupal);

View file

@ -0,0 +1,226 @@
/**
* @file
* Provides utility functions for Quick Edit.
*/
(function($, Drupal) {
/**
* @namespace
*/
Drupal.quickedit.util = Drupal.quickedit.util || {};
/**
* @namespace
*/
Drupal.quickedit.util.constants = {};
/**
*
* @type {string}
*/
Drupal.quickedit.util.constants.transitionEnd =
'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit';
/**
* Converts a field id into a formatted url path.
*
* @example
* Drupal.quickedit.util.buildUrl(
* 'node/1/body/und/full',
* '/quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode'
* );
*
* @param {string} id
* The id of an editable field.
* @param {string} urlFormat
* The Controller route for field processing.
*
* @return {string}
* The formatted URL.
*/
Drupal.quickedit.util.buildUrl = function(id, urlFormat) {
const parts = id.split('/');
return Drupal.formatString(decodeURIComponent(urlFormat), {
'!entity_type': parts[0],
'!id': parts[1],
'!field_name': parts[2],
'!langcode': parts[3],
'!view_mode': parts[4],
});
};
/**
* Shows a network error modal dialog.
*
* @param {string} title
* The title to use in the modal dialog.
* @param {string} message
* The message to use in the modal dialog.
*/
Drupal.quickedit.util.networkErrorModal = function(title, message) {
const $message = $(`<div>${message}</div>`);
const networkErrorModal = Drupal.dialog($message.get(0), {
title,
dialogClass: 'quickedit-network-error',
buttons: [
{
text: Drupal.t('OK'),
click() {
networkErrorModal.close();
},
primary: true,
},
],
create() {
$(this)
.parent()
.find('.ui-dialog-titlebar-close')
.remove();
},
close(event) {
// Automatically destroy the DOM element that was used for the dialog.
$(event.target).remove();
},
});
networkErrorModal.showModal();
};
/**
* @namespace
*/
Drupal.quickedit.util.form = {
/**
* Loads a form, calls a callback to insert.
*
* Leverages {@link Drupal.Ajax}' ability to have scoped (per-instance)
* command implementations to be able to call a callback.
*
* @param {object} options
* An object with the following keys:
* @param {string} options.fieldID
* The field ID that uniquely identifies the field for which this form
* will be loaded.
* @param {bool} options.nocssjs
* Boolean indicating whether no CSS and JS should be returned (necessary
* when the form is invisible to the user).
* @param {bool} options.reset
* Boolean indicating whether the data stored for this field's entity in
* PrivateTempStore should be used or reset.
* @param {function} callback
* A callback function that will receive the form to be inserted, as well
* as the ajax object, necessary if the callback wants to perform other
* Ajax commands.
*/
load(options, callback) {
const fieldID = options.fieldID;
// Create a Drupal.ajax instance to load the form.
const formLoaderAjax = Drupal.ajax({
url: Drupal.quickedit.util.buildUrl(
fieldID,
Drupal.url(
'quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode',
),
),
submit: {
nocssjs: options.nocssjs,
reset: options.reset,
},
error(xhr, url) {
// Show a modal to inform the user of the network error.
const fieldLabel = Drupal.quickedit.metadata.get(fieldID, 'label');
const message = Drupal.t(
'Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.',
{ '@field-label': fieldLabel },
);
Drupal.quickedit.util.networkErrorModal(
Drupal.t('Network problem!'),
message,
);
// Change the state back to "candidate", to allow the user to start
// in-place editing of the field again.
const fieldModel = Drupal.quickedit.app.model.get('activeField');
fieldModel.set('state', 'candidate');
},
});
// Implement a scoped quickeditFieldForm AJAX command: calls the callback.
formLoaderAjax.commands.quickeditFieldForm = function(
ajax,
response,
status,
) {
callback(response.data, ajax);
Drupal.ajax.instances[this.instanceIndex] = null;
};
// This will ensure our scoped quickeditFieldForm AJAX command gets
// called.
formLoaderAjax.execute();
},
/**
* Creates a {@link Drupal.Ajax} instance that is used to save a form.
*
* @param {object} options
* Submit options to the form.
* @param {bool} options.nocssjs
* Boolean indicating whether no CSS and JS should be returned (necessary
* when the form is invisible to the user).
* @param {Array.<string>} options.other_view_modes
* Array containing view mode IDs (of other instances of this field on the
* page).
* @param {jQuery} $submit
* The submit element.
*
* @return {Drupal.Ajax}
* A {@link Drupal.Ajax} instance.
*/
ajaxifySaving(options, $submit) {
// Re-wire the form to handle submit.
const settings = {
url: $submit.closest('form').attr('action'),
setClick: true,
event: 'click.quickedit',
progress: false,
submit: {
nocssjs: options.nocssjs,
other_view_modes: options.other_view_modes,
},
/**
* Reimplement the success handler.
*
* Ensure {@link Drupal.attachBehaviors} does not get called on the
* form.
*
* @param {Drupal.AjaxCommands~commandDefinition} response
* The Drupal AJAX response.
* @param {number} [status]
* The HTTP status code.
*/
success(response, status) {
Object.keys(response || {}).forEach(i => {
if (response[i].command && this.commands[response[i].command]) {
this.commands[response[i].command](this, response[i], status);
}
});
},
base: $submit.attr('id'),
element: $submit[0],
};
return Drupal.ajax(settings);
},
/**
* Cleans up the {@link Drupal.Ajax} instance that is used to save the form.
*
* @param {Drupal.Ajax} ajax
* A {@link Drupal.Ajax} instance that was returned by
* {@link Drupal.quickedit.form.ajaxifySaving}.
*/
unajaxifySaving(ajax) {
$(ajax.element).off('click.quickedit');
},
};
})(jQuery, Drupal);

View file

@ -1,45 +1,17 @@
/**
* @file
* Provides utility functions for Quick Edit.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Drupal) {
'use strict';
/**
* @namespace
*/
Drupal.quickedit.util = Drupal.quickedit.util || {};
/**
* @namespace
*/
Drupal.quickedit.util.constants = {};
/**
*
* @type {string}
*/
Drupal.quickedit.util.constants.transitionEnd = 'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit';
/**
* Converts a field id into a formatted url path.
*
* @example
* Drupal.quickedit.util.buildUrl(
* 'node/1/body/und/full',
* '/quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode'
* );
*
* @param {string} id
* The id of an editable field.
* @param {string} urlFormat
* The Controller route for field processing.
*
* @return {string}
* The formatted URL.
*/
Drupal.quickedit.util.buildUrl = function (id, urlFormat) {
var parts = id.split('/');
return Drupal.formatString(decodeURIComponent(urlFormat), {
@ -51,117 +23,57 @@
});
};
/**
* Shows a network error modal dialog.
*
* @param {string} title
* The title to use in the modal dialog.
* @param {string} message
* The message to use in the modal dialog.
*/
Drupal.quickedit.util.networkErrorModal = function (title, message) {
var $message = $('<div>' + message + '</div>');
var networkErrorModal = Drupal.dialog($message.get(0), {
title: title,
dialogClass: 'quickedit-network-error',
buttons: [
{
text: Drupal.t('OK'),
click: function () {
networkErrorModal.close();
},
primary: true
}
],
create: function () {
buttons: [{
text: Drupal.t('OK'),
click: function click() {
networkErrorModal.close();
},
primary: true
}],
create: function create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
},
close: function (event) {
// Automatically destroy the DOM element that was used for the dialog.
close: function close(event) {
$(event.target).remove();
}
});
networkErrorModal.showModal();
};
/**
* @namespace
*/
Drupal.quickedit.util.form = {
/**
* Loads a form, calls a callback to insert.
*
* Leverages {@link Drupal.Ajax}' ability to have scoped (per-instance)
* command implementations to be able to call a callback.
*
* @param {object} options
* An object with the following keys:
* @param {string} options.fieldID
* The field ID that uniquely identifies the field for which this form
* will be loaded.
* @param {bool} options.nocssjs
* Boolean indicating whether no CSS and JS should be returned (necessary
* when the form is invisible to the user).
* @param {bool} options.reset
* Boolean indicating whether the data stored for this field's entity in
* PrivateTempStore should be used or reset.
* @param {function} callback
* A callback function that will receive the form to be inserted, as well
* as the ajax object, necessary if the callback wants to perform other
* Ajax commands.
*/
load: function (options, callback) {
load: function load(options, callback) {
var fieldID = options.fieldID;
// Create a Drupal.ajax instance to load the form.
var formLoaderAjax = Drupal.ajax({
url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode')),
submit: {
nocssjs: options.nocssjs,
reset: options.reset
},
error: function (xhr, url) {
// Show a modal to inform the user of the network error.
error: function error(xhr, url) {
var fieldLabel = Drupal.quickedit.metadata.get(fieldID, 'label');
var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', {'@field-label': fieldLabel});
var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', { '@field-label': fieldLabel });
Drupal.quickedit.util.networkErrorModal(Drupal.t('Network problem!'), message);
// Change the state back to "candidate", to allow the user to start
// in-place editing of the field again.
var fieldModel = Drupal.quickedit.app.model.get('activeField');
fieldModel.set('state', 'candidate');
}
});
// Implement a scoped quickeditFieldForm AJAX command: calls the callback.
formLoaderAjax.commands.quickeditFieldForm = function (ajax, response, status) {
callback(response.data, ajax);
Drupal.ajax.instances[this.instanceIndex] = null;
};
// This will ensure our scoped quickeditFieldForm AJAX command gets
// called.
formLoaderAjax.execute();
},
/**
* Creates a {@link Drupal.Ajax} instance that is used to save a form.
*
* @param {object} options
* Submit options to the form.
* @param {bool} options.nocssjs
* Boolean indicating whether no CSS and JS should be returned (necessary
* when the form is invisible to the user).
* @param {Array.<string>} options.other_view_modes
* Array containing view mode IDs (of other instances of this field on the
* page).
* @param {jQuery} $submit
* The submit element.
*
* @return {Drupal.Ajax}
* A {@link Drupal.Ajax} instance.
*/
ajaxifySaving: function (options, $submit) {
// Re-wire the form to handle submit.
ajaxifySaving: function ajaxifySaving(options, $submit) {
var settings = {
url: $submit.closest('form').attr('action'),
setClick: true,
@ -172,42 +84,24 @@
other_view_modes: options.other_view_modes
},
/**
* Reimplement the success handler.
*
* Ensure {@link Drupal.attachBehaviors} does not get called on the
* form.
*
* @param {Drupal.AjaxCommands~commandDefinition} response
* The Drupal AJAX response.
* @param {number} [status]
* The HTTP status code.
*/
success: function (response, status) {
for (var i in response) {
if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) {
this.commands[response[i].command](this, response[i], status);
success: function success(response, status) {
var _this = this;
Object.keys(response || {}).forEach(function (i) {
if (response[i].command && _this.commands[response[i].command]) {
_this.commands[response[i].command](_this, response[i], status);
}
}
});
},
base: $submit.attr('id'),
element: $submit[0]
};
return Drupal.ajax(settings);
},
/**
* Cleans up the {@link Drupal.Ajax} instance that is used to save the form.
*
* @param {Drupal.Ajax} ajax
* A {@link Drupal.Ajax} instance that was returned by
* {@link Drupal.quickedit.form.ajaxifySaving}.
*/
unajaxifySaving: function (ajax) {
unajaxifySaving: function unajaxifySaving(ajax) {
$(ajax.element).off('click.quickedit');
}
};
})(jQuery, Drupal);
})(jQuery, Drupal);

View file

@ -0,0 +1,656 @@
/**
* @file
* A Backbone View that controls the overall "in-place editing application".
*
* @see Drupal.quickedit.AppModel
*/
(function($, _, Backbone, Drupal) {
// Indicates whether the page should be reloaded after in-place editing has
// shut down. A page reload is necessary to re-instate the original HTML of
// the edited fields if in-place editing has been canceled and one or more of
// the entity's fields were saved to PrivateTempStore: one of them may have
// been changed to the empty value and hence may have been rerendered as the
// empty string, which makes it impossible for Quick Edit to know where to
// restore the original HTML.
let reload = false;
Drupal.quickedit.AppView = Backbone.View.extend(
/** @lends Drupal.quickedit.AppView# */ {
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.AppModel} options.model
* The application state model.
* @param {Drupal.quickedit.EntityCollection} options.entitiesCollection
* All on-page entities.
* @param {Drupal.quickedit.FieldCollection} options.fieldsCollection
* All on-page fields
*/
initialize(options) {
// AppView's configuration for handling states.
// @see Drupal.quickedit.FieldModel.states
this.activeFieldStates = ['activating', 'active'];
this.singleFieldStates = ['highlighted', 'activating', 'active'];
this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid'];
this.readyFieldStates = ['candidate', 'highlighted'];
// Track app state.
this.listenTo(
options.entitiesCollection,
'change:state',
this.appStateChange,
);
this.listenTo(
options.entitiesCollection,
'change:isActive',
this.enforceSingleActiveEntity,
);
// Track app state.
this.listenTo(
options.fieldsCollection,
'change:state',
this.editorStateChange,
);
// Respond to field model HTML representation change events.
this.listenTo(
options.fieldsCollection,
'change:html',
this.renderUpdatedField,
);
this.listenTo(
options.fieldsCollection,
'change:html',
this.propagateUpdatedField,
);
// Respond to addition.
this.listenTo(
options.fieldsCollection,
'add',
this.rerenderedFieldToCandidate,
);
// Respond to destruction.
this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor);
},
/**
* Handles setup/teardown and state changes when the active entity changes.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* An instance of the EntityModel class.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.EntityModel.states}.
*/
appStateChange(entityModel, state) {
const app = this;
let entityToolbarView;
switch (state) {
case 'launching':
reload = false;
// First, create an entity toolbar view.
entityToolbarView = new Drupal.quickedit.EntityToolbarView({
model: entityModel,
appModel: this.model,
});
entityModel.toolbarView = entityToolbarView;
// Second, set up in-place editors.
// They must be notified of state changes, hence this must happen
// while the associated fields are still in the 'inactive' state.
entityModel.get('fields').each(fieldModel => {
app.setupEditor(fieldModel);
});
// Third, transition the entity to the 'opening' state, which will
// transition all fields from 'inactive' to 'candidate'.
_.defer(() => {
entityModel.set('state', 'opening');
});
break;
case 'closed':
entityToolbarView = entityModel.toolbarView;
// First, tear down the in-place editors.
entityModel.get('fields').each(fieldModel => {
app.teardownEditor(fieldModel);
});
// Second, tear down the entity toolbar view.
if (entityToolbarView) {
entityToolbarView.remove();
delete entityModel.toolbarView;
}
// A page reload may be necessary to re-instate the original HTML of
// the edited fields.
if (reload) {
reload = false;
window.location.reload();
}
break;
}
},
/**
* Accepts or reject editor (Editor) state changes.
*
* This is what ensures that the app is in control of what happens.
*
* @param {string} from
* The previous state.
* @param {string} to
* The new state.
* @param {null|object} context
* The context that is trying to trigger the state change.
* @param {Drupal.quickedit.FieldModel} fieldModel
* The fieldModel to which this change applies.
*
* @return {bool}
* Whether the editor change was accepted or rejected.
*/
acceptEditorStateChange(from, to, context, fieldModel) {
let accept = true;
// If the app is in view mode, then reject all state changes except for
// those to 'inactive'.
if (
context &&
(context.reason === 'stop' || context.reason === 'rerender')
) {
if (from === 'candidate' && to === 'inactive') {
accept = true;
}
}
// Handling of edit mode state changes is more granular.
else {
// In general, enforce the states sequence. Disallow going back from a
// "later" state to an "earlier" state, except in explicitly allowed
// cases.
if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) {
accept = false;
// Allow: activating/active -> candidate.
// Necessary to stop editing a field.
if (
_.indexOf(this.activeFieldStates, from) !== -1 &&
to === 'candidate'
) {
accept = true;
}
// Allow: changed/invalid -> candidate.
// Necessary to stop editing a field when it is changed or invalid.
else if (
(from === 'changed' || from === 'invalid') &&
to === 'candidate'
) {
accept = true;
}
// Allow: highlighted -> candidate.
// Necessary to stop highlighting a field.
else if (from === 'highlighted' && to === 'candidate') {
accept = true;
}
// Allow: saved -> candidate.
// Necessary when successfully saved a field.
else if (from === 'saved' && to === 'candidate') {
accept = true;
}
// Allow: invalid -> saving.
// Necessary to be able to save a corrected, invalid field.
else if (from === 'invalid' && to === 'saving') {
accept = true;
}
// Allow: invalid -> activating.
// Necessary to be able to correct a field that turned out to be
// invalid after the user already had moved on to the next field
// (which we explicitly allow to have a fluent UX).
else if (from === 'invalid' && to === 'activating') {
accept = true;
}
}
// If it's not against the general principle, then here are more
// disallowed cases to check.
if (accept) {
let activeField;
let activeFieldState;
// Ensure only one field (editor) at a time is active … but allow a
// user to hop from one field to the next, even if we still have to
// start saving the field that is currently active: assume it will be
// valid, to allow for a fluent UX. (If it turns out to be invalid,
// this block of code also handles that.)
if (
(this.readyFieldStates.indexOf(from) !== -1 ||
from === 'invalid') &&
this.activeFieldStates.indexOf(to) !== -1
) {
activeField = this.model.get('activeField');
if (activeField && activeField !== fieldModel) {
activeFieldState = activeField.get('state');
// Allow the state change. If the state of the active field is:
// - 'activating' or 'active': change it to 'candidate'
// - 'changed' or 'invalid': change it to 'saving'
// - 'saving' or 'saved': don't do anything.
if (this.activeFieldStates.indexOf(activeFieldState) !== -1) {
activeField.set('state', 'candidate');
} else if (
activeFieldState === 'changed' ||
activeFieldState === 'invalid'
) {
activeField.set('state', 'saving');
}
// If the field that's being activated is in fact already in the
// invalid state (which can only happen because above we allowed
// the user to move on to another field to allow for a fluent UX;
// we assumed it would be saved successfully), then we shouldn't
// allow the field to enter the 'activating' state, instead, we
// simply change the active editor. All guarantees and
// assumptions for this field still hold!
if (from === 'invalid') {
this.model.set('activeField', fieldModel);
accept = false;
}
// Do not reject: the field is either in the 'candidate' or
// 'highlighted' state and we allow it to enter the 'activating'
// state!
}
}
// Reject going from activating/active to candidate because of a
// mouseleave.
else if (
_.indexOf(this.activeFieldStates, from) !== -1 &&
to === 'candidate'
) {
if (context && context.reason === 'mouseleave') {
accept = false;
}
}
// When attempting to stop editing a changed/invalid property, ask for
// confirmation.
else if (
(from === 'changed' || from === 'invalid') &&
to === 'candidate'
) {
if (context && context.reason === 'mouseleave') {
accept = false;
}
// Check whether the transition has been confirmed?
else if (context && context.confirmed) {
accept = true;
}
}
}
}
return accept;
},
/**
* Sets up the in-place editor for the given field.
*
* Must happen before the fieldModel's state is changed to 'candidate'.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The field for which an in-place editor must be set up.
*/
setupEditor(fieldModel) {
// Get the corresponding entity toolbar.
const entityModel = fieldModel.get('entity');
const entityToolbarView = entityModel.toolbarView;
// Get the field toolbar DOM root from the entity toolbar.
const fieldToolbarRoot = entityToolbarView.getToolbarRoot();
// Create in-place editor.
const editorName = fieldModel.get('metadata').editor;
const editorModel = new Drupal.quickedit.EditorModel();
const editorView = new Drupal.quickedit.editors[editorName]({
el: $(fieldModel.get('el')),
model: editorModel,
fieldModel,
});
// Create in-place editor's toolbar for this field — stored inside the
// entity toolbar, the entity toolbar will position itself appropriately
// above (or below) the edited element.
const toolbarView = new Drupal.quickedit.FieldToolbarView({
el: fieldToolbarRoot,
model: fieldModel,
$editedElement: $(editorView.getEditedElement()),
editorView,
entityModel,
});
// Create decoration for edited element: padding if necessary, sets
// classes on the element to style it according to the current state.
const decorationView = new Drupal.quickedit.FieldDecorationView({
el: $(editorView.getEditedElement()),
model: fieldModel,
editorView,
});
// Track these three views in FieldModel so that we can tear them down
// correctly.
fieldModel.editorView = editorView;
fieldModel.toolbarView = toolbarView;
fieldModel.decorationView = decorationView;
},
/**
* Tears down the in-place editor for the given field.
*
* Must happen after the fieldModel's state is changed to 'inactive'.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The field for which an in-place editor must be torn down.
*/
teardownEditor(fieldModel) {
// Early-return if this field was not yet decorated.
if (typeof fieldModel.editorView === 'undefined') {
return;
}
// Unbind event handlers; remove toolbar element; delete toolbar view.
fieldModel.toolbarView.remove();
delete fieldModel.toolbarView;
// Unbind event handlers; delete decoration view. Don't remove the element
// because that would remove the field itself.
fieldModel.decorationView.remove();
delete fieldModel.decorationView;
// Unbind event handlers; delete editor view. Don't remove the element
// because that would remove the field itself.
fieldModel.editorView.remove();
delete fieldModel.editorView;
},
/**
* Asks the user to confirm whether he wants to stop editing via a modal.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* An instance of the EntityModel class.
*
* @see Drupal.quickedit.AppView#acceptEditorStateChange
*/
confirmEntityDeactivation(entityModel) {
const that = this;
let discardDialog;
function closeDiscardDialog(action) {
discardDialog.close(action);
// The active modal has been removed.
that.model.set('activeModal', null);
// If the targetState is saving, the field must be saved, then the
// entity must be saved.
if (action === 'save') {
entityModel.set('state', 'committing', { confirmed: true });
} else {
entityModel.set('state', 'deactivating', { confirmed: true });
// Editing has been canceled and the changes will not be saved. Mark
// the page for reload if the entityModel declares that it requires
// a reload.
if (entityModel.get('reload')) {
reload = true;
entityModel.set('reload', false);
}
}
}
// Only instantiate if there isn't a modal instance visible yet.
if (!this.model.get('activeModal')) {
const $unsavedChanges = $(
`<div>${Drupal.t('You have unsaved changes')}</div>`,
);
discardDialog = Drupal.dialog($unsavedChanges.get(0), {
title: Drupal.t('Discard changes?'),
dialogClass: 'quickedit-discard-modal',
resizable: false,
buttons: [
{
text: Drupal.t('Save'),
click() {
closeDiscardDialog('save');
},
primary: true,
},
{
text: Drupal.t('Discard changes'),
click() {
closeDiscardDialog('discard');
},
},
],
// Prevent this modal from being closed without the user making a
// choice as per http://stackoverflow.com/a/5438771.
closeOnEscape: false,
create() {
$(this)
.parent()
.find('.ui-dialog-titlebar-close')
.remove();
},
beforeClose: false,
close(event) {
// Automatically destroy the DOM element that was used for the
// dialog.
$(event.target).remove();
},
});
this.model.set('activeModal', discardDialog);
discardDialog.showModal();
}
},
/**
* Reacts to field state changes; tracks global state.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The `fieldModel` holding the state.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
editorStateChange(fieldModel, state) {
const from = fieldModel.previous('state');
const to = state;
// Keep track of the highlighted field in the global state.
if (
_.indexOf(this.singleFieldStates, to) !== -1 &&
this.model.get('highlightedField') !== fieldModel
) {
this.model.set('highlightedField', fieldModel);
} else if (
this.model.get('highlightedField') === fieldModel &&
to === 'candidate'
) {
this.model.set('highlightedField', null);
}
// Keep track of the active field in the global state.
if (
_.indexOf(this.activeFieldStates, to) !== -1 &&
this.model.get('activeField') !== fieldModel
) {
this.model.set('activeField', fieldModel);
} else if (
this.model.get('activeField') === fieldModel &&
to === 'candidate'
) {
// Discarded if it transitions from a changed state to 'candidate'.
if (from === 'changed' || from === 'invalid') {
fieldModel.editorView.revert();
}
this.model.set('activeField', null);
}
},
/**
* Render an updated field (a field whose 'html' attribute changed).
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The FieldModel whose 'html' attribute changed.
* @param {string} html
* The updated 'html' attribute.
* @param {object} options
* An object with the following keys:
* @param {bool} options.propagation
* Whether this change to the 'html' attribute occurred because of the
* propagation of changes to another instance of this field.
*/
renderUpdatedField(fieldModel, html, options) {
// Get data necessary to rerender property before it is unavailable.
const $fieldWrapper = $(fieldModel.get('el'));
const $context = $fieldWrapper.parent();
const renderField = function() {
// Destroy the field model; this will cause all attached views to be
// destroyed too, and removal from all collections in which it exists.
fieldModel.destroy();
// Replace the old content with the new content.
$fieldWrapper.replaceWith(html);
// Attach behaviors again to the modified piece of HTML; this will
// create a new field model and call rerenderedFieldToCandidate() with
// it.
Drupal.attachBehaviors($context.get(0));
};
// When propagating the changes of another instance of this field, this
// field is not being actively edited and hence no state changes are
// necessary. So: only update the state of this field when the rerendering
// of this field happens not because of propagation, but because it is
// being edited itself.
if (!options.propagation) {
// Deferred because renderUpdatedField is reacting to a field model
// change event, and we want to make sure that event fully propagates
// before making another change to the same model.
_.defer(() => {
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// Similarly, the above .set() call's change event must fully
// propagate before calling it again.
_.defer(() => {
// Set the field's state to 'inactive', to enable the updating of
// its DOM value.
fieldModel.set('state', 'inactive', { reason: 'rerender' });
renderField();
});
});
} else {
renderField();
}
},
/**
* Propagates changes to an updated field to all instances of that field.
*
* @param {Drupal.quickedit.FieldModel} updatedField
* The FieldModel whose 'html' attribute changed.
* @param {string} html
* The updated 'html' attribute.
* @param {object} options
* An object with the following keys:
* @param {bool} options.propagation
* Whether this change to the 'html' attribute occurred because of the
* propagation of changes to another instance of this field.
*
* @see Drupal.quickedit.AppView#renderUpdatedField
*/
propagateUpdatedField(updatedField, html, options) {
// Don't propagate field updates that themselves were caused by
// propagation.
if (options.propagation) {
return;
}
const htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes');
Drupal.quickedit.collections.fields
// Find all instances of fields that display the same logical field
// (same entity, same field, just a different instance and maybe a
// different view mode).
.where({ logicalFieldID: updatedField.get('logicalFieldID') })
.forEach(field => {
if (field === updatedField) {
// Ignore the field that was already updated.
}
// If this other instance of the field has the same view mode, we can
// update it easily.
else if (field.getViewMode() === updatedField.getViewMode()) {
field.set('html', updatedField.get('html'));
}
// If this other instance of the field has a different view mode, and
// that is one of the view modes for which a re-rendered version is
// available (and that should be the case unless this field was only
// added to the page after editing of the updated field began), then
// use that view mode's re-rendered version.
else if (field.getViewMode() in htmlForOtherViewModes) {
field.set('html', htmlForOtherViewModes[field.getViewMode()], {
propagation: true,
});
}
});
},
/**
* If the new in-place editable field is for the entity that's currently
* being edited, then transition it to the 'candidate' state.
*
* This happens when a field was modified, saved and hence rerendered.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* A field that was just added to the collection of fields.
*/
rerenderedFieldToCandidate(fieldModel) {
const activeEntity = Drupal.quickedit.collections.entities.findWhere({
isActive: true,
});
// Early-return if there is no active entity.
if (!activeEntity) {
return;
}
// If the field's entity is the active entity, make it a candidate.
if (fieldModel.get('entity') === activeEntity) {
this.setupEditor(fieldModel);
fieldModel.set('state', 'candidate');
}
},
/**
* EntityModel Collection change handler.
*
* Handler is called `change:isActive` and enforces a single active entity.
*
* @param {Drupal.quickedit.EntityModel} changedEntityModel
* The entityModel instance whose active state has changed.
*/
enforceSingleActiveEntity(changedEntityModel) {
// When an entity is deactivated, we don't need to enforce anything.
if (changedEntityModel.get('isActive') === false) {
return;
}
// This entity was activated; deactivate all other entities.
changedEntityModel.collection
.chain()
.filter(
entityModel =>
entityModel.get('isActive') === true &&
entityModel !== changedEntityModel,
)
.each(entityModel => {
entityModel.set('state', 'deactivating');
});
},
},
);
})(jQuery, _, Backbone, Drupal);

View file

@ -1,91 +1,49 @@
/**
* @file
* A Backbone View that controls the overall "in-place editing application".
*
* @see Drupal.quickedit.AppModel
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Backbone, Drupal) {
'use strict';
// Indicates whether the page should be reloaded after in-place editing has
// shut down. A page reload is necessary to re-instate the original HTML of
// the edited fields if in-place editing has been canceled and one or more of
// the entity's fields were saved to PrivateTempStore: one of them may have
// been changed to the empty value and hence may have been rerendered as the
// empty string, which makes it impossible for Quick Edit to know where to
// restore the original HTML.
var reload = false;
Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.AppModel} options.model
* The application state model.
* @param {Drupal.quickedit.EntityCollection} options.entitiesCollection
* All on-page entities.
* @param {Drupal.quickedit.FieldCollection} options.fieldsCollection
* All on-page fields
*/
initialize: function (options) {
// AppView's configuration for handling states.
// @see Drupal.quickedit.FieldModel.states
Drupal.quickedit.AppView = Backbone.View.extend({
initialize: function initialize(options) {
this.activeFieldStates = ['activating', 'active'];
this.singleFieldStates = ['highlighted', 'activating', 'active'];
this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid'];
this.readyFieldStates = ['candidate', 'highlighted'];
// Track app state.
this.listenTo(options.entitiesCollection, 'change:state', this.appStateChange);
this.listenTo(options.entitiesCollection, 'change:isActive', this.enforceSingleActiveEntity);
// Track app state.
this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange);
// Respond to field model HTML representation change events.
this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField);
this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField);
// Respond to addition.
this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate);
// Respond to destruction.
this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor);
},
/**
* Handles setup/teardown and state changes when the active entity changes.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* An instance of the EntityModel class.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.EntityModel.states}.
*/
appStateChange: function (entityModel, state) {
appStateChange: function appStateChange(entityModel, state) {
var app = this;
var entityToolbarView;
var entityToolbarView = void 0;
switch (state) {
case 'launching':
reload = false;
// First, create an entity toolbar view.
entityToolbarView = new Drupal.quickedit.EntityToolbarView({
model: entityModel,
appModel: this.model
});
entityModel.toolbarView = entityToolbarView;
// Second, set up in-place editors.
// They must be notified of state changes, hence this must happen
// while the associated fields are still in the 'inactive' state.
entityModel.get('fields').each(function (fieldModel) {
app.setupEditor(fieldModel);
});
// Third, transition the entity to the 'opening' state, which will
// transition all fields from 'inactive' to 'candidate'.
_.defer(function () {
entityModel.set('state', 'opening');
});
@ -93,175 +51,91 @@
case 'closed':
entityToolbarView = entityModel.toolbarView;
// First, tear down the in-place editors.
entityModel.get('fields').each(function (fieldModel) {
app.teardownEditor(fieldModel);
});
// Second, tear down the entity toolbar view.
if (entityToolbarView) {
entityToolbarView.remove();
delete entityModel.toolbarView;
}
// A page reload may be necessary to re-instate the original HTML of
// the edited fields.
if (reload) {
reload = false;
location.reload();
window.location.reload();
}
break;
}
},
/**
* Accepts or reject editor (Editor) state changes.
*
* This is what ensures that the app is in control of what happens.
*
* @param {string} from
* The previous state.
* @param {string} to
* The new state.
* @param {null|object} context
* The context that is trying to trigger the state change.
* @param {Drupal.quickedit.FieldModel} fieldModel
* The fieldModel to which this change applies.
*
* @return {bool}
* Whether the editor change was accepted or rejected.
*/
acceptEditorStateChange: function (from, to, context, fieldModel) {
acceptEditorStateChange: function acceptEditorStateChange(from, to, context, fieldModel) {
var accept = true;
// If the app is in view mode, then reject all state changes except for
// those to 'inactive'.
if (context && (context.reason === 'stop' || context.reason === 'rerender')) {
if (from === 'candidate' && to === 'inactive') {
accept = true;
}
}
// Handling of edit mode state changes is more granular.
else {
// In general, enforce the states sequence. Disallow going back from a
// "later" state to an "earlier" state, except in explicitly allowed
// cases.
if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) {
accept = false;
// Allow: activating/active -> candidate.
// Necessary to stop editing a field.
if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
accept = true;
}
// Allow: changed/invalid -> candidate.
// Necessary to stop editing a field when it is changed or invalid.
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
accept = true;
}
// Allow: highlighted -> candidate.
// Necessary to stop highlighting a field.
else if (from === 'highlighted' && to === 'candidate') {
accept = true;
}
// Allow: saved -> candidate.
// Necessary when successfully saved a field.
else if (from === 'saved' && to === 'candidate') {
accept = true;
}
// Allow: invalid -> saving.
// Necessary to be able to save a corrected, invalid field.
else if (from === 'invalid' && to === 'saving') {
accept = true;
}
// Allow: invalid -> activating.
// Necessary to be able to correct a field that turned out to be
// invalid after the user already had moved on to the next field
// (which we explicitly allow to have a fluent UX).
else if (from === 'invalid' && to === 'activating') {
accept = true;
}
}
} else {
if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) {
accept = false;
// If it's not against the general principle, then here are more
// disallowed cases to check.
if (accept) {
var activeField;
var activeFieldState;
// Ensure only one field (editor) at a time is active … but allow a
// user to hop from one field to the next, even if we still have to
// start saving the field that is currently active: assume it will be
// valid, to allow for a fluent UX. (If it turns out to be invalid,
// this block of code also handles that.)
if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) {
activeField = this.model.get('activeField');
if (activeField && activeField !== fieldModel) {
activeFieldState = activeField.get('state');
// Allow the state change. If the state of the active field is:
// - 'activating' or 'active': change it to 'candidate'
// - 'changed' or 'invalid': change it to 'saving'
// - 'saving' or 'saved': don't do anything.
if (this.activeFieldStates.indexOf(activeFieldState) !== -1) {
activeField.set('state', 'candidate');
}
else if (activeFieldState === 'changed' || activeFieldState === 'invalid') {
activeField.set('state', 'saving');
}
// If the field that's being activated is in fact already in the
// invalid state (which can only happen because above we allowed
// the user to move on to another field to allow for a fluent UX;
// we assumed it would be saved successfully), then we shouldn't
// allow the field to enter the 'activating' state, instead, we
// simply change the active editor. All guarantees and
// assumptions for this field still hold!
if (from === 'invalid') {
this.model.set('activeField', fieldModel);
accept = false;
}
// Do not reject: the field is either in the 'candidate' or
// 'highlighted' state and we allow it to enter the 'activating'
// state!
}
}
// Reject going from activating/active to candidate because of a
// mouseleave.
else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
}
}
// When attempting to stop editing a changed/invalid property, ask for
// confirmation.
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
}
else {
// Check whether the transition has been confirmed?
if (context && context.confirmed) {
if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
accept = true;
} else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
accept = true;
} else if (from === 'highlighted' && to === 'candidate') {
accept = true;
} else if (from === 'saved' && to === 'candidate') {
accept = true;
} else if (from === 'invalid' && to === 'saving') {
accept = true;
} else if (from === 'invalid' && to === 'activating') {
accept = true;
}
}
if (accept) {
var activeField = void 0;
var activeFieldState = void 0;
if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) {
activeField = this.model.get('activeField');
if (activeField && activeField !== fieldModel) {
activeFieldState = activeField.get('state');
if (this.activeFieldStates.indexOf(activeFieldState) !== -1) {
activeField.set('state', 'candidate');
} else if (activeFieldState === 'changed' || activeFieldState === 'invalid') {
activeField.set('state', 'saving');
}
if (from === 'invalid') {
this.model.set('activeField', fieldModel);
accept = false;
}
}
}
} else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
}
} else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
if (context && context.reason === 'mouseleave') {
accept = false;
} else if (context && context.confirmed) {
accept = true;
}
}
}
}
}
return accept;
},
/**
* Sets up the in-place editor for the given field.
*
* Must happen before the fieldModel's state is changed to 'candidate'.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The field for which an in-place editor must be set up.
*/
setupEditor: function (fieldModel) {
// Get the corresponding entity toolbar.
setupEditor: function setupEditor(fieldModel) {
var entityModel = fieldModel.get('entity');
var entityToolbarView = entityModel.toolbarView;
// Get the field toolbar DOM root from the entity toolbar.
var fieldToolbarRoot = entityToolbarView.getToolbarRoot();
// Create in-place editor.
var editorName = fieldModel.get('metadata').editor;
var editorModel = new Drupal.quickedit.EditorModel();
var editorView = new Drupal.quickedit.editors[editorName]({
@ -270,9 +144,6 @@
fieldModel: fieldModel
});
// Create in-place editor's toolbar for this field — stored inside the
// entity toolbar, the entity toolbar will position itself appropriately
// above (or below) the edited element.
var toolbarView = new Drupal.quickedit.FieldToolbarView({
el: fieldToolbarRoot,
model: fieldModel,
@ -281,77 +152,44 @@
entityModel: entityModel
});
// Create decoration for edited element: padding if necessary, sets
// classes on the element to style it according to the current state.
var decorationView = new Drupal.quickedit.FieldDecorationView({
el: $(editorView.getEditedElement()),
model: fieldModel,
editorView: editorView
});
// Track these three views in FieldModel so that we can tear them down
// correctly.
fieldModel.editorView = editorView;
fieldModel.toolbarView = toolbarView;
fieldModel.decorationView = decorationView;
},
/**
* Tears down the in-place editor for the given field.
*
* Must happen after the fieldModel's state is changed to 'inactive'.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The field for which an in-place editor must be torn down.
*/
teardownEditor: function (fieldModel) {
// Early-return if this field was not yet decorated.
teardownEditor: function teardownEditor(fieldModel) {
if (typeof fieldModel.editorView === 'undefined') {
return;
}
// Unbind event handlers; remove toolbar element; delete toolbar view.
fieldModel.toolbarView.remove();
delete fieldModel.toolbarView;
// Unbind event handlers; delete decoration view. Don't remove the element
// because that would remove the field itself.
fieldModel.decorationView.remove();
delete fieldModel.decorationView;
// Unbind event handlers; delete editor view. Don't remove the element
// because that would remove the field itself.
fieldModel.editorView.remove();
delete fieldModel.editorView;
},
/**
* Asks the user to confirm whether he wants to stop editing via a modal.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* An instance of the EntityModel class.
*
* @see Drupal.quickedit.AppView#acceptEditorStateChange
*/
confirmEntityDeactivation: function (entityModel) {
confirmEntityDeactivation: function confirmEntityDeactivation(entityModel) {
var that = this;
var discardDialog;
var discardDialog = void 0;
function closeDiscardDialog(action) {
discardDialog.close(action);
// The active modal has been removed.
that.model.set('activeModal', null);
// If the targetState is saving, the field must be saved, then the
// entity must be saved.
if (action === 'save') {
entityModel.set('state', 'committing', {confirmed: true});
}
else {
entityModel.set('state', 'deactivating', {confirmed: true});
// Editing has been canceled and the changes will not be saved. Mark
// the page for reload if the entityModel declares that it requires
// a reload.
entityModel.set('state', 'committing', { confirmed: true });
} else {
entityModel.set('state', 'deactivating', { confirmed: true });
if (entityModel.get('reload')) {
reload = true;
entityModel.set('reload', false);
@ -359,38 +197,33 @@
}
}
// Only instantiate if there isn't a modal instance visible yet.
if (!this.model.get('activeModal')) {
var $unsavedChanges = $('<div>' + Drupal.t('You have unsaved changes') + '</div>');
discardDialog = Drupal.dialog($unsavedChanges.get(0), {
title: Drupal.t('Discard changes?'),
dialogClass: 'quickedit-discard-modal',
resizable: false,
buttons: [
{
text: Drupal.t('Save'),
click: function () {
closeDiscardDialog('save');
},
primary: true
buttons: [{
text: Drupal.t('Save'),
click: function click() {
closeDiscardDialog('save');
},
{
text: Drupal.t('Discard changes'),
click: function () {
closeDiscardDialog('discard');
}
primary: true
}, {
text: Drupal.t('Discard changes'),
click: function click() {
closeDiscardDialog('discard');
}
],
// Prevent this modal from being closed without the user making a
// choice as per http://stackoverflow.com/a/5438771.
}],
closeOnEscape: false,
create: function () {
create: function create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
},
beforeClose: false,
close: function (event) {
// Automatically destroy the DOM element that was used for the
// dialog.
close: function close(event) {
$(event.target).remove();
}
});
@ -399,202 +232,91 @@
discardDialog.showModal();
}
},
/**
* Reacts to field state changes; tracks global state.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The `fieldModel` holding the state.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
editorStateChange: function (fieldModel, state) {
editorStateChange: function editorStateChange(fieldModel, state) {
var from = fieldModel.previous('state');
var to = state;
// Keep track of the highlighted field in the global state.
if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) {
this.model.set('highlightedField', fieldModel);
}
else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') {
} else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') {
this.model.set('highlightedField', null);
}
// Keep track of the active field in the global state.
if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) {
this.model.set('activeField', fieldModel);
}
else if (this.model.get('activeField') === fieldModel && to === 'candidate') {
// Discarded if it transitions from a changed state to 'candidate'.
} else if (this.model.get('activeField') === fieldModel && to === 'candidate') {
if (from === 'changed' || from === 'invalid') {
fieldModel.editorView.revert();
}
this.model.set('activeField', null);
}
},
/**
* Render an updated field (a field whose 'html' attribute changed).
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The FieldModel whose 'html' attribute changed.
* @param {string} html
* The updated 'html' attribute.
* @param {object} options
* An object with the following keys:
* @param {bool} options.propagation
* Whether this change to the 'html' attribute occurred because of the
* propagation of changes to another instance of this field.
*/
renderUpdatedField: function (fieldModel, html, options) {
// Get data necessary to rerender property before it is unavailable.
renderUpdatedField: function renderUpdatedField(fieldModel, html, options) {
var $fieldWrapper = $(fieldModel.get('el'));
var $context = $fieldWrapper.parent();
var renderField = function () {
// Destroy the field model; this will cause all attached views to be
// destroyed too, and removal from all collections in which it exists.
var renderField = function renderField() {
fieldModel.destroy();
// Replace the old content with the new content.
$fieldWrapper.replaceWith(html);
// Attach behaviors again to the modified piece of HTML; this will
// create a new field model and call rerenderedFieldToCandidate() with
// it.
Drupal.attachBehaviors($context.get(0));
};
// When propagating the changes of another instance of this field, this
// field is not being actively edited and hence no state changes are
// necessary. So: only update the state of this field when the rerendering
// of this field happens not because of propagation, but because it is
// being edited itself.
if (!options.propagation) {
// Deferred because renderUpdatedField is reacting to a field model
// change event, and we want to make sure that event fully propagates
// before making another change to the same model.
_.defer(function () {
// First set the state to 'candidate', to allow all attached views to
// clean up all their "active state"-related changes.
fieldModel.set('state', 'candidate');
// Similarly, the above .set() call's change event must fully
// propagate before calling it again.
_.defer(function () {
// Set the field's state to 'inactive', to enable the updating of
// its DOM value.
fieldModel.set('state', 'inactive', {reason: 'rerender'});
fieldModel.set('state', 'inactive', { reason: 'rerender' });
renderField();
});
});
}
else {
} else {
renderField();
}
},
/**
* Propagates changes to an updated field to all instances of that field.
*
* @param {Drupal.quickedit.FieldModel} updatedField
* The FieldModel whose 'html' attribute changed.
* @param {string} html
* The updated 'html' attribute.
* @param {object} options
* An object with the following keys:
* @param {bool} options.propagation
* Whether this change to the 'html' attribute occurred because of the
* propagation of changes to another instance of this field.
*
* @see Drupal.quickedit.AppView#renderUpdatedField
*/
propagateUpdatedField: function (updatedField, html, options) {
// Don't propagate field updates that themselves were caused by
// propagation.
propagateUpdatedField: function propagateUpdatedField(updatedField, html, options) {
if (options.propagation) {
return;
}
var htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes');
Drupal.quickedit.collections.fields
// Find all instances of fields that display the same logical field
// (same entity, same field, just a different instance and maybe a
// different view mode).
.where({logicalFieldID: updatedField.get('logicalFieldID')})
.forEach(function (field) {
// Ignore the field that was already updated.
if (field === updatedField) {
return;
}
// If this other instance of the field has the same view mode, we can
// update it easily.
else if (field.getViewMode() === updatedField.getViewMode()) {
Drupal.quickedit.collections.fields.where({ logicalFieldID: updatedField.get('logicalFieldID') }).forEach(function (field) {
if (field === updatedField) {} else if (field.getViewMode() === updatedField.getViewMode()) {
field.set('html', updatedField.get('html'));
}
// If this other instance of the field has a different view mode, and
// that is one of the view modes for which a re-rendered version is
// available (and that should be the case unless this field was only
// added to the page after editing of the updated field began), then
// use that view mode's re-rendered version.
else {
if (field.getViewMode() in htmlForOtherViewModes) {
field.set('html', htmlForOtherViewModes[field.getViewMode()], {propagation: true});
} else if (field.getViewMode() in htmlForOtherViewModes) {
field.set('html', htmlForOtherViewModes[field.getViewMode()], {
propagation: true
});
}
}
});
});
},
rerenderedFieldToCandidate: function rerenderedFieldToCandidate(fieldModel) {
var activeEntity = Drupal.quickedit.collections.entities.findWhere({
isActive: true
});
/**
* If the new in-place editable field is for the entity that's currently
* being edited, then transition it to the 'candidate' state.
*
* This happens when a field was modified, saved and hence rerendered.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* A field that was just added to the collection of fields.
*/
rerenderedFieldToCandidate: function (fieldModel) {
var activeEntity = Drupal.quickedit.collections.entities.findWhere({isActive: true});
// Early-return if there is no active entity.
if (!activeEntity) {
return;
}
// If the field's entity is the active entity, make it a candidate.
if (fieldModel.get('entity') === activeEntity) {
this.setupEditor(fieldModel);
fieldModel.set('state', 'candidate');
}
},
/**
* EntityModel Collection change handler.
*
* Handler is called `change:isActive` and enforces a single active entity.
*
* @param {Drupal.quickedit.EntityModel} changedEntityModel
* The entityModel instance whose active state has changed.
*/
enforceSingleActiveEntity: function (changedEntityModel) {
// When an entity is deactivated, we don't need to enforce anything.
enforceSingleActiveEntity: function enforceSingleActiveEntity(changedEntityModel) {
if (changedEntityModel.get('isActive') === false) {
return;
}
// This entity was activated; deactivate all other entities.
changedEntityModel.collection.chain()
.filter(function (entityModel) {
return entityModel.get('isActive') === true && entityModel !== changedEntityModel;
})
.each(function (entityModel) {
entityModel.set('state', 'deactivating');
});
changedEntityModel.collection.chain().filter(function (entityModel) {
return entityModel.get('isActive') === true && entityModel !== changedEntityModel;
}).each(function (entityModel) {
entityModel.set('state', 'deactivating');
});
}
});
}(jQuery, _, Backbone, Drupal));
})(jQuery, _, Backbone, Drupal);

View file

@ -0,0 +1,77 @@
/**
* @file
* A Backbone View that provides a dynamic contextual link.
*/
(function($, Backbone, Drupal) {
Drupal.quickedit.ContextualLinkView = Backbone.View.extend(
/** @lends Drupal.quickedit.ContextualLinkView# */ {
/**
* Define all events to listen to.
*
* @return {object}
* A map of events.
*/
events() {
// Prevents delay and simulated mouse events.
function touchEndToClick(event) {
event.preventDefault();
event.target.click();
}
return {
'click a': function(event) {
event.preventDefault();
this.model.set('state', 'launching');
},
'touchEnd a': touchEndToClick,
};
},
/**
* Create a new contextual link view.
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EntityModel} options.model
* The associated entity's model.
* @param {Drupal.quickedit.AppModel} options.appModel
* The application state model.
* @param {object} options.strings
* The strings for the "Quick edit" link.
*/
initialize(options) {
// Insert the text of the quick edit toggle.
this.$el.find('a').text(options.strings.quickEdit);
// Initial render.
this.render();
// Re-render whenever this entity's isActive attribute changes.
this.listenTo(this.model, 'change:isActive', this.render);
},
/**
* Render function for the contextual link view.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The associated `EntityModel`.
* @param {bool} isActive
* Whether the in-place editor is active or not.
*
* @return {Drupal.quickedit.ContextualLinkView}
* The `ContextualLinkView` in question.
*/
render(entityModel, isActive) {
this.$el.find('a').attr('aria-pressed', isActive);
// Hides the contextual links if an in-place editor is active.
this.$el.closest('.contextual').toggle(!isActive);
return this;
},
},
);
})(jQuery, Backbone, Drupal);

View file

@ -1,81 +1,39 @@
/**
* @file
* A Backbone View that provides a dynamic contextual link.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Backbone, Drupal) {
'use strict';
Drupal.quickedit.ContextualLinkView = Backbone.View.extend(/** @lends Drupal.quickedit.ContextualLinkView# */{
/**
* Define all events to listen to.
*
* @return {object}
* A map of events.
*/
events: function () {
// Prevents delay and simulated mouse events.
Drupal.quickedit.ContextualLinkView = Backbone.View.extend({
events: function events() {
function touchEndToClick(event) {
event.preventDefault();
event.target.click();
}
return {
'click a': function (event) {
'click a': function clickA(event) {
event.preventDefault();
this.model.set('state', 'launching');
},
'touchEnd a': touchEndToClick
};
},
/**
* Create a new contextual link view.
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EntityModel} options.model
* The associated entity's model.
* @param {Drupal.quickedit.AppModel} options.appModel
* The application state model.
* @param {object} options.strings
* The strings for the "Quick edit" link.
*/
initialize: function (options) {
// Insert the text of the quick edit toggle.
initialize: function initialize(options) {
this.$el.find('a').text(options.strings.quickEdit);
// Initial render.
this.render();
// Re-render whenever this entity's isActive attribute changes.
this.listenTo(this.model, 'change:isActive', this.render);
},
/**
* Render function for the contextual link view.
*
* @param {Drupal.quickedit.EntityModel} entityModel
* The associated `EntityModel`.
* @param {bool} isActive
* Whether the in-place editor is active or not.
*
* @return {Drupal.quickedit.ContextualLinkView}
* The `ContextualLinkView` in question.
*/
render: function (entityModel, isActive) {
render: function render(entityModel, isActive) {
this.$el.find('a').attr('aria-pressed', isActive);
// Hides the contextual links if an in-place editor is active.
this.$el.closest('.contextual').toggle(!isActive);
return this;
}
});
})(jQuery, Backbone, Drupal);
})(jQuery, Backbone, Drupal);

View file

@ -0,0 +1,325 @@
/**
* @file
* An abstract Backbone View that controls an in-place editor.
*/
(function($, Backbone, Drupal) {
Drupal.quickedit.EditorView = Backbone.View.extend(
/** @lends Drupal.quickedit.EditorView# */ {
/**
* A base implementation that outlines the structure for in-place editors.
*
* Specific in-place editor implementations should subclass (extend) this
* View and override whichever method they deem necessary to override.
*
* Typically you would want to override this method to set the
* originalValue attribute in the FieldModel to such a value that your
* in-place editor can revert to the original value when necessary.
*
* @example
* <caption>If you override this method, you should call this
* method (the parent class' initialize()) first.</caption>
* Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EditorModel} options.model
* The in-place editor state model.
* @param {Drupal.quickedit.FieldModel} options.fieldModel
* The field model.
*
* @see Drupal.quickedit.EditorModel
* @see Drupal.quickedit.editors.plain_text
*/
initialize(options) {
this.fieldModel = options.fieldModel;
this.listenTo(this.fieldModel, 'change:state', this.stateChange);
},
/**
* @inheritdoc
*/
remove() {
// The el property is the field, which should not be removed. Remove the
// pointer to it, then call Backbone.View.prototype.remove().
this.setElement();
Backbone.View.prototype.remove.call(this);
},
/**
* Returns the edited element.
*
* For some single cardinality fields, it may be necessary or useful to
* not in-place edit (and hence decorate) the DOM element with the
* data-quickedit-field-id attribute (which is the field's wrapper), but a
* specific element within the field's wrapper.
* e.g. using a WYSIWYG editor on a body field should happen on the DOM
* element containing the text itself, not on the field wrapper.
*
* @return {jQuery}
* A jQuery-wrapped DOM element.
*
* @see Drupal.quickedit.editors.plain_text
*/
getEditedElement() {
return this.$el;
},
/**
*
* @return {object}
* Returns 3 Quick Edit UI settings that depend on the in-place editor:
* - Boolean padding: indicates whether padding should be applied to the
* edited element, to guarantee legibility of text.
* - Boolean unifiedToolbar: provides the in-place editor with the ability
* to insert its own toolbar UI into Quick Edit's tightly integrated
* toolbar.
* - Boolean fullWidthToolbar: indicates whether Quick Edit's tightly
* integrated toolbar should consume the full width of the element,
* rather than being just long enough to accommodate a label.
*/
getQuickEditUISettings() {
return {
padding: false,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false,
};
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The quickedit `FieldModel` that holds the state.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange(fieldModel, state) {
const from = fieldModel.previous('state');
const to = state;
switch (to) {
case 'inactive':
// An in-place editor view will not yet exist in this state, hence
// this will never be reached. Listed for sake of completeness.
break;
case 'candidate':
// Nothing to do for the typical in-place editor: it should not be
// visible yet. Except when we come from the 'invalid' state, then we
// clean up.
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
// Nothing to do for the typical in-place editor: it should not be
// visible yet.
break;
case 'activating': {
// The user has indicated he wants to do in-place editing: if
// something needs to be loaded (CSS/JavaScript/server data/…), then
// do so at this stage, and once the in-place editor is ready,
// set the 'active' state. A "loading" indicator will be shown in the
// UI for as long as the field remains in this state.
const loadDependencies = function(callback) {
// Do the loading here.
callback();
};
loadDependencies(() => {
fieldModel.set('state', 'active');
});
break;
}
case 'active':
// The user can now actually use the in-place editor.
break;
case 'changed':
// Nothing to do for the typical in-place editor. The UI will show an
// indicator that the field has changed.
break;
case 'saving':
// When the user has indicated he wants to save his changes to this
// field, this state will be entered. If the previous saving attempt
// resulted in validation errors, the previous state will be
// 'invalid'. Clean up those validation errors while the user is
// saving.
if (from === 'invalid') {
this.removeValidationErrors();
}
this.save();
break;
case 'saved':
// Nothing to do for the typical in-place editor. Immediately after
// being saved, a field will go to the 'candidate' state, where it
// should no longer be visible (after all, the field will then again
// just be a *candidate* to be in-place edited).
break;
case 'invalid':
// The modified field value was attempted to be saved, but there were
// validation errors.
this.showValidationErrors();
break;
}
},
/**
* Reverts the modified value to the original, before editing started.
*/
revert() {
// A no-op by default; each editor should implement reverting itself.
// Note that if the in-place editor does not cause the FieldModel's
// element to be modified, then nothing needs to happen.
},
/**
* Saves the modified value in the in-place editor for this field.
*/
save() {
const fieldModel = this.fieldModel;
const editorModel = this.model;
const backstageId = `quickedit_backstage-${this.fieldModel.id.replace(
/[/[\]_\s]/g,
'-',
)}`;
function fillAndSubmitForm(value) {
const $form = $(`#${backstageId}`).find('form');
// Fill in the value in any <input> that isn't hidden or a submit
// button.
$form
.find(':input[type!="hidden"][type!="submit"]:not(select)')
// Don't mess with the node summary.
.not('[name$="\\[summary\\]"]')
.val(value);
// Submit the form.
$form.find('.quickedit-form-submit').trigger('click.quickedit');
}
const formOptions = {
fieldID: this.fieldModel.get('fieldID'),
$el: this.$el,
nocssjs: true,
other_view_modes: fieldModel.findOtherViewModes(),
// Reset an existing entry for this entity in the PrivateTempStore (if
// any) when saving the field. Logically speaking, this should happen in
// a separate request because this is an entity-level operation, not a
// field-level operation. But that would require an additional request,
// that might not even be necessary: it is only when a user saves a
// first changed field for an entity that this needs to happen:
// precisely now!
reset: !this.fieldModel.get('entity').get('inTempStore'),
};
const self = this;
Drupal.quickedit.util.form.load(formOptions, (form, ajax) => {
// Create a backstage area for storing forms that are hidden from view
// (hence "backstage" — since the editing doesn't happen in the form, it
// happens "directly" in the content, the form is only used for saving).
const $backstage = $(
Drupal.theme('quickeditBackstage', { id: backstageId }),
).appendTo('body');
// Hidden forms are stuffed into the backstage container for this field.
const $form = $(form).appendTo($backstage);
// Disable the browser's HTML5 validation; we only care about server-
// side validation. (Not disabling this will actually cause problems
// because browsers don't like to set HTML5 validation errors on hidden
// forms.)
$form.prop('novalidate', true);
const $submit = $form.find('.quickedit-form-submit');
self.formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(
formOptions,
$submit,
);
function removeHiddenForm() {
Drupal.quickedit.util.form.unajaxifySaving(self.formSaveAjax);
delete self.formSaveAjax;
$backstage.remove();
}
// Successfully saved.
self.formSaveAjax.commands.quickeditFieldFormSaved = function(
ajax,
response,
status,
) {
removeHiddenForm();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Second, set the 'htmlForOtherViewModes' attribute, so that when
// this field is rerendered, the change can be propagated to other
// instances of this field, which may be displayed in different view
// modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will
// cause the field to be rerendered.
fieldModel.set('html', response.data);
};
// Unsuccessfully saved; validation errors.
self.formSaveAjax.commands.quickeditFieldFormValidationErrors = function(
ajax,
response,
status,
) {
removeHiddenForm();
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
// The quickeditFieldForm AJAX command is only called upon loading the
// form for the first time, and when there are validation errors in the
// form; Form API then marks which form items have errors. This is
// useful for the form-based in-place editor, but pointless for any
// other: the form itself won't be visible at all anyway! So, we just
// ignore it.
self.formSaveAjax.commands.quickeditFieldForm = function() {};
fillAndSubmitForm(editorModel.get('currentValue'));
});
},
/**
* Shows validation error messages.
*
* Should be called when the state is changed to 'invalid'.
*/
showValidationErrors() {
const $errors = $(
'<div class="quickedit-validation-errors"></div>',
).append(this.model.get('validationErrors'));
this.getEditedElement()
.addClass('quickedit-validation-error')
.after($errors);
},
/**
* Cleans up validation error messages.
*
* Should be called when the state is changed to 'candidate' or 'saving'. In
* the case of the latter: the user has modified the value in the in-place
* editor again to attempt to save again. In the case of the latter: the
* invalid value was discarded.
*/
removeValidationErrors() {
this.getEditedElement()
.removeClass('quickedit-validation-error')
.next('.quickedit-validation-errors')
.remove();
},
},
);
})(jQuery, Backbone, Drupal);

View file

@ -1,156 +1,65 @@
/**
* @file
* An abstract Backbone View that controls an in-place editor.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Backbone, Drupal) {
'use strict';
Drupal.quickedit.EditorView = Backbone.View.extend(/** @lends Drupal.quickedit.EditorView# */{
/**
* A base implementation that outlines the structure for in-place editors.
*
* Specific in-place editor implementations should subclass (extend) this
* View and override whichever method they deem necessary to override.
*
* Typically you would want to override this method to set the
* originalValue attribute in the FieldModel to such a value that your
* in-place editor can revert to the original value when necessary.
*
* @example
* <caption>If you override this method, you should call this
* method (the parent class' initialize()) first.</caption>
* Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
*
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EditorModel} options.model
* The in-place editor state model.
* @param {Drupal.quickedit.FieldModel} options.fieldModel
* The field model.
*
* @see Drupal.quickedit.EditorModel
* @see Drupal.quickedit.editors.plain_text
*/
initialize: function (options) {
Drupal.quickedit.EditorView = Backbone.View.extend({
initialize: function initialize(options) {
this.fieldModel = options.fieldModel;
this.listenTo(this.fieldModel, 'change:state', this.stateChange);
},
/**
* @inheritdoc
*/
remove: function () {
// The el property is the field, which should not be removed. Remove the
// pointer to it, then call Backbone.View.prototype.remove().
remove: function remove() {
this.setElement();
Backbone.View.prototype.remove.call(this);
},
/**
* Returns the edited element.
*
* For some single cardinality fields, it may be necessary or useful to
* not in-place edit (and hence decorate) the DOM element with the
* data-quickedit-field-id attribute (which is the field's wrapper), but a
* specific element within the field's wrapper.
* e.g. using a WYSIWYG editor on a body field should happen on the DOM
* element containing the text itself, not on the field wrapper.
*
* @return {jQuery}
* A jQuery-wrapped DOM element.
*
* @see Drupal.quickedit.editors.plain_text
*/
getEditedElement: function () {
getEditedElement: function getEditedElement() {
return this.$el;
},
/**
*
* @return {object}
* Returns 3 Quick Edit UI settings that depend on the in-place editor:
* - Boolean padding: indicates whether padding should be applied to the
* edited element, to guarantee legibility of text.
* - Boolean unifiedToolbar: provides the in-place editor with the ability
* to insert its own toolbar UI into Quick Edit's tightly integrated
* toolbar.
* - Boolean fullWidthToolbar: indicates whether Quick Edit's tightly
* integrated toolbar should consume the full width of the element,
* rather than being just long enough to accommodate a label.
*/
getQuickEditUISettings: function () {
return {padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false};
getQuickEditUISettings: function getQuickEditUISettings() {
return {
padding: false,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false
};
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} fieldModel
* The quickedit `FieldModel` that holds the state.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange: function (fieldModel, state) {
stateChange: function stateChange(fieldModel, state) {
var from = fieldModel.previous('state');
var to = state;
switch (to) {
case 'inactive':
// An in-place editor view will not yet exist in this state, hence
// this will never be reached. Listed for sake of completeness.
break;
case 'candidate':
// Nothing to do for the typical in-place editor: it should not be
// visible yet. Except when we come from the 'invalid' state, then we
// clean up.
if (from === 'invalid') {
this.removeValidationErrors();
}
break;
case 'highlighted':
// Nothing to do for the typical in-place editor: it should not be
// visible yet.
break;
case 'activating':
// The user has indicated he wants to do in-place editing: if
// something needs to be loaded (CSS/JavaScript/server data/…), then
// do so at this stage, and once the in-place editor is ready,
// set the 'active' state. A "loading" indicator will be shown in the
// UI for as long as the field remains in this state.
var loadDependencies = function (callback) {
// Do the loading here.
callback();
};
loadDependencies(function () {
fieldModel.set('state', 'active');
});
break;
{
var loadDependencies = function loadDependencies(callback) {
callback();
};
loadDependencies(function () {
fieldModel.set('state', 'active');
});
break;
}
case 'active':
// The user can now actually use the in-place editor.
break;
case 'changed':
// Nothing to do for the typical in-place editor. The UI will show an
// indicator that the field has changed.
break;
case 'saving':
// When the user has indicated he wants to save his changes to this
// field, this state will be entered. If the previous saving attempt
// resulted in validation errors, the previous state will be
// 'invalid'. Clean up those validation errors while the user is
// saving.
if (from === 'invalid') {
this.removeValidationErrors();
}
@ -158,45 +67,24 @@
break;
case 'saved':
// Nothing to do for the typical in-place editor. Immediately after
// being saved, a field will go to the 'candidate' state, where it
// should no longer be visible (after all, the field will then again
// just be a *candidate* to be in-place edited).
break;
case 'invalid':
// The modified field value was attempted to be saved, but there were
// validation errors.
this.showValidationErrors();
break;
}
},
/**
* Reverts the modified value to the original, before editing started.
*/
revert: function () {
// A no-op by default; each editor should implement reverting itself.
// Note that if the in-place editor does not cause the FieldModel's
// element to be modified, then nothing needs to happen.
},
/**
* Saves the modified value in the in-place editor for this field.
*/
save: function () {
revert: function revert() {},
save: function save() {
var fieldModel = this.fieldModel;
var editorModel = this.model;
var backstageId = 'quickedit_backstage-' + this.fieldModel.id.replace(/[\/\[\]\_\s]/g, '-');
var backstageId = 'quickedit_backstage-' + this.fieldModel.id.replace(/[/[\]_\s]/g, '-');
function fillAndSubmitForm(value) {
var $form = $('#' + backstageId).find('form');
// Fill in the value in any <input> that isn't hidden or a submit
// button.
$form.find(':input[type!="hidden"][type!="submit"]:not(select)')
// Don't mess with the node summary.
.not('[name$="\\[summary\\]"]').val(value);
// Submit the form.
$form.find(':input[type!="hidden"][type!="submit"]:not(select)').not('[name$="\\[summary\\]"]').val(value);
$form.find('.quickedit-form-submit').trigger('click.quickedit');
}
@ -205,28 +93,16 @@
$el: this.$el,
nocssjs: true,
other_view_modes: fieldModel.findOtherViewModes(),
// Reset an existing entry for this entity in the PrivateTempStore (if
// any) when saving the field. Logically speaking, this should happen in
// a separate request because this is an entity-level operation, not a
// field-level operation. But that would require an additional request,
// that might not even be necessary: it is only when a user saves a
// first changed field for an entity that this needs to happen:
// precisely now!
reset: !this.fieldModel.get('entity').get('inTempStore')
};
var self = this;
Drupal.quickedit.util.form.load(formOptions, function (form, ajax) {
// Create a backstage area for storing forms that are hidden from view
// (hence "backstage" — since the editing doesn't happen in the form, it
// happens "directly" in the content, the form is only used for saving).
var $backstage = $(Drupal.theme('quickeditBackstage', {id: backstageId})).appendTo('body');
// Hidden forms are stuffed into the backstage container for this field.
var $backstage = $(Drupal.theme('quickeditBackstage', { id: backstageId })).appendTo('body');
var $form = $(form).appendTo($backstage);
// Disable the browser's HTML5 validation; we only care about server-
// side validation. (Not disabling this will actually cause problems
// because browsers don't like to set HTML5 validation errors on hidden
// forms.)
$form.prop('novalidate', true);
var $submit = $form.find('.quickedit-form-submit');
self.formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(formOptions, $submit);
@ -237,68 +113,33 @@
$backstage.remove();
}
// Successfully saved.
self.formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) {
removeHiddenForm();
// First, transition the state to 'saved'.
fieldModel.set('state', 'saved');
// Second, set the 'htmlForOtherViewModes' attribute, so that when
// this field is rerendered, the change can be propagated to other
// instances of this field, which may be displayed in different view
// modes.
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
// Finally, set the 'html' attribute on the field model. This will
// cause the field to be rerendered.
fieldModel.set('html', response.data);
};
// Unsuccessfully saved; validation errors.
self.formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) {
removeHiddenForm();
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
// The quickeditFieldForm AJAX command is only called upon loading the
// form for the first time, and when there are validation errors in the
// form; Form API then marks which form items have errors. This is
// useful for the form-based in-place editor, but pointless for any
// other: the form itself won't be visible at all anyway! So, we just
// ignore it.
self.formSaveAjax.commands.quickeditFieldForm = function () {};
fillAndSubmitForm(editorModel.get('currentValue'));
});
},
/**
* Shows validation error messages.
*
* Should be called when the state is changed to 'invalid'.
*/
showValidationErrors: function () {
var $errors = $('<div class="quickedit-validation-errors"></div>')
.append(this.model.get('validationErrors'));
this.getEditedElement()
.addClass('quickedit-validation-error')
.after($errors);
showValidationErrors: function showValidationErrors() {
var $errors = $('<div class="quickedit-validation-errors"></div>').append(this.model.get('validationErrors'));
this.getEditedElement().addClass('quickedit-validation-error').after($errors);
},
/**
* Cleans up validation error messages.
*
* Should be called when the state is changed to 'candidate' or 'saving'. In
* the case of the latter: the user has modified the value in the in-place
* editor again to attempt to save again. In the case of the latter: the
* invalid value was discarded.
*/
removeValidationErrors: function () {
this.getEditedElement()
.removeClass('quickedit-validation-error')
.next('.quickedit-validation-errors')
.remove();
removeValidationErrors: function removeValidationErrors() {
this.getEditedElement().removeClass('quickedit-validation-error').next('.quickedit-validation-errors').remove();
}
});
}(jQuery, Backbone, Drupal));
})(jQuery, Backbone, Drupal);

View file

@ -0,0 +1,39 @@
/**
* @file
* A Backbone view that decorates the in-place editable entity.
*/
(function(Drupal, $, Backbone) {
Drupal.quickedit.EntityDecorationView = Backbone.View.extend(
/** @lends Drupal.quickedit.EntityDecorationView# */ {
/**
* Associated with the DOM root node of an editable entity.
*
* @constructs
*
* @augments Backbone.View
*/
initialize() {
this.listenTo(this.model, 'change', this.render);
},
/**
* @inheritdoc
*/
render() {
this.$el.toggleClass(
'quickedit-entity-active',
this.model.get('isActive'),
);
},
/**
* @inheritdoc
*/
remove() {
this.setElement(null);
Backbone.View.prototype.remove.call(this);
},
},
);
})(Drupal, jQuery, Backbone);

View file

@ -1,40 +1,21 @@
/**
* @file
* A Backbone view that decorates the in-place editable entity.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function (Drupal, $, Backbone) {
'use strict';
Drupal.quickedit.EntityDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityDecorationView# */{
/**
* Associated with the DOM root node of an editable entity.
*
* @constructs
*
* @augments Backbone.View
*/
initialize: function () {
Drupal.quickedit.EntityDecorationView = Backbone.View.extend({
initialize: function initialize() {
this.listenTo(this.model, 'change', this.render);
},
/**
* @inheritdoc
*/
render: function () {
render: function render() {
this.$el.toggleClass('quickedit-entity-active', this.model.get('isActive'));
},
/**
* @inheritdoc
*/
remove: function () {
remove: function remove() {
this.setElement(null);
Backbone.View.prototype.remove.call(this);
}
});
}(Drupal, jQuery, Backbone));
})(Drupal, jQuery, Backbone);

View file

@ -0,0 +1,582 @@
/**
* @file
* A Backbone View that provides an entity level toolbar.
*/
(function($, _, Backbone, Drupal, debounce) {
Drupal.quickedit.EntityToolbarView = Backbone.View.extend(
/** @lends Drupal.quickedit.EntityToolbarView# */ {
/**
* @type {jQuery}
*/
_fieldToolbarRoot: null,
/**
* @return {object}
* A map of events.
*/
events() {
const map = {
'click button.action-save': 'onClickSave',
'click button.action-cancel': 'onClickCancel',
mouseenter: 'onMouseenter',
};
return map;
},
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options to construct the view.
* @param {Drupal.quickedit.AppModel} options.appModel
* A quickedit `AppModel` to use in the view.
*/
initialize(options) {
const that = this;
this.appModel = options.appModel;
this.$entity = $(this.model.get('el'));
// Rerender whenever the entity state changes.
this.listenTo(
this.model,
'change:isActive change:isDirty change:state',
this.render,
);
// Also rerender whenever a different field is highlighted or activated.
this.listenTo(
this.appModel,
'change:highlightedField change:activeField',
this.render,
);
// Rerender when a field of the entity changes state.
this.listenTo(
this.model.get('fields'),
'change:state',
this.fieldStateChange,
);
// Reposition the entity toolbar as the viewport and the position within
// the viewport changes.
$(window).on(
'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
debounce($.proxy(this.windowChangeHandler, this), 150),
);
// Adjust the fence placement within which the entity toolbar may be
// positioned.
$(document).on(
'drupalViewportOffsetChange.quickedit',
(event, offsets) => {
if (that.$fence) {
that.$fence.css(offsets);
}
},
);
// Set the entity toolbar DOM element as the el for this view.
const $toolbar = this.buildToolbarEl();
this.setElement($toolbar);
this._fieldToolbarRoot = $toolbar
.find('.quickedit-toolbar-field')
.get(0);
// Initial render.
this.render();
},
/**
* @inheritdoc
*
* @return {Drupal.quickedit.EntityToolbarView}
* The entity toolbar view.
*/
render() {
if (this.model.get('isActive')) {
// If the toolbar container doesn't exist, create it.
const $body = $('body');
if ($body.children('#quickedit-entity-toolbar').length === 0) {
$body.append(this.$el);
}
// The fence will define a area on the screen that the entity toolbar
// will be position within.
if ($body.children('#quickedit-toolbar-fence').length === 0) {
this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
.css(Drupal.displace())
.appendTo($body);
}
// Adds the entity title to the toolbar.
this.label();
// Show the save and cancel buttons.
this.show('ops');
// If render is being called and the toolbar is already visible, just
// reposition it.
this.position();
}
// The save button text and state varies with the state of the entity
// model.
const $button = this.$el.find('.quickedit-button.action-save');
const isDirty = this.model.get('isDirty');
// Adjust the save button according to the state of the model.
switch (this.model.get('state')) {
// Quick editing is active, but no field is being edited.
case 'opened':
// The saving throbber is not managed by AJAX system. The
// EntityToolbarView manages this visual element.
$button
.removeClass('action-saving icon-throbber icon-end')
.text(Drupal.t('Save'))
.removeAttr('disabled')
.attr('aria-hidden', !isDirty);
break;
// The changes to the fields of the entity are being committed.
case 'committing':
$button
.addClass('action-saving icon-throbber icon-end')
.text(Drupal.t('Saving'))
.attr('disabled', 'disabled');
break;
default:
$button.attr('aria-hidden', true);
break;
}
return this;
},
/**
* @inheritdoc
*/
remove() {
// Remove additional DOM elements controlled by this View.
this.$fence.remove();
// Stop listening to additional events.
$(window).off(
'resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit',
);
$(document).off('drupalViewportOffsetChange.quickedit');
Backbone.View.prototype.remove.call(this);
},
/**
* Repositions the entity toolbar on window scroll and resize.
*
* @param {jQuery.Event} event
* The scroll or resize event.
*/
windowChangeHandler(event) {
this.position();
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The `FieldModel` model.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
fieldStateChange(model, state) {
switch (state) {
case 'active':
this.render();
break;
case 'invalid':
this.render();
break;
}
},
/**
* Uses the jQuery.ui.position() method to position the entity toolbar.
*
* @param {HTMLElement} [element]
* The element against which the entity toolbar is positioned.
*/
position(element) {
clearTimeout(this.timer);
const that = this;
// Vary the edge of the positioning according to the direction of language
// in the document.
const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left';
// A time unit to wait until the entity toolbar is repositioned.
let delay = 0;
// Determines what check in the series of checks below should be
// evaluated.
let check = 0;
// When positioned against an active field that has padding, we should
// ignore that padding when positioning the toolbar, to not unnecessarily
// move the toolbar horizontally, which feels annoying.
let horizontalPadding = 0;
let of;
let activeField;
let highlightedField;
// There are several elements in the page that the entity toolbar might be
// positioned against. They are considered below in a priority order.
do {
switch (check) {
case 0:
// Position against a specific element.
of = element;
break;
case 1:
// Position against a form container.
activeField = Drupal.quickedit.app.model.get('activeField');
of =
activeField &&
activeField.editorView &&
activeField.editorView.$formContainer &&
activeField.editorView.$formContainer.find('.quickedit-form');
break;
case 2:
// Position against an active field.
of =
activeField &&
activeField.editorView &&
activeField.editorView.getEditedElement();
if (
activeField &&
activeField.editorView &&
activeField.editorView.getQuickEditUISettings().padding
) {
horizontalPadding = 5;
}
break;
case 3:
// Position against a highlighted field.
highlightedField = Drupal.quickedit.app.model.get(
'highlightedField',
);
of =
highlightedField &&
highlightedField.editorView &&
highlightedField.editorView.getEditedElement();
delay = 250;
break;
default: {
const fieldModels = this.model.get('fields').models;
let topMostPosition = 1000000;
let topMostField = null;
// Position against the topmost field.
for (let i = 0; i < fieldModels.length; i++) {
const pos = fieldModels[i].get('el').getBoundingClientRect()
.top;
if (pos < topMostPosition) {
topMostPosition = pos;
topMostField = fieldModels[i];
}
}
of = topMostField.get('el');
delay = 50;
break;
}
}
// Prepare to check the next possible element to position against.
check++;
} while (!of);
/**
* Refines the positioning algorithm of jquery.ui.position().
*
* Invoked as the 'using' callback of jquery.ui.position() in
* positionToolbar().
*
* @param {*} view
* The view the positions will be calculated from.
* @param {object} suggested
* A hash of top and left values for the position that should be set. It
* can be forwarded to .css() or .animate().
* @param {object} info
* The position and dimensions of both the 'my' element and the 'of'
* elements, as well as calculations to their relative position. This
* object contains the following properties:
* @param {object} info.element
* A hash that contains information about the HTML element that will be
* positioned. Also known as the 'my' element.
* @param {object} info.target
* A hash that contains information about the HTML element that the
* 'my' element will be positioned against. Also known as the 'of'
* element.
*/
function refinePosition(view, suggested, info) {
// Determine if the pointer should be on the top or bottom.
const isBelow = suggested.top > info.target.top;
info.element.element.toggleClass(
'quickedit-toolbar-pointer-top',
isBelow,
);
// Don't position the toolbar past the first or last editable field if
// the entity is the target.
if (view.$entity[0] === info.target.element[0]) {
// Get the first or last field according to whether the toolbar is
// above or below the entity.
const $field = view.$entity
.find('.quickedit-editable')
.eq(isBelow ? -1 : 0);
if ($field.length > 0) {
suggested.top = isBelow
? $field.offset().top + $field.outerHeight(true)
: $field.offset().top - info.element.element.outerHeight(true);
}
}
// Don't let the toolbar go outside the fence.
const fenceTop = view.$fence.offset().top;
const fenceHeight = view.$fence.height();
const toolbarHeight = info.element.element.outerHeight(true);
if (suggested.top < fenceTop) {
suggested.top = fenceTop;
} else if (suggested.top + toolbarHeight > fenceTop + fenceHeight) {
suggested.top = fenceTop + fenceHeight - toolbarHeight;
}
// Position the toolbar.
info.element.element.css({
left: Math.floor(suggested.left),
top: Math.floor(suggested.top),
});
}
/**
* Calls the jquery.ui.position() method on the $el of this view.
*/
function positionToolbar() {
that.$el
.position({
my: `${edge} bottom`,
// Move the toolbar 1px towards the start edge of the 'of' element,
// plus any horizontal padding that may have been added to the
// element that is being added, to prevent unwanted horizontal
// movement.
at: `${edge}+${1 + horizontalPadding} top`,
of,
collision: 'flipfit',
using: refinePosition.bind(null, that),
within: that.$fence,
})
// Resize the toolbar to match the dimensions of the field, up to a
// maximum width that is equal to 90% of the field's width.
.css({
'max-width':
document.documentElement.clientWidth < 450
? document.documentElement.clientWidth
: 450,
// Set a minimum width of 240px for the entity toolbar, or the width
// of the client if it is less than 240px, so that the toolbar
// never folds up into a squashed and jumbled mess.
'min-width':
document.documentElement.clientWidth < 240
? document.documentElement.clientWidth
: 240,
width: '100%',
});
}
// Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
// only after the user has focused on an editable for 250ms. This prevents
// the toolbar from jumping around the screen.
this.timer = setTimeout(() => {
// Render the position in the next execution cycle, so that animations
// on the field have time to process. This is not strictly speaking, a
// guarantee that all animations will be finished, but it's a simple
// way to get better positioning without too much additional code.
_.defer(positionToolbar);
}, delay);
},
/**
* Set the model state to 'saving' when the save button is clicked.
*
* @param {jQuery.Event} event
* The click event.
*/
onClickSave(event) {
event.stopPropagation();
event.preventDefault();
// Save the model.
this.model.set('state', 'committing');
},
/**
* Sets the model state to candidate when the cancel button is clicked.
*
* @param {jQuery.Event} event
* The click event.
*/
onClickCancel(event) {
event.preventDefault();
this.model.set('state', 'deactivating');
},
/**
* Clears the timeout that will eventually reposition the entity toolbar.
*
* Without this, it may reposition itself, away from the user's cursor!
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseenter(event) {
clearTimeout(this.timer);
},
/**
* Builds the entity toolbar HTML; attaches to DOM; sets starting position.
*
* @return {jQuery}
* The toolbar element.
*/
buildToolbarEl() {
const $toolbar = $(
Drupal.theme('quickeditEntityToolbar', {
id: 'quickedit-entity-toolbar',
}),
);
$toolbar
.find('.quickedit-toolbar-entity')
// Append the "ops" toolgroup into the toolbar.
.prepend(
Drupal.theme('quickeditToolgroup', {
classes: ['ops'],
buttons: [
{
label: Drupal.t('Save'),
type: 'submit',
classes: 'action-save quickedit-button icon',
attributes: {
'aria-hidden': true,
},
},
{
label: Drupal.t('Close'),
classes:
'action-cancel quickedit-button icon icon-close icon-only',
},
],
}),
);
// Give the toolbar a sensible starting position so that it doesn't
// animate on to the screen from a far off corner.
$toolbar.css({
left: this.$entity.offset().left,
top: this.$entity.offset().top,
});
return $toolbar;
},
/**
* Returns the DOM element that fields will attach their toolbars to.
*
* @return {jQuery}
* The DOM element that fields will attach their toolbars to.
*/
getToolbarRoot() {
return this._fieldToolbarRoot;
},
/**
* Generates a state-dependent label for the entity toolbar.
*/
label() {
// The entity label.
let label = '';
const entityLabel = this.model.get('label');
// Label of an active field, if it exists.
const activeField = Drupal.quickedit.app.model.get('activeField');
const activeFieldLabel =
activeField && activeField.get('metadata').label;
// Label of a highlighted field, if it exists.
const highlightedField = Drupal.quickedit.app.model.get(
'highlightedField',
);
const highlightedFieldLabel =
highlightedField && highlightedField.get('metadata').label;
// The label is constructed in a priority order.
if (activeFieldLabel) {
label = Drupal.theme('quickeditEntityToolbarLabel', {
entityLabel,
fieldLabel: activeFieldLabel,
});
} else if (highlightedFieldLabel) {
label = Drupal.theme('quickeditEntityToolbarLabel', {
entityLabel,
fieldLabel: highlightedFieldLabel,
});
} else {
// @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
label = Drupal.checkPlain(entityLabel);
}
this.$el.find('.quickedit-toolbar-label').html(label);
},
/**
* Adds classes to a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
* @param {string} classes
* A string of space-delimited class names that will be applied to the
* wrapping element of the toolbar group.
*/
addClass(toolgroup, classes) {
this._find(toolgroup).addClass(classes);
},
/**
* Removes classes from a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
* @param {string} classes
* A string of space-delimited class names that will be removed from the
* wrapping element of the toolbar group.
*/
removeClass(toolgroup, classes) {
this._find(toolgroup).removeClass(classes);
},
/**
* Finds a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*
* @return {jQuery}
* The toolgroup DOM element.
*/
_find(toolgroup) {
return this.$el.find(
`.quickedit-toolbar .quickedit-toolgroup.${toolgroup}`,
);
},
/**
* Shows a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*/
show(toolgroup) {
this.$el.removeClass('quickedit-animate-invisible');
},
},
);
})(jQuery, _, Backbone, Drupal, Drupal.debounce);

View file

@ -1,128 +1,75 @@
/**
* @file
* A Backbone View that provides an entity level toolbar.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Backbone, Drupal, debounce) {
'use strict';
Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
/**
* @type {jQuery}
*/
Drupal.quickedit.EntityToolbarView = Backbone.View.extend({
_fieldToolbarRoot: null,
/**
* @return {object}
* A map of events.
*/
events: function () {
events: function events() {
var map = {
'click button.action-save': 'onClickSave',
'click button.action-cancel': 'onClickCancel',
'mouseenter': 'onMouseenter'
mouseenter: 'onMouseenter'
};
return map;
},
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options to construct the view.
* @param {Drupal.quickedit.AppModel} options.appModel
* A quickedit `AppModel` to use in the view.
*/
initialize: function (options) {
initialize: function initialize(options) {
var that = this;
this.appModel = options.appModel;
this.$entity = $(this.model.get('el'));
// Rerender whenever the entity state changes.
this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render);
// Also rerender whenever a different field is highlighted or activated.
this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render);
// Rerender when a field of the entity changes state.
this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange);
// Reposition the entity toolbar as the viewport and the position within
// the viewport changes.
$(window).on('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150));
// Adjust the fence placement within which the entity toolbar may be
// positioned.
$(document).on('drupalViewportOffsetChange.quickedit', function (event, offsets) {
if (that.$fence) {
that.$fence.css(offsets);
}
});
// Set the entity toolbar DOM element as the el for this view.
var $toolbar = this.buildToolbarEl();
this.setElement($toolbar);
this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0);
// Initial render.
this.render();
},
/**
* @inheritdoc
*
* @return {Drupal.quickedit.EntityToolbarView}
* The entity toolbar view.
*/
render: function () {
render: function render() {
if (this.model.get('isActive')) {
// If the toolbar container doesn't exist, create it.
var $body = $('body');
if ($body.children('#quickedit-entity-toolbar').length === 0) {
$body.append(this.$el);
}
// The fence will define a area on the screen that the entity toolbar
// will be position within.
if ($body.children('#quickedit-toolbar-fence').length === 0) {
this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
.css(Drupal.displace())
.appendTo($body);
this.$fence = $(Drupal.theme('quickeditEntityToolbarFence')).css(Drupal.displace()).appendTo($body);
}
// Adds the entity title to the toolbar.
this.label();
// Show the save and cancel buttons.
this.show('ops');
// If render is being called and the toolbar is already visible, just
// reposition it.
this.position();
}
// The save button text and state varies with the state of the entity
// model.
var $button = this.$el.find('.quickedit-button.action-save');
var isDirty = this.model.get('isDirty');
// Adjust the save button according to the state of the model.
switch (this.model.get('state')) {
// Quick editing is active, but no field is being edited.
case 'opened':
// The saving throbber is not managed by AJAX system. The
// EntityToolbarView manages this visual element.
$button
.removeClass('action-saving icon-throbber icon-end')
.text(Drupal.t('Save'))
.removeAttr('disabled')
.attr('aria-hidden', !isDirty);
$button.removeClass('action-saving icon-throbber icon-end').text(Drupal.t('Save')).removeAttr('disabled').attr('aria-hidden', !isDirty);
break;
// The changes to the fields of the entity are being committed.
case 'committing':
$button
.addClass('action-saving icon-throbber icon-end')
.text(Drupal.t('Saving'))
.attr('disabled', 'disabled');
$button.addClass('action-saving icon-throbber icon-end').text(Drupal.t('Saving')).attr('disabled', 'disabled');
break;
default:
@ -132,41 +79,18 @@
return this;
},
/**
* @inheritdoc
*/
remove: function () {
// Remove additional DOM elements controlled by this View.
remove: function remove() {
this.$fence.remove();
// Stop listening to additional events.
$(window).off('resize.quickedit scroll.quickedit drupalViewportOffsetChange.quickedit');
$(document).off('drupalViewportOffsetChange.quickedit');
Backbone.View.prototype.remove.call(this);
},
/**
* Repositions the entity toolbar on window scroll and resize.
*
* @param {jQuery.Event} event
* The scroll or resize event.
*/
windowChangeHandler: function (event) {
windowChangeHandler: function windowChangeHandler(event) {
this.position();
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The `FieldModel` model.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
fieldStateChange: function (model, state) {
fieldStateChange: function fieldStateChange(model, state) {
switch (state) {
case 'active':
this.render();
@ -177,49 +101,34 @@
break;
}
},
/**
* Uses the jQuery.ui.position() method to position the entity toolbar.
*
* @param {HTMLElement} [element]
* The element against which the entity toolbar is positioned.
*/
position: function (element) {
position: function position(element) {
clearTimeout(this.timer);
var that = this;
// Vary the edge of the positioning according to the direction of language
// in the document.
var edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
// A time unit to wait until the entity toolbar is repositioned.
var edge = document.documentElement.dir === 'rtl' ? 'right' : 'left';
var delay = 0;
// Determines what check in the series of checks below should be
// evaluated.
var check = 0;
// When positioned against an active field that has padding, we should
// ignore that padding when positioning the toolbar, to not unnecessarily
// move the toolbar horizontally, which feels annoying.
var horizontalPadding = 0;
var of;
var activeField;
var highlightedField;
// There are several elements in the page that the entity toolbar might be
// positioned against. They are considered below in a priority order.
var of = void 0;
var activeField = void 0;
var highlightedField = void 0;
do {
switch (check) {
case 0:
// Position against a specific element.
of = element;
break;
case 1:
// Position against a form container.
activeField = Drupal.quickedit.app.model.get('activeField');
of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form');
break;
case 2:
// Position against an active field.
of = activeField && activeField.editorView && activeField.editorView.getEditedElement();
if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) {
horizontalPadding = 5;
@ -227,302 +136,160 @@
break;
case 3:
// Position against a highlighted field.
highlightedField = Drupal.quickedit.app.model.get('highlightedField');
of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
delay = 250;
break;
default:
var fieldModels = this.model.get('fields').models;
var topMostPosition = 1000000;
var topMostField = null;
// Position against the topmost field.
for (var i = 0; i < fieldModels.length; i++) {
var pos = fieldModels[i].get('el').getBoundingClientRect().top;
if (pos < topMostPosition) {
topMostPosition = pos;
topMostField = fieldModels[i];
{
var fieldModels = this.model.get('fields').models;
var topMostPosition = 1000000;
var topMostField = null;
for (var i = 0; i < fieldModels.length; i++) {
var pos = fieldModels[i].get('el').getBoundingClientRect().top;
if (pos < topMostPosition) {
topMostPosition = pos;
topMostField = fieldModels[i];
}
}
of = topMostField.get('el');
delay = 50;
break;
}
of = topMostField.get('el');
delay = 50;
break;
}
// Prepare to check the next possible element to position against.
check++;
} while (!of);
/**
* Refines the positioning algorithm of jquery.ui.position().
*
* Invoked as the 'using' callback of jquery.ui.position() in
* positionToolbar().
*
* @param {*} view
* The view the positions will be calculated from.
* @param {object} suggested
* A hash of top and left values for the position that should be set. It
* can be forwarded to .css() or .animate().
* @param {object} info
* The position and dimensions of both the 'my' element and the 'of'
* elements, as well as calculations to their relative position. This
* object contains the following properties:
* @param {object} info.element
* A hash that contains information about the HTML element that will be
* positioned. Also known as the 'my' element.
* @param {object} info.target
* A hash that contains information about the HTML element that the
* 'my' element will be positioned against. Also known as the 'of'
* element.
*/
function refinePosition(view, suggested, info) {
// Determine if the pointer should be on the top or bottom.
var isBelow = suggested.top > info.target.top;
info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow);
// Don't position the toolbar past the first or last editable field if
// the entity is the target.
if (view.$entity[0] === info.target.element[0]) {
// Get the first or last field according to whether the toolbar is
// above or below the entity.
var $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0);
var $field = view.$entity.find('.quickedit-editable').eq(isBelow ? -1 : 0);
if ($field.length > 0) {
suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true);
suggested.top = isBelow ? $field.offset().top + $field.outerHeight(true) : $field.offset().top - info.element.element.outerHeight(true);
}
}
// Don't let the toolbar go outside the fence.
var fenceTop = view.$fence.offset().top;
var fenceHeight = view.$fence.height();
var toolbarHeight = info.element.element.outerHeight(true);
if (suggested.top < fenceTop) {
suggested.top = fenceTop;
}
else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
} else if (suggested.top + toolbarHeight > fenceTop + fenceHeight) {
suggested.top = fenceTop + fenceHeight - toolbarHeight;
}
// Position the toolbar.
info.element.element.css({
left: Math.floor(suggested.left),
top: Math.floor(suggested.top)
});
}
/**
* Calls the jquery.ui.position() method on the $el of this view.
*/
function positionToolbar() {
that.$el
.position({
my: edge + ' bottom',
// Move the toolbar 1px towards the start edge of the 'of' element,
// plus any horizontal padding that may have been added to the
// element that is being added, to prevent unwanted horizontal
// movement.
at: edge + '+' + (1 + horizontalPadding) + ' top',
of: of,
collision: 'flipfit',
using: refinePosition.bind(null, that),
within: that.$fence
})
// Resize the toolbar to match the dimensions of the field, up to a
// maximum width that is equal to 90% of the field's width.
.css({
'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450,
// Set a minimum width of 240px for the entity toolbar, or the width
// of the client if it is less than 240px, so that the toolbar
// never folds up into a squashed and jumbled mess.
'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240,
'width': '100%'
});
that.$el.position({
my: edge + ' bottom',
at: edge + '+' + (1 + horizontalPadding) + ' top',
of: of,
collision: 'flipfit',
using: refinePosition.bind(null, that),
within: that.$fence
}).css({
'max-width': document.documentElement.clientWidth < 450 ? document.documentElement.clientWidth : 450,
'min-width': document.documentElement.clientWidth < 240 ? document.documentElement.clientWidth : 240,
width: '100%'
});
}
// Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
// only after the user has focused on an editable for 250ms. This prevents
// the toolbar from jumping around the screen.
this.timer = setTimeout(function () {
// Render the position in the next execution cycle, so that animations
// on the field have time to process. This is not strictly speaking, a
// guarantee that all animations will be finished, but it's a simple
// way to get better positioning without too much additional code.
_.defer(positionToolbar);
}, delay);
},
/**
* Set the model state to 'saving' when the save button is clicked.
*
* @param {jQuery.Event} event
* The click event.
*/
onClickSave: function (event) {
onClickSave: function onClickSave(event) {
event.stopPropagation();
event.preventDefault();
// Save the model.
this.model.set('state', 'committing');
},
/**
* Sets the model state to candidate when the cancel button is clicked.
*
* @param {jQuery.Event} event
* The click event.
*/
onClickCancel: function (event) {
onClickCancel: function onClickCancel(event) {
event.preventDefault();
this.model.set('state', 'deactivating');
},
/**
* Clears the timeout that will eventually reposition the entity toolbar.
*
* Without this, it may reposition itself, away from the user's cursor!
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseenter: function (event) {
onMouseenter: function onMouseenter(event) {
clearTimeout(this.timer);
},
/**
* Builds the entity toolbar HTML; attaches to DOM; sets starting position.
*
* @return {jQuery}
* The toolbar element.
*/
buildToolbarEl: function () {
buildToolbarEl: function buildToolbarEl() {
var $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
id: 'quickedit-entity-toolbar'
}));
$toolbar
.find('.quickedit-toolbar-entity')
// Append the "ops" toolgroup into the toolbar.
.prepend(Drupal.theme('quickeditToolgroup', {
classes: ['ops'],
buttons: [
{
label: Drupal.t('Save'),
type: 'submit',
classes: 'action-save quickedit-button icon',
attributes: {
'aria-hidden': true
}
},
{
label: Drupal.t('Close'),
classes: 'action-cancel quickedit-button icon icon-close icon-only'
}
]
}));
$toolbar.find('.quickedit-toolbar-entity').prepend(Drupal.theme('quickeditToolgroup', {
classes: ['ops'],
buttons: [{
label: Drupal.t('Save'),
type: 'submit',
classes: 'action-save quickedit-button icon',
attributes: {
'aria-hidden': true
}
}, {
label: Drupal.t('Close'),
classes: 'action-cancel quickedit-button icon icon-close icon-only'
}]
}));
// Give the toolbar a sensible starting position so that it doesn't
// animate on to the screen from a far off corner.
$toolbar
.css({
left: this.$entity.offset().left,
top: this.$entity.offset().top
});
$toolbar.css({
left: this.$entity.offset().left,
top: this.$entity.offset().top
});
return $toolbar;
},
/**
* Returns the DOM element that fields will attach their toolbars to.
*
* @return {jQuery}
* The DOM element that fields will attach their toolbars to.
*/
getToolbarRoot: function () {
getToolbarRoot: function getToolbarRoot() {
return this._fieldToolbarRoot;
},
/**
* Generates a state-dependent label for the entity toolbar.
*/
label: function () {
// The entity label.
label: function label() {
var label = '';
var entityLabel = this.model.get('label');
// Label of an active field, if it exists.
var activeField = Drupal.quickedit.app.model.get('activeField');
var activeFieldLabel = activeField && activeField.get('metadata').label;
// Label of a highlighted field, if it exists.
var highlightedField = Drupal.quickedit.app.model.get('highlightedField');
var highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label;
// The label is constructed in a priority order.
if (activeFieldLabel) {
label = Drupal.theme('quickeditEntityToolbarLabel', {
entityLabel: entityLabel,
fieldLabel: activeFieldLabel
});
}
else if (highlightedFieldLabel) {
} else if (highlightedFieldLabel) {
label = Drupal.theme('quickeditEntityToolbarLabel', {
entityLabel: entityLabel,
fieldLabel: highlightedFieldLabel
});
}
else {
// @todo Add XSS regression test coverage in https://www.drupal.org/node/2547437
} else {
label = Drupal.checkPlain(entityLabel);
}
this.$el
.find('.quickedit-toolbar-label')
.html(label);
this.$el.find('.quickedit-toolbar-label').html(label);
},
/**
* Adds classes to a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
* @param {string} classes
* A string of space-delimited class names that will be applied to the
* wrapping element of the toolbar group.
*/
addClass: function (toolgroup, classes) {
addClass: function addClass(toolgroup, classes) {
this._find(toolgroup).addClass(classes);
},
/**
* Removes classes from a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
* @param {string} classes
* A string of space-delimited class names that will be removed from the
* wrapping element of the toolbar group.
*/
removeClass: function (toolgroup, classes) {
removeClass: function removeClass(toolgroup, classes) {
this._find(toolgroup).removeClass(classes);
},
/**
* Finds a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*
* @return {jQuery}
* The toolgroup DOM element.
*/
_find: function (toolgroup) {
_find: function _find(toolgroup) {
return this.$el.find('.quickedit-toolbar .quickedit-toolgroup.' + toolgroup);
},
/**
* Shows a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*/
show: function (toolgroup) {
show: function show(toolgroup) {
this.$el.removeClass('quickedit-animate-invisible');
}
});
})(jQuery, _, Backbone, Drupal, Drupal.debounce);
})(jQuery, _, Backbone, Drupal, Drupal.debounce);

View file

@ -0,0 +1,368 @@
/**
* @file
* A Backbone View that decorates the in-place edited element.
*/
(function($, Backbone, Drupal) {
Drupal.quickedit.FieldDecorationView = Backbone.View.extend(
/** @lends Drupal.quickedit.FieldDecorationView# */ {
/**
* @type {null}
*/
_widthAttributeIsEmpty: null,
/**
* @type {object}
*/
events: {
'mouseenter.quickedit': 'onMouseEnter',
'mouseleave.quickedit': 'onMouseLeave',
click: 'onClick',
'tabIn.quickedit': 'onMouseEnter',
'tabOut.quickedit': 'onMouseLeave',
},
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EditorView} options.editorView
* The editor object view.
*/
initialize(options) {
this.editorView = options.editorView;
this.listenTo(this.model, 'change:state', this.stateChange);
this.listenTo(
this.model,
'change:isChanged change:inTempStore',
this.renderChanged,
);
},
/**
* @inheritdoc
*/
remove() {
// The el property is the field, which should not be removed. Remove the
// pointer to it, then call Backbone.View.prototype.remove().
this.setElement();
Backbone.View.prototype.remove.call(this);
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The `FieldModel` model.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange(model, state) {
const from = model.previous('state');
const to = state;
switch (to) {
case 'inactive':
this.undecorate();
break;
case 'candidate':
this.decorate();
if (from !== 'inactive') {
this.stopHighlight();
if (from !== 'highlighted') {
this.model.set('isChanged', false);
this.stopEdit();
}
}
this._unpad();
break;
case 'highlighted':
this.startHighlight();
break;
case 'activating':
// NOTE: this state is not used by every editor! It's only used by
// those that need to interact with the server.
this.prepareEdit();
break;
case 'active':
if (from !== 'activating') {
this.prepareEdit();
}
if (this.editorView.getQuickEditUISettings().padding) {
this._pad();
}
break;
case 'changed':
this.model.set('isChanged', true);
break;
case 'saving':
break;
case 'saved':
break;
case 'invalid':
break;
}
},
/**
* Adds a class to the edited element that indicates whether the field has
* been changed by the user (i.e. locally) or the field has already been
* changed and stored before by the user (i.e. remotely, stored in
* PrivateTempStore).
*/
renderChanged() {
this.$el.toggleClass(
'quickedit-changed',
this.model.get('isChanged') || this.model.get('inTempStore'),
);
},
/**
* Starts hover; transitions to 'highlight' state.
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseEnter(event) {
const that = this;
that.model.set('state', 'highlighted');
event.stopPropagation();
},
/**
* Stops hover; transitions to 'candidate' state.
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseLeave(event) {
const that = this;
that.model.set('state', 'candidate', { reason: 'mouseleave' });
event.stopPropagation();
},
/**
* Transition to 'activating' stage.
*
* @param {jQuery.Event} event
* The click event.
*/
onClick(event) {
this.model.set('state', 'activating');
event.preventDefault();
event.stopPropagation();
},
/**
* Adds classes used to indicate an elements editable state.
*/
decorate() {
this.$el.addClass('quickedit-candidate quickedit-editable');
},
/**
* Removes classes used to indicate an elements editable state.
*/
undecorate() {
this.$el.removeClass(
'quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing',
);
},
/**
* Adds that class that indicates that an element is highlighted.
*/
startHighlight() {
// Animations.
const that = this;
// Use a timeout to grab the next available animation frame.
that.$el.addClass('quickedit-highlighted');
},
/**
* Removes the class that indicates that an element is highlighted.
*/
stopHighlight() {
this.$el.removeClass('quickedit-highlighted');
},
/**
* Removes the class that indicates that an element as editable.
*/
prepareEdit() {
this.$el.addClass('quickedit-editing');
// Allow the field to be styled differently while editing in a pop-up
// in-place editor.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.addClass('quickedit-editor-is-popup');
}
},
/**
* Removes the class that indicates that an element is being edited.
*
* Reapplies the class that indicates that a candidate editable element is
* again available to be edited.
*/
stopEdit() {
this.$el.removeClass('quickedit-highlighted quickedit-editing');
// Done editing in a pop-up in-place editor; remove the class.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.removeClass('quickedit-editor-is-popup');
}
// Make the other editors show up again.
$('.quickedit-candidate').addClass('quickedit-editable');
},
/**
* Adds padding around the editable element to make it pop visually.
*/
_pad() {
// Early return if the element has already been padded.
if (this.$el.data('quickedit-padded')) {
return;
}
const self = this;
// Add 5px padding for readability. This means we'll freeze the current
// width and *then* add 5px padding, hence ensuring the padding is added
// "on the outside".
// 1) Freeze the width (if it's not already set); don't use animations.
if (this.$el[0].style.width === '') {
this._widthAttributeIsEmpty = true;
this.$el
.addClass('quickedit-animate-disable-width')
.css('width', this.$el.width());
}
// 2) Add padding; use animations.
const posProp = this._getPositionProperties(this.$el);
setTimeout(() => {
// Re-enable width animations (padding changes affect width too!).
self.$el.removeClass('quickedit-animate-disable-width');
// Pad the editable.
self.$el
.css({
position: 'relative',
top: `${posProp.top - 5}px`,
left: `${posProp.left - 5}px`,
'padding-top': `${posProp['padding-top'] + 5}px`,
'padding-left': `${posProp['padding-left'] + 5}px`,
'padding-right': `${posProp['padding-right'] + 5}px`,
'padding-bottom': `${posProp['padding-bottom'] + 5}px`,
'margin-bottom': `${posProp['margin-bottom'] - 10}px`,
})
.data('quickedit-padded', true);
}, 0);
},
/**
* Removes the padding around the element being edited when editing ceases.
*/
_unpad() {
// Early return if the element has not been padded.
if (!this.$el.data('quickedit-padded')) {
return;
}
const self = this;
// 1) Set the empty width again.
if (this._widthAttributeIsEmpty) {
this.$el.addClass('quickedit-animate-disable-width').css('width', '');
}
// 2) Remove padding; use animations (these will run simultaneously with)
// the fading out of the toolbar as its gets removed).
const posProp = this._getPositionProperties(this.$el);
setTimeout(() => {
// Re-enable width animations (padding changes affect width too!).
self.$el.removeClass('quickedit-animate-disable-width');
// Unpad the editable.
self.$el.css({
position: 'relative',
top: `${posProp.top + 5}px`,
left: `${posProp.left + 5}px`,
'padding-top': `${posProp['padding-top'] - 5}px`,
'padding-left': `${posProp['padding-left'] - 5}px`,
'padding-right': `${posProp['padding-right'] - 5}px`,
'padding-bottom': `${posProp['padding-bottom'] - 5}px`,
'margin-bottom': `${posProp['margin-bottom'] + 10}px`,
});
}, 0);
// Remove the marker that indicates that this field has padding. This is
// done outside the timed out function above so that we don't get numerous
// queued functions that will remove padding before the data marker has
// been removed.
this.$el.removeData('quickedit-padded');
},
/**
* Gets the top and left properties of an element.
*
* Convert extraneous values and information into numbers ready for
* subtraction.
*
* @param {jQuery} $e
* The element to get position properties from.
*
* @return {object}
* An object containing css values for the needed properties.
*/
_getPositionProperties($e) {
let p;
const r = {};
const props = [
'top',
'left',
'bottom',
'right',
'padding-top',
'padding-left',
'padding-right',
'padding-bottom',
'margin-bottom',
];
const propCount = props.length;
for (let i = 0; i < propCount; i++) {
p = props[i];
r[p] = parseInt(this._replaceBlankPosition($e.css(p)), 10);
}
return r;
},
/**
* Replaces blank or 'auto' CSS `position: <value>` values with "0px".
*
* @param {string} [pos]
* The value for a CSS position declaration.
*
* @return {string}
* A CSS value that is valid for `position`.
*/
_replaceBlankPosition(pos) {
if (pos === 'auto' || !pos) {
pos = '0px';
}
return pos;
},
},
);
})(jQuery, Backbone, Drupal);

View file

@ -1,67 +1,33 @@
/**
* @file
* A Backbone View that decorates the in-place edited element.
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, Backbone, Drupal) {
'use strict';
Drupal.quickedit.FieldDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldDecorationView# */{
/**
* @type {null}
*/
Drupal.quickedit.FieldDecorationView = Backbone.View.extend({
_widthAttributeIsEmpty: null,
/**
* @type {object}
*/
events: {
'mouseenter.quickedit': 'onMouseEnter',
'mouseleave.quickedit': 'onMouseLeave',
'click': 'onClick',
click: 'onClick',
'tabIn.quickedit': 'onMouseEnter',
'tabOut.quickedit': 'onMouseLeave'
},
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* An object with the following keys:
* @param {Drupal.quickedit.EditorView} options.editorView
* The editor object view.
*/
initialize: function (options) {
initialize: function initialize(options) {
this.editorView = options.editorView;
this.listenTo(this.model, 'change:state', this.stateChange);
this.listenTo(this.model, 'change:isChanged change:inTempStore', this.renderChanged);
},
/**
* @inheritdoc
*/
remove: function () {
// The el property is the field, which should not be removed. Remove the
// pointer to it, then call Backbone.View.prototype.remove().
remove: function remove() {
this.setElement();
Backbone.View.prototype.remove.call(this);
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The `FieldModel` model.
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange: function (model, state) {
stateChange: function stateChange(model, state) {
var from = model.previous('state');
var to = state;
switch (to) {
@ -86,8 +52,6 @@
break;
case 'activating':
// NOTE: this state is not used by every editor! It's only used by
// those that need to interact with the server.
this.prepareEdit();
break;
@ -114,222 +78,113 @@
break;
}
},
/**
* Adds a class to the edited element that indicates whether the field has
* been changed by the user (i.e. locally) or the field has already been
* changed and stored before by the user (i.e. remotely, stored in
* PrivateTempStore).
*/
renderChanged: function () {
renderChanged: function renderChanged() {
this.$el.toggleClass('quickedit-changed', this.model.get('isChanged') || this.model.get('inTempStore'));
},
/**
* Starts hover; transitions to 'highlight' state.
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseEnter: function (event) {
onMouseEnter: function onMouseEnter(event) {
var that = this;
that.model.set('state', 'highlighted');
event.stopPropagation();
},
/**
* Stops hover; transitions to 'candidate' state.
*
* @param {jQuery.Event} event
* The mouse event.
*/
onMouseLeave: function (event) {
onMouseLeave: function onMouseLeave(event) {
var that = this;
that.model.set('state', 'candidate', {reason: 'mouseleave'});
that.model.set('state', 'candidate', { reason: 'mouseleave' });
event.stopPropagation();
},
/**
* Transition to 'activating' stage.
*
* @param {jQuery.Event} event
* The click event.
*/
onClick: function (event) {
onClick: function onClick(event) {
this.model.set('state', 'activating');
event.preventDefault();
event.stopPropagation();
},
/**
* Adds classes used to indicate an elements editable state.
*/
decorate: function () {
decorate: function decorate() {
this.$el.addClass('quickedit-candidate quickedit-editable');
},
/**
* Removes classes used to indicate an elements editable state.
*/
undecorate: function () {
undecorate: function undecorate() {
this.$el.removeClass('quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing');
},
/**
* Adds that class that indicates that an element is highlighted.
*/
startHighlight: function () {
// Animations.
startHighlight: function startHighlight() {
var that = this;
// Use a timeout to grab the next available animation frame.
that.$el.addClass('quickedit-highlighted');
},
/**
* Removes the class that indicates that an element is highlighted.
*/
stopHighlight: function () {
stopHighlight: function stopHighlight() {
this.$el.removeClass('quickedit-highlighted');
},
/**
* Removes the class that indicates that an element as editable.
*/
prepareEdit: function () {
prepareEdit: function prepareEdit() {
this.$el.addClass('quickedit-editing');
// Allow the field to be styled differently while editing in a pop-up
// in-place editor.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.addClass('quickedit-editor-is-popup');
}
},
/**
* Removes the class that indicates that an element is being edited.
*
* Reapplies the class that indicates that a candidate editable element is
* again available to be edited.
*/
stopEdit: function () {
stopEdit: function stopEdit() {
this.$el.removeClass('quickedit-highlighted quickedit-editing');
// Done editing in a pop-up in-place editor; remove the class.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.removeClass('quickedit-editor-is-popup');
}
// Make the other editors show up again.
$('.quickedit-candidate').addClass('quickedit-editable');
},
/**
* Adds padding around the editable element to make it pop visually.
*/
_pad: function () {
// Early return if the element has already been padded.
_pad: function _pad() {
if (this.$el.data('quickedit-padded')) {
return;
}
var self = this;
// Add 5px padding for readability. This means we'll freeze the current
// width and *then* add 5px padding, hence ensuring the padding is added
// "on the outside".
// 1) Freeze the width (if it's not already set); don't use animations.
if (this.$el[0].style.width === '') {
this._widthAttributeIsEmpty = true;
this.$el
.addClass('quickedit-animate-disable-width')
.css('width', this.$el.width());
this.$el.addClass('quickedit-animate-disable-width').css('width', this.$el.width());
}
// 2) Add padding; use animations.
var posProp = this._getPositionProperties(this.$el);
setTimeout(function () {
// Re-enable width animations (padding changes affect width too!).
self.$el.removeClass('quickedit-animate-disable-width');
// Pad the editable.
self.$el
.css({
'position': 'relative',
'top': posProp.top - 5 + 'px',
'left': posProp.left - 5 + 'px',
'padding-top': posProp['padding-top'] + 5 + 'px',
'padding-left': posProp['padding-left'] + 5 + 'px',
'padding-right': posProp['padding-right'] + 5 + 'px',
'padding-bottom': posProp['padding-bottom'] + 5 + 'px',
'margin-bottom': posProp['margin-bottom'] - 10 + 'px'
})
.data('quickedit-padded', true);
self.$el.css({
position: 'relative',
top: posProp.top - 5 + 'px',
left: posProp.left - 5 + 'px',
'padding-top': posProp['padding-top'] + 5 + 'px',
'padding-left': posProp['padding-left'] + 5 + 'px',
'padding-right': posProp['padding-right'] + 5 + 'px',
'padding-bottom': posProp['padding-bottom'] + 5 + 'px',
'margin-bottom': posProp['margin-bottom'] - 10 + 'px'
}).data('quickedit-padded', true);
}, 0);
},
/**
* Removes the padding around the element being edited when editing ceases.
*/
_unpad: function () {
// Early return if the element has not been padded.
_unpad: function _unpad() {
if (!this.$el.data('quickedit-padded')) {
return;
}
var self = this;
// 1) Set the empty width again.
if (this._widthAttributeIsEmpty) {
this.$el
.addClass('quickedit-animate-disable-width')
.css('width', '');
this.$el.addClass('quickedit-animate-disable-width').css('width', '');
}
// 2) Remove padding; use animations (these will run simultaneously with)
// the fading out of the toolbar as its gets removed).
var posProp = this._getPositionProperties(this.$el);
setTimeout(function () {
// Re-enable width animations (padding changes affect width too!).
self.$el.removeClass('quickedit-animate-disable-width');
// Unpad the editable.
self.$el
.css({
'position': 'relative',
'top': posProp.top + 5 + 'px',
'left': posProp.left + 5 + 'px',
'padding-top': posProp['padding-top'] - 5 + 'px',
'padding-left': posProp['padding-left'] - 5 + 'px',
'padding-right': posProp['padding-right'] - 5 + 'px',
'padding-bottom': posProp['padding-bottom'] - 5 + 'px',
'margin-bottom': posProp['margin-bottom'] + 10 + 'px'
});
self.$el.css({
position: 'relative',
top: posProp.top + 5 + 'px',
left: posProp.left + 5 + 'px',
'padding-top': posProp['padding-top'] - 5 + 'px',
'padding-left': posProp['padding-left'] - 5 + 'px',
'padding-right': posProp['padding-right'] - 5 + 'px',
'padding-bottom': posProp['padding-bottom'] - 5 + 'px',
'margin-bottom': posProp['margin-bottom'] + 10 + 'px'
});
}, 0);
// Remove the marker that indicates that this field has padding. This is
// done outside the timed out function above so that we don't get numerous
// queued functions that will remove padding before the data marker has
// been removed.
this.$el.removeData('quickedit-padded');
},
/**
* Gets the top and left properties of an element.
*
* Convert extraneous values and information into numbers ready for
* subtraction.
*
* @param {jQuery} $e
* The element to get position properties from.
*
* @return {object}
* An object containing css values for the needed properties.
*/
_getPositionProperties: function ($e) {
var p;
_getPositionProperties: function _getPositionProperties($e) {
var p = void 0;
var r = {};
var props = [
'top', 'left', 'bottom', 'right',
'padding-top', 'padding-left', 'padding-right', 'padding-bottom',
'margin-bottom'
];
var props = ['top', 'left', 'bottom', 'right', 'padding-top', 'padding-left', 'padding-right', 'padding-bottom', 'margin-bottom'];
var propCount = props.length;
for (var i = 0; i < propCount; i++) {
@ -338,23 +193,11 @@
}
return r;
},
/**
* Replaces blank or 'auto' CSS `position: <value>` values with "0px".
*
* @param {string} [pos]
* The value for a CSS position declaration.
*
* @return {string}
* A CSS value that is valid for `position`.
*/
_replaceBlankPosition: function (pos) {
_replaceBlankPosition: function _replaceBlankPosition(pos) {
if (pos === 'auto' || !pos) {
pos = '0px';
}
return pos;
}
});
})(jQuery, Backbone, Drupal);
})(jQuery, Backbone, Drupal);

View file

@ -0,0 +1,244 @@
/**
* @file
* A Backbone View that provides an interactive toolbar (1 per in-place editor).
*/
(function($, _, Backbone, Drupal) {
Drupal.quickedit.FieldToolbarView = Backbone.View.extend(
/** @lends Drupal.quickedit.FieldToolbarView# */ {
/**
* The edited element, as indicated by EditorView.getEditedElement.
*
* @type {jQuery}
*/
$editedElement: null,
/**
* A reference to the in-place editor.
*
* @type {Drupal.quickedit.EditorView}
*/
editorView: null,
/**
* @type {string}
*/
_id: null,
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options object to construct the field toolbar.
* @param {jQuery} options.$editedElement
* The element being edited.
* @param {Drupal.quickedit.EditorView} options.editorView
* The EditorView the toolbar belongs to.
*/
initialize(options) {
this.$editedElement = options.$editedElement;
this.editorView = options.editorView;
/**
* @type {jQuery}
*/
this.$root = this.$el;
// Generate a DOM-compatible ID for the form container DOM element.
this._id = `quickedit-toolbar-for-${this.model.id.replace(
/[/[\]]/g,
'_',
)}`;
this.listenTo(this.model, 'change:state', this.stateChange);
},
/**
* @inheritdoc
*
* @return {Drupal.quickedit.FieldToolbarView}
* The current FieldToolbarView.
*/
render() {
// Render toolbar and set it as the view's element.
this.setElement(
$(
Drupal.theme('quickeditFieldToolbar', {
id: this._id,
}),
),
);
// Attach to the field toolbar $root element in the entity toolbar.
this.$el.prependTo(this.$root);
return this;
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The quickedit FieldModel
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange(model, state) {
const from = model.previous('state');
const to = state;
switch (to) {
case 'inactive':
break;
case 'candidate':
// Remove the view's existing element if we went to the 'activating'
// state or later, because it will be recreated. Not doing this would
// result in memory leaks.
if (from !== 'inactive' && from !== 'highlighted') {
this.$el.remove();
this.setElement();
}
break;
case 'highlighted':
break;
case 'activating':
this.render();
if (this.editorView.getQuickEditUISettings().fullWidthToolbar) {
this.$el.addClass('quickedit-toolbar-fullwidth');
}
if (this.editorView.getQuickEditUISettings().unifiedToolbar) {
this.insertWYSIWYGToolGroups();
}
break;
case 'active':
break;
case 'changed':
break;
case 'saving':
break;
case 'saved':
break;
case 'invalid':
break;
}
},
/**
* Insert WYSIWYG markup into the associated toolbar.
*/
insertWYSIWYGToolGroups() {
this.$el
.append(
Drupal.theme('quickeditToolgroup', {
id: this.getFloatedWysiwygToolgroupId(),
classes: [
'wysiwyg-floated',
'quickedit-animate-slow',
'quickedit-animate-invisible',
'quickedit-animate-delay-veryfast',
],
buttons: [],
}),
)
.append(
Drupal.theme('quickeditToolgroup', {
id: this.getMainWysiwygToolgroupId(),
classes: [
'wysiwyg-main',
'quickedit-animate-slow',
'quickedit-animate-invisible',
'quickedit-animate-delay-veryfast',
],
buttons: [],
}),
);
// Animate the toolgroups into visibility.
this.show('wysiwyg-floated');
this.show('wysiwyg-main');
},
/**
* Retrieves the ID for this toolbar's container.
*
* Only used to make sane hovering behavior possible.
*
* @return {string}
* A string that can be used as the ID for this toolbar's container.
*/
getId() {
return `quickedit-toolbar-for-${this._id}`;
},
/**
* Retrieves the ID for this toolbar's floating WYSIWYG toolgroup.
*
* Used to provide an abstraction for any WYSIWYG editor to plug in.
*
* @return {string}
* A string that can be used as the ID.
*/
getFloatedWysiwygToolgroupId() {
return `quickedit-wysiwyg-floated-toolgroup-for-${this._id}`;
},
/**
* Retrieves the ID for this toolbar's main WYSIWYG toolgroup.
*
* Used to provide an abstraction for any WYSIWYG editor to plug in.
*
* @return {string}
* A string that can be used as the ID.
*/
getMainWysiwygToolgroupId() {
return `quickedit-wysiwyg-main-toolgroup-for-${this._id}`;
},
/**
* Finds a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*
* @return {jQuery}
* The toolgroup element.
*/
_find(toolgroup) {
return this.$el.find(`.quickedit-toolgroup.${toolgroup}`);
},
/**
* Shows a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*/
show(toolgroup) {
const $group = this._find(toolgroup);
// Attach a transitionEnd event handler to the toolbar group so that
// update events can be triggered after the animations have ended.
$group.on(Drupal.quickedit.util.constants.transitionEnd, event => {
$group.off(Drupal.quickedit.util.constants.transitionEnd);
});
// The call to remove the class and start the animation must be started in
// the next animation frame or the event handler attached above won't be
// triggered.
window.setTimeout(() => {
$group.removeClass('quickedit-animate-invisible');
}, 0);
},
},
);
})(jQuery, _, Backbone, Drupal);

View file

@ -1,88 +1,38 @@
/**
* @file
* A Backbone View that provides an interactive toolbar (1 per in-place editor).
*/
* DO NOT EDIT THIS FILE.
* See the following change record for more information,
* https://www.drupal.org/node/2815083
* @preserve
**/
(function ($, _, Backbone, Drupal) {
'use strict';
Drupal.quickedit.FieldToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldToolbarView# */{
/**
* The edited element, as indicated by EditorView.getEditedElement.
*
* @type {jQuery}
*/
Drupal.quickedit.FieldToolbarView = Backbone.View.extend({
$editedElement: null,
/**
* A reference to the in-place editor.
*
* @type {Drupal.quickedit.EditorView}
*/
editorView: null,
/**
* @type {string}
*/
_id: null,
/**
* @constructs
*
* @augments Backbone.View
*
* @param {object} options
* Options object to construct the field toolbar.
* @param {jQuery} options.$editedElement
* The element being edited.
* @param {Drupal.quickedit.EditorView} options.editorView
* The EditorView the toolbar belongs to.
*/
initialize: function (options) {
initialize: function initialize(options) {
this.$editedElement = options.$editedElement;
this.editorView = options.editorView;
/**
* @type {jQuery}
*/
this.$root = this.$el;
// Generate a DOM-compatible ID for the form container DOM element.
this._id = 'quickedit-toolbar-for-' + this.model.id.replace(/[\/\[\]]/g, '_');
this._id = 'quickedit-toolbar-for-' + this.model.id.replace(/[/[\]]/g, '_');
this.listenTo(this.model, 'change:state', this.stateChange);
},
/**
* @inheritdoc
*
* @return {Drupal.quickedit.FieldToolbarView}
* The current FieldToolbarView.
*/
render: function () {
// Render toolbar and set it as the view's element.
render: function render() {
this.setElement($(Drupal.theme('quickeditFieldToolbar', {
id: this._id
})));
// Attach to the field toolbar $root element in the entity toolbar.
this.$el.prependTo(this.$root);
return this;
},
/**
* Determines the actions to take given a change of state.
*
* @param {Drupal.quickedit.FieldModel} model
* The quickedit FieldModel
* @param {string} state
* The state of the associated field. One of
* {@link Drupal.quickedit.FieldModel.states}.
*/
stateChange: function (model, state) {
stateChange: function stateChange(model, state) {
var from = model.previous('state');
var to = state;
switch (to) {
@ -90,9 +40,6 @@
break;
case 'candidate':
// Remove the view's existing element if we went to the 'activating'
// state or later, because it will be recreated. Not doing this would
// result in memory leaks.
if (from !== 'inactive' && from !== 'highlighted') {
this.$el.remove();
this.setElement();
@ -130,98 +77,42 @@
break;
}
},
insertWYSIWYGToolGroups: function insertWYSIWYGToolGroups() {
this.$el.append(Drupal.theme('quickeditToolgroup', {
id: this.getFloatedWysiwygToolgroupId(),
classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
buttons: []
})).append(Drupal.theme('quickeditToolgroup', {
id: this.getMainWysiwygToolgroupId(),
classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
buttons: []
}));
/**
* Insert WYSIWYG markup into the associated toolbar.
*/
insertWYSIWYGToolGroups: function () {
this.$el
.append(Drupal.theme('quickeditToolgroup', {
id: this.getFloatedWysiwygToolgroupId(),
classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
buttons: []
}))
.append(Drupal.theme('quickeditToolgroup', {
id: this.getMainWysiwygToolgroupId(),
classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
buttons: []
}));
// Animate the toolgroups into visibility.
this.show('wysiwyg-floated');
this.show('wysiwyg-main');
},
/**
* Retrieves the ID for this toolbar's container.
*
* Only used to make sane hovering behavior possible.
*
* @return {string}
* A string that can be used as the ID for this toolbar's container.
*/
getId: function () {
getId: function getId() {
return 'quickedit-toolbar-for-' + this._id;
},
/**
* Retrieves the ID for this toolbar's floating WYSIWYG toolgroup.
*
* Used to provide an abstraction for any WYSIWYG editor to plug in.
*
* @return {string}
* A string that can be used as the ID.
*/
getFloatedWysiwygToolgroupId: function () {
getFloatedWysiwygToolgroupId: function getFloatedWysiwygToolgroupId() {
return 'quickedit-wysiwyg-floated-toolgroup-for-' + this._id;
},
/**
* Retrieves the ID for this toolbar's main WYSIWYG toolgroup.
*
* Used to provide an abstraction for any WYSIWYG editor to plug in.
*
* @return {string}
* A string that can be used as the ID.
*/
getMainWysiwygToolgroupId: function () {
getMainWysiwygToolgroupId: function getMainWysiwygToolgroupId() {
return 'quickedit-wysiwyg-main-toolgroup-for-' + this._id;
},
/**
* Finds a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*
* @return {jQuery}
* The toolgroup element.
*/
_find: function (toolgroup) {
_find: function _find(toolgroup) {
return this.$el.find('.quickedit-toolgroup.' + toolgroup);
},
/**
* Shows a toolgroup.
*
* @param {string} toolgroup
* A toolgroup name.
*/
show: function (toolgroup) {
show: function show(toolgroup) {
var $group = this._find(toolgroup);
// Attach a transitionEnd event handler to the toolbar group so that
// update events can be triggered after the animations have ended.
$group.on(Drupal.quickedit.util.constants.transitionEnd, function (event) {
$group.off(Drupal.quickedit.util.constants.transitionEnd);
});
// The call to remove the class and start the animation must be started in
// the next animation frame or the event handler attached above won't be
// triggered.
window.setTimeout(function () {
$group.removeClass('quickedit-animate-invisible');
}, 0);
}
});
})(jQuery, _, Backbone, Drupal);
})(jQuery, _, Backbone, Drupal);

View file

@ -5,6 +5,6 @@ package: Core
core: 8.x
version: VERSION
dependencies:
- contextual
- field
- filter
- drupal:contextual
- drupal:field
- drupal:filter

View file

@ -11,6 +11,7 @@
* entities, enabling them for in-place editing.
*/
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Routing\RouteMatchInterface;
@ -76,7 +77,7 @@ function quickedit_library_info_alter(&$libraries, $extension) {
$theme = Drupal::config('system.theme')->get('admin');
// First let the base theme modify the library, then the actual theme.
$alter_library = function(&$library, $theme) use (&$alter_library) {
$alter_library = function (&$library, $theme) use (&$alter_library) {
if (isset($theme) && $theme_path = drupal_get_path('theme', $theme)) {
$info = system_get_info('theme', $theme);
// Recurse to process base theme(s) first.
@ -128,14 +129,14 @@ function quickedit_preprocess_page_title(&$variables) {
*/
function quickedit_preprocess_field(&$variables) {
$variables['#cache']['contexts'][] = 'user.permissions';
if (!\Drupal::currentUser()->hasPermission('access in-place editing')) {
$element = $variables['element'];
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$entity = $element['#object'];
if (!\Drupal::currentUser()->hasPermission('access in-place editing') || !$entity->isLatestRevision()) {
return;
}
$element = $variables['element'];
/** @var $entity \Drupal\Core\Entity\EntityInterface */
$entity = $element['#object'];
// Quick Edit module only supports view modes, not dynamically defined
// "display options" (which \Drupal\Core\Field\FieldItemListInterface::view()
// always names the "_custom" view mode).
@ -156,10 +157,32 @@ function quickedit_preprocess_field(&$variables) {
* Implements hook_entity_view_alter().
*/
function quickedit_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
/** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
$build['#cache']['contexts'][] = 'user.permissions';
if (!\Drupal::currentUser()->hasPermission('access in-place editing')) {
if (!\Drupal::currentUser()->hasPermission('access in-place editing') || !$entity->isLatestRevision()) {
return;
}
$build['#attributes']['data-quickedit-entity-id'] = $entity->getEntityTypeId() . '/' . $entity->id();
}
/**
* Check if a loaded entity is the latest revision.
*
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
* The entity to check.
*
* @return bool
* TRUE if the loaded entity is the latest revision, FALSE otherwise.
*
* @deprecated in Drupal 8.5.0 and will be removed before Drupal 9.0.0. Use
* \Drupal\Core\Entity\RevisionableInterface::isLatestRevision() instead.
* As internal API, _quickedit_entity_is_latest_revision() may also be removed
* in a minor release.
*
* @internal
*/
function _quickedit_entity_is_latest_revision(ContentEntityInterface $entity) {
@trigger_error('_quickedit_entity_is_latest_revision() is deprecated in Drupal 8.5.0 and will be removed before Drupal 9.0.0. Use \Drupal\Core\Entity\RevisionableInterface::isLatestRevision() instead. As internal API, _quickedit_entity_is_latest_revision() may also be removed in a minor release.', E_USER_DEPRECATED);
return $entity->isLatestRevision();
}

View file

@ -3,7 +3,7 @@ services:
class: Drupal\quickedit\Plugin\InPlaceEditorManager
parent: default_plugin_manager
access_check.quickedit.entity_field:
class: Drupal\quickedit\Access\EditEntityFieldAccessCheck
class: Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck
tags:
- { name: access_check, applies_to: _access_quickedit_entity_field }
quickedit.editor.selector:

View file

@ -2,61 +2,9 @@
namespace Drupal\quickedit\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for editing entity fields.
* @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0.
*/
class EditEntityFieldAccessCheck implements AccessInterface, EditEntityFieldAccessCheckInterface {
/**
* Checks Quick Edit access to the field.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity containing the field.
* @param string $field_name
* The field name.
* @param string $langcode
* The langcode.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @todo Use the $account argument: https://www.drupal.org/node/2266809.
*/
public function access(EntityInterface $entity, $field_name, $langcode, AccountInterface $account) {
if (!$this->validateRequestAttributes($entity, $field_name, $langcode)) {
return AccessResult::forbidden();
}
return $this->accessEditEntityField($entity, $field_name);
}
/**
* {@inheritdoc}
*/
public function accessEditEntityField(EntityInterface $entity, $field_name) {
return $entity->access('update', NULL, TRUE)->andIf($entity->get($field_name)->access('edit', NULL, TRUE));
}
/**
* Validates request attributes.
*/
protected function validateRequestAttributes(EntityInterface $entity, $field_name, $langcode) {
// Validate the field name and language.
if (!$field_name || !$entity->hasField($field_name)) {
return FALSE;
}
if (!$langcode || !$entity->hasTranslation($langcode)) {
return FALSE;
}
return TRUE;
}
class EditEntityFieldAccessCheck extends QuickEditEntityFieldAccessCheck {
}

View file

@ -2,24 +2,9 @@
namespace Drupal\quickedit\Access;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for editing entity fields.
* @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0.
*/
interface EditEntityFieldAccessCheckInterface {
/**
* Checks access to edit the requested field of the requested entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string $field_name
* The field name.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function accessEditEntityField(EntityInterface $entity, $field_name);
interface EditEntityFieldAccessCheckInterface extends QuickEditEntityFieldAccessCheckInterface {
}

View file

@ -0,0 +1,62 @@
<?php
namespace Drupal\quickedit\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for in-place editing entity fields.
*/
class QuickEditEntityFieldAccessCheck implements AccessInterface, QuickEditEntityFieldAccessCheckInterface {
/**
* Checks Quick Edit access to the field.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity containing the field.
* @param string $field_name
* The field name.
* @param string $langcode
* The langcode.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*
* @todo Use the $account argument: https://www.drupal.org/node/2266809.
*/
public function access(EntityInterface $entity, $field_name, $langcode, AccountInterface $account) {
if (!$this->validateRequestAttributes($entity, $field_name, $langcode)) {
return AccessResult::forbidden();
}
return $this->accessEditEntityField($entity, $field_name);
}
/**
* {@inheritdoc}
*/
public function accessEditEntityField(EntityInterface $entity, $field_name) {
return $entity->access('update', NULL, TRUE)->andIf($entity->get($field_name)->access('edit', NULL, TRUE));
}
/**
* Validates request attributes.
*/
protected function validateRequestAttributes(EntityInterface $entity, $field_name, $langcode) {
// Validate the field name and language.
if (!$field_name || !$entity->hasField($field_name)) {
return FALSE;
}
if (!$langcode || !$entity->hasTranslation($langcode)) {
return FALSE;
}
return TRUE;
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Drupal\quickedit\Access;
use Drupal\Core\Entity\EntityInterface;
/**
* Access check for in-place editing entity fields.
*/
interface QuickEditEntityFieldAccessCheckInterface {
/**
* Checks access to edit the requested field of the requested entity.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param string $field_name
* The field name.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function accessEditEntityField(EntityInterface $entity, $field_name);
}

View file

@ -22,7 +22,7 @@ class EditorSelector implements EditorSelectorInterface {
/**
* The manager for formatter plugins.
*
* @var \Drupal\Core\Field\FormatterPluginManager.
* @var \Drupal\Core\Field\FormatterPluginManager
*/
protected $formatterManager;

View file

@ -10,19 +10,20 @@ use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\user\PrivateTempStoreFactory;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Builds and process a form for editing a single entity field.
*
* @internal
*/
class QuickEditFieldForm extends FormBase {
/**
* Stores the tempstore factory.
*
* @var \Drupal\user\PrivateTempStoreFactory
* @var \Drupal\Core\TempStore\PrivateTempStoreFactory
*/
protected $tempStoreFactory;
@ -40,30 +41,20 @@ class QuickEditFieldForm extends FormBase {
*/
protected $nodeTypeStorage;
/**
* The typed data validator.
*
* @var \Symfony\Component\Validator\Validator\ValidatorInterface
*/
protected $validator;
/**
* Constructs a new EditFieldForm.
*
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
* @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory
* The tempstore factory.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Entity\EntityStorageInterface $node_type_storage
* The node type storage.
* @param \Symfony\Component\Validator\Validator\ValidatorInterface $validator
* The typed data validator service.
*/
public function __construct(PrivateTempStoreFactory $temp_store_factory, ModuleHandlerInterface $module_handler, EntityStorageInterface $node_type_storage, ValidatorInterface $validator) {
public function __construct(PrivateTempStoreFactory $temp_store_factory, ModuleHandlerInterface $module_handler, EntityStorageInterface $node_type_storage) {
$this->moduleHandler = $module_handler;
$this->nodeTypeStorage = $node_type_storage;
$this->tempStoreFactory = $temp_store_factory;
$this->validator = $validator;
}
/**
@ -71,10 +62,9 @@ class QuickEditFieldForm extends FormBase {
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('user.private_tempstore'),
$container->get('tempstore.private'),
$container->get('module_handler'),
$container->get('entity.manager')->getStorage('node_type'),
$container->get('typed_data_manager')->getValidator()
$container->get('entity.manager')->getStorage('node_type')
);
}
@ -114,6 +104,10 @@ class QuickEditFieldForm extends FormBase {
'#attributes' => ['class' => ['quickedit-form-submit']],
];
// Use the non-inline form error display for Quick Edit forms, because in
// this case the errors are already near the form element.
$form['#disable_inline_form_errors'] = TRUE;
// Simplify it for optimal in-place use.
$this->simplify($form, $form_state);

View file

@ -5,7 +5,7 @@ namespace Drupal\quickedit;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface;
use Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface;
use Drupal\Core\Entity\Entity\EntityViewDisplay;
/**
@ -16,7 +16,7 @@ class MetadataGenerator implements MetadataGeneratorInterface {
/**
* An object that checks if a user has access to edit a given entity field.
*
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface
* @var \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface
*/
protected $accessChecker;
@ -37,14 +37,14 @@ class MetadataGenerator implements MetadataGeneratorInterface {
/**
* Constructs a new MetadataGenerator.
*
* @param \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface $access_checker
* @param \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface $access_checker
* An object that checks if a user has access to edit a given field.
* @param \Drupal\quickedit\EditorSelectorInterface $editor_selector
* An object that determines which editor to attach to a given field.
* @param \Drupal\Component\Plugin\PluginManagerInterface $editor_manager
* The manager for editor plugins.
*/
public function __construct(EditEntityFieldAccessCheckInterface $access_checker, EditorSelectorInterface $editor_selector, PluginManagerInterface $editor_manager) {
public function __construct(QuickEditEntityFieldAccessCheckInterface $access_checker, EditorSelectorInterface $editor_selector, PluginManagerInterface $editor_manager) {
$this->accessChecker = $access_checker;
$this->editorSelector = $editor_selector;
$this->editorManager = $editor_manager;

View file

@ -5,7 +5,7 @@ namespace Drupal\quickedit;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Form\FormState;
use Drupal\Core\Render\RendererInterface;
use Drupal\user\PrivateTempStoreFactory;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -25,7 +25,7 @@ class QuickEditController extends ControllerBase {
/**
* The PrivateTempStore factory.
*
* @var \Drupal\user\PrivateTempStoreFactory
* @var \Drupal\Core\TempStore\PrivateTempStoreFactory
*/
protected $tempStoreFactory;
@ -53,7 +53,7 @@ class QuickEditController extends ControllerBase {
/**
* Constructs a new QuickEditController.
*
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
* @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory
* The PrivateTempStore factory.
* @param \Drupal\quickedit\MetadataGeneratorInterface $metadata_generator
* The in-place editing metadata generator.
@ -74,7 +74,7 @@ class QuickEditController extends ControllerBase {
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('user.private_tempstore'),
$container->get('tempstore.private'),
$container->get('quickedit.metadata.generator'),
$container->get('quickedit.editor.selector'),
$container->get('renderer')
@ -221,7 +221,7 @@ class QuickEditController extends ControllerBase {
$errors = $form_state->getErrors();
if (count($errors)) {
$status_messages = [
'#type' => 'status_messages'
'#type' => 'status_messages',
];
$response->addCommand(new FieldFormValidationErrorsCommand($this->renderer->renderRoot($status_messages)));
}
@ -293,7 +293,7 @@ class QuickEditController extends ControllerBase {
// to identify it.
$output = [
'entity_type' => $entity->getEntityTypeId(),
'entity_id' => $entity->id()
'entity_id' => $entity->id(),
];
// Respond to client that the entity was saved properly.

View file

@ -6,10 +6,10 @@ use Drupal\Component\Serialization\Json;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
use Drupal\simpletest\WebTestBase;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\taxonomy\Entity\Term;
use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
/**
* Tests in-place editing of autocomplete tags.

View file

@ -3,7 +3,6 @@
namespace Drupal\quickedit\Tests;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Unicode;
use Drupal\block_content\Entity\BlockContent;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
@ -42,6 +41,13 @@ class QuickEditLoadingTest extends WebTestBase {
*/
protected $authorUser;
/**
* A test node.
*
* @var \Drupal\node\NodeInterface
*/
protected $testNode;
/**
* A author user with permissions to access in-place editor.
*
@ -74,13 +80,13 @@ class QuickEditLoadingTest extends WebTestBase {
$node_type->save();
// Create one node of the above node type using the above text format.
$this->drupalCreateNode([
$this->testNode = $this->drupalCreateNode([
'type' => 'article',
'body' => [
0 => [
'value' => '<p>How are you?</p>',
'format' => 'filtered_html',
]
],
],
'revision_log' => $this->randomString(),
]);
@ -115,12 +121,12 @@ class QuickEditLoadingTest extends WebTestBase {
$this->assertIdentical(Json::encode(['message' => "The 'access in-place editing' permission is required."]), $response);
$this->assertResponse(403);
// Quick Edit's JavaScript would SearchRankingTestnever hit these endpoints if the metadata
// Quick Edit's JavaScript would never hit these endpoints if the metadata
// was empty as above, but we need to make sure that malicious users aren't
// able to use any of the other endpoints either.
$post = ['editors[0]' => 'form'] + $this->getAjaxPageStatePostData();
$response = $this->drupalPost('quickedit/attachments', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
$message = Json::encode(['message' => "A fatal error occurred: The 'access in-place editing' permission is required."]);
$message = Json::encode(['message' => "The 'access in-place editing' permission is required."]);
$this->assertIdentical($message, $response);
$this->assertResponse(403);
$post = ['nocssjs' => 'true'] + $this->getAjaxPageStatePostData();
@ -181,7 +187,7 @@ class QuickEditLoadingTest extends WebTestBase {
'label' => 'Body',
'access' => TRUE,
'editor' => 'form',
]
],
];
$this->assertIdentical(Json::decode($response), $expected, 'The metadata HTTP request answers with the correct JSON response.');
// Restore drupalSettings to build the next requests; simpletest wipes them
@ -209,7 +215,7 @@ class QuickEditLoadingTest extends WebTestBase {
$ajax_commands = Json::decode($response);
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
$this->assertIdentical('quickeditFieldForm', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldForm command.');
$this->assertIdentical('<form ', Unicode::substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
$this->assertIdentical('<form ', mb_substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
// Prepare form values for submission. drupalPostAjaxForm() is not suitable
// for handling pages with JSON responses, so we need our own solution here.
@ -279,7 +285,7 @@ class QuickEditLoadingTest extends WebTestBase {
$ajax_commands = Json::decode($response);
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
$this->assertIdentical('quickeditFieldForm', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldForm command.');
$this->assertIdentical('<form ', Unicode::substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
$this->assertIdentical('<form ', mb_substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
// Submit field form.
preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match);
@ -317,6 +323,34 @@ class QuickEditLoadingTest extends WebTestBase {
}
}
/**
* Test quickedit does not appear for entities with pending revisions.
*/
public function testWithPendingRevision() {
$this->drupalLogin($this->editorUser);
// Verify that the preview is loaded correctly.
$this->drupalPostForm('node/add/article', ['title[0][value]' => 'foo'], 'Preview');
$this->assertResponse(200);
// Verify that quickedit is not active on preview.
$this->assertNoRaw('data-quickedit-entity-id="node/' . $this->testNode->id() . '"');
$this->assertNoRaw('data-quickedit-field-id="node/' . $this->testNode->id() . '/title/' . $this->testNode->language()->getId() . '/full"');
$this->drupalGet('node/' . $this->testNode->id());
$this->assertRaw('data-quickedit-entity-id="node/' . $this->testNode->id() . '"');
$this->assertRaw('data-quickedit-field-id="node/' . $this->testNode->id() . '/title/' . $this->testNode->language()->getId() . '/full"');
$this->testNode->title = 'Updated node';
$this->testNode->setNewRevision(TRUE);
$this->testNode->isDefaultRevision(FALSE);
$this->testNode->save();
$this->drupalGet('node/' . $this->testNode->id());
$this->assertResponse(200);
$this->assertNoRaw('data-quickedit-entity-id="node/' . $this->testNode->id() . '"');
$this->assertNoRaw('data-quickedit-field-id="node/' . $this->testNode->id() . '/title/' . $this->testNode->language()->getId() . '/full"');
}
/**
* Tests the loading of Quick Edit for the title base field.
*/
@ -339,7 +373,7 @@ class QuickEditLoadingTest extends WebTestBase {
'label' => 'Title',
'access' => TRUE,
'editor' => 'plain_text',
]
],
];
$this->assertIdentical(Json::decode($response), $expected, 'The metadata HTTP request answers with the correct JSON response.');
// Restore drupalSettings to build the next requests; simpletest wipes them
@ -354,7 +388,7 @@ class QuickEditLoadingTest extends WebTestBase {
$ajax_commands = Json::decode($response);
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
$this->assertIdentical('quickeditFieldForm', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldForm command.');
$this->assertIdentical('<form ', Unicode::substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
$this->assertIdentical('<form ', mb_substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
// Prepare form values for submission. drupalPostAjaxForm() is not suitable
// for handling pages with JSON responses, so we need our own solution
@ -567,7 +601,7 @@ class QuickEditLoadingTest extends WebTestBase {
$response = $this->drupalPost('quickedit/form/node/1/field_image/en/full', '', ['nocssjs' => 'true'] + $this->getAjaxPageStatePostData(), ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
$this->assertResponse(200);
$ajax_commands = Json::decode($response);
$this->assertIdentical('<form ', Unicode::substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
$this->assertIdentical('<form ', mb_substr($ajax_commands[0]['data'], 0, 6), 'The quickeditFieldForm command contains a form.');
}
}

View file

@ -2,19 +2,9 @@
namespace Drupal\quickedit_test;
use Drupal\Core\Entity\EntityInterface;
use Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface;
/**
* Access check for editing entity fields.
* @deprecated in Drupal 8.4.x and will be removed before Drupal 9.0.0.
*/
class MockEditEntityFieldAccessCheck implements EditEntityFieldAccessCheckInterface {
/**
* {@inheritdoc}
*/
public function accessEditEntityField(EntityInterface $entity, $field_name) {
return TRUE;
}
class MockEditEntityFieldAccessCheck extends MockQuickEditEntityFieldAccessCheck {
}

View file

@ -0,0 +1,20 @@
<?php
namespace Drupal\quickedit_test;
use Drupal\Core\Entity\EntityInterface;
use Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface;
/**
* Access check for in-place editing entity fields.
*/
class MockQuickEditEntityFieldAccessCheck implements QuickEditEntityFieldAccessCheckInterface {
/**
* {@inheritdoc}
*/
public function accessEditEntityField(EntityInterface $entity, $field_name) {
return TRUE;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Drupal\Tests\quickedit\FunctionalJavascript;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\Tests\contextual\FunctionalJavascript\ContextualLinkClickTrait;
/**
* Tests quickedit.
*
* @group quickedit
*/
class FieldTest extends WebDriverTestBase {
use ContextualLinkClickTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'ckeditor',
'contextual',
'quickedit',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create a text format and associate CKEditor.
$filtered_html_format = FilterFormat::create([
'format' => 'filtered_html',
'name' => 'Filtered HTML',
'weight' => 0,
]);
$filtered_html_format->save();
Editor::create([
'format' => 'filtered_html',
'editor' => 'ckeditor',
])->save();
// Create note type with body field.
$node_type = NodeType::create(['type' => 'page', 'name' => 'Page']);
$node_type->save();
node_add_body_field($node_type);
$account = $this->drupalCreateUser([
'access content',
'administer nodes',
'edit any page content',
'use text format filtered_html',
'access contextual links',
'access in-place editing',
]);
$this->drupalLogin($account);
}
/**
* Tests that quickeditor works correctly for field with CKEditor.
*/
public function testFieldWithCkeditor() {
$body_value = '<p>Sapere aude</p>';
$node = Node::create([
'type' => 'page',
'title' => 'Page node',
'body' => [['value' => $body_value, 'format' => 'filtered_html']],
]);
$node->save();
$page = $this->getSession()->getPage();
$assert = $this->assertSession();
$this->drupalGet('node/' . $node->id());
// Wait "Quick edit" button for node.
$this->assertSession()->waitForElement('css', '[data-quickedit-entity-id="node/' . $node->id() . '"] .contextual .quickedit');
// Click by "Quick edit".
$this->clickContextualLink('[data-quickedit-entity-id="node/' . $node->id() . '"]', 'Quick edit');
// Switch to body field.
$page->find('css', '[data-quickedit-field-id="node/' . $node->id() . '/body/en/full"]')->click();
// Wait and click by "Blockquote" button from editor for body field.
$this->assertSession()->waitForElementVisible('css', '.cke_button.cke_button__blockquote')->click();
// Wait and click by "Save" button after body field was changed.
$this->assertSession()->waitForElementVisible('css', '.quickedit-toolgroup.ops [type="submit"][aria-hidden="false"]')->click();
// Wait until the save occurs and the editor UI disappears.
$this->waitForNoElement('.cke_button.cke_button__blockquote');
// Ensure that the changes take effect.
$assert->responseMatches("|<blockquote>\s*$body_value\s*</blockquote>|");
}
/**
* Waits for an element to be removed from the page.
*
* @param string $selector
* CSS selector.
* @param int $timeout
* (optional) Timeout in milliseconds, defaults to 10000.
*/
protected function waitForNoElement($selector, $timeout = 10000) {
$condition = "(typeof jQuery !== 'undefined' && jQuery('$selector').length === 0)";
$this->assertJsCondition($condition, $timeout);
}
}

View file

@ -0,0 +1,100 @@
<?php
namespace Drupal\Tests\quickedit\FunctionalJavascript;
use Drupal\file\Entity\File;
use Drupal\node\Entity\Node;
use Drupal\Tests\file\Functional\FileFieldCreationTrait;
use Drupal\Tests\TestFileCreationTrait;
/**
* @group quickedit
*/
class QuickEditFileTest extends QuickEditJavascriptTestBase {
use FileFieldCreationTrait;
use TestFileCreationTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'file',
];
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create the Article node type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Add file field to Article node type.
$this->createFileField('field_file', 'node', 'article', ['file_extensions' => 'txt']);
// Log in as a content author who can use Quick Edit and edit Articles.
$user = $this->drupalCreateUser([
'access contextual links',
'access toolbar',
'access in-place editing',
'access content',
'create article content',
'edit any article content',
]);
$this->drupalLogin($user);
}
/**
* Tests if a file can be in-place removed with Quick Edit.
*/
public function testRemove() {
$assert_session = $this->assertSession();
// Create test file.
$this->generateFile('test', 64, 10, 'text');
$file = File::create([
'uri' => 'public://test.txt',
'filename' => 'test.txt',
]);
$file->setPermanent();
$file->save();
// Create test node.
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('My Test Node'),
'field_file' => [
'target_id' => $file->id(),
],
]);
$this->drupalGet($node->toUrl()->toString());
// Start Quick Edit.
$this->awaitQuickEditForEntity('node', 1);
$this->startQuickEditViaToolbar('node', 1, 0);
// Click the file field.
$assert_session->waitForElementVisible('css', '[data-quickedit-field-id="node/1/field_file/en/full"]');
$this->click('[data-quickedit-field-id="node/1/field_file/en/full"]');
$assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="file"]');
// Remove the file.
$remove = $assert_session->waitForButton('Remove');
$remove->click();
// Wait for remove.
$assert_session->waitForElement('css', 'input[name="files[field_file_0]"]');
$this->saveQuickEdit();
// Wait for save.
$this->assertJsCondition("Drupal.quickedit.collections.entities.get('node/1[0]').get('state') === 'closed'");
// Assert file is removed from node.
$assert_session->pageTextNotContains('test.txt');
$node = Node::load($node->id());
$this->assertEmpty($node->get('field_file')->getValue());
}
}

View file

@ -0,0 +1,343 @@
<?php
namespace Drupal\Tests\quickedit\FunctionalJavascript;
use Drupal\block_content\Entity\BlockContent;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\editor\Entity\Editor;
use Drupal\filter\Entity\FilterFormat;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
/**
* @group quickedit
*/
class QuickEditIntegrationTest extends QuickEditJavascriptTestBase {
use EntityReferenceTestTrait;
/**
* {@inheritdoc}
*/
public static $modules = [
'node',
'editor',
'ckeditor',
'taxonomy',
'block',
'block_content',
'hold_test',
];
/**
* A user with permissions to edit Articles and use Quick Edit.
*
* @var \Drupal\user\UserInterface
*/
protected $contentAuthorUser;
/**
* {@inheritdoc}
*/
protected function setUp() {
parent::setUp();
// Create text format, associate CKEditor.
FilterFormat::create([
'format' => 'some_format',
'name' => 'Some format',
'weight' => 0,
'filters' => [
'filter_html' => [
'status' => 1,
'settings' => [
'allowed_html' => '<h2 id> <h3> <h4> <h5> <h6> <p> <br> <strong> <a href hreflang>',
],
],
],
])->save();
Editor::create([
'format' => 'some_format',
'editor' => 'ckeditor',
])->save();
// Create the Article node type.
$this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']);
// Add "tags" vocabulary + field to the Article node type.
$vocabulary = Vocabulary::create([
'name' => 'Tags',
'vid' => 'tags',
]);
$vocabulary->save();
$field_name = 'field_' . $vocabulary->id();
$handler_settings = [
'target_bundles' => [
$vocabulary->id() => $vocabulary->id(),
],
'auto_create' => TRUE,
];
$this->createEntityReferenceField('node', 'article', $field_name, 'Tags', 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
// Add formatter & widget for "tags" field.
\Drupal::entityTypeManager()
->getStorage('entity_form_display')
->load('node.article.default')
->setComponent($field_name, ['type' => 'entity_reference_autocomplete_tags'])
->save();
\Drupal::entityTypeManager()
->getStorage('entity_view_display')
->load('node.article.default')
->setComponent($field_name, ['type' => 'entity_reference_label'])
->save();
$this->drupalPlaceBlock('page_title_block');
$this->drupalPlaceBlock('system_main_block');
// Log in as a content author who can use Quick Edit and edit Articles.
$this->contentAuthorUser = $this->drupalCreateUser([
'access contextual links',
'access toolbar',
'access in-place editing',
'access content',
'create article content',
'edit any article content',
'use text format some_format',
'edit terms in tags',
'administer blocks',
]);
$this->drupalLogin($this->contentAuthorUser);
}
/**
* Tests if an article node can be in-place edited with Quick Edit.
*/
public function testArticleNode() {
$term = Term::create([
'name' => 'foo',
'vid' => 'tags',
]);
$term->save();
$node = $this->drupalCreateNode([
'type' => 'article',
'title' => t('My Test Node'),
'body' => [
'value' => '<p>Hello world!</p><p>I do not know what to say…</p><p>I wish I were eloquent.</p>',
'format' => 'some_format',
],
'field_tags' => [
['target_id' => $term->id()],
],
]);
$this->drupalGet('node/' . $node->id());
// Initial state.
$this->awaitQuickEditForEntity('node', 1);
$this->assertEntityInstanceStates([
'node/1[0]' => 'closed',
]);
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'inactive',
'node/1/uid/en/full' => 'inactive',
'node/1/created/en/full' => 'inactive',
'node/1/body/en/full' => 'inactive',
'node/1/field_tags/en/full' => 'inactive',
]);
// Start in-place editing of the article node.
$this->startQuickEditViaToolbar('node', 1, 0);
$this->assertEntityInstanceStates([
'node/1[0]' => 'opened',
]);
$this->assertQuickEditEntityToolbar((string) $node->label(), NULL);
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'candidate',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'candidate',
]);
$assert_session = $this->assertSession();
// Click the title field.
$this->click('[data-quickedit-field-id="node/1/title/en/full"].quickedit-candidate');
$assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="title"]');
$this->assertQuickEditEntityToolbar((string) $node->label(), 'Title');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'active',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'candidate',
]);
$this->assertEntityInstanceFieldMarkup('node', 1, 0, [
'node/1/title/en/full' => '[contenteditable="true"]',
]);
// Append something to the title.
$this->typeInPlainTextEditor('[data-quickedit-field-id="node/1/title/en/full"].quickedit-candidate', ' Llamas are awesome!');
$this->awaitEntityInstanceFieldState('node', 1, 0, 'title', 'en', 'changed');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'changed',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'candidate',
]);
// Click the body field.
hold_test_response(TRUE);
$this->click('[data-quickedit-entity-id="node/1"] .field--name-body');
$assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="body"]');
$this->assertQuickEditEntityToolbar((string) $node->label(), 'Body');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/title/en/full' => 'saving',
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'active',
'node/1/field_tags/en/full' => 'candidate',
]);
hold_test_response(FALSE);
// Wait for CKEditor to load, then verify it has.
$this->assertJsCondition('CKEDITOR.status === "loaded"');
$this->assertEntityInstanceFieldMarkup('node', 1, 0, [
'node/1/body/en/full' => '.cke_editable_inline',
'node/1/field_tags/en/full' => ':not(.quickedit-editor-is-popup)',
]);
$this->assertSession()->elementExists('css', '#quickedit-entity-toolbar .quickedit-toolgroup.wysiwyg-main > .cke_chrome .cke_top[role="presentation"] .cke_toolbar[role="toolbar"] .cke_toolgroup[role="presentation"] > .cke_button[title~="Bold"][role="button"]');
// Wait for the validating & saving of the title to complete.
$this->awaitEntityInstanceFieldState('node', 1, 0, 'title', 'en', 'candidate');
// Click the tags field.
hold_test_response(TRUE);
$this->click('[data-quickedit-field-id="node/1/field_tags/en/full"]');
$assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="tags"]');
$this->assertQuickEditEntityToolbar((string) $node->label(), 'Tags');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'activating',
'node/1/title/en/full' => 'candidate',
]);
$this->assertEntityInstanceFieldMarkup('node', 1, 0, [
'node/1/title/en/full' => '.quickedit-changed',
'node/1/field_tags/en/full' => '.quickedit-editor-is-popup',
]);
// Assert the "Loading…" popup appears.
$this->assertSession()->elementExists('css', '.quickedit-form-container > .quickedit-form[role="dialog"] > .placeholder');
hold_test_response(FALSE);
// Wait for the form to load.
$this->assertJsCondition('document.querySelector(\'.quickedit-form-container > .quickedit-form[role="dialog"] > .placeholder\') === null');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'active',
'node/1/title/en/full' => 'candidate',
]);
// Enter an additional tag.
$this->typeInFormEditorTextInputField('field_tags[target_id]', 'foo, bar');
$this->awaitEntityInstanceFieldState('node', 1, 0, 'field_tags', 'en', 'changed');
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'changed',
'node/1/title/en/full' => 'candidate',
]);
// Click 'Save'.
hold_test_response(TRUE);
$this->saveQuickEdit();
$this->assertEntityInstanceStates([
'node/1[0]' => 'committing',
]);
$this->assertEntityInstanceFieldStates('node', 1, 0, [
'node/1/uid/en/full' => 'candidate',
'node/1/created/en/full' => 'candidate',
'node/1/body/en/full' => 'candidate',
'node/1/field_tags/en/full' => 'saving',
'node/1/title/en/full' => 'candidate',
]);
hold_test_response(FALSE);
$this->assertEntityInstanceFieldMarkup('node', 1, 0, [
'node/1/title/en/full' => '.quickedit-changed',
'node/1/field_tags/en/full' => '.quickedit-changed',
]);
// Wait for the saving of the tags field to complete.
$this->assertJsCondition("Drupal.quickedit.collections.entities.get('node/1[0]').get('state') === 'closed'");
$this->assertEntityInstanceStates([
'node/1[0]' => 'closed',
]);
}
/**
* Tests if a custom can be in-place edited with Quick Edit.
*/
public function testCustomBlock() {
$block_content_type = BlockContentType::create([
'id' => 'basic',
'label' => 'basic',
'revision' => FALSE,
]);
$block_content_type->save();
block_content_add_body_field($block_content_type->id());
$block_content = BlockContent::create([
'info' => 'Llama',
'type' => 'basic',
'body' => [
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'some_format',
],
]);
$block_content->save();
$this->drupalPlaceBlock('block_content:' . $block_content->uuid(), [
'label' => 'My custom block!',
]);
$this->drupalGet('');
// Initial state.
$this->awaitQuickEditForEntity('block_content', 1);
$this->assertEntityInstanceStates([
'block_content/1[0]' => 'closed',
]);
// Start in-place editing of the article node.
$this->startQuickEditViaToolbar('block_content', 1, 0);
$this->assertEntityInstanceStates([
'block_content/1[0]' => 'opened',
]);
$this->assertQuickEditEntityToolbar((string) $block_content->label(), 'Body');
$this->assertEntityInstanceFieldStates('block_content', 1, 0, [
'block_content/1/body/en/full' => 'highlighted',
]);
// Click the body field.
$this->click('[data-quickedit-entity-id="block_content/1"] .field--name-body');
$assert_session = $this->assertSession();
$assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="body"]');
$this->assertQuickEditEntityToolbar((string) $block_content->label(), 'Body');
$this->assertEntityInstanceFieldStates('block_content', 1, 0, [
'block_content/1/body/en/full' => 'active',
]);
// Wait for CKEditor to load, then verify it has.
$this->assertJsCondition('CKEDITOR.status === "loaded"');
$this->assertEntityInstanceFieldMarkup('block_content', 1, 0, [
'block_content/1/body/en/full' => '.cke_editable_inline',
]);
$this->assertSession()->elementExists('css', '#quickedit-entity-toolbar .quickedit-toolgroup.wysiwyg-main > .cke_chrome .cke_top[role="presentation"] .cke_toolbar[role="toolbar"] .cke_toolgroup[role="presentation"] > .cke_button[title~="Bold"][role="button"]');
}
}

View file

@ -0,0 +1,318 @@
<?php
namespace Drupal\Tests\quickedit\FunctionalJavascript;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use WebDriver\Key;
/**
* Base class for testing the QuickEdit.
*/
class QuickEditJavascriptTestBase extends WebDriverTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['contextual', 'quickedit', 'toolbar'];
/**
* A user with permissions to edit Articles and use Quick Edit.
*
* @var \Drupal\user\UserInterface
*/
protected $contentAuthorUser;
protected static $expectedFieldStateAttributes = [
'inactive' => '.quickedit-field:not(.quickedit-editable):not(.quickedit-candidate):not(.quickedit-highlighted):not(.quickedit-editing):not(.quickedit-changed)',
// A field in 'candidate' state may still have the .quickedit-changed class
// because when its changes were saved to tempstore, it'll still be changed.
// It's just not currently being edited, so that's why it is not in the
// 'changed' state.
'candidate' => '.quickedit-field.quickedit-editable.quickedit-candidate:not(.quickedit-highlighted):not(.quickedit-editing)',
'highlighted' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted:not(.quickedit-editing)',
'activating' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)',
'active' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)',
'changed' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed',
'saving' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed',
];
/**
* Starts in-place editing of the given entity instance.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
* @param int $entity_instance_id
* The entity instance ID. (Instance on the page.)
*/
protected function startQuickEditViaToolbar($entity_type_id, $entity_id, $entity_instance_id) {
$page = $this->getSession()->getPage();
$toolbar_edit_button_selector = '#toolbar-bar .contextual-toolbar-tab button';
$entity_instance_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"][data-quickedit-entity-instance-id="' . $entity_instance_id . '"]';
$contextual_links_trigger_selector = '[data-contextual-id] > .trigger';
// Assert the original page state does not have the toolbar's "Edit" button
// pressed/activated, and hence none of the contextual link triggers should
// be visible.
$toolbar_edit_button = $page->find('css', $toolbar_edit_button_selector);
$this->assertSame('false', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is not yet pressed.');
$this->assertFalse($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is not yet marked as active.');
foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) {
/** @var \Behat\Mink\Element\NodeElement $dom_node */
$this->assertTrue($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is hidden.');
}
$this->assertTrue(TRUE, 'All contextual links triggers are hidden.');
// Click the "Edit" button in the toolbar.
$this->click($toolbar_edit_button_selector);
// Assert the toolbar's "Edit" button is now pressed/activated, and hence
// all of the contextual link triggers should be visible.
$this->assertSame('true', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is pressed.');
$this->assertTrue($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is marked as active.');
foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) {
/** @var \Behat\Mink\Element\NodeElement $dom_node */
$this->assertFalse($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is visible.');
}
$this->assertTrue(TRUE, 'All contextual links triggers are visible.');
// @todo Press tab key to verify that tabbing is now contrained to only
// contextual links triggers: https://www.drupal.org/node/2834776
// Assert that the contextual links associated with the entity's contextual
// links trigger are not visible.
/** @var \Behat\Mink\Element\NodeElement $entity_contextual_links_container */
$entity_contextual_links_container = $page->find('css', $entity_instance_selector)
->find('css', $contextual_links_trigger_selector)
->getParent();
$this->assertFalse($entity_contextual_links_container->hasClass('open'));
$this->assertTrue($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden'));
// Click the contextual link trigger for the entity we want to Quick Edit.
$this->click($entity_instance_selector . ' ' . $contextual_links_trigger_selector);
$this->assertTrue($entity_contextual_links_container->hasClass('open'));
$this->assertFalse($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden'));
// Click the "Quick edit" contextual link.
$this->click($entity_instance_selector . ' [data-contextual-id] ul.contextual-links li.quickedit a');
// Assert the Quick Edit internal state is correct.
$js_condition = <<<JS
Drupal.quickedit.collections.entities.where({isActive: true}).length === 1 && Drupal.quickedit.collections.entities.where({isActive: true})[0].get('entityID') === '$entity_type_id/$entity_id';
JS;
$this->assertJsCondition($js_condition);
}
/**
* Clicks the 'Save' button in the Quick Edit entity toolbar.
*/
protected function saveQuickEdit() {
$quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar');
$save_button = $quickedit_entity_toolbar->find('css', 'button.action-save');
$save_button->press();
$this->assertSame('Saving', $save_button->getText());
}
/**
* Awaits Quick Edit to be initiated for all instances of the given entity.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
*/
protected function awaitQuickEditForEntity($entity_type_id, $entity_id) {
$entity_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"]';
$condition = "document.querySelectorAll('" . $entity_selector . "').length === document.querySelectorAll('" . $entity_selector . " .quickedit').length";
$this->assertJsCondition($condition, 10000);
}
/**
* Awaits a particular field instance to reach a particular state.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
* @param int $entity_instance_id
* The entity instance ID. (Instance on the page.)
* @param string $field_name
* The field name.
* @param string $langcode
* The language code.
* @param string $awaited_state
* One of the possible field states.
*/
protected function awaitEntityInstanceFieldState($entity_type_id, $entity_id, $entity_instance_id, $field_name, $langcode, $awaited_state) {
$entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']';
$logical_field_id = $entity_type_id . '/' . $entity_id . '/' . $field_name . '/' . $langcode;
$this->assertJsCondition("Drupal.quickedit.collections.entities.get('$entity_page_id').get('fields').findWhere({logicalFieldID: '$logical_field_id'}).get('state') === '$awaited_state';");
}
/**
* Asserts the state of the Quick Edit entity toolbar.
*
* @param string $expected_entity_label
* The expected label in the Quick Edit Entity Toolbar.
*/
protected function assertQuickEditEntityToolbar($expected_entity_label, $expected_field_label) {
$quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar');
// We cannot use ->getText() because it also returns the text of all child
// nodes. We also cannot use XPath to select text node in Selenium. So we
// use JS expression to select only the text node.
$this->assertSame($expected_entity_label, $this->getSession()->evaluateScript("return window.jQuery('#quickedit-entity-toolbar .quickedit-toolbar-label').clone().children().remove().end().text();"));
if ($expected_field_label !== NULL) {
$field_label = $quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field');
// Only try to find the text content of the element if it was actually
// found; otherwise use the returned value for assertion. This helps
// us find a more useful stack/error message from testbot instead of the
// trimmed partial exception stack.
if ($field_label) {
$field_label = $field_label->getText();
}
$this->assertSame($expected_field_label, $field_label);
}
else {
$this->assertFalse($quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field'));
}
}
/**
* Asserts all EntityModels (entity instances) on the page.
*
* @param array $expected_entity_states
* Must describe the expected state of all in-place editable entity
* instances on the page.
*
* @see Drupal.quickedit.EntityModel
*/
protected function assertEntityInstanceStates(array $expected_entity_states) {
$js_get_all_field_states_for_entity = <<<JS
function () {
Drupal.quickedit.collections.entities.reduce(function (result, fieldModel) { result[fieldModel.get('id')] = fieldModel.get('state'); return result; }, {})
var entityCollection = Drupal.quickedit.collections.entities;
return entityCollection.reduce(function (result, entityModel) {
result[entityModel.id] = entityModel.get('state');
return result;
}, {});
}()
JS;
$this->assertSame($expected_entity_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity));
}
/**
* Asserts all FieldModels for the given entity instance.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
* @param int $entity_instance_id
* The entity instance ID. (Instance on the page.)
* @param array $expected_field_states
* Must describe the expected state of all in-place editable fields of the
* given entity instance.
*/
protected function assertEntityInstanceFieldStates($entity_type_id, $entity_id, $entity_instance_id, array $expected_field_states) {
// Get all FieldModel states for the entity instance being asserted. This
// ensures that $expected_field_states must describe the state of all fields
// of the entity instance.
$entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']';
$js_get_all_field_states_for_entity = <<<JS
function () {
var entityCollection = Drupal.quickedit.collections.entities;
var entityModel = entityCollection.get('$entity_page_id');
return entityModel.get('fields').reduce(function (result, fieldModel) {
result[fieldModel.get('fieldID')] = fieldModel.get('state');
return result;
}, {});
}()
JS;
$this->assertEquals($expected_field_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity));
// Assert that those fields also have the appropriate DOM decorations.
$expected_field_attributes = [];
foreach ($expected_field_states as $quickedit_field_id => $expected_field_state) {
$expected_field_attributes[$quickedit_field_id] = static::$expectedFieldStateAttributes[$expected_field_state];
}
$this->assertEntityInstanceFieldMarkup($entity_type_id, $entity_id, $entity_instance_id, $expected_field_attributes);
}
/**
* Asserts all in-place editable fields with markup expectations.
*
* @param string $entity_type_id
* The entity type ID.
* @param int $entity_id
* The entity ID.
* @param int $entity_instance_id
* The entity instance ID. (Instance on the page.)
* @param array $expected_field_attributes
* Must describe the expected markup attributes for all given in-place
* editable fields.
*/
protected function assertEntityInstanceFieldMarkup($entity_type_id, $entity_id, $entity_instance_id, array $expected_field_attributes) {
$entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']';
$expected_field_attributes_json = json_encode($expected_field_attributes);
$js_match_field_element_attributes = <<<JS
function () {
var expectations = $expected_field_attributes_json;
var entityCollection = Drupal.quickedit.collections.entities;
var entityModel = entityCollection.get('$entity_page_id');
return entityModel.get('fields').reduce(function (result, fieldModel) {
var fieldID = fieldModel.get('fieldID');
var element = fieldModel.get('el');
var matches = element.webkitMatchesSelector(expectations[fieldID]);
result[fieldID] = matches ? matches : element.outerHTML;
return result;
}, {});
}()
JS;
$result = $this->getSession()->evaluateScript($js_match_field_element_attributes);
foreach ($expected_field_attributes as $quickedit_field_id => $expectation) {
$this->assertSame(TRUE, $result[$quickedit_field_id], 'Field ' . $quickedit_field_id . ' did not match its expectation selector (' . $expectation . '), actual HTML: ' . $result[$quickedit_field_id]);
}
}
/**
* Simulates typing in a 'plain_text' in-place editor.
*
* @param string $css_selector
* The CSS selector to find the DOM element (with the 'contenteditable=true'
* attribute set), to type in.
* @param string $text
* The text to type.
*
* @see \Drupal\quickedit\Plugin\InPlaceEditor\PlainTextEditor
*/
protected function typeInPlainTextEditor($css_selector, $text) {
$field = $this->getSession()->getPage()->find('css', $css_selector);
$field->setValue(Key::END . $text);
}
/**
* Simulates typing in an input[type=text] inside a 'form' in-place editor.
*
* @param string $input_name
* The "name" attribute of the input[type=text] to type in.
* @param string $text
* The text to type.
*
* @see \Drupal\quickedit\Plugin\InPlaceEditor\FormEditor
*/
protected function typeInFormEditorTextInputField($input_name, $text) {
$input = $this->cssSelect('.quickedit-form-container > .quickedit-form[role="dialog"] form.quickedit-field-form input[type=text][name="' . $input_name . '"]')[0];
$input->setValue($text);
$js_simulate_user_typing = <<<JS
function () {
var el = document.querySelector('.quickedit-form-container > .quickedit-form[role="dialog"] form.quickedit-field-form input[name="$input_name"]');
window.jQuery(el).trigger('formUpdated');
}()
JS;
$this->getSession()->evaluateScript($js_simulate_user_typing);
}
}

View file

@ -5,7 +5,7 @@ namespace Drupal\Tests\quickedit\Kernel;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\quickedit\EditorSelector;
use Drupal\quickedit\MetadataGenerator;
use Drupal\quickedit_test\MockEditEntityFieldAccessCheck;
use Drupal\quickedit_test\MockQuickEditEntityFieldAccessCheck;
use Drupal\filter\Entity\FilterFormat;
/**
@ -30,7 +30,7 @@ class MetadataGeneratorTest extends QuickEditTestBase {
/**
* The metadata generator object to be tested.
*
* @var \Drupal\quickedit\MetadataGeneratorInterface.php
* @var \Drupal\quickedit\MetadataGeneratorInterface
*/
protected $metadataGenerator;
@ -44,7 +44,7 @@ class MetadataGeneratorTest extends QuickEditTestBase {
/**
* The access checker object to be used by the metadata generator object.
*
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface
* @var \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheckInterface
*/
protected $accessChecker;
@ -52,7 +52,7 @@ class MetadataGeneratorTest extends QuickEditTestBase {
parent::setUp();
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
$this->accessChecker = new MockEditEntityFieldAccessCheck();
$this->accessChecker = new MockQuickEditEntityFieldAccessCheck();
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
$this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager);
}
@ -169,10 +169,10 @@ class MetadataGeneratorTest extends QuickEditTestBase {
'label' => 'Rich text field',
'editor' => 'wysiwyg',
'custom' => [
'format' => 'full_html'
'format' => 'full_html',
],
];
$this->assertEqual($expected, $metadata); //, 'The correct metadata (including custom metadata) is generated.');
$this->assertEqual($expected, $metadata, 'The correct metadata (including custom metadata) is generated.');
}
}

View file

@ -97,7 +97,7 @@ abstract class QuickEditTestBase extends KernelTestBase {
->setComponent($field_name, [
'label' => 'above',
'type' => $formatter_type,
'settings' => $formatter_settings
'settings' => $formatter_settings,
])
->save();
}

View file

@ -5,21 +5,21 @@ namespace Drupal\Tests\quickedit\Unit\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\DependencyInjection\Container;
use Drupal\quickedit\Access\EditEntityFieldAccessCheck;
use Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck;
use Drupal\Tests\UnitTestCase;
use Drupal\Core\Language\LanguageInterface;
/**
* @coversDefaultClass \Drupal\quickedit\Access\EditEntityFieldAccessCheck
* @coversDefaultClass \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck
* @group Access
* @group quickedit
*/
class EditEntityFieldAccessCheckTest extends UnitTestCase {
class QuickEditEntityFieldAccessCheckTest extends UnitTestCase {
/**
* The tested access checker.
*
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheck
* @var \Drupal\quickedit\Access\QuickEditEntityFieldAccessCheck
*/
protected $editAccessCheck;
@ -27,7 +27,7 @@ class EditEntityFieldAccessCheckTest extends UnitTestCase {
* {@inheritdoc}
*/
protected function setUp() {
$this->editAccessCheck = new EditEntityFieldAccessCheck();
$this->editAccessCheck = new QuickEditEntityFieldAccessCheck();
$cache_contexts_manager = $this->prophesize(CacheContextsManager::class);
$cache_contexts_manager->assertValidTokens()->willReturn(TRUE);
@ -40,7 +40,7 @@ class EditEntityFieldAccessCheckTest extends UnitTestCase {
/**
* Provides test data for testAccess().
*
* @see \Drupal\Tests\edit\Unit\quickedit\Access\EditEntityFieldAccessCheckTest::testAccess()
* @see \Drupal\Tests\edit\Unit\quickedit\Access\QuickEditEntityFieldAccessCheckTest::testAccess()
*/
public function providerTestAccess() {
$data = [];