updated core to 8.6.1 via composer

This commit is contained in:
2018-09-12 13:58:26 +02:00
parent a9a219f2ed
commit ea56b9fba3
4443 changed files with 112098 additions and 40708 deletions
@@ -86,7 +86,6 @@
* Entity toolbar.
*/
.quickedit-toolbar-container {
max-width: 100%;
position: absolute;
max-width: 320px;
width: 320px;
@@ -3,250 +3,277 @@
* Form-based in-place editor. Works for any field type.
*/
(function ($, Drupal, _) {
(function($, Drupal, _) {
/**
* @constructor
*
* @augments Drupal.quickedit.EditorView
*/
Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.form# */{
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,
/**
* 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,
/**
* 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;
/**
* @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 'candidate':
if (from !== 'inactive') {
this.removeForm();
}
break;
case 'highlighted':
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 'activating':
// If coming from an invalid state, then the form is already loaded.
if (from !== 'invalid') {
this.loadForm();
}
break;
case 'active':
break;
case 'active':
break;
case 'changed':
break;
case 'changed':
break;
case 'saving':
this.save();
break;
case 'saving':
this.save();
break;
case 'saved':
break;
case 'saved':
break;
case 'invalid':
this.showValidationErrors();
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,
};
},
/**
* @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;
/**
* 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`,
});
// 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
.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;
}
.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`,
});
// The in-place editor has loaded; change state to 'active'.
fieldModel.set('state', 'active');
});
},
$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;
}
});
/**
* 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;
function cleanUpAjax() {
Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax);
formSaveAjax = null;
}
// 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);
// 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);
// The in-place editor has loaded; change state to 'active'.
fieldModel.set('state', 'active');
});
};
},
// Unsuccessfully saved; validation errors.
formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) {
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
/**
* Removes the form for this field, detaches behaviors and event handlers.
*/
removeForm() {
if (this.$formContainer === null) {
return;
}
// 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`,
});
};
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;
},
// Click the form's submit button; the scoped AJAX commands above will
// handle the server's response.
$submit.trigger('click.quickedit');
/**
* @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'));
},
},
/**
* @inheritdoc
*/
showValidationErrors() {
this.$formContainer
.find('.quickedit-form')
.addClass('quickedit-validation-error')
.find('form')
.prepend(this.model.get('validationErrors'));
},
});
}(jQuery, Drupal, _));
);
})(jQuery, Drupal, _);
@@ -52,7 +52,12 @@
}
},
getQuickEditUISettings: function getQuickEditUISettings() {
return { padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true };
return {
padding: true,
unifiedToolbar: true,
fullWidthToolbar: true,
popup: true
};
},
loadForm: function loadForm() {
var fieldModel = this.fieldModel;
@@ -122,16 +127,16 @@
var editorModel = this.model;
var fieldModel = this.fieldModel;
function cleanUpAjax() {
Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax);
formSaveAjax = null;
}
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;
}
formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) {
cleanUpAjax();
@@ -3,133 +3,138 @@
* 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# */{
(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,
/**
* 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);
/**
* @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;
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()));
// 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');
}
});
},
// 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'));
},
},
/**
* @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));
);
})(jQuery, _, Drupal);
@@ -81,7 +81,12 @@
}
},
getQuickEditUISettings: function getQuickEditUISettings() {
return { padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false };
return {
padding: true,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false
};
},
revert: function revert() {
this.$textElement.html(this.model.get('originalValue'));
@@ -5,49 +5,48 @@
* @see Drupal.quickedit.AppView
*/
(function (Backbone, Drupal) {
(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# */{
Drupal.quickedit.AppModel = Backbone.Model.extend(
/** @lends Drupal.quickedit.AppModel# */ {
/**
* The currently state='highlighted' Drupal.quickedit.FieldModel, if any.
* @type {object}
*
* @type {Drupal.quickedit.FieldModel}
*
* @see Drupal.quickedit.FieldModel.states
* @prop {Drupal.quickedit.FieldModel} highlightedField
* @prop {Drupal.quickedit.FieldModel} activeField
* @prop {Drupal.dialog~dialogDefinition} activeModal
*/
highlightedField: null,
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,
/**
* 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,
/**
* Reference to a {@link Drupal.dialog} instance if a state change
* requires confirmation.
*
* @type {Drupal.dialog~dialogDefinition}
*/
activeModal: null,
},
},
});
}(Backbone, Drupal));
);
})(Backbone, Drupal);
@@ -14,6 +14,5 @@
activeModal: null
}
});
})(Backbone, Drupal);
@@ -3,54 +3,53 @@
* A Backbone Model subclass that enforces validation when calling set().
*/
(function (Drupal, Backbone) {
Drupal.quickedit.BaseModel = Backbone.Model.extend(/** @lends Drupal.quickedit.BaseModel# */{
(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);
},
/**
* @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 = {};
/**
* 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;
}
options.validate = true;
}
}
return Backbone.Model.prototype.set.call(this, key, val, options);
return Backbone.Model.prototype.set.call(this, key, val, options);
},
},
});
}(Drupal, Backbone));
);
})(Drupal, Backbone);
@@ -5,46 +5,45 @@
* @see Drupal.quickedit.EditorView
*/
(function (Backbone, Drupal) {
(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# */{
Drupal.quickedit.EditorModel = Backbone.Model.extend(
/** @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 {object}
*
* @type {string}
* @prop {string} originalValue
* @prop {string} currentValue
* @prop {Array} validationErrors
*/
originalValue: null,
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,
/**
* Analogous to originalValue, but the current value.
*
* @type {string}
*/
currentValue: null,
/**
* Stores any validation errors to be rendered.
*
* @type {Array}
*/
validationErrors: null,
/**
* Stores any validation errors to be rendered.
*
* @type {Array}
*/
validationErrors: null,
},
},
});
}(Backbone, Drupal));
);
})(Backbone, Drupal);
@@ -14,6 +14,5 @@
validationErrors: null
}
});
})(Backbone, Drupal);
File diff suppressed because it is too large Load Diff
@@ -193,7 +193,9 @@
error: function error() {
entityModel.set('isCommitting', false);
entityModel.set('state', 'opened', { reason: 'networkerror' });
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);
@@ -256,9 +258,11 @@
if (!this._acceptStateChange(currentState, nextState, options)) {
return 'state change not accepted';
} else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'state change not accepted because fields are not in acceptable state';
}
}
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
return 'state change not accepted because fields are not in acceptable state';
}
}
var currentIsCommitting = this.get('isCommitting');
+323 -307
View File
@@ -3,335 +3,351 @@
* 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# */{
(function(_, Backbone, Drupal) {
Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(
/** @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.
* @type {object}
*/
el: null,
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,
},
/**
* A field ID, of the form
* `<entity type>/<id>/<field name>/<language>/<view mode>`
* State of an in-place editable field in the DOM.
*
* @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]`.
* @constructs
*
* @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.
* @augments Drupal.quickedit.BaseModel
*
* @example
* "node/1/field_tags/und".
* @param {object} options
* Options for the field model.
*/
logicalFieldID: null,
initialize(options) {
// Store the original full HTML representation of this field.
this.set('html', options.el.outerHTML);
// The attributes below are stateful. The ones above will never change
// during the life of a FieldModel instance.
// 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);
},
/**
* In-place editing state of this field. Defaults to the initial state.
* Possible values: {@link Drupal.quickedit.FieldModel.states}.
* Destroys the field model.
*
* @param {object} options
* Options for the field model.
*/
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`;
destroy(options) {
if (this.get('state') !== 'inactive') {
throw new Error(
'FieldModel cannot be destroyed if it is not inactive state.',
);
}
// Check if the acceptStateChange callback accepts it.
if (!this.get('acceptStateChange')(current, next, options, this)) {
return 'state change not accepted';
}
}
},
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
},
/**
* 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('/');
},
/**
* @inheritdoc
*/
sync() {
// We don't use REST updates to sync.
},
/**
* 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());
/**
* 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`;
}
});
return otherViewModes;
// 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',
],
}, /** @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);
/**
* 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));
Drupal.quickedit.FieldCollection = Backbone.Collection.extend(
/** @lends Drupal.quickedit.FieldCollection */ {
/**
* @type {Drupal.quickedit.FieldModel}
*/
model: Drupal.quickedit.FieldModel,
},
);
})(_, Backbone, Drupal);
+476 -392
View File
@@ -17,8 +17,9 @@
* 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,
(function($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
const options = $.extend(
drupalSettings.quickedit,
// Merge strings on top of drupalSettings so that they are not mutable.
{
strings: {
@@ -60,249 +61,6 @@
*/
const entityInstancesTracker = {};
/**
*
* @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);
}
}
});
/**
* 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.
*
@@ -334,77 +92,16 @@
const entityID = entityElement.getAttribute('data-quickedit-entity-id');
if (!entityInstancesTracker.hasOwnProperty(entityID)) {
entityInstancesTracker[entityID] = 0;
}
else {
} else {
entityInstancesTracker[entityID]++;
}
// Set the calculated entity instance ID for this element.
const 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) {
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 });
}
entityElement.setAttribute(
'data-quickedit-entity-instance-id',
entityInstanceID,
);
}
/**
@@ -434,51 +131,16 @@
id: `${fieldID}[${entity.get('entityInstanceID')}]`,
entity,
metadata: Drupal.quickedit.metadata.get(fieldID),
acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app),
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) {
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);
},
});
}
}
/**
* Loads missing in-place editor's attachments (JavaScript and CSS files).
*
@@ -492,7 +154,7 @@
function loadMissingEditors(callback) {
const loadedEditors = _.keys(Drupal.quickedit.editors);
let missingEditors = [];
Drupal.quickedit.collections.fields.each((fieldModel) => {
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);
@@ -519,7 +181,7 @@
// 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) {
loadEditorsAjax.commands.insert = function(ajax, response, status) {
_.defer(callback);
realInsert(ajax, response, status);
};
@@ -580,7 +242,7 @@
// 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)) {
if (hasFieldWithPermission(fieldIDs)) {
const entityModel = new Drupal.quickedit.EntityModel({
el: contextualLink.region,
entityID: contextualLink.entityID,
@@ -598,8 +260,13 @@
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);
_.each(fields, field => {
initializeField(
field.el,
field.fieldID,
contextualLink.entityID,
contextualLink.entityInstanceID,
);
});
fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
@@ -607,11 +274,18 @@
// 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));
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);
});
@@ -622,13 +296,108 @@
}
// 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)) {
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.
*
@@ -647,40 +416,355 @@
* 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();
}
$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;
}
// Filter queue.
function hasOtherRegion(contextualLink) {
return contextualLink.region !== entityElement;
}
contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
});
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');
$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;
}
// Filter queues.
function hasOtherFieldElement(field) {
return field.el !== fieldElement;
}
fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
});
fieldsMetadataQueue = _.filter(
fieldsMetadataQueue,
hasOtherFieldElement,
);
fieldsAvailableQueue = _.filter(
fieldsAvailableQueue,
hasOtherFieldElement,
);
});
}
}(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage));
/**
* 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,
);
+261 -247
View File
@@ -20,6 +20,267 @@
var entityInstancesTracker = {};
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 attach(context) {
$('body').once('quickedit-init').each(initQuickEdit);
@@ -124,251 +385,4 @@
}
}
});
function extractEntityID(fieldID) {
return fieldID.split('/').slice(0, 2).join('/');
}
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 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 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 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);
}
});
}
}
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;
} 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);
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;
} else if (allMetadataExists(fieldIDs)) {
return true;
}
return false;
}
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);
});
}
})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
+26 -18
View File
@@ -3,7 +3,7 @@
* Provides theme functions for all of Quick Edit's client-side HTML.
*/
(function ($, Drupal) {
(function($, Drupal) {
/**
* Theme function for a "backstage" for the Quick Edit module.
*
@@ -15,7 +15,7 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditBackstage = function (settings) {
Drupal.theme.quickeditBackstage = function(settings) {
let html = '';
html += `<div id="${settings.id}" />`;
return html;
@@ -32,15 +32,19 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbar = function (settings) {
Drupal.theme.quickeditEntityToolbar = function(settings) {
let html = '';
html += `<div id="${settings.id}" class="quickedit quickedit-toolbar-container clearfix">`;
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 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 class="quickedit-toolbar quickedit-toolbar-field clearfix" />';
html += '</div><div class="quickedit-toolbar-lining"></div></div>';
return html;
};
@@ -58,9 +62,11 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarLabel = function (settings) {
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)}`;
return `<span class="field">${Drupal.checkPlain(
settings.fieldLabel,
)}</span>${Drupal.checkPlain(settings.entityLabel)}`;
};
/**
@@ -69,7 +75,7 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditEntityToolbarFence = function () {
Drupal.theme.quickeditEntityToolbarFence = function() {
return '<div id="quickedit-toolbar-fence" />';
};
@@ -84,7 +90,7 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFieldToolbar = function (settings) {
Drupal.theme.quickeditFieldToolbar = function(settings) {
return `<div id="${settings.id}" />`;
};
@@ -103,9 +109,9 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditToolgroup = function (settings) {
Drupal.theme.quickeditToolgroup = function(settings) {
// Classes.
const classes = (settings.classes || []);
const classes = settings.classes || [];
classes.unshift('quickedit-toolgroup');
let html = '';
html += `<div class="${classes.join(' ')}"`;
@@ -134,7 +140,7 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditButtons = function (settings) {
Drupal.theme.quickeditButtons = function(settings) {
let html = '';
for (let i = 0; i < settings.buttons.length; i++) {
const button = settings.buttons[i];
@@ -144,10 +150,12 @@
// Attributes.
const attributes = [];
const attrMap = settings.buttons[i].attributes || {};
Object.keys(attrMap).forEach((attr) => {
attributes.push(attr + ((attrMap[attr]) ? `="${attrMap[attr]}"` : ''));
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>`;
html += `<button type="${button.type}" class="${
button.classes
}" ${attributes.join(' ')}>${button.label}</button>`;
}
return html;
};
@@ -165,7 +173,7 @@
* @return {string}
* The corresponding HTML.
*/
Drupal.theme.quickeditFormContainer = function (settings) {
Drupal.theme.quickeditFormContainer = function(settings) {
let html = '';
html += `<div id="${settings.id}" class="quickedit-form-container">`;
html += ' <div class="quickedit-form">';
@@ -176,4 +184,4 @@
html += '</div>';
return html;
};
}(jQuery, Drupal));
})(jQuery, Drupal);
+30 -13
View File
@@ -3,7 +3,7 @@
* Provides utility functions for Quick Edit.
*/
(function ($, Drupal) {
(function($, Drupal) {
/**
* @namespace
*/
@@ -18,7 +18,8 @@
*
* @type {string}
*/
Drupal.quickedit.util.constants.transitionEnd = 'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit';
Drupal.quickedit.util.constants.transitionEnd =
'transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit';
/**
* Converts a field id into a formatted url path.
@@ -37,7 +38,7 @@
* @return {string}
* The formatted URL.
*/
Drupal.quickedit.util.buildUrl = function (id, urlFormat) {
Drupal.quickedit.util.buildUrl = function(id, urlFormat) {
const parts = id.split('/');
return Drupal.formatString(decodeURIComponent(urlFormat), {
'!entity_type': parts[0],
@@ -56,7 +57,7 @@
* @param {string} message
* The message to use in the modal dialog.
*/
Drupal.quickedit.util.networkErrorModal = function (title, message) {
Drupal.quickedit.util.networkErrorModal = function(title, message) {
const $message = $(`<div>${message}</div>`);
const networkErrorModal = Drupal.dialog($message.get(0), {
title,
@@ -71,7 +72,10 @@
},
],
create() {
$(this).parent().find('.ui-dialog-titlebar-close').remove();
$(this)
.parent()
.find('.ui-dialog-titlebar-close')
.remove();
},
close(event) {
// Automatically destroy the DOM element that was used for the dialog.
@@ -85,7 +89,6 @@
* @namespace
*/
Drupal.quickedit.util.form = {
/**
* Loads a form, calls a callback to insert.
*
@@ -113,7 +116,12 @@
// 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')),
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,
@@ -121,8 +129,14 @@
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);
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.
@@ -131,7 +145,11 @@
},
});
// Implement a scoped quickeditFieldForm AJAX command: calls the callback.
formLoaderAjax.commands.quickeditFieldForm = function (ajax, response, status) {
formLoaderAjax.commands.quickeditFieldForm = function(
ajax,
response,
status,
) {
callback(response.data, ajax);
Drupal.ajax.instances[this.instanceIndex] = null;
};
@@ -181,7 +199,7 @@
* The HTTP status code.
*/
success(response, status) {
Object.keys(response || {}).forEach((i) => {
Object.keys(response || {}).forEach(i => {
if (response[i].command && this.commands[response[i].command]) {
this.commands[response[i].command](this, response[i], status);
}
@@ -204,6 +222,5 @@
unajaxifySaving(ajax) {
$(ajax.element).off('click.quickedit');
},
};
}(jQuery, Drupal));
})(jQuery, Drupal);
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -63,7 +63,7 @@
if (reload) {
reload = false;
location.reload();
window.location.reload();
}
break;
}
@@ -287,12 +287,16 @@
if (field === updatedField) {} else if (field.getViewMode() === updatedField.getViewMode()) {
field.set('html', updatedField.get('html'));
} else if (field.getViewMode() in htmlForOtherViewModes) {
field.set('html', htmlForOtherViewModes[field.getViewMode()], { propagation: true });
field.set('html', htmlForOtherViewModes[field.getViewMode()], {
propagation: true
});
}
});
},
rerenderedFieldToCandidate: function rerenderedFieldToCandidate(fieldModel) {
var activeEntity = Drupal.quickedit.collections.entities.findWhere({ isActive: true });
var activeEntity = Drupal.quickedit.collections.entities.findWhere({
isActive: true
});
if (!activeEntity) {
return;
@@ -3,75 +3,75 @@
* 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) {
(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();
this.model.set('state', 'launching');
},
'touchEnd a': touchEndToClick,
};
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;
},
},
/**
* 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));
);
})(jQuery, Backbone, Drupal);
+304 -280
View File
@@ -3,299 +3,323 @@
* An abstract Backbone View that controls an in-place editor.
*/
(function ($, Backbone, Drupal) {
Drupal.quickedit.EditorView = Backbone.View.extend(/** @lends Drupal.quickedit.EditorView# */{
(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);
},
/**
* 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);
},
/**
* @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;
},
/**
* 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,
};
},
/**
*
* @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;
/**
* 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 '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();
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;
}
break;
case 'highlighted':
// Nothing to do for the typical in-place editor: it should not be
// visible yet.
break;
case 'active':
// The user can now actually use the in-place editor.
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();
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);
};
loadDependencies(() => {
fieldModel.set('state', 'active');
});
break;
}
case 'active':
// The user can now actually use the in-place editor.
break;
// Unsuccessfully saved; validation errors.
self.formSaveAjax.commands.quickeditFieldFormValidationErrors = function(
ajax,
response,
status,
) {
removeHiddenForm();
editorModel.set('validationErrors', response.data);
fieldModel.set('state', 'invalid');
};
case 'changed':
// Nothing to do for the typical in-place editor. The UI will show an
// indicator that the field has changed.
break;
// 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() {};
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;
fillAndSubmitForm(editorModel.get('currentValue'));
});
},
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;
/**
* 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);
},
case 'invalid':
// The modified field value was attempted to be saved, but there were
// validation errors.
this.showValidationErrors();
break;
}
/**
* 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();
},
},
/**
* 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));
);
})(jQuery, Backbone, Drupal);
@@ -19,7 +19,12 @@
return this.$el;
},
getQuickEditUISettings: function getQuickEditUISettings() {
return { padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false };
return {
padding: false,
unifiedToolbar: false,
fullWidthToolbar: false,
popup: false
};
},
stateChange: function stateChange(fieldModel, state) {
var from = fieldModel.previous('state');
@@ -3,34 +3,37 @@
* A Backbone view that decorates the in-place editable entity.
*/
(function (Drupal, $, Backbone) {
Drupal.quickedit.EntityDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityDecorationView# */{
(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);
},
/**
* 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);
},
},
/**
* @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));
);
})(Drupal, jQuery, Backbone);
File diff suppressed because it is too large Load Diff
@@ -3,293 +3,299 @@
* A Backbone View that decorates the in-place edited element.
*/
(function ($, Backbone, Drupal) {
Drupal.quickedit.FieldDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldDecorationView# */{
(function($, Backbone, Drupal) {
Drupal.quickedit.FieldDecorationView = Backbone.View.extend(
/** @lends Drupal.quickedit.FieldDecorationView# */ {
/**
* @type {null}
*/
_widthAttributeIsEmpty: null,
/**
* @type {null}
*/
_widthAttributeIsEmpty: null,
/**
* @type {object}
*/
events: {
'mouseenter.quickedit': 'onMouseEnter',
'mouseleave.quickedit': 'onMouseLeave',
click: 'onClick',
'tabIn.quickedit': 'onMouseEnter',
'tabOut.quickedit': 'onMouseLeave',
},
/**
* @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;
/**
* @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,
);
},
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);
},
/**
* @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;
/**
* 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();
case 'candidate':
this.decorate();
if (from !== 'inactive') {
this.stopHighlight();
if (from !== 'highlighted') {
this.model.set('isChanged', false);
this.stopEdit();
}
}
}
this._unpad();
break;
this._unpad();
break;
case 'highlighted':
this.startHighlight();
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') {
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();
}
if (this.editorView.getQuickEditUISettings().padding) {
this._pad();
}
break;
break;
case 'changed':
this.model.set('isChanged', true);
break;
case 'active':
if (from !== 'activating') {
this.prepareEdit();
}
if (this.editorView.getQuickEditUISettings().padding) {
this._pad();
}
break;
case 'saving':
break;
case 'changed':
this.model.set('isChanged', true);
break;
case 'saved':
break;
case 'saving':
break;
case 'invalid':
break;
}
},
case 'saved':
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'));
},
case 'invalid':
break;
}
},
/**
* 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();
},
/**
* 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'),
);
},
/**
* 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();
},
/**
* 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();
},
/**
* Transition to 'activating' stage.
*
* @param {jQuery.Event} event
* The click event.
*/
onClick(event) {
this.model.set('state', 'activating');
event.preventDefault();
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();
},
/**
* Adds classes used to indicate an elements editable state.
*/
decorate() {
this.$el.addClass('quickedit-candidate quickedit-editable');
},
/**
* Transition to 'activating' stage.
*
* @param {jQuery.Event} event
* The click event.
*/
onClick(event) {
this.model.set('state', 'activating');
event.preventDefault();
event.stopPropagation();
},
/**
* Removes classes used to indicate an elements editable state.
*/
undecorate() {
this.$el.removeClass('quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing');
},
/**
* Adds classes used to indicate an elements editable state.
*/
decorate() {
this.$el.addClass('quickedit-candidate quickedit-editable');
},
/**
* 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 classes used to indicate an elements editable state.
*/
undecorate() {
this.$el.removeClass(
'quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing',
);
},
/**
* Removes the class that indicates that an element is highlighted.
*/
stopHighlight() {
this.$el.removeClass('quickedit-highlighted');
},
/**
* 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 as editable.
*/
prepareEdit() {
this.$el.addClass('quickedit-editing');
/**
* Removes the class that indicates that an element is highlighted.
*/
stopHighlight() {
this.$el.removeClass('quickedit-highlighted');
},
// 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 as editable.
*/
prepareEdit() {
this.$el.addClass('quickedit-editing');
/**
* 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');
// 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');
}
},
// Done editing in a pop-up in-place editor; remove the class.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.removeClass('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');
// Make the other editors show up again.
$('.quickedit-candidate').addClass('quickedit-editable');
},
// Done editing in a pop-up in-place editor; remove the class.
if (this.editorView.getQuickEditUISettings().popup) {
this.$el.removeClass('quickedit-editor-is-popup');
}
/**
* 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;
// Make the other editors show up again.
$('.quickedit-candidate').addClass('quickedit-editable');
},
// 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());
}
/**
* 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;
// 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');
// 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());
}
// 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);
},
// 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');
/**
* 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;
// 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);
},
// 1) Set the empty width again.
if (this._widthAttributeIsEmpty) {
this.$el
.addClass('quickedit-animate-disable-width')
.css('width', '');
}
/**
* 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;
// 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');
// 1) Set the empty width again.
if (this._widthAttributeIsEmpty) {
this.$el.addClass('quickedit-animate-disable-width').css('width', '');
}
// Unpad the editable.
self.$el
.css({
// 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`,
@@ -299,58 +305,64 @@
'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');
}, 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;
},
},
/**
* 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));
);
})(jQuery, Backbone, Drupal);
@@ -3,221 +3,242 @@
* 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;
(function($, _, Backbone, Drupal) {
Drupal.quickedit.FieldToolbarView = Backbone.View.extend(
/** @lends Drupal.quickedit.FieldToolbarView# */ {
/**
* The edited element, as indicated by EditorView.getEditedElement.
*
* @type {jQuery}
*/
this.$root = this.$el;
$editedElement: null,
// Generate a DOM-compatible ID for the form container DOM element.
this._id = `quickedit-toolbar-for-${this.model.id.replace(/[/[\]]/g, '_')}`;
/**
* A reference to the in-place editor.
*
* @type {Drupal.quickedit.EditorView}
*/
editorView: null,
this.listenTo(this.model, 'change:state', this.stateChange);
/**
* @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);
},
},
/**
* @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));
);
})(jQuery, _, Backbone, Drupal);
+3 -3
View File
@@ -5,6 +5,6 @@ package: Core
core: 8.x
version: VERSION
dependencies:
- contextual
- field
- filter
- drupal:contextual
- drupal:field
- drupal:filter
@@ -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;
@@ -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.
@@ -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;
@@ -87,7 +86,7 @@ class QuickEditLoadingTest extends WebTestBase {
0 => [
'value' => '<p>How are you?</p>',
'format' => 'filtered_html',
]
],
],
'revision_log' => $this->randomString(),
]);
@@ -188,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
@@ -216,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.
@@ -286,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);
@@ -374,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
@@ -389,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
@@ -602,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.');
}
}
@@ -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);
}
}
@@ -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\field\Tests\EntityReference\EntityReferenceTestTrait;
use Drupal\filter\Entity\FilterFormat;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
/**
* @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"]');
}
}
@@ -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);
}
}
@@ -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;
@@ -169,7 +169,7 @@ 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.');
@@ -97,7 +97,7 @@ abstract class QuickEditTestBase extends KernelTestBase {
->setComponent($field_name, [
'label' => 'above',
'type' => $formatter_type,
'settings' => $formatter_settings
'settings' => $formatter_settings,
])
->save();
}