Drupal 8.0.0 beta 12. More info: https://www.drupal.org/node/2514176
This commit is contained in:
commit
9921556621
13277 changed files with 1459781 additions and 0 deletions
74
core/modules/quickedit/css/quickedit.icons.theme.css
Normal file
74
core/modules/quickedit/css/quickedit.icons.theme.css
Normal file
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* @file
|
||||
* Icons for Quick Edit module.
|
||||
*/
|
||||
|
||||
.quickedit .icon {
|
||||
min-height: 1em;
|
||||
min-width: 2.5em;
|
||||
position: relative;
|
||||
}
|
||||
.quickedit .icon.icon-only {
|
||||
text-indent: -9999px;
|
||||
}
|
||||
.quickedit .icon.icon-end {
|
||||
padding-right: 2.5em; /* LTR */
|
||||
}
|
||||
[dir="rtl"] .quickedit .icon.icon-end {
|
||||
padding-left: 2.5em;
|
||||
padding-right: 0;
|
||||
}
|
||||
.quickedit .icon:before {
|
||||
background-attachment: scroll;
|
||||
background-color: transparent;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
left: 0; /* LTR */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
[dir="rtl"] .quickedit .icon:before {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.quickedit .icon-end:before {
|
||||
left: auto; /* LTR */
|
||||
right: 0.5em; /* LTR */
|
||||
width: 18px;
|
||||
}
|
||||
[dir="rtl"] .quickedit .icon-end:before {
|
||||
left: 0.5em;
|
||||
right: auto;
|
||||
}
|
||||
.quickedit button.icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
.quickedit .icon-pencil {
|
||||
margin-left: .5em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
/**
|
||||
* Images.
|
||||
*/
|
||||
.quickedit .icon-close:before {
|
||||
background-image: url(../../../misc/icons/787878/ex.svg);
|
||||
height: 12px;
|
||||
top: 10px;
|
||||
}
|
||||
.quickedit .icon-close:hover:before,
|
||||
.quickedit .icon-close:active:before {
|
||||
background-image: url(../../../misc/icons/000000/ex.svg);
|
||||
}
|
||||
.quickedit .icon-throbber:before {
|
||||
background-image: url(../images/icon-throbber.gif);
|
||||
}
|
||||
.quickedit .icon-pencil:before {
|
||||
background-image: url(../../../misc/icons/5181c6/pencil.svg);
|
||||
background-position: left center;
|
||||
background-size: 1.3em;
|
||||
}
|
123
core/modules/quickedit/css/quickedit.module.css
Normal file
123
core/modules/quickedit/css/quickedit.module.css
Normal file
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file
|
||||
* Generic base styles for Quick Edit module.
|
||||
*
|
||||
* Note: every class is prefixed with "quickedit-" to prevent collisions with
|
||||
* modules or themes. In Edit module-specific DOM subtrees, this is not
|
||||
* necessary.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Editable.
|
||||
*/
|
||||
.quickedit-editable {
|
||||
z-index: 98;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.quickedit-editable:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlighted (hovered) editable.
|
||||
*/
|
||||
.quickedit-editable.quickedit-highlighted {
|
||||
z-index: 99;
|
||||
}
|
||||
.quickedit-validation-errors > .messages {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
.quickedit-validation-errors > .messages > ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place editors that don't use a popup.
|
||||
*/
|
||||
.quickedit-validation-errors {
|
||||
z-index: 300;
|
||||
position: relative;
|
||||
}
|
||||
.quickedit-validation-errors .messages.error {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -5px; /* LTR */
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
[dir="rtl"] .quickedit-validation-errors .messages.error {
|
||||
left: auto;
|
||||
right: -5px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling specific to the 'form' in-place editor.
|
||||
*/
|
||||
#quickedit_backstage {
|
||||
display: none;
|
||||
}
|
||||
.quickedit-form {
|
||||
position: absolute;
|
||||
z-index: 300;
|
||||
max-width: 35em;
|
||||
}
|
||||
.quickedit-form .placeholder {
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default form styling overrides.
|
||||
*/
|
||||
.quickedit-form .form-wrapper .form-wrapper {
|
||||
margin: inherit;
|
||||
}
|
||||
.quickedit-form .form-actions {
|
||||
display: none;
|
||||
}
|
||||
.quickedit-form input {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity toolbar.
|
||||
*/
|
||||
.quickedit-toolbar-container {
|
||||
max-width: 100%;
|
||||
position: absolute;
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
z-index: 100;
|
||||
}
|
||||
.quickedit-toolbar-container > .quickedit-toolbar-pointer,
|
||||
.quickedit-toolbar-container > .quickedit-toolbar-lining {
|
||||
display: none;
|
||||
}
|
||||
.quickedit-form-container {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
vertical-align: baseline;
|
||||
z-index: 100;
|
||||
}
|
||||
.quickedit-toolgroup.ops {
|
||||
float: right; /* LTR */
|
||||
}
|
||||
[dir="rtl"] .quickedit-toolgroup.ops {
|
||||
float: left;
|
||||
}
|
||||
.quickedit-toolbar-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
#quickedit-toolbar-fence {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
z-index: -1;
|
||||
}
|
254
core/modules/quickedit/css/quickedit.theme.css
Normal file
254
core/modules/quickedit/css/quickedit.theme.css
Normal file
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* @file
|
||||
* Styling for Quick Edit module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Editable.
|
||||
*/
|
||||
.quickedit-field.quickedit-editable,
|
||||
.quickedit-field .quickedit-editable {
|
||||
box-shadow: 0 0 0 2px #74b7ff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlighted (hovered) editable.
|
||||
*/
|
||||
.quickedit-field.quickedit-highlighted,
|
||||
.quickedit-form.quickedit-highlighted,
|
||||
.quickedit-field .quickedit-highlighted {
|
||||
box-shadow: 0 0 0 1px #74b7ff, 0 0 0 2px #007fff;
|
||||
}
|
||||
.quickedit-field.quickedit-changed,
|
||||
.quickedit-form.quickedit-changed,
|
||||
.quickedit-field .quickedit-changed {
|
||||
box-shadow: 0 0 0 1px #fec17e, 0 0 0 2px #f7870a;
|
||||
}
|
||||
.quickedit-editing.quickedit-validation-error,
|
||||
.quickedit-form.quickedit-validation-error {
|
||||
box-shadow: 0 0 0px 1px #ee8b74, 0 0 0 2px #fa2209;
|
||||
}
|
||||
.quickedit-editing.quickedit-editor-is-popup {
|
||||
box-shadow: none;
|
||||
}
|
||||
.quickedit-form .form-item .error {
|
||||
border: 1px solid #eea0a0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default form styling overrides.
|
||||
*/
|
||||
.quickedit-form form {
|
||||
padding: 0.5em;
|
||||
}
|
||||
.quickedit-form .form-item {
|
||||
margin: 0;
|
||||
}
|
||||
.quickedit-form .form-wrapper {
|
||||
margin: .5em;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animations.
|
||||
*/
|
||||
.quickedit-animate-invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
.quickedit-animate-default {
|
||||
-webkit-transition: all .4s ease;
|
||||
transition: all .4s ease;
|
||||
}
|
||||
.quickedit-animate-slow {
|
||||
-webkit-transition: all .6s ease;
|
||||
transition: all .6s ease;
|
||||
}
|
||||
.quickedit-animate-delay-veryfast {
|
||||
-webkit-transition-delay: .05s;
|
||||
transition-delay: .05s;
|
||||
}
|
||||
.quickedit-animate-delay-fast {
|
||||
-webkit-transition-delay: .2s;
|
||||
transition-delay: .2s;
|
||||
}
|
||||
.quickedit-animate-disable-width {
|
||||
-webkit-transition: width 0s;
|
||||
transition: width 0s;
|
||||
}
|
||||
.quickedit-animate-only-visibility {
|
||||
-webkit-transition: opacity .2s ease;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place editors that don't use a popup.
|
||||
*/
|
||||
.quickedit-validation-errors .messages.error {
|
||||
box-shadow: 0 0 1px 1px red, 0 0 3px 3px rgba(153, 153, 153, .5);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling specific to the 'form' in-place editor.
|
||||
*/
|
||||
.quickedit-form {
|
||||
box-shadow: 0 0 30px 4px #4f4f4f;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbars.
|
||||
*/
|
||||
.quickedit-toolbar-container {
|
||||
font-family: 'Source Sans Pro','Lucida Grande', sans-serif;
|
||||
padding-bottom: 7px;
|
||||
padding-top: 7px;
|
||||
-webkit-transition: all 1s;
|
||||
transition: all 1s;
|
||||
}
|
||||
.quickedit-toolbar-container > .quickedit-toolbar-content {
|
||||
background-image: -webkit-linear-gradient(top, #fff, #e4e4e4);
|
||||
background-image: linear-gradient(to bottom, #fff, #e4e4e4);
|
||||
box-sizing: border-box;
|
||||
color: black;
|
||||
padding: 0.1667em;
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.quickedit-toolbar-container > .quickedit-toolbar-pointer {
|
||||
background-color: #e4e4e4;
|
||||
bottom: 2px;
|
||||
box-shadow: 0 0 0 1px #818181, 0px 0px 0 4px rgba(150, 150, 150, 0.5);
|
||||
display: block;
|
||||
height: 16px;
|
||||
left: 18px; /* LTR */
|
||||
position: absolute;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
width: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
[dir="rtl"] .quickedit-toolbar-container > .quickedit-toolbar-pointer {
|
||||
left: auto;
|
||||
right: 18px;
|
||||
}
|
||||
.quickedit-toolbar-container.quickedit-toolbar-pointer-top > .quickedit-toolbar-pointer {
|
||||
bottom: auto;
|
||||
top: 2px;
|
||||
}
|
||||
.quickedit-toolbar-container > .quickedit-toolbar-lining {
|
||||
bottom: 7px;
|
||||
box-shadow: 0 0 0 1px #818181, 0px 3px 0px 1px rgba(150, 150, 150, 0.5);
|
||||
display: block;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 7px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.quickedit-toolbar-label {
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
padding: 0.333em 0.4em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.quickedit-toolbar-label .field:after {
|
||||
content: ' → '; /* LTR */
|
||||
}
|
||||
|
||||
[dir="rtl"] .quickedit-toolbar-label .field:after {
|
||||
content: ' ← ';
|
||||
}
|
||||
|
||||
/* The toolbar; these are not necessarily visible. */
|
||||
.quickedit-toolbar {
|
||||
font-family: 'Droid sans', 'Lucida Grande', sans-serif;
|
||||
}
|
||||
.quickedit-toolbar-entity {
|
||||
padding: 0.1667em 0.2em;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info toolgroup.
|
||||
*/
|
||||
.quickedit-toolbar-fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
.quickedit-toolgroup.wysiwyg-floated {
|
||||
float: right; /* LTR */
|
||||
}
|
||||
[dir="rtl"] .quickedit-toolgroup.wysiwyg-floated {
|
||||
float: left;
|
||||
}
|
||||
.quickedit-toolgroup.wysiwyg-main {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
padding-left: 0; /* LTR */
|
||||
}
|
||||
[dir="rtl"] .quickedit-toolgroup.wysiwyg-main {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons.
|
||||
*/
|
||||
.quickedit-button {
|
||||
background-color: #e4e4e4;
|
||||
border: 1px solid #d2d2d2;
|
||||
color: #5a5a5a;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
padding: 0.345em;
|
||||
-webkit-transition: opacity .1s ease;
|
||||
transition: opacity .1s ease;
|
||||
}
|
||||
.quickedit-button[aria-hidden="true"] {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
.quickedit-button + .quickedit-button {
|
||||
margin-left: 0.2em; /* LTR */
|
||||
}
|
||||
[dir="rtl"] .quickedit-button + .quickedit-button {
|
||||
margin-left: auto;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
/* Button with icons. */
|
||||
.quickedit-button:hover,
|
||||
.quickedit-button:active {
|
||||
background-color: #c8c8c8;
|
||||
border: 1px solid #a0a0a0;
|
||||
color: #2e2e2e;
|
||||
}
|
||||
.quickedit-toolbar-container .quickedit-button.action-cancel {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.quickedit-button.action-save {
|
||||
color: white;
|
||||
background-color: #50a0e9;
|
||||
background-image: -webkit-linear-gradient(top, #50a0e9, #4481dc);
|
||||
background-image: linear-gradient(to bottom, #50a0e9, #4481dc);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.quickedit-button.action-save:hover,
|
||||
.quickedit-button.action-save:active {
|
||||
border: 1px solid #a0a0a0;
|
||||
}
|
||||
.quickedit-button.action-saving,
|
||||
.quickedit-button.action-saving:hover,
|
||||
.quickedit-button.action-saving:active {
|
||||
background-color: #e4e4e4;
|
||||
background-image: none;
|
||||
border-color: #d2d2d2;
|
||||
color: #5a5a5a;
|
||||
}
|
BIN
core/modules/quickedit/images/icon-throbber.gif
Normal file
BIN
core/modules/quickedit/images/icon-throbber.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
253
core/modules/quickedit/js/editors/formEditor.js
Normal file
253
core/modules/quickedit/js/editors/formEditor.js
Normal file
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* @file
|
||||
* Form-based in-place editor. Works for any field type.
|
||||
*/
|
||||
|
||||
(function ($, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*
|
||||
* @augments Drupal.quickedit.EditorView
|
||||
*/
|
||||
Drupal.quickedit.editors.form = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.form# */{
|
||||
|
||||
/**
|
||||
* Tracks form container DOM element that is used while in-place editing.
|
||||
*
|
||||
* @type {jQuery}
|
||||
*/
|
||||
$formContainer: null,
|
||||
|
||||
/**
|
||||
* Holds the {@link Drupal.Ajax} object.
|
||||
*
|
||||
* @type {Drupal.Ajax}
|
||||
*/
|
||||
formSaveAjax: null,
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* @param {object} fieldModel
|
||||
* @param {string} state
|
||||
*/
|
||||
stateChange: function (fieldModel, state) {
|
||||
var from = fieldModel.previous('state');
|
||||
var to = state;
|
||||
switch (to) {
|
||||
case 'inactive':
|
||||
break;
|
||||
|
||||
case 'candidate':
|
||||
if (from !== 'inactive') {
|
||||
this.removeForm();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'highlighted':
|
||||
break;
|
||||
|
||||
case 'activating':
|
||||
// If coming from an invalid state, then the form is already loaded.
|
||||
if (from !== 'invalid') {
|
||||
this.loadForm();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'active':
|
||||
break;
|
||||
|
||||
case 'changed':
|
||||
break;
|
||||
|
||||
case 'saving':
|
||||
this.save();
|
||||
break;
|
||||
|
||||
case 'saved':
|
||||
break;
|
||||
|
||||
case 'invalid':
|
||||
this.showValidationErrors();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
getQuickEditUISettings: function () {
|
||||
return {padding: true, unifiedToolbar: true, fullWidthToolbar: true, popup: true};
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the form for this field, displays it on top of the actual field.
|
||||
*/
|
||||
loadForm: function () {
|
||||
var fieldModel = this.fieldModel;
|
||||
|
||||
// Generate a DOM-compatible ID for the form container DOM element.
|
||||
var id = 'quickedit-form-for-' + fieldModel.id.replace(/[\/\[\]]/g, '_');
|
||||
|
||||
// Render form container.
|
||||
var $formContainer = this.$formContainer = $(Drupal.theme('quickeditFormContainer', {
|
||||
id: id,
|
||||
loadingMsg: Drupal.t('Loading…')
|
||||
}
|
||||
));
|
||||
$formContainer
|
||||
.find('.quickedit-form')
|
||||
.addClass('quickedit-editable quickedit-highlighted quickedit-editing')
|
||||
.attr('role', 'dialog');
|
||||
|
||||
// Insert form container in DOM.
|
||||
if (this.$el.css('display') === 'inline') {
|
||||
$formContainer.prependTo(this.$el.offsetParent());
|
||||
// Position the form container to render on top of the field's element.
|
||||
var pos = this.$el.position();
|
||||
$formContainer.css('left', pos.left).css('top', pos.top);
|
||||
}
|
||||
else {
|
||||
$formContainer.insertBefore(this.$el);
|
||||
}
|
||||
|
||||
// Load form, insert it into the form container and attach event handlers.
|
||||
var formOptions = {
|
||||
fieldID: fieldModel.get('fieldID'),
|
||||
$el: this.$el,
|
||||
nocssjs: false,
|
||||
// Reset an existing entry for this entity in the PrivateTempStore (if
|
||||
// any) when loading the field. Logically speaking, this should happen
|
||||
// in a separate request because this is an entity-level operation, not
|
||||
// a field-level operation. But that would require an additional
|
||||
// request, that might not even be necessary: it is only when a user
|
||||
// loads a first changed field for an entity that this needs to happen:
|
||||
// precisely now!
|
||||
reset: !fieldModel.get('entity').get('inTempStore')
|
||||
};
|
||||
Drupal.quickedit.util.form.load(formOptions, function (form, ajax) {
|
||||
Drupal.AjaxCommands.prototype.insert(ajax, {
|
||||
data: form,
|
||||
selector: '#' + id + ' .placeholder'
|
||||
});
|
||||
|
||||
$formContainer
|
||||
.on('formUpdated.quickedit', ':input', function (event) {
|
||||
var state = fieldModel.get('state');
|
||||
// If the form is in an invalid state, it will persist on the page.
|
||||
// Set the field to activating so that the user can correct the
|
||||
// invalid value.
|
||||
if (state === 'invalid') {
|
||||
fieldModel.set('state', 'activating');
|
||||
}
|
||||
// Otherwise assume that the fieldModel is in a candidate state and
|
||||
// set it to changed on formUpdate.
|
||||
else {
|
||||
fieldModel.set('state', 'changed');
|
||||
}
|
||||
})
|
||||
.on('keypress.quickedit', 'input', function (event) {
|
||||
if (event.keyCode === 13) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// The in-place editor has loaded; change state to 'active'.
|
||||
fieldModel.set('state', 'active');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the form for this field, detaches behaviors and event handlers.
|
||||
*/
|
||||
removeForm: function () {
|
||||
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: function () {
|
||||
var $formContainer = this.$formContainer;
|
||||
var $submit = $formContainer.find('.quickedit-form-submit');
|
||||
var editorModel = this.model;
|
||||
var fieldModel = this.fieldModel;
|
||||
|
||||
function cleanUpAjax() {
|
||||
Drupal.quickedit.util.form.unajaxifySaving(formSaveAjax);
|
||||
formSaveAjax = null;
|
||||
}
|
||||
|
||||
// Create an AJAX object for the form associated with the field.
|
||||
var formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving({
|
||||
nocssjs: false,
|
||||
other_view_modes: fieldModel.findOtherViewModes()
|
||||
}, $submit);
|
||||
|
||||
// Successfully saved.
|
||||
formSaveAjax.commands.quickeditFieldFormSaved = function (ajax, response, status) {
|
||||
cleanUpAjax();
|
||||
// First, transition the state to 'saved'.
|
||||
fieldModel.set('state', 'saved');
|
||||
// Second, set the 'htmlForOtherViewModes' attribute, so that when this
|
||||
// field is rerendered, the change can be propagated to other instances
|
||||
// of this field, which may be displayed in different view modes.
|
||||
fieldModel.set('htmlForOtherViewModes', response.other_view_modes);
|
||||
// Finally, set the 'html' attribute on the field model. This will cause
|
||||
// the field to be rerendered.
|
||||
_.defer(function () {
|
||||
fieldModel.set('html', response.data);
|
||||
});
|
||||
};
|
||||
|
||||
// Unsuccessfully saved; validation errors.
|
||||
formSaveAjax.commands.quickeditFieldFormValidationErrors = function (ajax, response, status) {
|
||||
editorModel.set('validationErrors', response.data);
|
||||
fieldModel.set('state', 'invalid');
|
||||
};
|
||||
|
||||
// The quickeditFieldForm AJAX command is called upon attempting to save
|
||||
// the form; Form API will mark which form items have errors, if any. This
|
||||
// command is invoked only if validation errors exist and then it runs
|
||||
// before editFieldFormValidationErrors().
|
||||
formSaveAjax.commands.quickeditFieldForm = function (ajax, response, status) {
|
||||
Drupal.AjaxCommands.prototype.insert(ajax, {
|
||||
data: response.data,
|
||||
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: function () {
|
||||
this.$formContainer
|
||||
.find('.quickedit-form')
|
||||
.addClass('quickedit-validation-error')
|
||||
.find('form')
|
||||
.prepend(this.model.get('validationErrors'));
|
||||
}
|
||||
});
|
||||
|
||||
})(jQuery, Drupal);
|
138
core/modules/quickedit/js/editors/plainTextEditor.js
Normal file
138
core/modules/quickedit/js/editors/plainTextEditor.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* @file
|
||||
* ContentEditable-based in-place editor for plain text content.
|
||||
*/
|
||||
|
||||
(function ($, _, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.editors.plain_text = Drupal.quickedit.EditorView.extend(/** @lends Drupal.quickedit.editors.plain_text# */{
|
||||
|
||||
/**
|
||||
* Stores the textual DOM element that is being in-place edited.
|
||||
*/
|
||||
$textElement: null,
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Drupal.quickedit.EditorView
|
||||
*
|
||||
* @param {object} options
|
||||
*/
|
||||
initialize: function (options) {
|
||||
Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
|
||||
|
||||
var editorModel = this.model;
|
||||
var fieldModel = this.fieldModel;
|
||||
|
||||
// Store the original value of this field. Necessary for reverting
|
||||
// changes.
|
||||
var $textElement;
|
||||
var $fieldItems = this.$el.find('.field-item');
|
||||
if ($fieldItems.length) {
|
||||
$textElement = this.$textElement = $fieldItems.eq(0);
|
||||
}
|
||||
else {
|
||||
$textElement = this.$textElement = this.$el;
|
||||
}
|
||||
editorModel.set('originalValue', $.trim(this.$textElement.text()));
|
||||
|
||||
// Sets the state to 'changed' whenever the value changes.
|
||||
var previousText = editorModel.get('originalValue');
|
||||
$textElement.on('keyup paste', function (event) {
|
||||
var currentText = $.trim($textElement.text());
|
||||
if (previousText !== currentText) {
|
||||
previousText = currentText;
|
||||
editorModel.set('currentValue', currentText);
|
||||
fieldModel.set('state', 'changed');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* @return {jQuery}
|
||||
*/
|
||||
getEditedElement: function () {
|
||||
return this.$textElement;
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* @param {object} fieldModel
|
||||
* @param {string} state
|
||||
* @param {object} options
|
||||
*/
|
||||
stateChange: function (fieldModel, state, options) {
|
||||
var from = fieldModel.previous('state');
|
||||
var 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(function () {
|
||||
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}
|
||||
*/
|
||||
getQuickEditUISettings: function () {
|
||||
return {padding: true, unifiedToolbar: false, fullWidthToolbar: false, popup: false};
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
revert: function () {
|
||||
this.$textElement.html(this.model.get('originalValue'));
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(jQuery, _, Drupal);
|
57
core/modules/quickedit/js/models/AppModel.js
Normal file
57
core/modules/quickedit/js/models/AppModel.js
Normal file
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone Model for the state of the in-place editing application.
|
||||
*
|
||||
* @see Drupal.quickedit.AppView
|
||||
*/
|
||||
|
||||
(function (Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*
|
||||
* @augments Backbone.Model
|
||||
*/
|
||||
Drupal.quickedit.AppModel = Backbone.Model.extend(/** @lends Drupal.quickedit.AppModel# */{
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*
|
||||
* @prop {Drupal.quickedit.FieldModel} highlightedField
|
||||
* @prop {Drupal.quickedit.FieldModel} activeField
|
||||
* @prop {Drupal.dialog~dialogDefinition} activeModal
|
||||
*/
|
||||
defaults: /** @lends Drupal.quickedit.AppModel# */{
|
||||
|
||||
/**
|
||||
* The currently state='highlighted' Drupal.quickedit.FieldModel, if any.
|
||||
*
|
||||
* @type {Drupal.quickedit.FieldModel}
|
||||
*
|
||||
* @see Drupal.quickedit.FieldModel.states
|
||||
*/
|
||||
highlightedField: null,
|
||||
|
||||
/**
|
||||
* The currently state = 'active' Drupal.quickedit.FieldModel, if any.
|
||||
*
|
||||
* @type {Drupal.quickedit.FieldModel}
|
||||
*
|
||||
* @see Drupal.quickedit.FieldModel.states
|
||||
*/
|
||||
activeField: null,
|
||||
|
||||
/**
|
||||
* Reference to a {@link Drupal.dialog} instance if a state change
|
||||
* requires confirmation.
|
||||
*
|
||||
* @type {Drupal.dialog~dialogDefinition}
|
||||
*/
|
||||
activeModal: null
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(Backbone, Drupal));
|
52
core/modules/quickedit/js/models/BaseModel.js
Normal file
52
core/modules/quickedit/js/models/BaseModel.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone Model subclass that enforces validation when calling set().
|
||||
*/
|
||||
|
||||
(function (Backbone) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.BaseModel = Backbone.Model.extend(/** @lends Drupal.quickedit.BaseModel# */{
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.Model
|
||||
*
|
||||
* @param {object} options
|
||||
*
|
||||
* @return {Drupal.quickedit.BaseModel}
|
||||
*/
|
||||
initialize: function (options) {
|
||||
this.__initialized = true;
|
||||
return Backbone.Model.prototype.initialize.call(this, options);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object|string} key
|
||||
* @param {*} val
|
||||
* @param {object} [options]
|
||||
*
|
||||
* @return {*}
|
||||
*/
|
||||
set: function (key, val, options) {
|
||||
if (this.__initialized) {
|
||||
// Deal with both the "key", value and {key:value}-style arguments.
|
||||
if (typeof key === 'object') {
|
||||
key.validate = true;
|
||||
}
|
||||
else {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
options.validate = true;
|
||||
}
|
||||
}
|
||||
return Backbone.Model.prototype.set.call(this, key, val, options);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(Backbone));
|
54
core/modules/quickedit/js/models/EditorModel.js
Normal file
54
core/modules/quickedit/js/models/EditorModel.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone Model for the state of an in-place editor.
|
||||
*
|
||||
* @see Drupal.quickedit.EditorView
|
||||
*/
|
||||
|
||||
(function (Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*
|
||||
* @augments Backbone.Model
|
||||
*/
|
||||
Drupal.quickedit.EditorModel = Backbone.Model.extend(/** @lends Drupal.quickedit.EditorModel# */{
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*
|
||||
* @prop {string} originalValue
|
||||
* @prop {string} currentValue
|
||||
* @prop {Array} validationErrors
|
||||
*/
|
||||
defaults: /** @lends Drupal.quickedit.EditorModel# */{
|
||||
|
||||
/**
|
||||
* Not the full HTML representation of this field, but the "actual"
|
||||
* original value of the field, stored by the used in-place editor, and
|
||||
* in a representation that can be chosen by the in-place editor.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
originalValue: null,
|
||||
|
||||
/**
|
||||
* Analogous to originalValue, but the current value.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
currentValue: null,
|
||||
|
||||
/**
|
||||
* Stores any validation errors to be rendered.
|
||||
*
|
||||
* @type {Array}
|
||||
*/
|
||||
validationErrors: null
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(Backbone, Drupal));
|
716
core/modules/quickedit/js/models/EntityModel.js
Normal file
716
core/modules/quickedit/js/models/EntityModel.js
Normal file
|
@ -0,0 +1,716 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone Model for the state of an in-place editable entity in the DOM.
|
||||
*/
|
||||
|
||||
(function (_, $, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.EntityModel# */{
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*/
|
||||
defaults: /** @lends Drupal.quickedit.EntityModel# */{
|
||||
|
||||
/**
|
||||
* The DOM element that represents this entity.
|
||||
*
|
||||
* It may seem bizarre to have a DOM element in a Backbone Model, but we
|
||||
* need to be able to map entities in the DOM to EntityModels in memory.
|
||||
*
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
el: null,
|
||||
|
||||
/**
|
||||
* An entity ID, of the form `<entity type>/<entity ID>`
|
||||
*
|
||||
* @example
|
||||
* "node/1"
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
entityID: null,
|
||||
|
||||
/**
|
||||
* An entity instance ID.
|
||||
*
|
||||
* The first instance of a specific entity (i.e. with a given entity ID)
|
||||
* is assigned 0, the second 1, and so on.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
entityInstanceID: null,
|
||||
|
||||
/**
|
||||
* The unique ID of this entity instance on the page, of the form
|
||||
* `<entity type>/<entity ID>[entity instance ID]`
|
||||
*
|
||||
* @example
|
||||
* "node/1[0]"
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
id: null,
|
||||
|
||||
/**
|
||||
* The label of the entity.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
label: null,
|
||||
|
||||
/**
|
||||
* A FieldCollection for all fields of the entity.
|
||||
*
|
||||
* @type {Drupal.quickedit.FieldCollection}
|
||||
*
|
||||
* @see Drupal.quickedit.FieldCollection
|
||||
*/
|
||||
fields: null,
|
||||
|
||||
// The attributes below are stateful. The ones above will never change
|
||||
// during the life of a EntityModel instance.
|
||||
|
||||
/**
|
||||
* Indicates whether this entity is currently being edited in-place.
|
||||
*
|
||||
* @type {bool}
|
||||
*/
|
||||
isActive: false,
|
||||
|
||||
/**
|
||||
* Whether one or more fields are already been stored in PrivateTempStore.
|
||||
*
|
||||
* @type {bool}
|
||||
*/
|
||||
inTempStore: false,
|
||||
|
||||
/**
|
||||
* Indicates whether a "Save" button is necessary or not.
|
||||
*
|
||||
* Whether one or more fields have already been stored in PrivateTempStore
|
||||
* *or* the field that's currently being edited is in the 'changed' or a
|
||||
* later state.
|
||||
*
|
||||
* @type {bool}
|
||||
*/
|
||||
isDirty: false,
|
||||
|
||||
/**
|
||||
* Whether the request to the server has been made to commit this entity.
|
||||
*
|
||||
* Used to prevent multiple such requests.
|
||||
*
|
||||
* @type {bool}
|
||||
*/
|
||||
isCommitting: false,
|
||||
|
||||
/**
|
||||
* The current processing state of an entity.
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
state: 'closed',
|
||||
|
||||
/**
|
||||
* IDs of fields whose new values have been stored in PrivateTempStore.
|
||||
*
|
||||
* We must store this on the EntityModel as well (even though it already
|
||||
* is on the FieldModel) because when a field is rerendered, its
|
||||
* FieldModel is destroyed and this allows us to transition it back to
|
||||
* the proper state.
|
||||
*
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
fieldsInTempStore: [],
|
||||
|
||||
/**
|
||||
* A flag the tells the application that this EntityModel must be reloaded
|
||||
* in order to restore the original values to its fields in the client.
|
||||
*
|
||||
* @type {bool}
|
||||
*/
|
||||
reload: false
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Drupal.quickedit.BaseModel
|
||||
*/
|
||||
initialize: function () {
|
||||
this.set('fields', new Drupal.quickedit.FieldCollection());
|
||||
|
||||
// Respond to entity state changes.
|
||||
this.listenTo(this, 'change:state', this.stateChange);
|
||||
|
||||
// The state of the entity is largely dependent on the state of its
|
||||
// fields.
|
||||
this.listenTo(this.get('fields'), 'change:state', this.fieldStateChange);
|
||||
|
||||
// Call Drupal.quickedit.BaseModel's initialize() method.
|
||||
Drupal.quickedit.BaseModel.prototype.initialize.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates FieldModels' states when an EntityModel change occurs.
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} entityModel
|
||||
* @param {string} state
|
||||
* The state of the associated entity. One of
|
||||
* {@link Drupal.quickedit.EntityModel.states}.
|
||||
* @param {object} options
|
||||
*/
|
||||
stateChange: function (entityModel, state, options) {
|
||||
var to = state;
|
||||
switch (to) {
|
||||
case 'closed':
|
||||
this.set({
|
||||
'isActive': false,
|
||||
'inTempStore': false,
|
||||
'isDirty': false
|
||||
});
|
||||
break;
|
||||
|
||||
case 'launching':
|
||||
break;
|
||||
|
||||
case 'opening':
|
||||
// Set the fields to candidate state.
|
||||
entityModel.get('fields').each(function (fieldModel) {
|
||||
fieldModel.set('state', 'candidate', options);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'opened':
|
||||
// The entity is now ready for editing!
|
||||
this.set('isActive', true);
|
||||
break;
|
||||
|
||||
case 'committing':
|
||||
// The user indicated they want to save the entity.
|
||||
var fields = this.get('fields');
|
||||
// For fields that are in an active state, transition them to candidate.
|
||||
fields.chain()
|
||||
.filter(function (fieldModel) {
|
||||
return _.intersection([fieldModel.get('state')], ['active']).length;
|
||||
})
|
||||
.each(function (fieldModel) {
|
||||
fieldModel.set('state', 'candidate');
|
||||
});
|
||||
// For fields that are in a changed state, field values must first be
|
||||
// stored in PrivateTempStore.
|
||||
fields.chain()
|
||||
.filter(function (fieldModel) {
|
||||
return _.intersection([fieldModel.get('state')], Drupal.quickedit.app.changedFieldStates).length;
|
||||
})
|
||||
.each(function (fieldModel) {
|
||||
fieldModel.set('state', 'saving');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'deactivating':
|
||||
var changedFields = this.get('fields')
|
||||
.filter(function (fieldModel) {
|
||||
return _.intersection([fieldModel.get('state')], ['changed', 'invalid']).length;
|
||||
});
|
||||
// If the entity contains unconfirmed or unsaved changes, return the
|
||||
// entity to an opened state and ask the user if they would like to save
|
||||
// the changes or discard the changes.
|
||||
// 1. One of the fields is in a changed state. The changed field might
|
||||
// just be a change in the client or it might have been saved to
|
||||
// tempstore.
|
||||
// 2. The saved flag is empty and the confirmed flag is empty. If the
|
||||
// entity has been saved to the server, the fields changed in the
|
||||
// client are irrelevant. If the changes are confirmed, then proceed
|
||||
// to set the fields to candidate state.
|
||||
if ((changedFields.length || this.get('fieldsInTempStore').length) && (!options.saved && !options.confirmed)) {
|
||||
// Cancel deactivation until the user confirms save or discard.
|
||||
this.set('state', 'opened', {confirming: true});
|
||||
// An action in reaction to state change must be deferred.
|
||||
_.defer(function () {
|
||||
Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
|
||||
});
|
||||
}
|
||||
else {
|
||||
var invalidFields = this.get('fields')
|
||||
.filter(function (fieldModel) {
|
||||
return _.intersection([fieldModel.get('state')], ['invalid']).length;
|
||||
});
|
||||
// Indicate if this EntityModel needs to be reloaded in order to
|
||||
// restore the original values of its fields.
|
||||
entityModel.set('reload', (this.get('fieldsInTempStore').length || invalidFields.length));
|
||||
// Set all fields to the 'candidate' state. A changed field may have to
|
||||
// go through confirmation first.
|
||||
entityModel.get('fields').each(function (fieldModel) {
|
||||
// If the field is already in the candidate state, trigger a change
|
||||
// event so that the entityModel can move to the next state in
|
||||
// deactivation.
|
||||
if (_.intersection([fieldModel.get('state')], ['candidate', 'highlighted']).length) {
|
||||
fieldModel.trigger('change:state', fieldModel, fieldModel.get('state'), options);
|
||||
}
|
||||
else {
|
||||
fieldModel.set('state', 'candidate', options);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'closing':
|
||||
// Set all fields to the 'inactive' state.
|
||||
options.reason = 'stop';
|
||||
this.get('fields').each(function (fieldModel) {
|
||||
fieldModel.set({
|
||||
'inTempStore': false,
|
||||
'state': 'inactive'
|
||||
}, options);
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates a Field and Entity model's "inTempStore" when appropriate.
|
||||
*
|
||||
* Helper function.
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} entityModel
|
||||
* The model of the entity for which a field's state attribute has changed.
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The model of the field whose state attribute has changed.
|
||||
*
|
||||
* @see Drupal.quickedit.EntityModel#fieldStateChange
|
||||
*/
|
||||
_updateInTempStoreAttributes: function (entityModel, fieldModel) {
|
||||
var current = fieldModel.get('state');
|
||||
var previous = fieldModel.previous('state');
|
||||
var fieldsInTempStore = entityModel.get('fieldsInTempStore');
|
||||
// If the fieldModel changed to the 'saved' state: remember that this
|
||||
// field was saved to PrivateTempStore.
|
||||
if (current === 'saved') {
|
||||
// Mark the entity as saved in PrivateTempStore, so that we can pass the
|
||||
// proper "reset PrivateTempStore" boolean value when communicating with
|
||||
// the server.
|
||||
entityModel.set('inTempStore', true);
|
||||
// Mark the field as saved in PrivateTempStore, so that visual
|
||||
// indicators signifying just that may be rendered.
|
||||
fieldModel.set('inTempStore', true);
|
||||
// Remember that this field is in PrivateTempStore, restore when
|
||||
// rerendered.
|
||||
fieldsInTempStore.push(fieldModel.get('fieldID'));
|
||||
fieldsInTempStore = _.uniq(fieldsInTempStore);
|
||||
entityModel.set('fieldsInTempStore', fieldsInTempStore);
|
||||
}
|
||||
// If the fieldModel changed to the 'candidate' state from the
|
||||
// 'inactive' state, then this is a field for this entity that got
|
||||
// rerendered. Restore its previous 'inTempStore' attribute value.
|
||||
else if (current === 'candidate' && previous === 'inactive') {
|
||||
fieldModel.set('inTempStore', _.intersection([fieldModel.get('fieldID')], fieldsInTempStore).length > 0);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reacts to state changes in this entity's fields.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The model of the field whose state attribute changed.
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
fieldStateChange: function (fieldModel, state) {
|
||||
var entityModel = this;
|
||||
var fieldState = state;
|
||||
// Switch on the entityModel state.
|
||||
// The EntityModel responds to FieldModel state changes as a function of its
|
||||
// state. For example, a field switching back to 'candidate' state when its
|
||||
// entity is in the 'opened' state has no effect on the entity. But that
|
||||
// same switch back to 'candidate' state of a field when the entity is in
|
||||
// the 'committing' state might allow the entity to proceed with the commit
|
||||
// flow.
|
||||
switch (this.get('state')) {
|
||||
case 'closed':
|
||||
case 'launching':
|
||||
// It should be impossible to reach these: fields can't change state
|
||||
// while the entity is closed or still launching.
|
||||
break;
|
||||
|
||||
case 'opening':
|
||||
// We must change the entity to the 'opened' state, but it must first be
|
||||
// confirmed that all of its fieldModels have transitioned to the
|
||||
// 'candidate' state.
|
||||
// We do this here, because this is called every time a fieldModel
|
||||
// changes state, hence each time this is called, we get closer to the
|
||||
// goal of having all fieldModels in the 'candidate' state.
|
||||
// A state change in reaction to another state change must be deferred.
|
||||
_.defer(function () {
|
||||
entityModel.set('state', 'opened', {
|
||||
'accept-field-states': Drupal.quickedit.app.readyFieldStates
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case 'opened':
|
||||
// Set the isDirty attribute when appropriate so that it is known when
|
||||
// to display the "Save" button in the entity toolbar.
|
||||
// Note that once a field has been changed, there's no way to discard
|
||||
// that change, hence it will have to be saved into PrivateTempStore,
|
||||
// or the in-place editing of this field will have to be stopped
|
||||
// completely. In other words: once any field enters the 'changed'
|
||||
// field, then for the remainder of the in-place editing session, the
|
||||
// entity is by definition dirty.
|
||||
if (fieldState === 'changed') {
|
||||
entityModel.set('isDirty', true);
|
||||
}
|
||||
else {
|
||||
this._updateInTempStoreAttributes(entityModel, fieldModel);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'committing':
|
||||
// If the field save returned a validation error, set the state of the
|
||||
// entity back to 'opened'.
|
||||
if (fieldState === 'invalid') {
|
||||
// A state change in reaction to another state change must be deferred.
|
||||
_.defer(function () {
|
||||
entityModel.set('state', 'opened', {reason: 'invalid'});
|
||||
});
|
||||
}
|
||||
else {
|
||||
this._updateInTempStoreAttributes(entityModel, fieldModel);
|
||||
}
|
||||
|
||||
// Attempt to save the entity. If the entity's fields are not yet all in
|
||||
// a ready state, the save will not be processed.
|
||||
var options = {
|
||||
'accept-field-states': Drupal.quickedit.app.readyFieldStates
|
||||
};
|
||||
if (entityModel.set('isCommitting', true, options)) {
|
||||
entityModel.save({
|
||||
success: function () {
|
||||
entityModel.set({
|
||||
'state': 'deactivating',
|
||||
'isCommitting': false
|
||||
}, {'saved': true});
|
||||
},
|
||||
error: function () {
|
||||
// Reset the "isCommitting" mutex.
|
||||
entityModel.set('isCommitting', false);
|
||||
// Change the state back to "opened", to allow the user to hit the
|
||||
// "Save" button again.
|
||||
entityModel.set('state', 'opened', {reason: 'networkerror'});
|
||||
// Show a modal to inform the user of the network error.
|
||||
var message = Drupal.t('Your changes to <q>@entity-title</q> could not be saved, either due to a website problem or a network connection problem.<br>Please try again.', {'@entity-title': entityModel.get('label')});
|
||||
Drupal.quickedit.util.networkErrorModal(Drupal.t('Sorry!'), message);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'deactivating':
|
||||
// When setting the entity to 'closing', require that all fieldModels
|
||||
// are in either the 'candidate' or 'highlighted' state.
|
||||
// A state change in reaction to another state change must be deferred.
|
||||
_.defer(function () {
|
||||
entityModel.set('state', 'closing', {
|
||||
'accept-field-states': Drupal.quickedit.app.readyFieldStates
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case 'closing':
|
||||
// When setting the entity to 'closed', require that all fieldModels are
|
||||
// in the 'inactive' state.
|
||||
// A state change in reaction to another state change must be deferred.
|
||||
_.defer(function () {
|
||||
entityModel.set('state', 'closed', {
|
||||
'accept-field-states': ['inactive']
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fires an AJAX request to the REST save URL for an entity.
|
||||
*
|
||||
* @param {object} options
|
||||
* An object of options that contains:
|
||||
* @param {function} [options.success]
|
||||
* A function to invoke if the entity is successfully saved.
|
||||
*/
|
||||
save: function (options) {
|
||||
var entityModel = this;
|
||||
|
||||
// Create a Drupal.ajax instance to save the entity.
|
||||
var entitySaverAjax = Drupal.ajax({
|
||||
url: Drupal.url('quickedit/entity/' + entityModel.get('entityID')),
|
||||
error: function () {
|
||||
// Let the Drupal.quickedit.EntityModel Backbone model's error()=
|
||||
// method handle errors.
|
||||
options.error.call(entityModel);
|
||||
}
|
||||
});
|
||||
// Entity saved successfully.
|
||||
entitySaverAjax.commands.quickeditEntitySaved = function (ajax, response, status) {
|
||||
// All fields have been moved from PrivateTempStore to permanent
|
||||
// storage, update the "inTempStore" attribute on FieldModels, on the
|
||||
// EntityModel and clear EntityModel's "fieldInTempStore" attribute.
|
||||
entityModel.get('fields').each(function (fieldModel) {
|
||||
fieldModel.set('inTempStore', false);
|
||||
});
|
||||
entityModel.set('inTempStore', false);
|
||||
entityModel.set('fieldsInTempStore', []);
|
||||
|
||||
// Invoke the optional success callback.
|
||||
if (options.success) {
|
||||
options.success.call(entityModel);
|
||||
}
|
||||
};
|
||||
// Trigger the AJAX request, which will will return the
|
||||
// quickeditEntitySaved AJAX command to which we then react.
|
||||
entitySaverAjax.execute();
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
validate: function (attrs, options) {
|
||||
var acceptedFieldStates = options['accept-field-states'] || [];
|
||||
|
||||
// Validate state change.
|
||||
var currentState = this.get('state');
|
||||
var nextState = attrs.state;
|
||||
if (currentState !== nextState) {
|
||||
// Ensure it's a valid state.
|
||||
if (_.indexOf(this.constructor.states, nextState) === -1) {
|
||||
return '"' + nextState + '" is an invalid state';
|
||||
}
|
||||
|
||||
// Ensure it's a state change that is allowed.
|
||||
// Check if the acceptStateChange function accepts it.
|
||||
if (!this._acceptStateChange(currentState, nextState, options)) {
|
||||
return 'state change not accepted';
|
||||
}
|
||||
// If that function accepts it, then ensure all fields are also in an
|
||||
// acceptable state.
|
||||
else if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
|
||||
return 'state change not accepted because fields are not in acceptable state';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate setting isCommitting = true.
|
||||
var currentIsCommitting = this.get('isCommitting');
|
||||
var nextIsCommitting = attrs.isCommitting;
|
||||
if (currentIsCommitting === false && nextIsCommitting === true) {
|
||||
if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
|
||||
return 'isCommitting change not accepted because fields are not in acceptable state';
|
||||
}
|
||||
}
|
||||
else if (currentIsCommitting === true && nextIsCommitting === true) {
|
||||
return "isCommitting is a mutex, hence only changes are allowed";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
* @param {object} context
|
||||
* @param {string} context.reason
|
||||
* @param {bool} context.confirming
|
||||
*
|
||||
* @return {bool}
|
||||
*
|
||||
* @see Drupal.quickedit.AppView#acceptEditorStateChange
|
||||
*/
|
||||
_acceptStateChange: function (from, to, context) {
|
||||
var accept = true;
|
||||
|
||||
// In general, enforce the states sequence. Disallow going back from a
|
||||
// "later" state to an "earlier" state, except in explicitly allowed
|
||||
// cases.
|
||||
if (!this.constructor.followsStateSequence(from, to)) {
|
||||
accept = false;
|
||||
|
||||
// Allow: closing -> closed.
|
||||
// Necessary to stop editing an entity.
|
||||
if (from === 'closing' && to === 'closed') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: committing -> opened.
|
||||
// Necessary to be able to correct an invalid field, or to hit the "Save"
|
||||
// button again after a server/network error.
|
||||
else if (from === 'committing' && to === 'opened' && context.reason && (context.reason === 'invalid' || context.reason === 'networkerror')) {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: deactivating -> opened.
|
||||
// Necessary to be able to confirm changes with the user.
|
||||
else if (from === 'deactivating' && to === 'opened' && context.confirming) {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: opened -> deactivating.
|
||||
// Necessary to be able to stop editing.
|
||||
else if (from === 'opened' && to === 'deactivating' && context.confirmed) {
|
||||
accept = true;
|
||||
}
|
||||
}
|
||||
|
||||
return accept;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Array} acceptedFieldStates
|
||||
*
|
||||
* @return {bool}
|
||||
*
|
||||
* @see Drupal.quickedit.EntityModel#validate
|
||||
*/
|
||||
_fieldsHaveAcceptableStates: function (acceptedFieldStates) {
|
||||
var accept = true;
|
||||
|
||||
// If no acceptable field states are provided, assume all field states are
|
||||
// acceptable. We want to let validation pass as a default and only
|
||||
// check validity on calls to set that explicitly request it.
|
||||
if (acceptedFieldStates.length > 0) {
|
||||
var fieldStates = this.get('fields').pluck('state') || [];
|
||||
// If not all fields are in one of the accepted field states, then we
|
||||
// still can't allow this state change.
|
||||
if (_.difference(fieldStates, acceptedFieldStates).length) {
|
||||
accept = false;
|
||||
}
|
||||
}
|
||||
|
||||
return accept;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
*/
|
||||
destroy: function (options) {
|
||||
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
|
||||
|
||||
this.stopListening();
|
||||
|
||||
// Destroy all fields of this entity.
|
||||
this.get('fields').each(function (fieldModel) {
|
||||
fieldModel.destroy();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
sync: function () {
|
||||
// We don't use REST updates to sync.
|
||||
return;
|
||||
}
|
||||
|
||||
}, /** @lends Drupal.quickedit.EntityModel */{
|
||||
|
||||
/**
|
||||
* Sequence of all possible states an entity can be in during quickediting.
|
||||
*
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
states: [
|
||||
// Initial state, like field's 'inactive' OR the user has just finished
|
||||
// in-place editing this entity.
|
||||
// - Trigger: none (initial) or EntityModel (finished).
|
||||
// - Expected behavior: (when not initial state): tear down
|
||||
// EntityToolbarView, in-place editors and related views.
|
||||
'closed',
|
||||
// User has activated in-place editing of this entity.
|
||||
// - Trigger: user.
|
||||
// - Expected behavior: the EntityToolbarView is gets set up, in-place
|
||||
// editors (EditorViews) and related views for this entity's fields are
|
||||
// set up. Upon completion of those, the state is changed to 'opening'.
|
||||
'launching',
|
||||
// Launching has finished.
|
||||
// - Trigger: application.
|
||||
// - Guarantees: in-place editors ready for use, all entity and field views
|
||||
// have been set up, all fields are in the 'inactive' state.
|
||||
// - Expected behavior: all fields are changed to the 'candidate' state and
|
||||
// once this is completed, the entity state will be changed to 'opened'.
|
||||
'opening',
|
||||
// Opening has finished.
|
||||
// - Trigger: EntityModel.
|
||||
// - Guarantees: see 'opening', all fields are in the 'candidate' state.
|
||||
// - Expected behavior: the user is able to actually use in-place editing.
|
||||
'opened',
|
||||
// User has clicked the 'Save' button (and has thus changed at least one
|
||||
// field).
|
||||
// - Trigger: user.
|
||||
// - Guarantees: see 'opened', plus: either a changed field is in
|
||||
// PrivateTempStore, or the user has just modified a field without
|
||||
// activating (switching to) another field.
|
||||
// - Expected behavior: 1) if any of the fields are not yet in
|
||||
// PrivateTempStore, save them to PrivateTempStore, 2) if then any of
|
||||
// the fields has the 'invalid' state, then change the entity state back
|
||||
// to 'opened', otherwise: save the entity by committing it from
|
||||
// PrivateTempStore into permanent storage.
|
||||
'committing',
|
||||
// User has clicked the 'Close' button, or has clicked the 'Save' button and
|
||||
// that was successfully completed.
|
||||
// - Trigger: user or EntityModel.
|
||||
// - Guarantees: when having clicked 'Close' hardly any: fields may be in a
|
||||
// variety of states; when having clicked 'Save': all fields are in the
|
||||
// 'candidate' state.
|
||||
// - Expected behavior: transition all fields to the 'candidate' state,
|
||||
// possibly requiring confirmation in the case of having clicked 'Close'.
|
||||
'deactivating',
|
||||
// Deactivation has been completed.
|
||||
// - Trigger: EntityModel.
|
||||
// - Guarantees: all fields are in the 'candidate' state.
|
||||
// - Expected behavior: change all fields to the 'inactive' state.
|
||||
'closing'
|
||||
],
|
||||
|
||||
/**
|
||||
* Indicates whether the 'from' state comes before the 'to' state.
|
||||
*
|
||||
* @param {string} from
|
||||
* One of {@link Drupal.quickedit.EntityModel.states}.
|
||||
* @param {string} to
|
||||
* One of {@link Drupal.quickedit.EntityModel.states}.
|
||||
*
|
||||
* @return {bool}
|
||||
*/
|
||||
followsStateSequence: function (from, to) {
|
||||
return _.indexOf(this.states, from) < _.indexOf(this.states, to);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*
|
||||
* @augments Backbone.Collection
|
||||
*/
|
||||
Drupal.quickedit.EntityCollection = Backbone.Collection.extend(/** @lends Drupal.quickedit.EntityCollection# */{
|
||||
|
||||
/**
|
||||
* @type {Drupal.quickedit.EntityModel}
|
||||
*/
|
||||
model: Drupal.quickedit.EntityModel
|
||||
});
|
||||
|
||||
}(_, jQuery, Backbone, Drupal));
|
342
core/modules/quickedit/js/models/FieldModel.js
Normal file
342
core/modules/quickedit/js/models/FieldModel.js
Normal file
|
@ -0,0 +1,342 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone Model for the state of an in-place editable field in the DOM.
|
||||
*/
|
||||
|
||||
(function (_, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.FieldModel = Drupal.quickedit.BaseModel.extend(/** @lends Drupal.quickedit.FieldModel# */{
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*/
|
||||
defaults: /** @lends Drupal.quickedit.FieldModel# */{
|
||||
|
||||
/**
|
||||
* The DOM element that represents this field. It may seem bizarre to have
|
||||
* a DOM element in a Backbone Model, but we need to be able to map fields
|
||||
* in the DOM to FieldModels in memory.
|
||||
*/
|
||||
el: null,
|
||||
|
||||
/**
|
||||
* A field ID, of the form
|
||||
* `<entity type>/<id>/<field name>/<language>/<view mode>`
|
||||
*
|
||||
* @example
|
||||
* "node/1/field_tags/und/full"
|
||||
*/
|
||||
fieldID: null,
|
||||
|
||||
/**
|
||||
* The unique ID of this field within its entity instance on the page, of
|
||||
* the form `<entity type>/<id>/<field name>/<language>/<view
|
||||
* mode>[entity instance ID]`.
|
||||
*
|
||||
* @example
|
||||
* "node/1/field_tags/und/full[0]"
|
||||
*/
|
||||
id: null,
|
||||
|
||||
/**
|
||||
* A {@link Drupal.quickedit.EntityModel}. Its "fields" attribute, which
|
||||
* is a FieldCollection, is automatically updated to include this
|
||||
* FieldModel.
|
||||
*/
|
||||
entity: null,
|
||||
|
||||
/**
|
||||
* This field's metadata as returned by the
|
||||
* QuickEditController::metadata().
|
||||
*/
|
||||
metadata: null,
|
||||
|
||||
/**
|
||||
* Callback function for validating changes between states. Receives the
|
||||
* previous state, new state, context, and a callback.
|
||||
*/
|
||||
acceptStateChange: null,
|
||||
|
||||
/**
|
||||
* A logical field ID, of the form
|
||||
* `<entity type>/<id>/<field name>/<language>`, i.e. the fieldID without
|
||||
* the view mode, to be able to identify other instances of the same
|
||||
* field on the page but rendered in a different view mode.
|
||||
*
|
||||
* @example
|
||||
* "node/1/field_tags/und".
|
||||
*/
|
||||
logicalFieldID: null,
|
||||
|
||||
// The attributes below are stateful. The ones above will never change
|
||||
// during the life of a FieldModel instance.
|
||||
|
||||
/**
|
||||
* In-place editing state of this field. Defaults to the initial state.
|
||||
* Possible values: {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
state: 'inactive',
|
||||
|
||||
/**
|
||||
* The field is currently in the 'changed' state or one of the following
|
||||
* states in which the field is still changed.
|
||||
*/
|
||||
isChanged: false,
|
||||
|
||||
/**
|
||||
* Is tracked by the EntityModel, is mirrored here solely for decorative
|
||||
* purposes: so that FieldDecorationView.renderChanged() can react to it.
|
||||
*/
|
||||
inTempStore: false,
|
||||
|
||||
/**
|
||||
* The full HTML representation of this field (with the element that has
|
||||
* the data-quickedit-field-id as the outer element). Used to propagate
|
||||
* changes from this field to other instances of the same field storage.
|
||||
*/
|
||||
html: null,
|
||||
|
||||
/**
|
||||
* An object containing the full HTML representations (values) of other
|
||||
* view modes (keys) of this field, for other instances of this field
|
||||
* displayed in a different view mode.
|
||||
*/
|
||||
htmlForOtherViewModes: null
|
||||
},
|
||||
|
||||
/**
|
||||
* State of an in-place editable field in the DOM.
|
||||
*
|
||||
* @constructs
|
||||
*
|
||||
* @augments Drupal.quickedit.BaseModel
|
||||
*
|
||||
* @param {object} options
|
||||
*/
|
||||
initialize: function (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);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} options
|
||||
*/
|
||||
destroy: function (options) {
|
||||
if (this.get('state') !== 'inactive') {
|
||||
throw new Error("FieldModel cannot be destroyed if it is not inactive state.");
|
||||
}
|
||||
Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
sync: function () {
|
||||
// We don't use REST updates to sync.
|
||||
return;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @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}
|
||||
*/
|
||||
validate: function (attrs, options) {
|
||||
var current = this.get('state');
|
||||
var next = attrs.state;
|
||||
if (current !== next) {
|
||||
// Ensure it's a valid state.
|
||||
if (_.indexOf(this.constructor.states, next) === -1) {
|
||||
return '"' + next + '" is an invalid state';
|
||||
}
|
||||
// Check if the acceptStateChange callback accepts it.
|
||||
if (!this.get('acceptStateChange')(current, next, options, this)) {
|
||||
return 'state change not accepted';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the entity ID from this field's ID.
|
||||
*
|
||||
* @return {string}
|
||||
* An entity ID: a string of the format `<entity type>/<id>`.
|
||||
*/
|
||||
getEntityID: function () {
|
||||
return this.get('fieldID').split('/').slice(0, 2).join('/');
|
||||
},
|
||||
|
||||
/**
|
||||
* Extracts the view mode ID from this field's ID.
|
||||
*
|
||||
* @return {string}
|
||||
* A view mode ID.
|
||||
*/
|
||||
getViewMode: function () {
|
||||
return this.get('fieldID').split('/').pop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Find other instances of this field with different view modes.
|
||||
*
|
||||
* @return {Array}
|
||||
* An array containing view mode IDs.
|
||||
*/
|
||||
findOtherViewModes: function () {
|
||||
var currentField = this;
|
||||
var otherViewModes = [];
|
||||
Drupal.quickedit.collections.fields
|
||||
// Find all instances of fields that display the same logical field
|
||||
// (same entity, same field, just a different instance and maybe a
|
||||
// different view mode).
|
||||
.where({logicalFieldID: currentField.get('logicalFieldID')})
|
||||
.forEach(function (field) {
|
||||
// Ignore the current field.
|
||||
if (field === currentField) {
|
||||
return;
|
||||
}
|
||||
// Also ignore other fields with the same view mode.
|
||||
else if (field.get('fieldID') === currentField.get('fieldID')) {
|
||||
return;
|
||||
}
|
||||
else {
|
||||
otherViewModes.push(field.getViewMode());
|
||||
}
|
||||
});
|
||||
return otherViewModes;
|
||||
}
|
||||
|
||||
}, /** @lends Drupal.quickedit.FieldModel */{
|
||||
|
||||
/**
|
||||
* Sequence of all possible states a field can be in during quickediting.
|
||||
*
|
||||
* @type {Array.<string>}
|
||||
*/
|
||||
states: [
|
||||
// The field associated with this FieldModel is linked to an EntityModel;
|
||||
// the user can choose to start in-place editing that entity (and
|
||||
// consequently this field). No in-place editor (EditorView) is associated
|
||||
// with this field, because this field is not being in-place edited.
|
||||
// This is both the initial (not yet in-place editing) and the end state
|
||||
// (finished in-place editing).
|
||||
'inactive',
|
||||
// The user is in-place editing this entity, and this field is a
|
||||
// candidate
|
||||
// for in-place editing. In-place editor should not
|
||||
// - Trigger: user.
|
||||
// - Guarantees: entity is ready, in-place editor (EditorView) is
|
||||
// associated with the field.
|
||||
// - Expected behavior: visual indicators
|
||||
// around the field indicate it is available for in-place editing, no
|
||||
// in-place editor presented yet.
|
||||
'candidate',
|
||||
// User is highlighting this field.
|
||||
// - Trigger: user.
|
||||
// - Guarantees: see 'candidate'.
|
||||
// - Expected behavior: visual indicators to convey highlighting, in-place
|
||||
// editing toolbar shows field's label.
|
||||
'highlighted',
|
||||
// User has activated the in-place editing of this field; in-place editor
|
||||
// is activating.
|
||||
// - Trigger: user.
|
||||
// - Guarantees: see 'candidate'.
|
||||
// - Expected behavior: loading indicator, in-place editor is loading
|
||||
// remote data (e.g. retrieve form from back-end). Upon retrieval of
|
||||
// remote data, the in-place editor transitions the field's state to
|
||||
// 'active'.
|
||||
'activating',
|
||||
// In-place editor has finished loading remote data; ready for use.
|
||||
// - Trigger: in-place editor.
|
||||
// - Guarantees: see 'candidate'.
|
||||
// - Expected behavior: in-place editor for the field is ready for use.
|
||||
'active',
|
||||
// User has modified values in the in-place editor.
|
||||
// - Trigger: user.
|
||||
// - Guarantees: see 'candidate', plus in-place editor is ready for use.
|
||||
// - Expected behavior: visual indicator of change.
|
||||
'changed',
|
||||
// User is saving changed field data in in-place editor to
|
||||
// PrivateTempStore. The save mechanism of the in-place editor is called.
|
||||
// - Trigger: user.
|
||||
// - Guarantees: see 'candidate' and 'active'.
|
||||
// - Expected behavior: saving indicator, in-place editor is saving field
|
||||
// data into PrivateTempStore. Upon successful saving (without
|
||||
// validation errors), the in-place editor transitions the field's state
|
||||
// to 'saved', but to 'invalid' upon failed saving (with validation
|
||||
// errors).
|
||||
'saving',
|
||||
// In-place editor has successfully saved the changed field.
|
||||
// - Trigger: in-place editor.
|
||||
// - Guarantees: see 'candidate' and 'active'.
|
||||
// - Expected behavior: transition back to 'candidate' state because the
|
||||
// deed is done. Then: 1) transition to 'inactive' to allow the field
|
||||
// to be rerendered, 2) destroy the FieldModel (which also destroys
|
||||
// attached views like the EditorView), 3) replace the existing field
|
||||
// HTML with the existing HTML and 4) attach behaviors again so that the
|
||||
// field becomes available again for in-place editing.
|
||||
'saved',
|
||||
// In-place editor has failed to saved the changed field: there were
|
||||
// validation errors.
|
||||
// - Trigger: in-place editor.
|
||||
// - Guarantees: see 'candidate' and 'active'.
|
||||
// - Expected behavior: remain in 'invalid' state, let the user make more
|
||||
// changes so that he can save it again, without validation errors.
|
||||
'invalid'
|
||||
],
|
||||
|
||||
/**
|
||||
* Indicates whether the 'from' state comes before the 'to' state.
|
||||
*
|
||||
* @param {string} from
|
||||
* One of {@link Drupal.quickedit.FieldModel.states}.
|
||||
* @param {string} to
|
||||
* One of {@link Drupal.quickedit.FieldModel.states}.
|
||||
*
|
||||
* @return {bool}
|
||||
*/
|
||||
followsStateSequence: function (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));
|
660
core/modules/quickedit/js/quickedit.js
Normal file
660
core/modules/quickedit/js/quickedit.js
Normal file
|
@ -0,0 +1,660 @@
|
|||
/**
|
||||
* @file
|
||||
* Attaches behavior for the Quick Edit module.
|
||||
*
|
||||
* Everything happens asynchronously, to allow for:
|
||||
* - dynamically rendered contextual links
|
||||
* - asynchronously retrieved (and cached) per-field in-place editing metadata
|
||||
* - asynchronous setup of in-place editable field and "Quick edit" link.
|
||||
*
|
||||
* To achieve this, there are several queues:
|
||||
* - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
|
||||
* - fieldsAvailableQueue: queue of fields whose metadata is known, and for
|
||||
* which it has been confirmed that the user has permission to edit them.
|
||||
* However, FieldModels will only be created for them once there's a
|
||||
* contextual link for their entity: when it's possible to initiate editing.
|
||||
* - contextualLinksQueue: queue of contextual links on entities for which it
|
||||
* is not yet known whether the user has permission to edit at >=1 of them.
|
||||
*/
|
||||
|
||||
(function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
|
||||
|
||||
"use strict";
|
||||
|
||||
var options = $.extend(drupalSettings.quickedit,
|
||||
// Merge strings on top of drupalSettings so that they are not mutable.
|
||||
{
|
||||
strings: {
|
||||
quickEdit: Drupal.t('Quick edit')
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Tracks fields without metadata. Contains objects with the following keys:
|
||||
* - DOM el
|
||||
* - String fieldID
|
||||
* - String entityID
|
||||
*/
|
||||
var fieldsMetadataQueue = [];
|
||||
|
||||
/**
|
||||
* Tracks fields ready for use. Contains objects with the following keys:
|
||||
* - DOM el
|
||||
* - String fieldID
|
||||
* - String entityID
|
||||
*/
|
||||
var fieldsAvailableQueue = [];
|
||||
|
||||
/**
|
||||
* Tracks contextual links on entities. Contains objects with the following
|
||||
* keys:
|
||||
* - String entityID
|
||||
* - DOM el
|
||||
* - DOM region
|
||||
*/
|
||||
var contextualLinksQueue = [];
|
||||
|
||||
/**
|
||||
* Tracks how many instances exist for each unique entity. Contains key-value
|
||||
* pairs:
|
||||
* - String entityID
|
||||
* - Number count
|
||||
*/
|
||||
var entityInstancesTracker = {};
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {Drupal~behavior}
|
||||
*/
|
||||
Drupal.behaviors.quickedit = {
|
||||
attach: function (context) {
|
||||
// Initialize the Quick Edit app once per page load.
|
||||
$('body').once('quickedit-init').each(initQuickEdit);
|
||||
|
||||
// Find all in-place editable fields, if any.
|
||||
var $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
|
||||
if ($fields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each entity element: identical entities that appear multiple
|
||||
// times will get a numeric identifier, starting at 0.
|
||||
$(context).find('[data-quickedit-entity-id]').once('quickedit').each(function (index, entityElement) {
|
||||
processEntity(entityElement);
|
||||
});
|
||||
|
||||
// Process each field element: queue to be used or to fetch metadata.
|
||||
// When a field is being rerendered after editing, it will be processed
|
||||
// immediately. New fields will be unable to be processed immediately,
|
||||
// but will instead be queued to have their metadata fetched, which occurs
|
||||
// below in fetchMissingMetaData().
|
||||
$fields.each(function (index, fieldElement) {
|
||||
processField(fieldElement);
|
||||
});
|
||||
|
||||
// Entities and fields on the page have been detected, try to set up the
|
||||
// contextual links for those entities that already have the necessary
|
||||
// meta- data in the client-side cache.
|
||||
contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
|
||||
return !initializeEntityContextualLink(contextualLink);
|
||||
});
|
||||
|
||||
// Fetch metadata for any fields that are queued to retrieve it.
|
||||
fetchMissingMetadata(function (fieldElementsWithFreshMetadata) {
|
||||
// Metadata has been fetched, reprocess fields whose metadata was
|
||||
// missing.
|
||||
_.each(fieldElementsWithFreshMetadata, processField);
|
||||
|
||||
// Metadata has been fetched, try to set up more contextual links now.
|
||||
contextualLinksQueue = _.filter(contextualLinksQueue, function (contextualLink) {
|
||||
return !initializeEntityContextualLink(contextualLink);
|
||||
});
|
||||
});
|
||||
},
|
||||
detach: function (context, settings, trigger) {
|
||||
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: {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldID
|
||||
*
|
||||
* @return {bool}
|
||||
*/
|
||||
has: function (fieldID) {
|
||||
return storage.getItem(this._prefixFieldID(fieldID)) !== null;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldID
|
||||
* @param {object} metadata
|
||||
*/
|
||||
add: function (fieldID, metadata) {
|
||||
storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldID
|
||||
* @param {string} [key]
|
||||
* @return {object|*}
|
||||
*/
|
||||
get: function (fieldID, key) {
|
||||
var metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
|
||||
return (typeof key === 'undefined') ? metadata : metadata[key];
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
_prefixFieldID: function (fieldID) {
|
||||
return 'Drupal.quickedit.metadata.' + fieldID;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldID
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
_unprefixFieldID: function (fieldID) {
|
||||
// Strip "Drupal.quickedit.metadata.", which is 26 characters long.
|
||||
return fieldID.substring(26);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} fieldIDs
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
intersection: function (fieldIDs) {
|
||||
var prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
|
||||
var 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.
|
||||
var permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
|
||||
var permissionsHashValue = storage.getItem(permissionsHashKey);
|
||||
var permissionsHash = drupalSettings.user.permissionsHash;
|
||||
if (permissionsHashValue !== permissionsHash) {
|
||||
if (typeof permissionsHash === 'string') {
|
||||
_.chain(storage).keys().each(function (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
|
||||
* @param {object} data
|
||||
*
|
||||
* @listens event:drupalContextualLinkAdded
|
||||
*/
|
||||
$(document).on('drupalContextualLinkAdded', function (event, data) {
|
||||
if (data.$region.is('[data-quickedit-entity-id]')) {
|
||||
// If the contextual link is cached on the client side, an entity instance
|
||||
// will not yet have been assigned. So assign one.
|
||||
if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
|
||||
data.$region.once('quickedit');
|
||||
processEntity(data.$region.get(0));
|
||||
}
|
||||
var 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.
|
||||
*
|
||||
* @param {HTMLElement} bodyElement
|
||||
* This document's body element.
|
||||
*/
|
||||
function initQuickEdit(bodyElement) {
|
||||
Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
|
||||
Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
|
||||
|
||||
// Instantiate AppModel (application state) and AppView, which is the
|
||||
// controller of the whole in-place editing experience.
|
||||
Drupal.quickedit.app = new Drupal.quickedit.AppView({
|
||||
el: bodyElement,
|
||||
model: new Drupal.quickedit.AppModel(),
|
||||
entitiesCollection: Drupal.quickedit.collections.entities,
|
||||
fieldsCollection: Drupal.quickedit.collections.fields
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the entity an instance ID.
|
||||
*
|
||||
* @param {HTMLElement} entityElement
|
||||
* A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
|
||||
* attribute.
|
||||
*/
|
||||
function processEntity(entityElement) {
|
||||
var entityID = entityElement.getAttribute('data-quickedit-entity-id');
|
||||
if (!entityInstancesTracker.hasOwnProperty(entityID)) {
|
||||
entityInstancesTracker[entityID] = 0;
|
||||
}
|
||||
else {
|
||||
entityInstancesTracker[entityID]++;
|
||||
}
|
||||
|
||||
// Set the calculated entity instance ID for this element.
|
||||
var entityInstanceID = entityInstancesTracker[entityID];
|
||||
entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the field's metadata; queue or initialize it (if EntityModel exists).
|
||||
*
|
||||
* @param {HTMLElement} fieldElement
|
||||
* A Drupal Field API field's DOM element with a data-quickedit-field-id
|
||||
* attribute.
|
||||
*/
|
||||
function processField(fieldElement) {
|
||||
var metadata = Drupal.quickedit.metadata;
|
||||
var fieldID = fieldElement.getAttribute('data-quickedit-field-id');
|
||||
var entityID = extractEntityID(fieldID);
|
||||
// Figure out the instance ID by looking at the ancestor
|
||||
// [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
|
||||
// attribute.
|
||||
var entityElementSelector = '[data-quickedit-entity-id="' + entityID + '"]';
|
||||
var entityElement = $(fieldElement).closest(entityElementSelector);
|
||||
// In the case of a full entity view page, the entity title is rendered
|
||||
// outside of "the entity DOM node": it's rendered as the page title. So in
|
||||
// this case, we must find the entity in the mandatory "content" region.
|
||||
if (entityElement.length === 0) {
|
||||
entityElement = $('.region-content').find(entityElementSelector);
|
||||
}
|
||||
var entityInstanceID = entityElement
|
||||
.get(0)
|
||||
.getAttribute('data-quickedit-entity-instance-id');
|
||||
|
||||
// Early-return if metadata for this field is missing.
|
||||
if (!metadata.has(fieldID)) {
|
||||
fieldsMetadataQueue.push({
|
||||
el: fieldElement,
|
||||
fieldID: fieldID,
|
||||
entityID: entityID,
|
||||
entityInstanceID: entityInstanceID
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Early-return if the user is not allowed to in-place edit this field.
|
||||
if (metadata.get(fieldID, 'access') !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an EntityModel for this field already exists (and hence also a "Quick
|
||||
// edit" contextual link), then initialize it immediately.
|
||||
if (Drupal.quickedit.collections.entities.findWhere({entityID: entityID, entityInstanceID: entityInstanceID})) {
|
||||
initializeField(fieldElement, fieldID, entityID, entityInstanceID);
|
||||
}
|
||||
// Otherwise: queue the field. It is now available to be set up when its
|
||||
// corresponding entity becomes in-place editable.
|
||||
else {
|
||||
fieldsAvailableQueue.push({el: fieldElement, fieldID: fieldID, entityID: entityID, entityInstanceID: entityInstanceID});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a field; create FieldModel.
|
||||
*
|
||||
* @param {HTMLElement} fieldElement
|
||||
* The field's DOM element.
|
||||
* @param {string} fieldID
|
||||
* The field's ID.
|
||||
* @param {string} entityID
|
||||
* The field's entity's ID.
|
||||
* @param {string} entityInstanceID
|
||||
* The field's entity's instance ID.
|
||||
*/
|
||||
function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
|
||||
var entity = Drupal.quickedit.collections.entities.findWhere({
|
||||
entityID: entityID,
|
||||
entityInstanceID: entityInstanceID
|
||||
});
|
||||
|
||||
$(fieldElement).addClass('quickedit-field');
|
||||
|
||||
// The FieldModel stores the state of an in-place editable entity field.
|
||||
var field = new Drupal.quickedit.FieldModel({
|
||||
el: fieldElement,
|
||||
fieldID: fieldID,
|
||||
id: fieldID + '[' + entity.get('entityInstanceID') + ']',
|
||||
entity: entity,
|
||||
metadata: Drupal.quickedit.metadata.get(fieldID),
|
||||
acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app)
|
||||
});
|
||||
|
||||
// Track all fields on the page.
|
||||
Drupal.quickedit.collections.fields.add(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches metadata for fields whose metadata is missing.
|
||||
*
|
||||
* Fields whose metadata is missing are tracked at fieldsMetadataQueue.
|
||||
*
|
||||
* @param {function} callback
|
||||
* A callback function that receives field elements whose metadata will just
|
||||
* have been fetched.
|
||||
*/
|
||||
function fetchMissingMetadata(callback) {
|
||||
if (fieldsMetadataQueue.length) {
|
||||
var fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
|
||||
var fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
|
||||
var entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
|
||||
// Ensure we only request entityIDs for which we don't have metadata yet.
|
||||
entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
|
||||
fieldsMetadataQueue = [];
|
||||
|
||||
$.ajax({
|
||||
url: Drupal.url('quickedit/metadata'),
|
||||
type: 'POST',
|
||||
data: {
|
||||
'fields[]': fieldIDs,
|
||||
'entities[]': entityIDs
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function (results) {
|
||||
// Store the metadata.
|
||||
_.each(results, function (fieldMetadata, fieldID) {
|
||||
Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
|
||||
});
|
||||
|
||||
callback(fieldElementsWithoutMetadata);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads missing in-place editor's attachments (JavaScript and CSS files).
|
||||
*
|
||||
* Missing in-place editors are those whose fields are actively being used on
|
||||
* the page but don't have.
|
||||
*
|
||||
* @param {function} callback
|
||||
* Callback function to be called when the missing in-place editors (if any)
|
||||
* have been inserted into the DOM. i.e. they may still be loading.
|
||||
*/
|
||||
function loadMissingEditors(callback) {
|
||||
var loadedEditors = _.keys(Drupal.quickedit.editors);
|
||||
var missingEditors = [];
|
||||
Drupal.quickedit.collections.fields.each(function (fieldModel) {
|
||||
var metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
|
||||
if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
|
||||
missingEditors.push(metadata.editor);
|
||||
// Set a stub, to prevent subsequent calls to loadMissingEditors() from
|
||||
// loading the same in-place editor again. Loading an in-place editor
|
||||
// requires talking to a server, to download its JavaScript, then
|
||||
// executing its JavaScript, and only then its Drupal.quickedit.editors
|
||||
// entry will be set.
|
||||
Drupal.quickedit.editors[metadata.editor] = false;
|
||||
}
|
||||
});
|
||||
missingEditors = _.uniq(missingEditors);
|
||||
if (missingEditors.length === 0) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// @see https://www.drupal.org/node/2029999.
|
||||
// Create a Drupal.Ajax instance to load the form.
|
||||
var loadEditorsAjax = Drupal.ajax({
|
||||
url: Drupal.url('quickedit/attachments'),
|
||||
submit: {'editors[]': missingEditors}
|
||||
});
|
||||
// Implement a scoped insert AJAX command: calls the callback after all AJAX
|
||||
// command functions have been executed (hence the deferred calling).
|
||||
var realInsert = Drupal.AjaxCommands.prototype.insert;
|
||||
loadEditorsAjax.commands.insert = function (ajax, response, status) {
|
||||
_.defer(callback);
|
||||
realInsert(ajax, response, status);
|
||||
};
|
||||
// Trigger the AJAX request, which will should return AJAX commands to
|
||||
// insert any missing attachments.
|
||||
loadEditorsAjax.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to set up a "Quick edit" link and corresponding EntityModel.
|
||||
*
|
||||
* @param {object} contextualLink
|
||||
* An object with the following properties:
|
||||
* - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
|
||||
* "block_content/5".
|
||||
* - String entityInstanceID: a Quick Edit entity instance identifier,
|
||||
* e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
|
||||
* instance of this entity).
|
||||
* - DOM el: element pointing to the contextual links placeholder for this
|
||||
* entity.
|
||||
* - DOM region: element pointing to the contextual region of this entity.
|
||||
*
|
||||
* @return {bool}
|
||||
* Returns true when a contextual the given contextual link metadata can be
|
||||
* removed from the queue (either because the contextual link has been set
|
||||
* up or because it is certain that in-place editing is not allowed for any
|
||||
* of its fields). Returns false otherwise.
|
||||
*/
|
||||
function initializeEntityContextualLink(contextualLink) {
|
||||
var metadata = Drupal.quickedit.metadata;
|
||||
// Check if the user has permission to edit at least one of them.
|
||||
function hasFieldWithPermission(fieldIDs) {
|
||||
for (var i = 0; i < fieldIDs.length; i++) {
|
||||
var fieldID = fieldIDs[i];
|
||||
if (metadata.get(fieldID, 'access') === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Checks if the metadata for all given field IDs exists.
|
||||
function allMetadataExists(fieldIDs) {
|
||||
return fieldIDs.length === metadata.intersection(fieldIDs).length;
|
||||
}
|
||||
|
||||
// Find all fields for this entity instance and collect their field IDs.
|
||||
var fields = _.where(fieldsAvailableQueue, {
|
||||
entityID: contextualLink.entityID,
|
||||
entityInstanceID: contextualLink.entityInstanceID
|
||||
});
|
||||
var fieldIDs = _.pluck(fields, 'fieldID');
|
||||
|
||||
// No fields found yet.
|
||||
if (fieldIDs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// The entity for the given contextual link contains at least one field that
|
||||
// the current user may edit in-place; instantiate EntityModel,
|
||||
// EntityDecorationView and ContextualLinkView.
|
||||
else if (hasFieldWithPermission(fieldIDs)) {
|
||||
var entityModel = new Drupal.quickedit.EntityModel({
|
||||
el: contextualLink.region,
|
||||
entityID: contextualLink.entityID,
|
||||
entityInstanceID: contextualLink.entityInstanceID,
|
||||
id: contextualLink.entityID + '[' + contextualLink.entityInstanceID + ']',
|
||||
label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label')
|
||||
});
|
||||
Drupal.quickedit.collections.entities.add(entityModel);
|
||||
// Create an EntityDecorationView associated with the root DOM node of the
|
||||
// entity.
|
||||
var entityDecorationView = new Drupal.quickedit.EntityDecorationView({
|
||||
el: contextualLink.region,
|
||||
model: entityModel
|
||||
});
|
||||
entityModel.set('entityDecorationView', entityDecorationView);
|
||||
|
||||
// Initialize all queued fields within this entity (creates FieldModels).
|
||||
_.each(fields, function (field) {
|
||||
initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
|
||||
});
|
||||
fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
|
||||
|
||||
// Initialization should only be called once. Use Underscore's once method
|
||||
// to get a one-time use version of the function.
|
||||
var initContextualLink = _.once(function () {
|
||||
var $links = $(contextualLink.el).find('.contextual-links');
|
||||
var contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
|
||||
el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
|
||||
model: entityModel,
|
||||
appModel: Drupal.quickedit.app.model
|
||||
}, options));
|
||||
entityModel.set('contextualLinkView', contextualLinkView);
|
||||
});
|
||||
|
||||
// Set up ContextualLinkView after loading any missing in-place editors.
|
||||
loadMissingEditors(initContextualLink);
|
||||
|
||||
return true;
|
||||
}
|
||||
// There was not at least one field that the current user may edit in-place,
|
||||
// even though the metadata for all fields within this entity is available.
|
||||
else if (allMetadataExists(fieldIDs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete models and queue items that are contained within a given context.
|
||||
*
|
||||
* Deletes any contained EntityModels (plus their associated FieldModels and
|
||||
* ContextualLinkView) and FieldModels, as well as the corresponding queues.
|
||||
*
|
||||
* After EntityModels, FieldModels must also be deleted, because it is
|
||||
* possible in Drupal for a field DOM element to exist outside of the entity
|
||||
* DOM element, e.g. when viewing the full node, the title of the node is not
|
||||
* rendered within the node (the entity) but as the page title.
|
||||
*
|
||||
* Note: this will not delete an entity that is actively being in-place
|
||||
* edited.
|
||||
*
|
||||
* @param {jQuery} $context
|
||||
* The context within which to delete.
|
||||
*/
|
||||
function deleteContainedModelsAndQueues($context) {
|
||||
$context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each(function (index, entityElement) {
|
||||
// Delete entity model.
|
||||
var entityModel = Drupal.quickedit.collections.entities.findWhere({el: entityElement});
|
||||
if (entityModel) {
|
||||
var contextualLinkView = entityModel.get('contextualLinkView');
|
||||
contextualLinkView.undelegateEvents();
|
||||
contextualLinkView.remove();
|
||||
// Remove the EntityDecorationView.
|
||||
entityModel.get('entityDecorationView').remove();
|
||||
// Destroy the EntityModel; this will also destroy its FieldModels.
|
||||
entityModel.destroy();
|
||||
}
|
||||
|
||||
// Filter queue.
|
||||
function hasOtherRegion(contextualLink) {
|
||||
return contextualLink.region !== entityElement;
|
||||
}
|
||||
|
||||
contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
|
||||
});
|
||||
|
||||
$context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each(function (index, fieldElement) {
|
||||
// Delete field models.
|
||||
Drupal.quickedit.collections.fields.chain()
|
||||
.filter(function (fieldModel) { return fieldModel.get('el') === fieldElement; })
|
||||
.invoke('destroy');
|
||||
|
||||
// Filter queues.
|
||||
function hasOtherFieldElement(field) {
|
||||
return field.el !== fieldElement;
|
||||
}
|
||||
|
||||
fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
|
||||
fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
|
||||
});
|
||||
}
|
||||
|
||||
})(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage);
|
179
core/modules/quickedit/js/theme.js
Normal file
179
core/modules/quickedit/js/theme.js
Normal file
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* @file
|
||||
* Provides theme functions for all of Quick Edit's client-side HTML.
|
||||
*/
|
||||
|
||||
(function ($, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Theme function for a "backstage" for the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} settings.id
|
||||
* The id to apply to the backstage.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditBackstage = function (settings) {
|
||||
var html = '';
|
||||
html += '<div id="' + settings.id + '" />';
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for a toolbar container of the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} settings.id
|
||||
* the id to apply to the backstage.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditEntityToolbar = function (settings) {
|
||||
var html = '';
|
||||
html += '<div id="' + settings.id + '" class="quickedit quickedit-toolbar-container clearfix">';
|
||||
html += '<i class="quickedit-toolbar-pointer"></i>';
|
||||
html += '<div class="quickedit-toolbar-content">';
|
||||
html += '<div class="quickedit-toolbar quickedit-toolbar-entity clearfix icon icon-pencil">';
|
||||
html += '<div class="quickedit-toolbar-label" />';
|
||||
html += '</div>';
|
||||
html += '<div class="quickedit-toolbar quickedit-toolbar-field clearfix" />';
|
||||
html += '</div><div class="quickedit-toolbar-lining"></div></div>';
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for a toolbar container of the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} settings.entityLabel
|
||||
* The title of the active entity.
|
||||
* @param {string} settings.fieldLabel
|
||||
* The label of the highlighted or active field.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditEntityToolbarLabel = function (settings) {
|
||||
return '<span class="field">' + settings.fieldLabel + '</span>' + settings.entityLabel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Element defining a containing box for the placement of the entity toolbar.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditEntityToolbarFence = function () {
|
||||
return '<div id="quickedit-toolbar-fence" />';
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for a toolbar container of the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} settings.id
|
||||
* The id to apply to the toolbar container.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditFieldToolbar = function (settings) {
|
||||
return '<div id="' + settings.id + '" />';
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for a toolbar toolgroup of the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} [settings.id]
|
||||
* The id of the toolgroup.
|
||||
* @param {string} settings.classes
|
||||
* The class of the toolgroup.
|
||||
* @param {Array} settings.buttons
|
||||
* See {@link Drupal.theme.quickeditButtons}.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditToolgroup = function (settings) {
|
||||
// Classes.
|
||||
var classes = (settings.classes || []);
|
||||
classes.unshift('quickedit-toolgroup');
|
||||
var html = '';
|
||||
html += '<div class="' + classes.join(' ') + '"';
|
||||
if (settings.id) {
|
||||
html += ' id="' + settings.id + '"';
|
||||
}
|
||||
html += '>';
|
||||
html += Drupal.theme('quickeditButtons', {buttons: settings.buttons});
|
||||
html += '</div>';
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for buttons of the Quick Edit module.
|
||||
*
|
||||
* Can be used for the buttons both in the toolbar toolgroups and in the
|
||||
* modal.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {Array} settings.buttons
|
||||
* - String type: the type of the button (defaults to 'button')
|
||||
* - Array classes: the classes of the button.
|
||||
* - String label: the label of the button.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditButtons = function (settings) {
|
||||
var html = '';
|
||||
for (var i = 0; i < settings.buttons.length; i++) {
|
||||
var button = settings.buttons[i];
|
||||
if (!button.hasOwnProperty('type')) {
|
||||
button.type = 'button';
|
||||
}
|
||||
// Attributes.
|
||||
var attributes = [];
|
||||
var attrMap = settings.buttons[i].attributes || {};
|
||||
for (var attr in attrMap) {
|
||||
if (attrMap.hasOwnProperty(attr)) {
|
||||
attributes.push(attr + ((attrMap[attr]) ? '="' + attrMap[attr] + '"' : ''));
|
||||
}
|
||||
}
|
||||
html += '<button type="' + button.type + '" class="' + button.classes + '"' + ' ' + attributes.join(' ') + '>';
|
||||
html += button.label;
|
||||
html += '</button>';
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme function for a form container of the Quick Edit module.
|
||||
*
|
||||
* @param {object} settings
|
||||
* @param {string} settings.id
|
||||
* The id to apply to the toolbar container.
|
||||
* @param {string} settings.loadingMsg
|
||||
* The message to show while loading.
|
||||
*
|
||||
* @return {string}
|
||||
* The corresponding HTML.
|
||||
*/
|
||||
Drupal.theme.quickeditFormContainer = function (settings) {
|
||||
var html = '';
|
||||
html += '<div id="' + settings.id + '" class="quickedit-form-container">';
|
||||
html += ' <div class="quickedit-form">';
|
||||
html += ' <div class="placeholder">';
|
||||
html += settings.loadingMsg;
|
||||
html += ' </div>';
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
};
|
||||
|
||||
})(jQuery, Drupal);
|
208
core/modules/quickedit/js/util.js
Normal file
208
core/modules/quickedit/js/util.js
Normal file
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* @file
|
||||
* Provides utility functions for Quick Edit.
|
||||
*/
|
||||
|
||||
(function ($, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
*/
|
||||
Drupal.quickedit.util = Drupal.quickedit.util || {};
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
*/
|
||||
Drupal.quickedit.util.constants = {};
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
Drupal.quickedit.util.constants.transitionEnd = "transitionEnd.quickedit webkitTransitionEnd.quickedit transitionend.quickedit msTransitionEnd.quickedit oTransitionEnd.quickedit";
|
||||
|
||||
/**
|
||||
* Converts a field id into a formatted url path.
|
||||
*
|
||||
* @example
|
||||
* Drupal.quickedit.util.buildUrl(
|
||||
* 'node/1/body/und/full',
|
||||
* '/quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode'
|
||||
* );
|
||||
*
|
||||
* @param {string} id
|
||||
* The id of an editable field.
|
||||
* @param {string} urlFormat
|
||||
* The Controller route for field processing.
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
Drupal.quickedit.util.buildUrl = function (id, urlFormat) {
|
||||
var parts = id.split('/');
|
||||
return Drupal.formatString(decodeURIComponent(urlFormat), {
|
||||
'!entity_type': parts[0],
|
||||
'!id': parts[1],
|
||||
'!field_name': parts[2],
|
||||
'!langcode': parts[3],
|
||||
'!view_mode': parts[4]
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows a network error modal dialog.
|
||||
*
|
||||
* @param {string} title
|
||||
* The title to use in the modal dialog.
|
||||
* @param {string} message
|
||||
* The message to use in the modal dialog.
|
||||
*/
|
||||
Drupal.quickedit.util.networkErrorModal = function (title, message) {
|
||||
var $message = $('<div>' + message + '</div>');
|
||||
var networkErrorModal = Drupal.dialog($message.get(0), {
|
||||
title: title,
|
||||
dialogClass: 'quickedit-network-error',
|
||||
buttons: [
|
||||
{
|
||||
text: Drupal.t('OK'),
|
||||
click: function () {
|
||||
networkErrorModal.close();
|
||||
},
|
||||
primary: true
|
||||
}
|
||||
],
|
||||
create: function () {
|
||||
$(this).parent().find('.ui-dialog-titlebar-close').remove();
|
||||
},
|
||||
close: function (event) {
|
||||
// Automatically destroy the DOM element that was used for the dialog.
|
||||
$(event.target).remove();
|
||||
}
|
||||
});
|
||||
networkErrorModal.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace
|
||||
*/
|
||||
Drupal.quickedit.util.form = {
|
||||
|
||||
/**
|
||||
* Loads a form, calls a callback to insert.
|
||||
*
|
||||
* Leverages {@link Drupal.Ajax}' ability to have scoped (per-instance)
|
||||
* command implementations to be able to call a callback.
|
||||
*
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {string} options.fieldID
|
||||
* The field ID that uniquely identifies the field for which this form
|
||||
* will be loaded.
|
||||
* @param {bool} options.nocssjs
|
||||
* Boolean indicating whether no CSS and JS should be returned (necessary
|
||||
* when the form is invisible to the user).
|
||||
* @param {bool} options.reset
|
||||
* Boolean indicating whether the data stored for this field's entity in
|
||||
* PrivateTempStore should be used or reset.
|
||||
* @param {function} callback
|
||||
* A callback function that will receive the form to be inserted, as well
|
||||
* as the ajax object, necessary if the callback wants to perform other
|
||||
* Ajax commands.
|
||||
*/
|
||||
load: function (options, callback) {
|
||||
var fieldID = options.fieldID;
|
||||
|
||||
// Create a Drupal.ajax instance to load the form.
|
||||
var formLoaderAjax = Drupal.ajax({
|
||||
url: Drupal.quickedit.util.buildUrl(fieldID, Drupal.url('quickedit/form/!entity_type/!id/!field_name/!langcode/!view_mode')),
|
||||
submit: {
|
||||
nocssjs: options.nocssjs,
|
||||
reset: options.reset
|
||||
},
|
||||
error: function (xhr, url) {
|
||||
// Show a modal to inform the user of the network error.
|
||||
var fieldLabel = Drupal.quickedit.metadata.get(fieldID, 'label');
|
||||
var message = Drupal.t('Could not load the form for <q>@field-label</q>, either due to a website problem or a network connection problem.<br>Please try again.', {'@field-label': fieldLabel});
|
||||
Drupal.quickedit.util.networkErrorModal(Drupal.t('Sorry!'), message);
|
||||
|
||||
// Change the state back to "candidate", to allow the user to start
|
||||
// in-place editing of the field again.
|
||||
var fieldModel = Drupal.quickedit.app.model.get('activeField');
|
||||
fieldModel.set('state', 'candidate');
|
||||
}
|
||||
});
|
||||
// Implement a scoped quickeditFieldForm AJAX command: calls the callback.
|
||||
formLoaderAjax.commands.quickeditFieldForm = function (ajax, response, status) {
|
||||
callback(response.data, ajax);
|
||||
Drupal.ajax.instances[this.instanceIndex] = null;
|
||||
};
|
||||
// This will ensure our scoped quickeditFieldForm AJAX command gets
|
||||
// called.
|
||||
formLoaderAjax.execute();
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a {@link Drupal.Ajax} instance that is used to save a form.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {bool} options.nocssjs
|
||||
* Boolean indicating whether no CSS and JS should be returned (necessary
|
||||
* when the form is invisible to the user).
|
||||
* @param {Array.<string>} options.other_view_modes
|
||||
* Array containing view mode IDs (of other instances of this field on the
|
||||
* page).
|
||||
* @param {jQuery} $submit
|
||||
*
|
||||
* @return {Drupal.Ajax}
|
||||
* A {@link Drupal.Ajax} instance.
|
||||
*/
|
||||
ajaxifySaving: function (options, $submit) {
|
||||
// Re-wire the form to handle submit.
|
||||
var settings = {
|
||||
url: $submit.closest('form').attr('action'),
|
||||
setClick: true,
|
||||
event: 'click.quickedit',
|
||||
progress: false,
|
||||
submit: {
|
||||
nocssjs: options.nocssjs,
|
||||
other_view_modes: options.other_view_modes
|
||||
},
|
||||
|
||||
/**
|
||||
* Reimplement the success handler.
|
||||
*
|
||||
* Ensure {@link Drupal.attachBehaviors} does not get called on the
|
||||
* form.
|
||||
*
|
||||
* @param {Drupal.AjaxCommands~commandDefinition} response
|
||||
* @param {number} [status]
|
||||
*/
|
||||
success: function (response, status) {
|
||||
for (var i in response) {
|
||||
if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) {
|
||||
this.commands[response[i].command](this, response[i], status);
|
||||
}
|
||||
}
|
||||
},
|
||||
base: $submit.attr('id'),
|
||||
element: $submit[0]
|
||||
};
|
||||
|
||||
return Drupal.ajax(settings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans up the {@link Drupal.Ajax} instance that is used to save the form.
|
||||
*
|
||||
* @param {Drupal.Ajax} ajax
|
||||
* A {@link Drupal.Ajax} instance that was returned by
|
||||
* {@link Drupal.quickedit.form.ajaxifySaving}.
|
||||
*/
|
||||
unajaxifySaving: function (ajax) {
|
||||
$(ajax.element).off('click.quickedit');
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
})(jQuery, Drupal);
|
599
core/modules/quickedit/js/views/AppView.js
Normal file
599
core/modules/quickedit/js/views/AppView.js
Normal file
|
@ -0,0 +1,599 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone View that controls the overall "in-place editing application".
|
||||
*
|
||||
* @see Drupal.quickedit.AppModel
|
||||
*/
|
||||
|
||||
(function ($, _, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
// Indicates whether the page should be reloaded after in-place editing has
|
||||
// shut down. A page reload is necessary to re-instate the original HTML of
|
||||
// the edited fields if in-place editing has been canceled and one or more of
|
||||
// the entity's fields were saved to PrivateTempStore: one of them may have
|
||||
// been changed to the empty value and hence may have been rerendered as the
|
||||
// empty string, which makes it impossible for Quick Edit to know where to
|
||||
// restore the original HTML.
|
||||
var reload = false;
|
||||
|
||||
Drupal.quickedit.AppView = Backbone.View.extend(/** @lends Drupal.quickedit.AppView# */{
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {Drupal.quickedit.AppModel} options.model
|
||||
* The application state model.
|
||||
* @param {Drupal.quickedit.EntityCollection} options.entitiesCollection
|
||||
* All on-page entities.
|
||||
* @param {Drupal.quickedit.FieldCollection} options.fieldsCollection
|
||||
* All on-page fields
|
||||
*/
|
||||
initialize: function (options) {
|
||||
// AppView's configuration for handling states.
|
||||
// @see Drupal.quickedit.FieldModel.states
|
||||
this.activeFieldStates = ['activating', 'active'];
|
||||
this.singleFieldStates = ['highlighted', 'activating', 'active'];
|
||||
this.changedFieldStates = ['changed', 'saving', 'saved', 'invalid'];
|
||||
this.readyFieldStates = ['candidate', 'highlighted'];
|
||||
|
||||
this.listenTo(options.entitiesCollection, {
|
||||
// Track app state.
|
||||
'change:state': this.appStateChange,
|
||||
'change:isActive': this.enforceSingleActiveEntity
|
||||
});
|
||||
|
||||
// Track app state.
|
||||
this.listenTo(options.fieldsCollection, 'change:state', this.editorStateChange);
|
||||
// Respond to field model HTML representation change events.
|
||||
this.listenTo(options.fieldsCollection, 'change:html', this.renderUpdatedField);
|
||||
this.listenTo(options.fieldsCollection, 'change:html', this.propagateUpdatedField);
|
||||
// Respond to addition.
|
||||
this.listenTo(options.fieldsCollection, 'add', this.rerenderedFieldToCandidate);
|
||||
// Respond to destruction.
|
||||
this.listenTo(options.fieldsCollection, 'destroy', this.teardownEditor);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles setup/teardown and state changes when the active entity changes.
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} entityModel
|
||||
* An instance of the EntityModel class.
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.EntityModel.states}.
|
||||
*/
|
||||
appStateChange: function (entityModel, state) {
|
||||
var app = this;
|
||||
var entityToolbarView;
|
||||
switch (state) {
|
||||
case 'launching':
|
||||
reload = false;
|
||||
// First, create an entity toolbar view.
|
||||
entityToolbarView = new Drupal.quickedit.EntityToolbarView({
|
||||
model: entityModel,
|
||||
appModel: this.model
|
||||
});
|
||||
entityModel.toolbarView = entityToolbarView;
|
||||
// Second, set up in-place editors.
|
||||
// They must be notified of state changes, hence this must happen
|
||||
// while the associated fields are still in the 'inactive' state.
|
||||
entityModel.get('fields').each(function (fieldModel) {
|
||||
app.setupEditor(fieldModel);
|
||||
});
|
||||
// Third, transition the entity to the 'opening' state, which will
|
||||
// transition all fields from 'inactive' to 'candidate'.
|
||||
_.defer(function () {
|
||||
entityModel.set('state', 'opening');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
entityToolbarView = entityModel.toolbarView;
|
||||
// First, tear down the in-place editors.
|
||||
entityModel.get('fields').each(function (fieldModel) {
|
||||
app.teardownEditor(fieldModel);
|
||||
});
|
||||
// Second, tear down the entity toolbar view.
|
||||
if (entityToolbarView) {
|
||||
entityToolbarView.remove();
|
||||
delete entityModel.toolbarView;
|
||||
}
|
||||
// A page reload may be necessary to re-instate the original HTML of
|
||||
// the edited fields.
|
||||
if (reload) {
|
||||
reload = false;
|
||||
location.reload();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Accepts or reject editor (Editor) state changes.
|
||||
*
|
||||
* This is what ensures that the app is in control of what happens.
|
||||
*
|
||||
* @param {string} from
|
||||
* The previous state.
|
||||
* @param {string} to
|
||||
* The new state.
|
||||
* @param {null|object} context
|
||||
* The context that is trying to trigger the state change.
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The fieldModel to which this change applies.
|
||||
*
|
||||
* @return {bool}
|
||||
*/
|
||||
acceptEditorStateChange: function (from, to, context, fieldModel) {
|
||||
var accept = true;
|
||||
|
||||
// If the app is in view mode, then reject all state changes except for
|
||||
// those to 'inactive'.
|
||||
if (context && (context.reason === 'stop' || context.reason === 'rerender')) {
|
||||
if (from === 'candidate' && to === 'inactive') {
|
||||
accept = true;
|
||||
}
|
||||
}
|
||||
// Handling of edit mode state changes is more granular.
|
||||
else {
|
||||
// In general, enforce the states sequence. Disallow going back from a
|
||||
// "later" state to an "earlier" state, except in explicitly allowed
|
||||
// cases.
|
||||
if (!Drupal.quickedit.FieldModel.followsStateSequence(from, to)) {
|
||||
accept = false;
|
||||
// Allow: activating/active -> candidate.
|
||||
// Necessary to stop editing a field.
|
||||
if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: changed/invalid -> candidate.
|
||||
// Necessary to stop editing a field when it is changed or invalid.
|
||||
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: highlighted -> candidate.
|
||||
// Necessary to stop highlighting a field.
|
||||
else if (from === 'highlighted' && to === 'candidate') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: saved -> candidate.
|
||||
// Necessary when successfully saved a field.
|
||||
else if (from === 'saved' && to === 'candidate') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: invalid -> saving.
|
||||
// Necessary to be able to save a corrected, invalid field.
|
||||
else if (from === 'invalid' && to === 'saving') {
|
||||
accept = true;
|
||||
}
|
||||
// Allow: invalid -> activating.
|
||||
// Necessary to be able to correct a field that turned out to be
|
||||
// invalid after the user already had moved on to the next field
|
||||
// (which we explicitly allow to have a fluent UX).
|
||||
else if (from === 'invalid' && to === 'activating') {
|
||||
accept = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not against the general principle, then here are more
|
||||
// disallowed cases to check.
|
||||
if (accept) {
|
||||
var activeField;
|
||||
var activeFieldState;
|
||||
// Ensure only one field (editor) at a time is active … but allow a
|
||||
// user to hop from one field to the next, even if we still have to
|
||||
// start saving the field that is currently active: assume it will be
|
||||
// valid, to allow for a fluent UX. (If it turns out to be invalid,
|
||||
// this block of code also handles that.)
|
||||
if ((this.readyFieldStates.indexOf(from) !== -1 || from === 'invalid') && this.activeFieldStates.indexOf(to) !== -1) {
|
||||
activeField = this.model.get('activeField');
|
||||
if (activeField && activeField !== fieldModel) {
|
||||
activeFieldState = activeField.get('state');
|
||||
// Allow the state change. If the state of the active field is:
|
||||
// - 'activating' or 'active': change it to 'candidate'
|
||||
// - 'changed' or 'invalid': change it to 'saving'
|
||||
// - 'saving' or 'saved': don't do anything.
|
||||
if (this.activeFieldStates.indexOf(activeFieldState) !== -1) {
|
||||
activeField.set('state', 'candidate');
|
||||
}
|
||||
else if (activeFieldState === 'changed' || activeFieldState === 'invalid') {
|
||||
activeField.set('state', 'saving');
|
||||
}
|
||||
|
||||
// If the field that's being activated is in fact already in the
|
||||
// invalid state (which can only happen because above we allowed
|
||||
// the user to move on to another field to allow for a fluent UX;
|
||||
// we assumed it would be saved successfully), then we shouldn't
|
||||
// allow the field to enter the 'activating' state, instead, we
|
||||
// simply change the active editor. All guarantees and
|
||||
// assumptions for this field still hold!
|
||||
if (from === 'invalid') {
|
||||
this.model.set('activeField', fieldModel);
|
||||
accept = false;
|
||||
}
|
||||
// Do not reject: the field is either in the 'candidate' or
|
||||
// 'highlighted' state and we allow it to enter the 'activating'
|
||||
// state!
|
||||
}
|
||||
}
|
||||
// Reject going from activating/active to candidate because of a
|
||||
// mouseleave.
|
||||
else if (_.indexOf(this.activeFieldStates, from) !== -1 && to === 'candidate') {
|
||||
if (context && context.reason === 'mouseleave') {
|
||||
accept = false;
|
||||
}
|
||||
}
|
||||
// When attempting to stop editing a changed/invalid property, ask for
|
||||
// confirmation.
|
||||
else if ((from === 'changed' || from === 'invalid') && to === 'candidate') {
|
||||
if (context && context.reason === 'mouseleave') {
|
||||
accept = false;
|
||||
}
|
||||
else {
|
||||
// Check whether the transition has been confirmed?
|
||||
if (context && context.confirmed) {
|
||||
accept = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accept;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets up the in-place editor for the given field.
|
||||
*
|
||||
* Must happen before the fieldModel's state is changed to 'candidate'.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The field for which an in-place editor must be set up.
|
||||
*/
|
||||
setupEditor: function (fieldModel) {
|
||||
// Get the corresponding entity toolbar.
|
||||
var entityModel = fieldModel.get('entity');
|
||||
var entityToolbarView = entityModel.toolbarView;
|
||||
// Get the field toolbar DOM root from the entity toolbar.
|
||||
var fieldToolbarRoot = entityToolbarView.getToolbarRoot();
|
||||
// Create in-place editor.
|
||||
var editorName = fieldModel.get('metadata').editor;
|
||||
var editorModel = new Drupal.quickedit.EditorModel();
|
||||
var editorView = new Drupal.quickedit.editors[editorName]({
|
||||
el: $(fieldModel.get('el')),
|
||||
model: editorModel,
|
||||
fieldModel: fieldModel
|
||||
});
|
||||
|
||||
// Create in-place editor's toolbar for this field — stored inside the
|
||||
// entity toolbar, the entity toolbar will position itself appropriately
|
||||
// above (or below) the edited element.
|
||||
var toolbarView = new Drupal.quickedit.FieldToolbarView({
|
||||
el: fieldToolbarRoot,
|
||||
model: fieldModel,
|
||||
$editedElement: $(editorView.getEditedElement()),
|
||||
editorView: editorView,
|
||||
entityModel: entityModel
|
||||
});
|
||||
|
||||
// Create decoration for edited element: padding if necessary, sets
|
||||
// classes on the element to style it according to the current state.
|
||||
var decorationView = new Drupal.quickedit.FieldDecorationView({
|
||||
el: $(editorView.getEditedElement()),
|
||||
model: fieldModel,
|
||||
editorView: editorView
|
||||
});
|
||||
|
||||
// Track these three views in FieldModel so that we can tear them down
|
||||
// correctly.
|
||||
fieldModel.editorView = editorView;
|
||||
fieldModel.toolbarView = toolbarView;
|
||||
fieldModel.decorationView = decorationView;
|
||||
},
|
||||
|
||||
/**
|
||||
* Tears down the in-place editor for the given field.
|
||||
*
|
||||
* Must happen after the fieldModel's state is changed to 'inactive'.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The field for which an in-place editor must be torn down.
|
||||
*/
|
||||
teardownEditor: function (fieldModel) {
|
||||
// Early-return if this field was not yet decorated.
|
||||
if (typeof fieldModel.editorView === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unbind event handlers; remove toolbar element; delete toolbar view.
|
||||
fieldModel.toolbarView.remove();
|
||||
delete fieldModel.toolbarView;
|
||||
|
||||
// Unbind event handlers; delete decoration view. Don't remove the element
|
||||
// because that would remove the field itself.
|
||||
fieldModel.decorationView.remove();
|
||||
delete fieldModel.decorationView;
|
||||
|
||||
// Unbind event handlers; delete editor view. Don't remove the element
|
||||
// because that would remove the field itself.
|
||||
fieldModel.editorView.remove();
|
||||
delete fieldModel.editorView;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asks the user to confirm whether he wants to stop editing via a modal.
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} entityModel
|
||||
*
|
||||
* @see Drupal.quickedit.AppView#acceptEditorStateChange
|
||||
*/
|
||||
confirmEntityDeactivation: function (entityModel) {
|
||||
var that = this;
|
||||
var discardDialog;
|
||||
|
||||
function closeDiscardDialog(action) {
|
||||
discardDialog.close(action);
|
||||
// The active modal has been removed.
|
||||
that.model.set('activeModal', null);
|
||||
|
||||
// If the targetState is saving, the field must be saved, then the
|
||||
// entity must be saved.
|
||||
if (action === 'save') {
|
||||
entityModel.set('state', 'committing', {confirmed: true});
|
||||
}
|
||||
else {
|
||||
entityModel.set('state', 'deactivating', {confirmed: true});
|
||||
// Editing has been canceled and the changes will not be saved. Mark
|
||||
// the page for reload if the entityModel declares that it requires
|
||||
// a reload.
|
||||
if (entityModel.get('reload')) {
|
||||
reload = true;
|
||||
entityModel.set('reload', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only instantiate if there isn't a modal instance visible yet.
|
||||
if (!this.model.get('activeModal')) {
|
||||
var $unsavedChanges = $('<div>' + Drupal.t('You have unsaved changes') + '</div>');
|
||||
discardDialog = Drupal.dialog($unsavedChanges.get(0), {
|
||||
title: Drupal.t('Discard changes?'),
|
||||
dialogClass: 'quickedit-discard-modal',
|
||||
resizable: false,
|
||||
buttons: [
|
||||
{
|
||||
text: Drupal.t('Save'),
|
||||
click: function () {
|
||||
closeDiscardDialog('save');
|
||||
},
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
text: Drupal.t('Discard changes'),
|
||||
click: function () {
|
||||
closeDiscardDialog('discard');
|
||||
}
|
||||
}
|
||||
],
|
||||
// Prevent this modal from being closed without the user making a
|
||||
// choice as per http://stackoverflow.com/a/5438771.
|
||||
closeOnEscape: false,
|
||||
create: function () {
|
||||
$(this).parent().find('.ui-dialog-titlebar-close').remove();
|
||||
},
|
||||
beforeClose: false,
|
||||
close: function (event) {
|
||||
// Automatically destroy the DOM element that was used for the
|
||||
// dialog.
|
||||
$(event.target).remove();
|
||||
}
|
||||
});
|
||||
this.model.set('activeModal', discardDialog);
|
||||
|
||||
discardDialog.showModal();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reacts to field state changes; tracks global state.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
editorStateChange: function (fieldModel, state) {
|
||||
var from = fieldModel.previous('state');
|
||||
var to = state;
|
||||
|
||||
// Keep track of the highlighted field in the global state.
|
||||
if (_.indexOf(this.singleFieldStates, to) !== -1 && this.model.get('highlightedField') !== fieldModel) {
|
||||
this.model.set('highlightedField', fieldModel);
|
||||
}
|
||||
else if (this.model.get('highlightedField') === fieldModel && to === 'candidate') {
|
||||
this.model.set('highlightedField', null);
|
||||
}
|
||||
|
||||
// Keep track of the active field in the global state.
|
||||
if (_.indexOf(this.activeFieldStates, to) !== -1 && this.model.get('activeField') !== fieldModel) {
|
||||
this.model.set('activeField', fieldModel);
|
||||
}
|
||||
else if (this.model.get('activeField') === fieldModel && to === 'candidate') {
|
||||
// Discarded if it transitions from a changed state to 'candidate'.
|
||||
if (from === 'changed' || from === 'invalid') {
|
||||
fieldModel.editorView.revert();
|
||||
}
|
||||
this.model.set('activeField', null);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Render an updated field (a field whose 'html' attribute changed).
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* The FieldModel whose 'html' attribute changed.
|
||||
* @param {string} html
|
||||
* The updated 'html' attribute.
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {bool} options.propagation
|
||||
* Whether this change to the 'html' attribute occurred because of the
|
||||
* propagation of changes to another instance of this field.
|
||||
*/
|
||||
renderUpdatedField: function (fieldModel, html, options) {
|
||||
// Get data necessary to rerender property before it is unavailable.
|
||||
var $fieldWrapper = $(fieldModel.get('el'));
|
||||
var $context = $fieldWrapper.parent();
|
||||
|
||||
var renderField = function () {
|
||||
// Destroy the field model; this will cause all attached views to be
|
||||
// destroyed too, and removal from all collections in which it exists.
|
||||
fieldModel.destroy();
|
||||
|
||||
// Replace the old content with the new content.
|
||||
$fieldWrapper.replaceWith(html);
|
||||
|
||||
// Attach behaviors again to the modified piece of HTML; this will
|
||||
// create a new field model and call rerenderedFieldToCandidate() with
|
||||
// it.
|
||||
Drupal.attachBehaviors($context.get(0));
|
||||
};
|
||||
|
||||
// When propagating the changes of another instance of this field, this
|
||||
// field is not being actively edited and hence no state changes are
|
||||
// necessary. So: only update the state of this field when the rerendering
|
||||
// of this field happens not because of propagation, but because it is
|
||||
// being edited itself.
|
||||
if (!options.propagation) {
|
||||
// Deferred because renderUpdatedField is reacting to a field model
|
||||
// change event, and we want to make sure that event fully propagates
|
||||
// before making another change to the same model.
|
||||
_.defer(function () {
|
||||
// First set the state to 'candidate', to allow all attached views to
|
||||
// clean up all their "active state"-related changes.
|
||||
fieldModel.set('state', 'candidate');
|
||||
|
||||
// Similarly, the above .set() call's change event must fully
|
||||
// propagate before calling it again.
|
||||
_.defer(function () {
|
||||
// Set the field's state to 'inactive', to enable the updating of
|
||||
// its DOM value.
|
||||
fieldModel.set('state', 'inactive', {reason: 'rerender'});
|
||||
|
||||
renderField();
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
renderField();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Propagates changes to an updated field to all instances of that field.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} updatedField
|
||||
* The FieldModel whose 'html' attribute changed.
|
||||
* @param {string} html
|
||||
* The updated 'html' attribute.
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {bool} options.propagation
|
||||
* Whether this change to the 'html' attribute occurred because of the
|
||||
* propagation of changes to another instance of this field.
|
||||
*
|
||||
* @see Drupal.quickedit.AppView#renderUpdatedField
|
||||
*/
|
||||
propagateUpdatedField: function (updatedField, html, options) {
|
||||
// Don't propagate field updates that themselves were caused by
|
||||
// propagation.
|
||||
if (options.propagation) {
|
||||
return;
|
||||
}
|
||||
|
||||
var htmlForOtherViewModes = updatedField.get('htmlForOtherViewModes');
|
||||
Drupal.quickedit.collections.fields
|
||||
// Find all instances of fields that display the same logical field
|
||||
// (same entity, same field, just a different instance and maybe a
|
||||
// different view mode).
|
||||
.where({logicalFieldID: updatedField.get('logicalFieldID')})
|
||||
.forEach(function (field) {
|
||||
// Ignore the field that was already updated.
|
||||
if (field === updatedField) {
|
||||
return;
|
||||
}
|
||||
// If this other instance of the field has the same view mode, we can
|
||||
// update it easily.
|
||||
else if (field.getViewMode() === updatedField.getViewMode()) {
|
||||
field.set('html', updatedField.get('html'));
|
||||
}
|
||||
// If this other instance of the field has a different view mode, and
|
||||
// that is one of the view modes for which a re-rendered version is
|
||||
// available (and that should be the case unless this field was only
|
||||
// added to the page after editing of the updated field began), then
|
||||
// use that view mode's re-rendered version.
|
||||
else {
|
||||
if (field.getViewMode() in htmlForOtherViewModes) {
|
||||
field.set('html', htmlForOtherViewModes[field.getViewMode()], {propagation: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* If the new in-place editable field is for the entity that's currently
|
||||
* being edited, then transition it to the 'candidate' state.
|
||||
*
|
||||
* This happens when a field was modified, saved and hence rerendered.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* A field that was just added to the collection of fields.
|
||||
*/
|
||||
rerenderedFieldToCandidate: function (fieldModel) {
|
||||
var activeEntity = Drupal.quickedit.collections.entities.findWhere({isActive: true});
|
||||
|
||||
// Early-return if there is no active entity.
|
||||
if (!activeEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the field's entity is the active entity, make it a candidate.
|
||||
if (fieldModel.get('entity') === activeEntity) {
|
||||
this.setupEditor(fieldModel);
|
||||
fieldModel.set('state', 'candidate');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* EntityModel Collection change handler.
|
||||
*
|
||||
* Handler is called `change:isActive` and enforces a single active entity.
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} changedEntityModel
|
||||
* The entityModel instance whose active state has changed.
|
||||
*/
|
||||
enforceSingleActiveEntity: function (changedEntityModel) {
|
||||
// When an entity is deactivated, we don't need to enforce anything.
|
||||
if (changedEntityModel.get('isActive') === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This entity was activated; deactivate all other entities.
|
||||
changedEntityModel.collection.chain()
|
||||
.filter(function (entityModel) {
|
||||
return entityModel.get('isActive') === true && entityModel !== changedEntityModel;
|
||||
})
|
||||
.each(function (entityModel) {
|
||||
entityModel.set('state', 'deactivating');
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(jQuery, _, Backbone, Drupal));
|
74
core/modules/quickedit/js/views/ContextualLinkView.js
Normal file
74
core/modules/quickedit/js/views/ContextualLinkView.js
Normal file
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone View that provides a dynamic contextual link.
|
||||
*/
|
||||
|
||||
(function ($, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.ContextualLinkView = Backbone.View.extend(/** @lends Drupal.quickedit.ContextualLinkView# */{
|
||||
|
||||
/**
|
||||
* Define all events to listen to.
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
events: function () {
|
||||
// Prevents delay and simulated mouse events.
|
||||
function touchEndToClick(event) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
|
||||
return {
|
||||
'click a': function (event) {
|
||||
event.preventDefault();
|
||||
this.model.set('state', 'launching');
|
||||
},
|
||||
'touchEnd a': touchEndToClick
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {Drupal.quickedit.EntityModel} options.model
|
||||
* The associated entity's model.
|
||||
* @param {Drupal.quickedit.AppModel} options.appModel
|
||||
* The application state model.
|
||||
* @param {object} options.strings
|
||||
* The strings for the "Quick edit" link.
|
||||
*/
|
||||
initialize: function (options) {
|
||||
// Insert the text of the quick edit toggle.
|
||||
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);
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Drupal.quickedit.EntityModel} entityModel
|
||||
* @param {bool} isActive
|
||||
*
|
||||
* @return {Drupal.quickedit.ContextualLinkView}
|
||||
*/
|
||||
render: function (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);
|
303
core/modules/quickedit/js/views/EditorView.js
Normal file
303
core/modules/quickedit/js/views/EditorView.js
Normal file
|
@ -0,0 +1,303 @@
|
|||
/**
|
||||
* @file
|
||||
* An abstract Backbone View that controls an in-place editor.
|
||||
*/
|
||||
|
||||
(function ($, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.EditorView = Backbone.View.extend(/** @lends Drupal.quickedit.EditorView# */{
|
||||
|
||||
/**
|
||||
* A base implementation that outlines the structure for in-place editors.
|
||||
*
|
||||
* Specific in-place editor implementations should subclass (extend) this
|
||||
* View and override whichever method they deem necessary to override.
|
||||
*
|
||||
* Typically you would want to override this method to set the
|
||||
* originalValue attribute in the FieldModel to such a value that your
|
||||
* in-place editor can revert to the original value when necessary.
|
||||
*
|
||||
* @example
|
||||
* <caption>If you override this method, you should call this
|
||||
* method (the parent class' initialize()) first.</caption>
|
||||
* Drupal.quickedit.EditorView.prototype.initialize.call(this, options);
|
||||
*
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {Drupal.quickedit.EditorModel} options.model
|
||||
* The in-place editor state model.
|
||||
* @param {Drupal.quickedit.FieldModel} options.fieldModel
|
||||
* The field model.
|
||||
*
|
||||
* @see Drupal.quickedit.EditorModel
|
||||
* @see Drupal.quickedit.editors.plain_text
|
||||
*/
|
||||
initialize: function (options) {
|
||||
this.fieldModel = options.fieldModel;
|
||||
this.listenTo(this.fieldModel, 'change:state', this.stateChange);
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
remove: function () {
|
||||
// The el property is the field, which should not be removed. Remove the
|
||||
// pointer to it, then call Backbone.View.prototype.remove().
|
||||
this.setElement();
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the edited element.
|
||||
*
|
||||
* For some single cardinality fields, it may be necessary or useful to
|
||||
* not in-place edit (and hence decorate) the DOM element with the
|
||||
* data-quickedit-field-id attribute (which is the field's wrapper), but a
|
||||
* specific element within the field's wrapper.
|
||||
* e.g. using a WYSIWYG editor on a body field should happen on the DOM
|
||||
* element containing the text itself, not on the field wrapper.
|
||||
*
|
||||
* @return {jQuery}
|
||||
* A jQuery-wrapped DOM element.
|
||||
*
|
||||
* @see Drupal.quickedit.editors.plain_text
|
||||
*/
|
||||
getEditedElement: function () {
|
||||
return this.$el;
|
||||
},
|
||||
|
||||
/**
|
||||
*
|
||||
* @return {object}
|
||||
* Returns 3 Quick Edit UI settings that depend on the in-place editor:
|
||||
* - Boolean padding: indicates whether padding should be applied to the
|
||||
* edited element, to guarantee legibility of text.
|
||||
* - Boolean unifiedToolbar: provides the in-place editor with the ability
|
||||
* to insert its own toolbar UI into Quick Edit's tightly integrated
|
||||
* toolbar.
|
||||
* - Boolean fullWidthToolbar: indicates whether Quick Edit's tightly
|
||||
* integrated toolbar should consume the full width of the element,
|
||||
* rather than being just long enough to accommodate a label.
|
||||
*/
|
||||
getQuickEditUISettings: function () {
|
||||
return {padding: false, unifiedToolbar: false, fullWidthToolbar: false, popup: false};
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines the actions to take given a change of state.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} fieldModel
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
stateChange: function (fieldModel, state) {
|
||||
var from = fieldModel.previous('state');
|
||||
var to = state;
|
||||
switch (to) {
|
||||
case 'inactive':
|
||||
// An in-place editor view will not yet exist in this state, hence
|
||||
// this will never be reached. Listed for sake of completeness.
|
||||
break;
|
||||
|
||||
case 'candidate':
|
||||
// Nothing to do for the typical in-place editor: it should not be
|
||||
// visible yet. Except when we come from the 'invalid' state, then we
|
||||
// clean up.
|
||||
if (from === 'invalid') {
|
||||
this.removeValidationErrors();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'highlighted':
|
||||
// Nothing to do for the typical in-place editor: it should not be
|
||||
// visible yet.
|
||||
break;
|
||||
|
||||
case 'activating':
|
||||
// The user has indicated he wants to do in-place editing: if
|
||||
// something needs to be loaded (CSS/JavaScript/server data/…), then
|
||||
// do so at this stage, and once the in-place editor is ready,
|
||||
// set the 'active' state. A "loading" indicator will be shown in the
|
||||
// UI for as long as the field remains in this state.
|
||||
var loadDependencies = function (callback) {
|
||||
// Do the loading here.
|
||||
callback();
|
||||
};
|
||||
loadDependencies(function () {
|
||||
fieldModel.set('state', 'active');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'active':
|
||||
// The user can now actually use the in-place editor.
|
||||
break;
|
||||
|
||||
case 'changed':
|
||||
// Nothing to do for the typical in-place editor. The UI will show an
|
||||
// indicator that the field has changed.
|
||||
break;
|
||||
|
||||
case 'saving':
|
||||
// When the user has indicated he wants to save his changes to this
|
||||
// field, this state will be entered. If the previous saving attempt
|
||||
// resulted in validation errors, the previous state will be
|
||||
// 'invalid'. Clean up those validation errors while the user is
|
||||
// saving.
|
||||
if (from === 'invalid') {
|
||||
this.removeValidationErrors();
|
||||
}
|
||||
this.save();
|
||||
break;
|
||||
|
||||
case 'saved':
|
||||
// Nothing to do for the typical in-place editor. Immediately after
|
||||
// being saved, a field will go to the 'candidate' state, where it
|
||||
// should no longer be visible (after all, the field will then again
|
||||
// just be a *candidate* to be in-place edited).
|
||||
break;
|
||||
|
||||
case 'invalid':
|
||||
// The modified field value was attempted to be saved, but there were
|
||||
// validation errors.
|
||||
this.showValidationErrors();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reverts the modified value to the original, before editing started.
|
||||
*/
|
||||
revert: function () {
|
||||
// A no-op by default; each editor should implement reverting itself.
|
||||
// Note that if the in-place editor does not cause the FieldModel's
|
||||
// element to be modified, then nothing needs to happen.
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves the modified value in the in-place editor for this field.
|
||||
*/
|
||||
save: function () {
|
||||
var fieldModel = this.fieldModel;
|
||||
var editorModel = this.model;
|
||||
var backstageId = 'quickedit_backstage-' + this.fieldModel.id.replace(/[\/\[\]\_\s]/g, '-');
|
||||
|
||||
function fillAndSubmitForm(value) {
|
||||
var $form = $('#' + backstageId).find('form');
|
||||
// Fill in the value in any <input> that isn't hidden or a submit
|
||||
// button.
|
||||
$form.find(':input[type!="hidden"][type!="submit"]:not(select)')
|
||||
// Don't mess with the node summary.
|
||||
.not('[name$="\\[summary\\]"]').val(value);
|
||||
// Submit the form.
|
||||
$form.find('.quickedit-form-submit').trigger('click.quickedit');
|
||||
}
|
||||
|
||||
var 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')
|
||||
};
|
||||
|
||||
var self = this;
|
||||
Drupal.quickedit.util.form.load(formOptions, function (form, ajax) {
|
||||
// Create a backstage area for storing forms that are hidden from view
|
||||
// (hence "backstage" — since the editing doesn't happen in the form, it
|
||||
// happens "directly" in the content, the form is only used for saving).
|
||||
var $backstage = $(Drupal.theme('quickeditBackstage', {id: backstageId})).appendTo('body');
|
||||
// Hidden forms are stuffed into the backstage container for this field.
|
||||
var $form = $(form).appendTo($backstage);
|
||||
// Disable the browser's HTML5 validation; we only care about server-
|
||||
// side validation. (Not disabling this will actually cause problems
|
||||
// because browsers don't like to set HTML5 validation errors on hidden
|
||||
// forms.)
|
||||
$form.prop('novalidate', true);
|
||||
var $submit = $form.find('.quickedit-form-submit');
|
||||
self.formSaveAjax = Drupal.quickedit.util.form.ajaxifySaving(formOptions, $submit);
|
||||
|
||||
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: function () {
|
||||
var $errors = $('<div class="quickedit-validation-errors"></div>')
|
||||
.append(this.model.get('validationErrors'));
|
||||
this.getEditedElement()
|
||||
.addClass('quickedit-validation-error')
|
||||
.after($errors);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans up validation error messages.
|
||||
*
|
||||
* Should be called when the state is changed to 'candidate' or 'saving'. In
|
||||
* the case of the latter: the user has modified the value in the in-place
|
||||
* editor again to attempt to save again. In the case of the latter: the
|
||||
* invalid value was discarded.
|
||||
*/
|
||||
removeValidationErrors: function () {
|
||||
this.getEditedElement()
|
||||
.removeClass('quickedit-validation-error')
|
||||
.next('.quickedit-validation-errors')
|
||||
.remove();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(jQuery, Backbone, Drupal));
|
40
core/modules/quickedit/js/views/EntityDecorationView.js
Normal file
40
core/modules/quickedit/js/views/EntityDecorationView.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone view that decorates the in-place editable entity.
|
||||
*/
|
||||
|
||||
(function (Drupal, $, Backbone) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.EntityDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityDecorationView# */{
|
||||
|
||||
/**
|
||||
* Associated with the DOM root node of an editable entity.
|
||||
*
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*/
|
||||
initialize: function () {
|
||||
this.listenTo(this.model, 'change', this.render);
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
render: function () {
|
||||
this.$el.toggleClass('quickedit-entity-active', this.model.get('isActive'));
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
remove: function () {
|
||||
this.setElement(null);
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
}(Drupal, jQuery, Backbone));
|
516
core/modules/quickedit/js/views/EntityToolbarView.js
Normal file
516
core/modules/quickedit/js/views/EntityToolbarView.js
Normal file
|
@ -0,0 +1,516 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone View that provides an entity level toolbar.
|
||||
*/
|
||||
|
||||
(function ($, _, Backbone, Drupal, debounce) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.EntityToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.EntityToolbarView# */{
|
||||
|
||||
/**
|
||||
* @type {jQuery}
|
||||
*/
|
||||
_fieldToolbarRoot: null,
|
||||
|
||||
/**
|
||||
* @return {object}
|
||||
*/
|
||||
events: function () {
|
||||
var map = {
|
||||
'click button.action-save': 'onClickSave',
|
||||
'click button.action-cancel': 'onClickCancel',
|
||||
'mouseenter': 'onMouseenter'
|
||||
};
|
||||
return map;
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {Drupal.quickedit.AppModel} options.appModel
|
||||
*/
|
||||
initialize: function (options) {
|
||||
var that = this;
|
||||
this.appModel = options.appModel;
|
||||
this.$entity = $(this.model.get('el'));
|
||||
|
||||
// Rerender whenever the entity state changes.
|
||||
this.listenTo(this.model, 'change:isActive change:isDirty change:state', this.render);
|
||||
// Also rerender whenever a different field is highlighted or activated.
|
||||
this.listenTo(this.appModel, 'change:highlightedField change:activeField', this.render);
|
||||
// Rerender when a field of the entity changes state.
|
||||
this.listenTo(this.model.get('fields'), 'change:state', this.fieldStateChange);
|
||||
|
||||
// Reposition the entity toolbar as the viewport and the position within
|
||||
// the viewport changes.
|
||||
$(window).on('resize.quickedit scroll.quickedit', debounce($.proxy(this.windowChangeHandler, this), 150));
|
||||
|
||||
// Adjust the fence placement within which the entity toolbar may be
|
||||
// positioned.
|
||||
$(document).on('drupalViewportOffsetChange.quickedit', function (event, offsets) {
|
||||
if (that.$fence) {
|
||||
that.$fence.css(offsets);
|
||||
}
|
||||
});
|
||||
|
||||
// Set the entity toolbar DOM element as the el for this view.
|
||||
var $toolbar = this.buildToolbarEl();
|
||||
this.setElement($toolbar);
|
||||
this._fieldToolbarRoot = $toolbar.find('.quickedit-toolbar-field').get(0);
|
||||
|
||||
// Initial render.
|
||||
this.render();
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*
|
||||
* @return {Drupal.quickedit.EntityToolbarView}
|
||||
*/
|
||||
render: function () {
|
||||
if (this.model.get('isActive')) {
|
||||
// If the toolbar container doesn't exist, create it.
|
||||
var $body = $('body');
|
||||
if ($body.children('#quickedit-entity-toolbar').length === 0) {
|
||||
$body.append(this.$el);
|
||||
}
|
||||
// The fence will define a area on the screen that the entity toolbar
|
||||
// will be position within.
|
||||
if ($body.children('#quickedit-toolbar-fence').length === 0) {
|
||||
this.$fence = $(Drupal.theme('quickeditEntityToolbarFence'))
|
||||
.css(Drupal.displace())
|
||||
.appendTo($body);
|
||||
}
|
||||
// Adds the entity title to the toolbar.
|
||||
this.label();
|
||||
|
||||
// Show the save and cancel buttons.
|
||||
this.show('ops');
|
||||
// If render is being called and the toolbar is already visible, just
|
||||
// reposition it.
|
||||
this.position();
|
||||
}
|
||||
|
||||
// The save button text and state varies with the state of the entity
|
||||
// model.
|
||||
var $button = this.$el.find('.quickedit-button.action-save');
|
||||
var isDirty = this.model.get('isDirty');
|
||||
// Adjust the save button according to the state of the model.
|
||||
switch (this.model.get('state')) {
|
||||
// Quick editing is active, but no field is being edited.
|
||||
case 'opened':
|
||||
// The saving throbber is not managed by AJAX system. The
|
||||
// EntityToolbarView manages this visual element.
|
||||
$button
|
||||
.removeClass('action-saving icon-throbber icon-end')
|
||||
.text(Drupal.t('Save'))
|
||||
.removeAttr('disabled')
|
||||
.attr('aria-hidden', !isDirty);
|
||||
break;
|
||||
|
||||
// The changes to the fields of the entity are being committed.
|
||||
case 'committing':
|
||||
$button
|
||||
.addClass('action-saving icon-throbber icon-end')
|
||||
.text(Drupal.t('Saving'))
|
||||
.attr('disabled', 'disabled');
|
||||
break;
|
||||
|
||||
default:
|
||||
$button.attr('aria-hidden', true);
|
||||
break;
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
remove: function () {
|
||||
// Remove additional DOM elements controlled by this View.
|
||||
this.$fence.remove();
|
||||
|
||||
// Stop listening to additional events.
|
||||
$(window).off('resize.quickedit scroll.quickedit');
|
||||
$(document).off('drupalViewportOffsetChange.quickedit');
|
||||
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Repositions the entity toolbar on window scroll and resize.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
windowChangeHandler: function (event) {
|
||||
this.position();
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines the actions to take given a change of state.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} model
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
fieldStateChange: function (model, state) {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
this.render();
|
||||
break;
|
||||
|
||||
case 'invalid':
|
||||
this.render();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Uses the jQuery.ui.position() method to position the entity toolbar.
|
||||
*
|
||||
* @param {HTMLElement} [element]
|
||||
* The element against which the entity toolbar is positioned.
|
||||
*/
|
||||
position: function (element) {
|
||||
clearTimeout(this.timer);
|
||||
|
||||
var that = this;
|
||||
// Vary the edge of the positioning according to the direction of language
|
||||
// in the document.
|
||||
var edge = (document.documentElement.dir === 'rtl') ? 'right' : 'left';
|
||||
// A time unit to wait until the entity toolbar is repositioned.
|
||||
var delay = 0;
|
||||
// Determines what check in the series of checks below should be
|
||||
// evaluated.
|
||||
var check = 0;
|
||||
// When positioned against an active field that has padding, we should
|
||||
// ignore that padding when positioning the toolbar, to not unnecessarily
|
||||
// move the toolbar horizontally, which feels annoying.
|
||||
var horizontalPadding = 0;
|
||||
var of;
|
||||
var activeField;
|
||||
var highlightedField;
|
||||
// There are several elements in the page that the entity toolbar might be
|
||||
// positioned against. They are considered below in a priority order.
|
||||
do {
|
||||
switch (check) {
|
||||
case 0:
|
||||
// Position against a specific element.
|
||||
of = element;
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// Position against a form container.
|
||||
activeField = Drupal.quickedit.app.model.get('activeField');
|
||||
of = activeField && activeField.editorView && activeField.editorView.$formContainer && activeField.editorView.$formContainer.find('.quickedit-form');
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// Position against an active field.
|
||||
of = activeField && activeField.editorView && activeField.editorView.getEditedElement();
|
||||
if (activeField && activeField.editorView && activeField.editorView.getQuickEditUISettings().padding) {
|
||||
horizontalPadding = 5;
|
||||
}
|
||||
break;
|
||||
|
||||
case 3:
|
||||
// Position against a highlighted field.
|
||||
highlightedField = Drupal.quickedit.app.model.get('highlightedField');
|
||||
of = highlightedField && highlightedField.editorView && highlightedField.editorView.getEditedElement();
|
||||
delay = 250;
|
||||
break;
|
||||
|
||||
default:
|
||||
var fieldModels = this.model.get('fields').models;
|
||||
var topMostPosition = 1000000;
|
||||
var topMostField = null;
|
||||
// Position against the topmost field.
|
||||
for (var i = 0; i < fieldModels.length; i++) {
|
||||
var pos = fieldModels[i].get('el').getBoundingClientRect().top;
|
||||
if (pos < topMostPosition) {
|
||||
topMostPosition = pos;
|
||||
topMostField = fieldModels[i];
|
||||
}
|
||||
}
|
||||
of = topMostField.get('el');
|
||||
delay = 50;
|
||||
break;
|
||||
}
|
||||
// Prepare to check the next possible element to position against.
|
||||
check++;
|
||||
} while (!of);
|
||||
|
||||
/**
|
||||
* Refines the positioning algorithm of jquery.ui.position().
|
||||
*
|
||||
* Invoked as the 'using' callback of jquery.ui.position() in
|
||||
* positionToolbar().
|
||||
*
|
||||
* @param {*} view
|
||||
* @param {object} suggested
|
||||
* A hash of top and left values for the position that should be set. It
|
||||
* can be forwarded to .css() or .animate().
|
||||
* @param {object} info
|
||||
* The position and dimensions of both the 'my' element and the 'of'
|
||||
* elements, as well as calculations to their relative position. This
|
||||
* object contains the following properties:
|
||||
* @param {object} info.element
|
||||
* A hash that contains information about the HTML element that will be
|
||||
* positioned. Also known as the 'my' element.
|
||||
* @param {object} info.target
|
||||
* A hash that contains information about the HTML element that the
|
||||
* 'my' element will be positioned against. Also known as the 'of'
|
||||
* element.
|
||||
*/
|
||||
function refinePosition(view, suggested, info) {
|
||||
// Determine if the pointer should be on the top or bottom.
|
||||
var isBelow = suggested.top > info.target.top;
|
||||
info.element.element.toggleClass('quickedit-toolbar-pointer-top', isBelow);
|
||||
// Don't position the toolbar past the first or last editable field if
|
||||
// the entity is the target.
|
||||
if (view.$entity[0] === info.target.element[0]) {
|
||||
// Get the first or last field according to whether the toolbar is
|
||||
// above or below the entity.
|
||||
var $field = view.$entity.find('.quickedit-editable').eq((isBelow) ? -1 : 0);
|
||||
if ($field.length > 0) {
|
||||
suggested.top = (isBelow) ? ($field.offset().top + $field.outerHeight(true)) : $field.offset().top - info.element.element.outerHeight(true);
|
||||
}
|
||||
}
|
||||
// Don't let the toolbar go outside the fence.
|
||||
var fenceTop = view.$fence.offset().top;
|
||||
var fenceHeight = view.$fence.height();
|
||||
var toolbarHeight = info.element.element.outerHeight(true);
|
||||
if (suggested.top < fenceTop) {
|
||||
suggested.top = fenceTop;
|
||||
}
|
||||
else if ((suggested.top + toolbarHeight) > (fenceTop + fenceHeight)) {
|
||||
suggested.top = fenceTop + fenceHeight - toolbarHeight;
|
||||
}
|
||||
// Position the toolbar.
|
||||
info.element.element.css({
|
||||
left: Math.floor(suggested.left),
|
||||
top: Math.floor(suggested.top)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the jquery.ui.position() method on the $el of this view.
|
||||
*/
|
||||
function positionToolbar() {
|
||||
that.$el
|
||||
.position({
|
||||
my: edge + ' bottom',
|
||||
// Move the toolbar 1px towards the start edge of the 'of' element,
|
||||
// plus any horizontal padding that may have been added to the
|
||||
// element that is being added, to prevent unwanted horizontal
|
||||
// movement.
|
||||
at: edge + '+' + (1 + horizontalPadding) + ' top',
|
||||
of: of,
|
||||
collision: 'flipfit',
|
||||
using: refinePosition.bind(null, that),
|
||||
within: that.$fence
|
||||
})
|
||||
// Resize the toolbar to match the dimensions of the field, up to a
|
||||
// maximum width that is equal to 90% of the field's width.
|
||||
.css({
|
||||
'max-width': (document.documentElement.clientWidth < 450) ? document.documentElement.clientWidth : 450,
|
||||
// Set a minimum width of 240px for the entity toolbar, or the width
|
||||
// of the client if it is less than 240px, so that the toolbar
|
||||
// never folds up into a squashed and jumbled mess.
|
||||
'min-width': (document.documentElement.clientWidth < 240) ? document.documentElement.clientWidth : 240,
|
||||
'width': '100%'
|
||||
});
|
||||
}
|
||||
|
||||
// Uses the jQuery.ui.position() method. Use a timeout to move the toolbar
|
||||
// only after the user has focused on an editable for 250ms. This prevents
|
||||
// the toolbar from jumping around the screen.
|
||||
this.timer = setTimeout(function () {
|
||||
// Render the position in the next execution cycle, so that animations
|
||||
// on the field have time to process. This is not strictly speaking, a
|
||||
// guarantee that all animations will be finished, but it's a simple
|
||||
// way to get better positioning without too much additional code.
|
||||
_.defer(positionToolbar);
|
||||
}, delay);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the model state to 'saving' when the save button is clicked.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onClickSave: function (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// Save the model.
|
||||
this.model.set('state', 'committing');
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the model state to candidate when the cancel button is clicked.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onClickCancel: function (event) {
|
||||
event.preventDefault();
|
||||
this.model.set('state', 'deactivating');
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the timeout that will eventually reposition the entity toolbar.
|
||||
*
|
||||
* Without this, it may reposition itself, away from the user's cursor!
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onMouseenter: function (event) {
|
||||
clearTimeout(this.timer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Builds the entity toolbar HTML; attaches to DOM; sets starting position.
|
||||
*
|
||||
* @return {jQuery}
|
||||
*/
|
||||
buildToolbarEl: function () {
|
||||
var $toolbar = $(Drupal.theme('quickeditEntityToolbar', {
|
||||
id: 'quickedit-entity-toolbar'
|
||||
}));
|
||||
|
||||
$toolbar
|
||||
.find('.quickedit-toolbar-entity')
|
||||
// Append the "ops" toolgroup into the toolbar.
|
||||
.prepend(Drupal.theme('quickeditToolgroup', {
|
||||
classes: ['ops'],
|
||||
buttons: [
|
||||
{
|
||||
label: Drupal.t('Save'),
|
||||
type: 'submit',
|
||||
classes: 'action-save quickedit-button icon',
|
||||
attributes: {
|
||||
'aria-hidden': true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: Drupal.t('Close'),
|
||||
classes: 'action-cancel quickedit-button icon icon-close icon-only'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
// Give the toolbar a sensible starting position so that it doesn't
|
||||
// animate on to the screen from a far off corner.
|
||||
$toolbar
|
||||
.css({
|
||||
left: this.$entity.offset().left,
|
||||
top: this.$entity.offset().top
|
||||
});
|
||||
|
||||
return $toolbar;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the DOM element that fields will attach their toolbars to.
|
||||
*
|
||||
* @return {jQuery}
|
||||
* The DOM element that fields will attach their toolbars to.
|
||||
*/
|
||||
getToolbarRoot: function () {
|
||||
return this._fieldToolbarRoot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates a state-dependent label for the entity toolbar.
|
||||
*/
|
||||
label: function () {
|
||||
// The entity label.
|
||||
var label = '';
|
||||
var entityLabel = this.model.get('label');
|
||||
|
||||
// Label of an active field, if it exists.
|
||||
var activeField = Drupal.quickedit.app.model.get('activeField');
|
||||
var activeFieldLabel = activeField && activeField.get('metadata').label;
|
||||
// Label of a highlighted field, if it exists.
|
||||
var highlightedField = Drupal.quickedit.app.model.get('highlightedField');
|
||||
var highlightedFieldLabel = highlightedField && highlightedField.get('metadata').label;
|
||||
// The label is constructed in a priority order.
|
||||
if (activeFieldLabel) {
|
||||
label = Drupal.theme('quickeditEntityToolbarLabel', {
|
||||
entityLabel: entityLabel,
|
||||
fieldLabel: activeFieldLabel
|
||||
});
|
||||
}
|
||||
else if (highlightedFieldLabel) {
|
||||
label = Drupal.theme('quickeditEntityToolbarLabel', {
|
||||
entityLabel: entityLabel,
|
||||
fieldLabel: highlightedFieldLabel
|
||||
});
|
||||
}
|
||||
else {
|
||||
label = entityLabel;
|
||||
}
|
||||
|
||||
this.$el
|
||||
.find('.quickedit-toolbar-label')
|
||||
.html(label);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds classes to a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
* @param {string} classes
|
||||
* A string of space-delimited class names that will be applied to the
|
||||
* wrapping element of the toolbar group.
|
||||
*/
|
||||
addClass: function (toolgroup, classes) {
|
||||
this._find(toolgroup).addClass(classes);
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes classes from a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
* @param {string} classes
|
||||
* A string of space-delimited class names that will be removed from the
|
||||
* wrapping element of the toolbar group.
|
||||
*/
|
||||
removeClass: function (toolgroup, classes) {
|
||||
this._find(toolgroup).removeClass(classes);
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
*
|
||||
* @return {jQuery}
|
||||
* The toolgroup DOM element.
|
||||
*/
|
||||
_find: function (toolgroup) {
|
||||
return this.$el.find('.quickedit-toolbar .quickedit-toolgroup.' + toolgroup);
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
*/
|
||||
show: function (toolgroup) {
|
||||
this.$el.removeClass('quickedit-animate-invisible');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(jQuery, _, Backbone, Drupal, Drupal.debounce);
|
353
core/modules/quickedit/js/views/FieldDecorationView.js
Normal file
353
core/modules/quickedit/js/views/FieldDecorationView.js
Normal file
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone View that decorates the in-place edited element.
|
||||
*/
|
||||
|
||||
(function ($, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.FieldDecorationView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldDecorationView# */{
|
||||
|
||||
/**
|
||||
* @type {null}
|
||||
*/
|
||||
_widthAttributeIsEmpty: null,
|
||||
|
||||
/**
|
||||
* @type {object}
|
||||
*/
|
||||
events: {
|
||||
'mouseenter.quickedit': 'onMouseEnter',
|
||||
'mouseleave.quickedit': 'onMouseLeave',
|
||||
'click': 'onClick',
|
||||
'tabIn.quickedit': 'onMouseEnter',
|
||||
'tabOut.quickedit': 'onMouseLeave'
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructs
|
||||
*
|
||||
* @augments Backbone.View
|
||||
*
|
||||
* @param {object} options
|
||||
* An object with the following keys:
|
||||
* @param {Drupal.quickedit.EditorView} options.editorView
|
||||
* The editor object view.
|
||||
*/
|
||||
initialize: function (options) {
|
||||
this.editorView = options.editorView;
|
||||
|
||||
this.listenTo(this.model, 'change:state', this.stateChange);
|
||||
this.listenTo(this.model, 'change:isChanged change:inTempStore', this.renderChanged);
|
||||
},
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
remove: function () {
|
||||
// The el property is the field, which should not be removed. Remove the
|
||||
// pointer to it, then call Backbone.View.prototype.remove().
|
||||
this.setElement();
|
||||
Backbone.View.prototype.remove.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines the actions to take given a change of state.
|
||||
*
|
||||
* @param {Drupal.quickedit.FieldModel} model
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
stateChange: function (model, state) {
|
||||
var from = model.previous('state');
|
||||
var to = state;
|
||||
switch (to) {
|
||||
case 'inactive':
|
||||
this.undecorate();
|
||||
break;
|
||||
|
||||
case 'candidate':
|
||||
this.decorate();
|
||||
if (from !== 'inactive') {
|
||||
this.stopHighlight();
|
||||
if (from !== 'highlighted') {
|
||||
this.model.set('isChanged', false);
|
||||
this.stopEdit();
|
||||
}
|
||||
}
|
||||
this._unpad();
|
||||
break;
|
||||
|
||||
case 'highlighted':
|
||||
this.startHighlight();
|
||||
break;
|
||||
|
||||
case 'activating':
|
||||
// NOTE: this state is not used by every editor! It's only used by
|
||||
// those that need to interact with the server.
|
||||
this.prepareEdit();
|
||||
break;
|
||||
|
||||
case 'active':
|
||||
if (from !== 'activating') {
|
||||
this.prepareEdit();
|
||||
}
|
||||
if (this.editorView.getQuickEditUISettings().padding) {
|
||||
this._pad();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'changed':
|
||||
this.model.set('isChanged', true);
|
||||
break;
|
||||
|
||||
case 'saving':
|
||||
break;
|
||||
|
||||
case 'saved':
|
||||
break;
|
||||
|
||||
case 'invalid':
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds a class to the edited element that indicates whether the field has
|
||||
* been changed by the user (i.e. locally) or the field has already been
|
||||
* changed and stored before by the user (i.e. remotely, stored in
|
||||
* PrivateTempStore).
|
||||
*/
|
||||
renderChanged: function () {
|
||||
this.$el.toggleClass('quickedit-changed', this.model.get('isChanged') || this.model.get('inTempStore'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts hover; transitions to 'highlight' state.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onMouseEnter: function (event) {
|
||||
var that = this;
|
||||
that.model.set('state', 'highlighted');
|
||||
event.stopPropagation();
|
||||
},
|
||||
|
||||
/**
|
||||
* Stops hover; transitions to 'candidate' state.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onMouseLeave: function (event) {
|
||||
var that = this;
|
||||
that.model.set('state', 'candidate', {reason: 'mouseleave'});
|
||||
event.stopPropagation();
|
||||
},
|
||||
|
||||
/**
|
||||
* Transition to 'activating' stage.
|
||||
*
|
||||
* @param {jQuery.Event} event
|
||||
*/
|
||||
onClick: function (event) {
|
||||
this.model.set('state', 'activating');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds classes used to indicate an elements editable state.
|
||||
*/
|
||||
decorate: function () {
|
||||
this.$el.addClass('quickedit-candidate quickedit-editable');
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes classes used to indicate an elements editable state.
|
||||
*/
|
||||
undecorate: function () {
|
||||
this.$el.removeClass('quickedit-candidate quickedit-editable quickedit-highlighted quickedit-editing');
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds that class that indicates that an element is highlighted.
|
||||
*/
|
||||
startHighlight: function () {
|
||||
// Animations.
|
||||
var that = this;
|
||||
// Use a timeout to grab the next available animation frame.
|
||||
that.$el.addClass('quickedit-highlighted');
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the class that indicates that an element is highlighted.
|
||||
*/
|
||||
stopHighlight: function () {
|
||||
this.$el.removeClass('quickedit-highlighted');
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the class that indicates that an element as editable.
|
||||
*/
|
||||
prepareEdit: function () {
|
||||
this.$el.addClass('quickedit-editing');
|
||||
|
||||
// Allow the field to be styled differently while editing in a pop-up
|
||||
// in-place editor.
|
||||
if (this.editorView.getQuickEditUISettings().popup) {
|
||||
this.$el.addClass('quickedit-editor-is-popup');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the class that indicates that an element is being edited.
|
||||
*
|
||||
* Reapplies the class that indicates that a candidate editable element is
|
||||
* again available to be edited.
|
||||
*/
|
||||
stopEdit: function () {
|
||||
this.$el.removeClass('quickedit-highlighted quickedit-editing');
|
||||
|
||||
// Done editing in a pop-up in-place editor; remove the class.
|
||||
if (this.editorView.getQuickEditUISettings().popup) {
|
||||
this.$el.removeClass('quickedit-editor-is-popup');
|
||||
}
|
||||
|
||||
// Make the other editors show up again.
|
||||
$('.quickedit-candidate').addClass('quickedit-editable');
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds padding around the editable element to make it pop visually.
|
||||
*/
|
||||
_pad: function () {
|
||||
// Early return if the element has already been padded.
|
||||
if (this.$el.data('quickedit-padded')) {
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
|
||||
// Add 5px padding for readability. This means we'll freeze the current
|
||||
// width and *then* add 5px padding, hence ensuring the padding is added
|
||||
// "on the outside".
|
||||
// 1) Freeze the width (if it's not already set); don't use animations.
|
||||
if (this.$el[0].style.width === "") {
|
||||
this._widthAttributeIsEmpty = true;
|
||||
this.$el
|
||||
.addClass('quickedit-animate-disable-width')
|
||||
.css('width', this.$el.width());
|
||||
}
|
||||
|
||||
// 2) Add padding; use animations.
|
||||
var posProp = this._getPositionProperties(this.$el);
|
||||
setTimeout(function () {
|
||||
// Re-enable width animations (padding changes affect width too!).
|
||||
self.$el.removeClass('quickedit-animate-disable-width');
|
||||
|
||||
// Pad the editable.
|
||||
self.$el
|
||||
.css({
|
||||
'position': 'relative',
|
||||
'top': posProp.top - 5 + 'px',
|
||||
'left': posProp.left - 5 + 'px',
|
||||
'padding-top': posProp['padding-top'] + 5 + 'px',
|
||||
'padding-left': posProp['padding-left'] + 5 + 'px',
|
||||
'padding-right': posProp['padding-right'] + 5 + 'px',
|
||||
'padding-bottom': posProp['padding-bottom'] + 5 + 'px',
|
||||
'margin-bottom': posProp['margin-bottom'] - 10 + 'px'
|
||||
})
|
||||
.data('quickedit-padded', true);
|
||||
}, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes the padding around the element being edited when editing ceases.
|
||||
*/
|
||||
_unpad: function () {
|
||||
// Early return if the element has not been padded.
|
||||
if (!this.$el.data('quickedit-padded')) {
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
|
||||
// 1) Set the empty width again.
|
||||
if (this._widthAttributeIsEmpty) {
|
||||
this.$el
|
||||
.addClass('quickedit-animate-disable-width')
|
||||
.css('width', '');
|
||||
}
|
||||
|
||||
// 2) Remove padding; use animations (these will run simultaneously with)
|
||||
// the fading out of the toolbar as its gets removed).
|
||||
var posProp = this._getPositionProperties(this.$el);
|
||||
setTimeout(function () {
|
||||
// Re-enable width animations (padding changes affect width too!).
|
||||
self.$el.removeClass('quickedit-animate-disable-width');
|
||||
|
||||
// Unpad the editable.
|
||||
self.$el
|
||||
.css({
|
||||
'position': 'relative',
|
||||
'top': posProp.top + 5 + 'px',
|
||||
'left': posProp.left + 5 + 'px',
|
||||
'padding-top': posProp['padding-top'] - 5 + 'px',
|
||||
'padding-left': posProp['padding-left'] - 5 + 'px',
|
||||
'padding-right': posProp['padding-right'] - 5 + 'px',
|
||||
'padding-bottom': posProp['padding-bottom'] - 5 + 'px',
|
||||
'margin-bottom': posProp['margin-bottom'] + 10 + 'px'
|
||||
});
|
||||
}, 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
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
_getPositionProperties: function ($e) {
|
||||
var p;
|
||||
var r = {};
|
||||
var props = [
|
||||
'top', 'left', 'bottom', 'right',
|
||||
'padding-top', 'padding-left', 'padding-right', 'padding-bottom',
|
||||
'margin-bottom'
|
||||
];
|
||||
|
||||
var propCount = props.length;
|
||||
for (var i = 0; i < propCount; i++) {
|
||||
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}
|
||||
*/
|
||||
_replaceBlankPosition: function (pos) {
|
||||
if (pos === 'auto' || !pos) {
|
||||
pos = '0px';
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(jQuery, Backbone, Drupal);
|
222
core/modules/quickedit/js/views/FieldToolbarView.js
Normal file
222
core/modules/quickedit/js/views/FieldToolbarView.js
Normal file
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* @file
|
||||
* A Backbone View that provides an interactive toolbar (1 per in-place editor).
|
||||
*/
|
||||
|
||||
(function ($, _, Backbone, Drupal) {
|
||||
|
||||
"use strict";
|
||||
|
||||
Drupal.quickedit.FieldToolbarView = Backbone.View.extend(/** @lends Drupal.quickedit.FieldToolbarView# */{
|
||||
|
||||
/**
|
||||
* The edited element, as indicated by EditorView.getEditedElement.
|
||||
*
|
||||
* @type {jQuery}
|
||||
*/
|
||||
$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
|
||||
* @param {jQuery} options.$editedElement
|
||||
* @param {Drupal.quickedit.EditorView} options.editorView
|
||||
*/
|
||||
initialize: function (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}
|
||||
*/
|
||||
render: function () {
|
||||
// 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
|
||||
* @param {string} state
|
||||
* The state of the associated field. One of
|
||||
* {@link Drupal.quickedit.FieldModel.states}.
|
||||
*/
|
||||
stateChange: function (model, state) {
|
||||
var from = model.previous('state');
|
||||
var 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: function () {
|
||||
this.$el
|
||||
.append(Drupal.theme('quickeditToolgroup', {
|
||||
id: this.getFloatedWysiwygToolgroupId(),
|
||||
classes: ['wysiwyg-floated', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
|
||||
buttons: []
|
||||
}))
|
||||
.append(Drupal.theme('quickeditToolgroup', {
|
||||
id: this.getMainWysiwygToolgroupId(),
|
||||
classes: ['wysiwyg-main', 'quickedit-animate-slow', 'quickedit-animate-invisible', 'quickedit-animate-delay-veryfast'],
|
||||
buttons: []
|
||||
}));
|
||||
|
||||
// Animate the toolgroups into visibility.
|
||||
this.show('wysiwyg-floated');
|
||||
this.show('wysiwyg-main');
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieves the ID for this toolbar's container.
|
||||
*
|
||||
* Only used to make sane hovering behavior possible.
|
||||
*
|
||||
* @return {string}
|
||||
* A string that can be used as the ID for this toolbar's container.
|
||||
*/
|
||||
getId: function () {
|
||||
return 'quickedit-toolbar-for-' + this._id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieves the ID for this toolbar's floating WYSIWYG toolgroup.
|
||||
*
|
||||
* Used to provide an abstraction for any WYSIWYG editor to plug in.
|
||||
*
|
||||
* @return {string}
|
||||
* A string that can be used as the ID.
|
||||
*/
|
||||
getFloatedWysiwygToolgroupId: function () {
|
||||
return 'quickedit-wysiwyg-floated-toolgroup-for-' + this._id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieves the ID for this toolbar's main WYSIWYG toolgroup.
|
||||
*
|
||||
* Used to provide an abstraction for any WYSIWYG editor to plug in.
|
||||
*
|
||||
* @return {string}
|
||||
* A string that can be used as the ID.
|
||||
*/
|
||||
getMainWysiwygToolgroupId: function () {
|
||||
return 'quickedit-wysiwyg-main-toolgroup-for-' + this._id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Finds a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
*
|
||||
* @return {jQuery}
|
||||
*/
|
||||
_find: function (toolgroup) {
|
||||
return this.$el.find('.quickedit-toolgroup.' + toolgroup);
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows a toolgroup.
|
||||
*
|
||||
* @param {string} toolgroup
|
||||
* A toolgroup name.
|
||||
*/
|
||||
show: function (toolgroup) {
|
||||
var $group = this._find(toolgroup);
|
||||
// Attach a transitionEnd event handler to the toolbar group so that update
|
||||
// events can be triggered after the animations have ended.
|
||||
$group.on(Drupal.quickedit.util.constants.transitionEnd, function (event) {
|
||||
$group.off(Drupal.quickedit.util.constants.transitionEnd);
|
||||
});
|
||||
// The call to remove the class and start the animation must be started in
|
||||
// the next animation frame or the event handler attached above won't be
|
||||
// triggered.
|
||||
window.setTimeout(function () {
|
||||
$group.removeClass('quickedit-animate-invisible');
|
||||
}, 0);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(jQuery, _, Backbone, Drupal);
|
82
core/modules/quickedit/quickedit.api.php
Normal file
82
core/modules/quickedit/quickedit.api.php
Normal file
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Hooks provided by the Edit module.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @addtogroup hooks
|
||||
* @{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Allow modules to alter in-place editor plugin metadata.
|
||||
*
|
||||
* This hook is called after the in-place editor plugins have been discovered,
|
||||
* but before they are cached. Hence any alterations will be cached.
|
||||
*
|
||||
* @param array &$editors
|
||||
* An array of metadata on existing in-place editors, as collected by the
|
||||
* annotation discovery mechanism.
|
||||
*
|
||||
* @see \Drupal\quickedit\Annotation\InPlaceEditor
|
||||
* @see \Drupal\quickedit\Plugin\EditorManager
|
||||
*/
|
||||
function hook_quickedit_editor_alter(&$editors) {
|
||||
// Cleanly override editor.module's in-place editor plugin.
|
||||
$editors['editor']['class'] = 'Drupal\advanced_editor\Plugin\quickedit\editor\AdvancedEditor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a renderable array for the value of a single field in an entity.
|
||||
*
|
||||
* To integrate with in-place field editing when a non-standard render pipeline
|
||||
* is used (FieldItemListInterface::view() is not sufficient to render back the
|
||||
* field following in-place editing in the exact way it was displayed
|
||||
* originally), implement this hook.
|
||||
*
|
||||
* Edit module integrates with HTML elements with data-edit-field-id attributes.
|
||||
* For example:
|
||||
* data-edit-field-id="node/1/<field-name>/und/<module-name>-<custom-id>"
|
||||
* After the editing is complete, this hook is invoked on the module with
|
||||
* the custom render pipeline identifier (last part of data-edit-field-id) to
|
||||
* re-render the field. Use the same logic used when rendering the field for
|
||||
* the original display.
|
||||
*
|
||||
* The implementation should take care of invoking the prepare_view steps. It
|
||||
* should also respect field access permissions.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity containing the field to display.
|
||||
* @param string $field_name
|
||||
* The name of the field to display.
|
||||
* @param string $view_mode_id
|
||||
* View mode ID for the custom render pipeline this field view was destined
|
||||
* for. This is not a regular view mode ID for the Entity/Field API render
|
||||
* pipeline and is provided by the renderer module instead. An example could
|
||||
* be Views' render pipeline. In the example of Views, the view mode ID would
|
||||
* probably contain the View's ID, display and the row index. Views would
|
||||
* know the internal structure of this ID. The only structure imposed on this
|
||||
* ID is that it contains dash separated values and the first value is the
|
||||
* module name. Only that module's hook implementation will be invoked. Eg.
|
||||
* 'views-...-...'.
|
||||
* @param string $langcode
|
||||
* (Optional) The language code the field values are to be shown in.
|
||||
*
|
||||
* @return
|
||||
* A renderable array for the field value.
|
||||
*
|
||||
* @see \Drupal\Core\Field\FieldItemListInterface::view()
|
||||
*/
|
||||
function hook_quickedit_render_field(Drupal\Core\Entity\EntityInterface $entity, $field_name, $view_mode_id, $langcode) {
|
||||
return array(
|
||||
'#prefix' => '<div class="example-markup">',
|
||||
'field' => $entity->getTranslation($langcode)->get($field_name)->view($view_mode_id),
|
||||
'#suffix' => '</div>',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @} End of "addtogroup hooks".
|
||||
*/
|
10
core/modules/quickedit/quickedit.info.yml
Normal file
10
core/modules/quickedit/quickedit.info.yml
Normal file
|
@ -0,0 +1,10 @@
|
|||
name: Quick Edit
|
||||
type: module
|
||||
description: 'In-place content editing.'
|
||||
package: Core
|
||||
core: 8.x
|
||||
version: VERSION
|
||||
dependencies:
|
||||
- contextual
|
||||
- field
|
||||
- filter
|
56
core/modules/quickedit/quickedit.libraries.yml
Normal file
56
core/modules/quickedit/quickedit.libraries.yml
Normal file
|
@ -0,0 +1,56 @@
|
|||
quickedit:
|
||||
version: VERSION
|
||||
js:
|
||||
# Core.
|
||||
js/quickedit.js: {}
|
||||
js/util.js: {}
|
||||
# Models.
|
||||
js/models/BaseModel.js: {}
|
||||
js/models/AppModel.js: {}
|
||||
js/models/EntityModel.js: {}
|
||||
js/models/FieldModel.js: {}
|
||||
js/models/EditorModel.js: {}
|
||||
# Views.
|
||||
js/views/AppView.js: {}
|
||||
js/views/FieldDecorationView.js: {}
|
||||
js/views/EntityDecorationView.js: {}
|
||||
js/views/EntityToolbarView.js: {}
|
||||
js/views/ContextualLinkView.js: {}
|
||||
js/views/FieldToolbarView.js: {}
|
||||
js/views/EditorView.js: {}
|
||||
# Other.
|
||||
js/theme.js: {}
|
||||
css:
|
||||
component:
|
||||
css/quickedit.module.css: {}
|
||||
theme:
|
||||
css/quickedit.theme.css: {}
|
||||
css/quickedit.icons.theme.css: {}
|
||||
dependencies:
|
||||
- core/jquery
|
||||
- core/jquery.once
|
||||
- core/underscore
|
||||
- core/backbone
|
||||
- core/jquery.form
|
||||
- core/jquery.ui.position
|
||||
- core/drupal
|
||||
- core/drupal.displace
|
||||
- core/drupal.form
|
||||
- core/drupal.ajax
|
||||
- core/drupal.debounce
|
||||
- core/drupalSettings
|
||||
- core/drupal.dialog
|
||||
|
||||
quickedit.inPlaceEditor.form:
|
||||
version: VERSION
|
||||
js:
|
||||
js/editors/formEditor.js: {}
|
||||
dependencies:
|
||||
- quickedit/quickedit
|
||||
|
||||
quickedit.inPlaceEditor.plainText:
|
||||
version: VERSION
|
||||
js:
|
||||
js/editors/plainTextEditor.js: {}
|
||||
dependencies:
|
||||
- quickedit/quickedit
|
146
core/modules/quickedit/quickedit.module
Normal file
146
core/modules/quickedit/quickedit.module
Normal file
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Provides in-place content editing functionality for fields.
|
||||
*
|
||||
* The Quick Edit module makes content editable in-place. Rather than having to
|
||||
* visit a separate page to edit content, it may be edited in-place.
|
||||
*
|
||||
* Technically, this module adds classes and data- attributes to fields and
|
||||
* entities, enabling them for in-place editing.
|
||||
*/
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
|
||||
use Drupal\Core\Routing\RouteMatchInterface;
|
||||
|
||||
/**
|
||||
* Implements hook_help().
|
||||
*/
|
||||
function quickedit_help($route_name, RouteMatchInterface $route_match) {
|
||||
switch ($route_name) {
|
||||
case 'help.page.quickedit':
|
||||
$output = '<h3>' . t('About') . '</h3>';
|
||||
$output .= '<p>' . t('The Quick Edit module allows users with the <a href="!quickedit_permission">Access in-place editing</a> and <a href="!contextual_permission">Use contextual links</a> permissions to edit field content without visiting a separate page. For more information, see the <a href="!handbook_url">online documentation for the Quick Edit module</a>.', array('!handbook_url' => 'https://www.drupal.org/documentation/modules/edit', '!quickedit_permission' => \Drupal::url('user.admin_permissions', array(), array('fragment' => 'module-quickedit')), '!contextual_permission' => \Drupal::url('user.admin_permissions', array(), array('fragment' => 'module-contextual')))) . '</p>';
|
||||
$output .= '<h3>' . t('Uses') . '</h3>';
|
||||
$output .= '<dl>';
|
||||
$output .= '<dt>' . t('Editing content in-place') . '</dt>';
|
||||
$output .= '<dd>';
|
||||
$output .= '<p>' . t('To edit content in place, you need to activate quick edit mode for a content item. Activate quick edit mode by choosing Quick edit from the contextual links for an area displaying the content (see the <a href="!contextual">Contextual Links module help</a> for more information about how to use contextual links).', array('!contextual' => \Drupal::url('help.page', array('name' => 'contextual')))) . '</p>';
|
||||
$output .= '<p>' . t('Once quick edit mode is activated, you will be able to edit the individual fields of your content. In the default theme, with a JavaScript-enabled browser and a mouse, the output of different fields in your content is outlined in blue, a pop-up gives the field name as you hover over the field output, and clicking on a field activates the editor. Closing the pop-up window ends quick edit mode.') . '</p>';
|
||||
$output .= '</dd>';
|
||||
$output .= '</dl>';
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_page_attachments().
|
||||
*
|
||||
* Adds the quickedit library to the page for any user who has the 'access
|
||||
* in-place editing' permission.
|
||||
*/
|
||||
function quickedit_page_attachments(array &$page) {
|
||||
if (!\Drupal::currentUser()->hasPermission('access in-place editing')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In-place editing is only supported on the front-end.
|
||||
if (\Drupal::service('router.admin_context')->isAdminRoute()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$page['#attached']['library'][] = 'quickedit/quickedit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_library_info_alter().
|
||||
*
|
||||
* Includes additional stylesheets defined by the admin theme to allow it to
|
||||
* customize the Quick Edit toolbar appearance.
|
||||
*
|
||||
* An admin theme can specify CSS files to make the front-end administration
|
||||
* experience of in-place editing match the administration experience in the
|
||||
* back-end.
|
||||
*
|
||||
* The CSS files can be specified via the "edit_stylesheets" property in the
|
||||
* .info.yml file:
|
||||
* @code
|
||||
* quickedit_stylesheets:
|
||||
* - css/quickedit.css
|
||||
* @endcode
|
||||
*/
|
||||
function quickedit_library_info_alter(&$libraries, $extension) {
|
||||
if ($extension === 'quickedit' && isset($libraries['quickedit'])) {
|
||||
$theme = Drupal::config('system.theme')->get('admin');
|
||||
|
||||
// First let the base theme modify the library, then the actual theme.
|
||||
$alter_library = function(&$library, $theme) use (&$alter_library) {
|
||||
if (isset($theme) && $theme_path = drupal_get_path('theme', $theme)) {
|
||||
$info = system_get_info('theme', $theme);
|
||||
// Recurse to process base theme(s) first.
|
||||
if (isset($info['base theme'])) {
|
||||
$alter_library($library, $info['base theme']);
|
||||
}
|
||||
if (isset($info['quickedit_stylesheets'])) {
|
||||
foreach ($info['quickedit_stylesheets'] as $path) {
|
||||
$library['css']['theme']['/' . $theme_path . '/' . $path] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$alter_library($libraries['quickedit'], $theme);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_field_formatter_info_alter().
|
||||
*
|
||||
* Quick Edit extends the @FieldFormatter annotation with the following keys:
|
||||
* - quickedit: currently only contains one subkey 'editor' which indicates
|
||||
* which in-place editor should be used. Possible values are 'form',
|
||||
* 'plain_text', 'disabled' or another in-place editor other than the ones
|
||||
* Quick Edit module provides.
|
||||
*/
|
||||
function quickedit_field_formatter_info_alter(&$info) {
|
||||
foreach ($info as $key => $settings) {
|
||||
// Set in-place editor to 'form' if none is supplied.
|
||||
if (empty($settings['quickedit'])) {
|
||||
$info[$key]['quickedit'] = array('editor' => 'form');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_preprocess_HOOK() for field templates.
|
||||
*/
|
||||
function quickedit_preprocess_field(&$variables) {
|
||||
$element = $variables['element'];
|
||||
/** @var $entity \Drupal\Core\Entity\EntityInterface */
|
||||
$entity = $element['#object'];
|
||||
|
||||
// Quick Edit module only supports view modes, not dynamically defined
|
||||
// "display options" (which \Drupal\Core\Field\FieldItemListInterface::view()
|
||||
// always names the "_custom" view mode).
|
||||
// @see \Drupal\Core\Field\FieldItemListInterface::view()
|
||||
// @see https://www.drupal.org/node/2120335
|
||||
if ($element['#view_mode'] === '_custom') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fields that are not part of the entity (i.e. dynamically injected "pseudo
|
||||
// fields") and computed fields are not editable.
|
||||
$definition = $entity->getFieldDefinition($element['#field_name']);
|
||||
if ($definition && !$definition->isComputed()) {
|
||||
$variables['attributes']['data-quickedit-field-id'] = $entity->getEntityTypeId() . '/' . $entity->id() . '/' . $element['#field_name'] . '/' . $element['#language'] . '/' . $element['#view_mode'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_entity_view_alter().
|
||||
*/
|
||||
function quickedit_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
|
||||
$build['#attributes']['data-quickedit-entity-id'] = $entity->getEntityTypeId() . '/' . $entity->id();
|
||||
}
|
2
core/modules/quickedit/quickedit.permissions.yml
Normal file
2
core/modules/quickedit/quickedit.permissions.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
access in-place editing:
|
||||
title: 'Access in-place editing'
|
40
core/modules/quickedit/quickedit.routing.yml
Normal file
40
core/modules/quickedit/quickedit.routing.yml
Normal file
|
@ -0,0 +1,40 @@
|
|||
quickedit.metadata:
|
||||
path: '/quickedit/metadata'
|
||||
defaults:
|
||||
_controller: '\Drupal\quickedit\QuickEditController::metadata'
|
||||
options:
|
||||
_theme: ajax_base_page
|
||||
requirements:
|
||||
_permission: 'access in-place editing'
|
||||
|
||||
quickedit.attachments:
|
||||
path: '/quickedit/attachments'
|
||||
defaults:
|
||||
_controller: '\Drupal\quickedit\QuickEditController::attachments'
|
||||
requirements:
|
||||
_permission: 'access in-place editing'
|
||||
|
||||
quickedit.field_form:
|
||||
path: '/quickedit/form/{entity_type}/{entity}/{field_name}/{langcode}/{view_mode_id}'
|
||||
defaults:
|
||||
_controller: '\Drupal\quickedit\QuickEditController::fieldForm'
|
||||
options:
|
||||
_theme: ajax_base_page
|
||||
parameters:
|
||||
entity:
|
||||
type: entity:{entity_type}
|
||||
requirements:
|
||||
_permission: 'access in-place editing'
|
||||
_access_quickedit_entity_field: 'TRUE'
|
||||
|
||||
quickedit.entity_save:
|
||||
path: '/quickedit/entity/{entity_type}/{entity}'
|
||||
defaults:
|
||||
_controller: '\Drupal\quickedit\QuickEditController::entitySave'
|
||||
requirements:
|
||||
_permission: 'access in-place editing'
|
||||
_entity_access: 'entity.update'
|
||||
options:
|
||||
parameters:
|
||||
entity:
|
||||
type: entity:{entity_type}
|
14
core/modules/quickedit/quickedit.services.yml
Normal file
14
core/modules/quickedit/quickedit.services.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
services:
|
||||
plugin.manager.quickedit.editor:
|
||||
class: Drupal\quickedit\Plugin\InPlaceEditorManager
|
||||
parent: default_plugin_manager
|
||||
access_check.quickedit.entity_field:
|
||||
class: Drupal\quickedit\Access\EditEntityFieldAccessCheck
|
||||
tags:
|
||||
- { name: access_check, applies_to: _access_quickedit_entity_field }
|
||||
quickedit.editor.selector:
|
||||
class: Drupal\quickedit\EditorSelector
|
||||
arguments: ['@plugin.manager.quickedit.editor', '@plugin.manager.field.formatter']
|
||||
quickedit.metadata.generator:
|
||||
class: Drupal\quickedit\MetadataGenerator
|
||||
arguments: ['@access_check.quickedit.entity_field', '@quickedit.editor.selector', '@plugin.manager.quickedit.editor']
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Access\EditEntityFieldAccessCheck.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Access;
|
||||
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Routing\Access\AccessInterface;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* Access check for editing entity fields.
|
||||
*/
|
||||
class EditEntityFieldAccessCheck implements AccessInterface, EditEntityFieldAccessCheckInterface {
|
||||
|
||||
/**
|
||||
* Checks Quick Edit access to the field.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity containing the field.
|
||||
* @param string $field_name
|
||||
* The field name.
|
||||
* @param string $langcode
|
||||
* The langcode.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The currently logged in account.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface
|
||||
* The access result.
|
||||
*
|
||||
* @todo Use the $account argument: https://www.drupal.org/node/2266809.
|
||||
*/
|
||||
public function access(EntityInterface $entity, $field_name, $langcode, AccountInterface $account) {
|
||||
if (!$this->validateRequestAttributes($entity, $field_name, $langcode)) {
|
||||
return AccessResult::forbidden();
|
||||
}
|
||||
|
||||
return $this->accessEditEntityField($entity, $field_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function accessEditEntityField(EntityInterface $entity, $field_name) {
|
||||
return $entity->access('update', NULL, TRUE)->andIf($entity->get($field_name)->access('edit', NULL, TRUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request attributes.
|
||||
*/
|
||||
protected function validateRequestAttributes(EntityInterface $entity, $field_name, $langcode) {
|
||||
// Validate the field name and language.
|
||||
if (!$field_name || !$entity->hasField($field_name)) {
|
||||
return FALSE;
|
||||
}
|
||||
if (!$langcode || !$entity->hasTranslation($langcode)) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Access;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* Access check for editing entity fields.
|
||||
*/
|
||||
interface EditEntityFieldAccessCheckInterface {
|
||||
|
||||
/**
|
||||
* Checks access to edit the requested field of the requested entity.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity.
|
||||
* @param string $field_name
|
||||
* The field name.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface
|
||||
* The access result.
|
||||
*/
|
||||
public function accessEditEntityField(EntityInterface $entity, $field_name);
|
||||
|
||||
}
|
54
core/modules/quickedit/src/Ajax/BaseCommand.php
Normal file
54
core/modules/quickedit/src/Ajax/BaseCommand.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Ajax\BaseCommand.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Ajax;
|
||||
|
||||
use Drupal\Core\Ajax\CommandInterface;
|
||||
|
||||
/**
|
||||
* Base command that only exists to simplify Quick Edit's actual AJAX commands.
|
||||
*/
|
||||
class BaseCommand implements CommandInterface {
|
||||
|
||||
/**
|
||||
* The name of the command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $command;
|
||||
|
||||
/**
|
||||
* The data to pass on to the client side.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* Constructs a BaseCommand object.
|
||||
*
|
||||
* @param string $command
|
||||
* The name of the command.
|
||||
* @param string $data
|
||||
* The data to pass on to the client side.
|
||||
*/
|
||||
public function __construct($command, $data) {
|
||||
$this->command = $command;
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function render() {
|
||||
return array(
|
||||
'command' => $this->command,
|
||||
'data' => $this->data,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
26
core/modules/quickedit/src/Ajax/EntitySavedCommand.php
Normal file
26
core/modules/quickedit/src/Ajax/EntitySavedCommand.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Ajax\EntitySavedCommand.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Ajax;
|
||||
|
||||
/**
|
||||
* AJAX command to indicate the entity was loaded from PrivateTempStore and
|
||||
* saved into the database.
|
||||
*/
|
||||
class EntitySavedCommand extends BaseCommand {
|
||||
|
||||
/**
|
||||
* Constructs a EntitySaveCommand object.
|
||||
*
|
||||
* @param string $data
|
||||
* The data to pass on to the client side.
|
||||
*/
|
||||
public function __construct($data) {
|
||||
parent::__construct('quickeditEntitySaved', $data);
|
||||
}
|
||||
|
||||
}
|
26
core/modules/quickedit/src/Ajax/FieldFormCommand.php
Normal file
26
core/modules/quickedit/src/Ajax/FieldFormCommand.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Ajax\FieldFormCommand.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Ajax;
|
||||
|
||||
/**
|
||||
* AJAX command for passing a rendered field form to Quick Edit's JavaScript
|
||||
* app.
|
||||
*/
|
||||
class FieldFormCommand extends BaseCommand {
|
||||
|
||||
/**
|
||||
* Constructs a FieldFormCommand object.
|
||||
*
|
||||
* @param string $data
|
||||
* The data to pass on to the client side.
|
||||
*/
|
||||
public function __construct($data) {
|
||||
parent::__construct('quickeditFieldForm', $data);
|
||||
}
|
||||
|
||||
}
|
50
core/modules/quickedit/src/Ajax/FieldFormSavedCommand.php
Normal file
50
core/modules/quickedit/src/Ajax/FieldFormSavedCommand.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Ajax\FieldFormSavedCommand.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Ajax;
|
||||
|
||||
/**
|
||||
* AJAX command to indicate a field was saved into PrivateTempStore without
|
||||
* validation errors and pass the rerendered field to Quick Edit's JavaScript
|
||||
* app.
|
||||
*/
|
||||
class FieldFormSavedCommand extends BaseCommand {
|
||||
|
||||
/**
|
||||
* The same re-rendered edited field, but in different view modes.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $other_view_modes;
|
||||
|
||||
/**
|
||||
* Constructs a FieldFormSavedCommand object.
|
||||
*
|
||||
* @param string $data
|
||||
* The re-rendered edited field to pass on to the client side.
|
||||
* @param array $other_view_modes
|
||||
* The same re-rendered edited field, but in different view modes, for other
|
||||
* instances of the same field on the user's page. Keyed by view mode.
|
||||
*/
|
||||
public function __construct($data, $other_view_modes = array()) {
|
||||
parent::__construct('quickeditFieldFormSaved', $data);
|
||||
|
||||
$this->other_view_modes = $other_view_modes;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function render() {
|
||||
return array(
|
||||
'command' => $this->command,
|
||||
'data' => $this->data,
|
||||
'other_view_modes' => $this->other_view_modes,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Ajax\FieldFormValidationErrorsCommand.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Ajax;
|
||||
|
||||
/**
|
||||
* AJAX command to indicate a field form was attempted to be saved but failed
|
||||
* validation and pass the validation errors.
|
||||
*/
|
||||
class FieldFormValidationErrorsCommand extends BaseCommand {
|
||||
|
||||
/**
|
||||
* Constructs a FieldFormValidationErrorsCommand object.
|
||||
*
|
||||
* @param string $data
|
||||
* The data to pass on to the client side.
|
||||
*/
|
||||
public function __construct($data) {
|
||||
parent::__construct('quickeditFieldFormValidationErrors', $data);
|
||||
}
|
||||
|
||||
}
|
50
core/modules/quickedit/src/Annotation/InPlaceEditor.php
Normal file
50
core/modules/quickedit/src/Annotation/InPlaceEditor.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Annotation\InPlaceEditor.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Annotation;
|
||||
|
||||
use Drupal\Component\Annotation\Plugin;
|
||||
|
||||
/**
|
||||
* Defines an InPlaceEditor annotation object.
|
||||
*
|
||||
* Plugin Namespace: Plugin\InPlaceEditor
|
||||
*
|
||||
* For a working example, see \Drupal\quickedit\Plugin\InPlaceEditor\PlainTextEditor
|
||||
*
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorBase
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorInterface
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorManager
|
||||
* @see plugin_api
|
||||
*
|
||||
* @Annotation
|
||||
*/
|
||||
class InPlaceEditor extends Plugin {
|
||||
|
||||
/**
|
||||
* The plugin ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* An array of in-place editors plugin IDs that have registered themselves as
|
||||
* alternatives to this in-place editor.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public $alternativeTo;
|
||||
|
||||
/**
|
||||
* The name of the module providing the in-place editor plugin.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $module;
|
||||
|
||||
}
|
119
core/modules/quickedit/src/EditorSelector.php
Normal file
119
core/modules/quickedit/src/EditorSelector.php
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\EditorSelector.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit;
|
||||
|
||||
use Drupal\Component\Plugin\PluginManagerInterface;
|
||||
use Drupal\Component\Utility\NestedArray;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\Core\Field\FormatterPluginManager;
|
||||
|
||||
/**
|
||||
* Selects an in-place editor (an InPlaceEditor plugin) for a field.
|
||||
*/
|
||||
class EditorSelector implements EditorSelectorInterface {
|
||||
|
||||
/**
|
||||
* The manager for editor plugins.
|
||||
*
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $editorManager;
|
||||
|
||||
/**
|
||||
* The manager for formatter plugins.
|
||||
*
|
||||
* @var \Drupal\Core\Field\FormatterPluginManager.
|
||||
*/
|
||||
protected $formatterManager;
|
||||
|
||||
/**
|
||||
* A list of alternative editor plugin IDs, keyed by editor plugin ID.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $alternatives;
|
||||
|
||||
/**
|
||||
* Constructs a new EditorSelector.
|
||||
*
|
||||
* @param \Drupal\Component\Plugin\PluginManagerInterface
|
||||
* The manager for editor plugins.
|
||||
* @param \Drupal\Core\Field\FormatterPluginManager
|
||||
* The manager for formatter plugins.
|
||||
*/
|
||||
public function __construct(PluginManagerInterface $editor_manager, FormatterPluginManager $formatter_manager) {
|
||||
$this->editorManager = $editor_manager;
|
||||
$this->formatterManager = $formatter_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEditor($formatter_type, FieldItemListInterface $items) {
|
||||
// Build a static cache of the editors that have registered themselves as
|
||||
// alternatives to a certain editor.
|
||||
if (!isset($this->alternatives)) {
|
||||
$editors = $this->editorManager->getDefinitions();
|
||||
foreach ($editors as $alternative_editor_id => $editor) {
|
||||
if (isset($editor['alternativeTo'])) {
|
||||
foreach ($editor['alternativeTo'] as $original_editor_id) {
|
||||
$this->alternatives[$original_editor_id][] = $alternative_editor_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the formatter defines an appropriate in-place editor. For
|
||||
// example, text formatters displaying untrimmed text can choose to use the
|
||||
// 'plain_text' editor. If the formatter doesn't specify, fall back to the
|
||||
// 'form' editor, since that can work for any field. Formatter definitions
|
||||
// can use 'disabled' to explicitly opt out of in-place editing.
|
||||
$formatter_info = $this->formatterManager->getDefinition($formatter_type);
|
||||
$editor_id = $formatter_info['quickedit']['editor'];
|
||||
if ($editor_id === 'disabled') {
|
||||
return;
|
||||
}
|
||||
elseif ($editor_id === 'form') {
|
||||
return 'form';
|
||||
}
|
||||
|
||||
// No early return, so create a list of all choices.
|
||||
$editor_choices = array($editor_id);
|
||||
if (isset($this->alternatives[$editor_id])) {
|
||||
$editor_choices = array_merge($editor_choices, $this->alternatives[$editor_id]);
|
||||
}
|
||||
|
||||
// Make a choice.
|
||||
foreach ($editor_choices as $editor_id) {
|
||||
$editor = $this->editorManager->createInstance($editor_id);
|
||||
if ($editor->isCompatible($items)) {
|
||||
return $editor_id;
|
||||
}
|
||||
}
|
||||
|
||||
// We still don't have a choice, so fall back to the default 'form' editor.
|
||||
return 'form';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEditorAttachments(array $editor_ids) {
|
||||
$attachments = array();
|
||||
$editor_ids = array_unique($editor_ids);
|
||||
|
||||
// Editor plugins' attachments.
|
||||
foreach ($editor_ids as $editor_id) {
|
||||
$editor = $this->editorManager->createInstance($editor_id);
|
||||
$attachments[] = $editor->getAttachments();
|
||||
}
|
||||
|
||||
return NestedArray::mergeDeepArray($attachments);
|
||||
}
|
||||
|
||||
}
|
43
core/modules/quickedit/src/EditorSelectorInterface.php
Normal file
43
core/modules/quickedit/src/EditorSelectorInterface.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\EditorSelectorInterface.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit;
|
||||
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
|
||||
/**
|
||||
* Interface for selecting an in-place editor (an Editor plugin) for a field.
|
||||
*/
|
||||
interface EditorSelectorInterface {
|
||||
|
||||
/**
|
||||
* Returns the in-place editor (an InPlaceEditor plugin) to use for a field.
|
||||
*
|
||||
* @param string $formatter_type
|
||||
* The field's formatter type name.
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $items
|
||||
* The field values to be in-place edited.
|
||||
*
|
||||
* @return string|null
|
||||
* The editor to use, or NULL to not enable in-place editing.
|
||||
*/
|
||||
public function getEditor($formatter_type, FieldItemListInterface $items);
|
||||
|
||||
/**
|
||||
* Returns the attachments for all editors.
|
||||
*
|
||||
* @param array $editor_ids
|
||||
* A list of all in-place editor IDs that should be attached.
|
||||
*
|
||||
* @return array
|
||||
* An array of attachments, for use with #attached.
|
||||
*
|
||||
* @see drupal_process_attached()
|
||||
*/
|
||||
public function getEditorAttachments(array $editor_ids);
|
||||
|
||||
}
|
238
core/modules/quickedit/src/Form/QuickEditFieldForm.php
Normal file
238
core/modules/quickedit/src/Form/QuickEditFieldForm.php
Normal file
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Form\QuickEditFieldForm.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Form;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\EntityStorageInterface;
|
||||
use Drupal\Core\Entity\EntityChangedInterface;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use Drupal\Core\Form\FormBase;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
use Drupal\Core\Render\Element;
|
||||
use Drupal\Core\Entity\Entity\EntityFormDisplay;
|
||||
use Drupal\user\PrivateTempStoreFactory;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Builds and process a form for editing a single entity field.
|
||||
*/
|
||||
class QuickEditFieldForm extends FormBase {
|
||||
|
||||
/**
|
||||
* Stores the tempstore factory.
|
||||
*
|
||||
* @var \Drupal\user\PrivateTempStoreFactory
|
||||
*/
|
||||
protected $tempStoreFactory;
|
||||
|
||||
/**
|
||||
* The module handler.
|
||||
*
|
||||
* @var \Drupal\Core\Extension\ModuleHandlerInterface
|
||||
*/
|
||||
protected $moduleHandler;
|
||||
|
||||
/**
|
||||
* The node type storage.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityStorageInterface
|
||||
*/
|
||||
protected $nodeTypeStorage;
|
||||
|
||||
/**
|
||||
* The typed data validator.
|
||||
*
|
||||
* @var \Symfony\Component\Validator\Validator\ValidatorInterface
|
||||
*/
|
||||
protected $validator;
|
||||
|
||||
/**
|
||||
* Constructs a new EditFieldForm.
|
||||
*
|
||||
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
|
||||
* The tempstore factory.
|
||||
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
|
||||
* The module handler.
|
||||
* @param \Drupal\Core\Entity\EntityStorageInterface $node_type_storage
|
||||
* The node type storage.
|
||||
* @param \Symfony\Component\Validator\Validator\ValidatorInterface $validator
|
||||
* The typed data validator service.
|
||||
*/
|
||||
public function __construct(PrivateTempStoreFactory $temp_store_factory, ModuleHandlerInterface $module_handler, EntityStorageInterface $node_type_storage, ValidatorInterface $validator) {
|
||||
$this->moduleHandler = $module_handler;
|
||||
$this->nodeTypeStorage = $node_type_storage;
|
||||
$this->tempStoreFactory = $temp_store_factory;
|
||||
$this->validator = $validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container) {
|
||||
return new static(
|
||||
$container->get('user.private_tempstore'),
|
||||
$container->get('module_handler'),
|
||||
$container->get('entity.manager')->getStorage('node_type'),
|
||||
$container->get('typed_data_manager')->getValidator()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFormId() {
|
||||
return 'quickedit_field_form';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Builds a form for a single entity field.
|
||||
*/
|
||||
public function buildForm(array $form, FormStateInterface $form_state, EntityInterface $entity = NULL, $field_name = NULL) {
|
||||
if (!$form_state->has('entity')) {
|
||||
$this->init($form_state, $entity, $field_name);
|
||||
}
|
||||
|
||||
// Add the field form.
|
||||
$form_state->get('form_display')->buildForm($entity, $form, $form_state);
|
||||
|
||||
// Add a dummy changed timestamp field to attach form errors to.
|
||||
if ($entity instanceof EntityChangedInterface) {
|
||||
$form['changed_field'] = array(
|
||||
'#type' => 'hidden',
|
||||
'#value' => $entity->getChangedTime(),
|
||||
);
|
||||
}
|
||||
|
||||
// Add a submit button. Give it a class for easy JavaScript targeting.
|
||||
$form['actions'] = array('#type' => 'actions');
|
||||
$form['actions']['submit'] = array(
|
||||
'#type' => 'submit',
|
||||
'#value' => t('Save'),
|
||||
'#attributes' => array('class' => array('quickedit-form-submit')),
|
||||
);
|
||||
|
||||
// Simplify it for optimal in-place use.
|
||||
$this->simplify($form, $form_state);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the form state and the entity before the first form build.
|
||||
*/
|
||||
protected function init(FormStateInterface $form_state, EntityInterface $entity, $field_name) {
|
||||
// @todo Rather than special-casing $node->revision, invoke prepareEdit()
|
||||
// once https://www.drupal.org/node/1863258 lands.
|
||||
if ($entity->getEntityTypeId() == 'node') {
|
||||
$node_type = $this->nodeTypeStorage->load($entity->bundle());
|
||||
$entity->setNewRevision($node_type->isNewRevision());
|
||||
$entity->revision_log = NULL;
|
||||
}
|
||||
|
||||
$form_state->set('entity', $entity);
|
||||
$form_state->set('field_name', $field_name);
|
||||
|
||||
// Fetch the display used by the form. It is the display for the 'default'
|
||||
// form mode, with only the current field visible.
|
||||
$display = EntityFormDisplay::collectRenderDisplay($entity, 'default');
|
||||
foreach ($display->getComponents() as $name => $options) {
|
||||
if ($name != $field_name) {
|
||||
$display->removeComponent($name);
|
||||
}
|
||||
}
|
||||
$form_state->set('form_display', $display);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validateForm(array &$form, FormStateInterface $form_state) {
|
||||
$entity = $this->buildEntity($form, $form_state);
|
||||
$form_state->get('form_display')->validateFormValues($entity, $form, $form_state);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Saves the entity with updated values for the edited field.
|
||||
*/
|
||||
public function submitForm(array &$form, FormStateInterface $form_state) {
|
||||
$entity = $this->buildEntity($form, $form_state);
|
||||
$form_state->set('entity', $entity);
|
||||
|
||||
// Store entity in tempstore with its UUID as tempstore key.
|
||||
$this->tempStoreFactory->get('quickedit')->set($entity->uuid(), $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cloned entity containing updated field values.
|
||||
*
|
||||
* Calling code may then validate the returned entity, and if valid, transfer
|
||||
* it back to the form state and save it.
|
||||
*/
|
||||
protected function buildEntity(array $form, FormStateInterface $form_state) {
|
||||
/** @var $entity \Drupal\Core\Entity\EntityInterface */
|
||||
$entity = clone $form_state->get('entity');
|
||||
$field_name = $form_state->get('field_name');
|
||||
|
||||
$form_state->get('form_display')->extractFormValues($entity, $form, $form_state);
|
||||
|
||||
// @todo Refine automated log messages and abstract them to all entity
|
||||
// types: https://www.drupal.org/node/1678002.
|
||||
if ($entity->getEntityTypeId() == 'node' && $entity->isNewRevision() && $entity->revision_log->isEmpty()) {
|
||||
$entity->revision_log = t('Updated the %field-name field through in-place editing.', array('%field-name' => $entity->get($field_name)->getFieldDefinition()->getLabel()));
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplifies the field edit form for in-place editing.
|
||||
*
|
||||
* This function:
|
||||
* - Hides the field label inside the form, because JavaScript displays it
|
||||
* outside the form.
|
||||
* - Adjusts textarea elements to fit their content.
|
||||
*
|
||||
* @param array &$form
|
||||
* A reference to an associative array containing the structure of the form.
|
||||
* @param \Drupal\Core\Form\FormStateInterface $form_state
|
||||
* The current state of the form.
|
||||
*/
|
||||
protected function simplify(array &$form, FormStateInterface $form_state) {
|
||||
$field_name = $form_state->get('field_name');
|
||||
$widget_element =& $form[$field_name]['widget'];
|
||||
|
||||
// Hide the field label from displaying within the form, because JavaScript
|
||||
// displays the equivalent label that was provided within an HTML data
|
||||
// attribute of the field's display element outside of the form. Do this for
|
||||
// widgets without child elements (like Option widgets) as well as for ones
|
||||
// with per-delta elements. Skip single checkboxes, because their title is
|
||||
// key to their UI. Also skip widgets with multiple subelements, because in
|
||||
// that case, per-element labeling is informative.
|
||||
$num_children = count(Element::children($widget_element));
|
||||
if ($num_children == 0 && $widget_element['#type'] != 'checkbox') {
|
||||
$widget_element['#title_display'] = 'invisible';
|
||||
}
|
||||
if ($num_children == 1 && isset($widget_element[0]['value'])) {
|
||||
// @todo While most widgets name their primary element 'value', not all
|
||||
// do, so generalize this.
|
||||
$widget_element[0]['value']['#title_display'] = 'invisible';
|
||||
}
|
||||
|
||||
// Adjust textarea elements to fit their content.
|
||||
if (isset($widget_element[0]['value']['#type']) && $widget_element[0]['value']['#type'] == 'textarea') {
|
||||
$lines = count(explode("\n", $widget_element[0]['value']['#default_value']));
|
||||
$widget_element[0]['value']['#rows'] = $lines + 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
105
core/modules/quickedit/src/MetadataGenerator.php
Normal file
105
core/modules/quickedit/src/MetadataGenerator.php
Normal file
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\MetadataGenerator.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit;
|
||||
|
||||
use Drupal\Component\Plugin\PluginManagerInterface;
|
||||
use Drupal\Component\Utility\SafeMarkup;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface;
|
||||
use Drupal\Core\Entity\Entity\EntityViewDisplay;
|
||||
|
||||
/**
|
||||
* Generates in-place editing metadata for an entity field.
|
||||
*/
|
||||
class MetadataGenerator implements MetadataGeneratorInterface {
|
||||
|
||||
/**
|
||||
* An object that checks if a user has access to edit a given entity field.
|
||||
*
|
||||
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface
|
||||
*/
|
||||
protected $accessChecker;
|
||||
|
||||
/**
|
||||
* An object that determines which editor to attach to a given field.
|
||||
*
|
||||
* @var \Drupal\quickedit\EditorSelectorInterface
|
||||
*/
|
||||
protected $editorSelector;
|
||||
|
||||
/**
|
||||
* The manager for editor plugins.
|
||||
*
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $editorManager;
|
||||
|
||||
/**
|
||||
* Constructs a new MetadataGenerator.
|
||||
*
|
||||
* @param \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface $access_checker
|
||||
* An object that checks if a user has access to edit a given field.
|
||||
* @param \Drupal\quickedit\EditorSelectorInterface $editor_selector
|
||||
* An object that determines which editor to attach to a given field.
|
||||
* @param \Drupal\Component\Plugin\PluginManagerInterface
|
||||
* The manager for editor plugins.
|
||||
*/
|
||||
public function __construct(EditEntityFieldAccessCheckInterface $access_checker, EditorSelectorInterface $editor_selector, PluginManagerInterface $editor_manager) {
|
||||
$this->accessChecker = $access_checker;
|
||||
$this->editorSelector = $editor_selector;
|
||||
$this->editorManager = $editor_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function generateEntityMetadata(EntityInterface $entity) {
|
||||
return array(
|
||||
'label' => $entity->label(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function generateFieldMetadata(FieldItemListInterface $items, $view_mode) {
|
||||
$entity = $items->getEntity();
|
||||
$field_name = $items->getFieldDefinition()->getName();
|
||||
|
||||
// Early-return if user does not have access.
|
||||
$access = $this->accessChecker->accessEditEntityField($entity, $field_name);
|
||||
if (!$access) {
|
||||
return array('access' => FALSE);
|
||||
}
|
||||
|
||||
// Early-return if no editor is available.
|
||||
$formatter_id = EntityViewDisplay::collectRenderDisplay($entity, $view_mode)->getRenderer($field_name)->getPluginId();
|
||||
$editor_id = $this->editorSelector->getEditor($formatter_id, $items);
|
||||
if (!isset($editor_id)) {
|
||||
return array('access' => FALSE);
|
||||
}
|
||||
|
||||
// Gather metadata, allow the editor to add additional metadata of its own.
|
||||
$label = $items->getFieldDefinition()->getLabel();
|
||||
$editor = $this->editorManager->createInstance($editor_id);
|
||||
$metadata = array(
|
||||
'label' => SafeMarkup::checkPlain($label),
|
||||
'access' => TRUE,
|
||||
'editor' => $editor_id,
|
||||
'aria' => t('Entity @type @id, field @field', array('@type' => $entity->getEntityTypeId(), '@id' => $entity->id(), '@field' => $label)),
|
||||
);
|
||||
$custom_metadata = $editor->getMetadata($items);
|
||||
if (count($custom_metadata)) {
|
||||
$metadata['custom'] = $custom_metadata;
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
}
|
46
core/modules/quickedit/src/MetadataGeneratorInterface.php
Normal file
46
core/modules/quickedit/src/MetadataGeneratorInterface.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\MetadataGeneratorInterface.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
|
||||
/**
|
||||
* Interface for generating in-place editing metadata.
|
||||
*/
|
||||
interface MetadataGeneratorInterface {
|
||||
|
||||
/**
|
||||
* Generates in-place editing metadata for an entity.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity, in the language in which one of its fields is being edited.
|
||||
* @return array
|
||||
* An array containing metadata with the following keys:
|
||||
* - label: the user-visible label for the entity in the given language.
|
||||
*/
|
||||
public function generateEntityMetadata(EntityInterface $entity);
|
||||
|
||||
/**
|
||||
* Generates in-place editing metadata for an entity field.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $items
|
||||
* The field values to be in-place edited.
|
||||
* @param string $view_mode
|
||||
* The view mode the field should be rerendered in.
|
||||
* @return array
|
||||
* An array containing metadata with the following keys:
|
||||
* - label: the user-visible label for the field.
|
||||
* - access: whether the current user may edit the field or not.
|
||||
* - editor: which editor should be used for the field.
|
||||
* - aria: the ARIA label.
|
||||
* - custom: (optional) any additional metadata that the editor provides.
|
||||
*/
|
||||
public function generateFieldMetadata(FieldItemListInterface $items, $view_mode);
|
||||
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Plugin\InPlaceEditor\FormEditor.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Plugin\InPlaceEditor;
|
||||
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\quickedit\Plugin\InPlaceEditorBase;
|
||||
|
||||
/**
|
||||
* Defines the form in-place editor.
|
||||
*
|
||||
* @InPlaceEditor(
|
||||
* id = "form"
|
||||
* )
|
||||
*/
|
||||
class FormEditor extends InPlaceEditorBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function isCompatible(FieldItemListInterface $items) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getAttachments() {
|
||||
return array(
|
||||
'library' => array(
|
||||
'quickedit/quickedit.inPlaceEditor.form',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Plugin\InPlaceEditor\PlainTextEditor.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Plugin\InPlaceEditor;
|
||||
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\quickedit\Plugin\InPlaceEditorBase;
|
||||
|
||||
/**
|
||||
* Defines the plain text in-place editor.
|
||||
*
|
||||
* @InPlaceEditor(
|
||||
* id = "plain_text"
|
||||
* )
|
||||
*/
|
||||
class PlainTextEditor extends InPlaceEditorBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function isCompatible(FieldItemListInterface $items) {
|
||||
$field_definition = $items->getFieldDefinition();
|
||||
|
||||
// This editor is incompatible with multivalued fields.
|
||||
if ($field_definition->getFieldStorageDefinition()->getCardinality() != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
// This editor is incompatible with formatted ("rich") text fields.
|
||||
elseif (in_array($field_definition->getType(), array('text', 'text_long', 'text_with_summary'), TRUE)) {
|
||||
return FALSE;
|
||||
}
|
||||
else {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getAttachments() {
|
||||
return array(
|
||||
'library' => array(
|
||||
'quickedit/quickedit.inPlaceEditor.plainText',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
30
core/modules/quickedit/src/Plugin/InPlaceEditorBase.php
Normal file
30
core/modules/quickedit/src/Plugin/InPlaceEditorBase.php
Normal file
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Plugin\InPlaceEditorBase.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Plugin;
|
||||
|
||||
use Drupal\Core\Plugin\PluginBase;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
|
||||
/**
|
||||
* Defines a base in-place editor implementation.
|
||||
*
|
||||
* @see \Drupal\quickedit\Annotation\InPlaceEditor
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorInterface
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorManager
|
||||
* @see plugin_api
|
||||
*/
|
||||
abstract class InPlaceEditorBase extends PluginBase implements InPlaceEditorInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
function getMetadata(FieldItemListInterface $items) {
|
||||
return array();
|
||||
}
|
||||
|
||||
}
|
59
core/modules/quickedit/src/Plugin/InPlaceEditorInterface.php
Normal file
59
core/modules/quickedit/src/Plugin/InPlaceEditorInterface.php
Normal file
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Plugin\InPlaceEditorInterface.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Plugin;
|
||||
|
||||
use Drupal\Component\Plugin\PluginInspectionInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
|
||||
/**
|
||||
* Defines an interface for in-place editors plugins.
|
||||
*
|
||||
* @see \Drupal\quickedit\Annotation\InPlaceEditor
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorBase
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorManager
|
||||
* @see plugin_api
|
||||
*/
|
||||
interface InPlaceEditorInterface extends PluginInspectionInterface {
|
||||
|
||||
/**
|
||||
* Checks whether this in-place editor is compatible with a given field.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $items
|
||||
* The field values to be in-place edited.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if it is compatible, FALSE otherwise.
|
||||
*/
|
||||
public function isCompatible(FieldItemListInterface $items);
|
||||
|
||||
/**
|
||||
* Generates metadata that is needed specifically for this editor.
|
||||
*
|
||||
* Will only be called by \Drupal\quickedit\MetadataGeneratorInterface::generate()
|
||||
* when the passed in field & item values will use this editor.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldItemListInterface $items
|
||||
* The field values to be in-place edited.
|
||||
*
|
||||
* @return array
|
||||
* A keyed array with metadata. Each key should be prefixed with the plugin
|
||||
* ID of the editor.
|
||||
*/
|
||||
public function getMetadata(FieldItemListInterface $items);
|
||||
|
||||
/**
|
||||
* Returns the attachments for this editor.
|
||||
*
|
||||
* @return array
|
||||
* An array of attachments, for use with #attached.
|
||||
*
|
||||
* @see drupal_process_attached()
|
||||
*/
|
||||
public function getAttachments();
|
||||
|
||||
}
|
43
core/modules/quickedit/src/Plugin/InPlaceEditorManager.php
Normal file
43
core/modules/quickedit/src/Plugin/InPlaceEditorManager.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Plugin\InPlaceEditorManager.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Plugin;
|
||||
|
||||
use Drupal\Core\Cache\CacheBackendInterface;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use Drupal\Core\Plugin\DefaultPluginManager;
|
||||
|
||||
/**
|
||||
* Provides an in-place editor manager.
|
||||
*
|
||||
* The 'form' in-place editor must always be available.
|
||||
*
|
||||
* @see \Drupal\quickedit\Annotation\InPlaceEditor
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorBase
|
||||
* @see \Drupal\quickedit\Plugin\InPlaceEditorInterface
|
||||
* @see plugin_api
|
||||
*/
|
||||
class InPlaceEditorManager extends DefaultPluginManager {
|
||||
|
||||
/**
|
||||
* Constructs a InPlaceEditorManager object.
|
||||
*
|
||||
* @param \Traversable $namespaces
|
||||
* An object that implements \Traversable which contains the root paths
|
||||
* keyed by the corresponding namespace to look for plugin implementations.
|
||||
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
|
||||
* Cache backend instance to use.
|
||||
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
|
||||
* The module handler to invoke the alter hook with.
|
||||
*/
|
||||
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
|
||||
parent::__construct('Plugin/InPlaceEditor', $namespaces, $module_handler, 'Drupal\quickedit\Plugin\InPlaceEditorInterface', 'Drupal\quickedit\Annotation\InPlaceEditor');
|
||||
$this->alterInfo('quickedit_editor');
|
||||
$this->setCacheBackend($cache_backend, 'quickedit:editor');
|
||||
}
|
||||
|
||||
}
|
310
core/modules/quickedit/src/QuickEditController.php
Normal file
310
core/modules/quickedit/src/QuickEditController.php
Normal file
|
@ -0,0 +1,310 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\QuickEditController.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit;
|
||||
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Drupal\Core\Form\FormState;
|
||||
use Drupal\Core\Render\RendererInterface;
|
||||
use Drupal\user\PrivateTempStoreFactory;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Drupal\Core\Ajax\AjaxResponse;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\quickedit\Ajax\FieldFormCommand;
|
||||
use Drupal\quickedit\Ajax\FieldFormSavedCommand;
|
||||
use Drupal\quickedit\Ajax\FieldFormValidationErrorsCommand;
|
||||
use Drupal\quickedit\Ajax\EntitySavedCommand;
|
||||
|
||||
/**
|
||||
* Returns responses for Quick Edit module routes.
|
||||
*/
|
||||
class QuickEditController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* The PrivateTempStore factory.
|
||||
*
|
||||
* @var \Drupal\user\PrivateTempStoreFactory
|
||||
*/
|
||||
protected $tempStoreFactory;
|
||||
|
||||
/**
|
||||
* The in-place editing metadata generator.
|
||||
*
|
||||
* @var \Drupal\quickedit\MetadataGeneratorInterface
|
||||
*/
|
||||
protected $metadataGenerator;
|
||||
|
||||
/**
|
||||
* The in-place editor selector.
|
||||
*
|
||||
* @var \Drupal\quickedit\EditorSelectorInterface
|
||||
*/
|
||||
protected $editorSelector;
|
||||
|
||||
/**
|
||||
* The renderer.
|
||||
*
|
||||
* @var \Drupal\Core\Render\RendererInterface
|
||||
*/
|
||||
protected $renderer;
|
||||
|
||||
/**
|
||||
* Constructs a new QuickEditController.
|
||||
*
|
||||
* @param \Drupal\user\PrivateTempStoreFactory $temp_store_factory
|
||||
* The PrivateTempStore factory.
|
||||
* @param \Drupal\quickedit\MetadataGeneratorInterface $metadata_generator
|
||||
* The in-place editing metadata generator.
|
||||
* @param \Drupal\quickedit\EditorSelectorInterface $editor_selector
|
||||
* The in-place editor selector.
|
||||
* @param \Drupal\Core\Render\RendererInterface $renderer
|
||||
* The renderer.
|
||||
*/
|
||||
public function __construct(PrivateTempStoreFactory $temp_store_factory, MetadataGeneratorInterface $metadata_generator, EditorSelectorInterface $editor_selector, RendererInterface $renderer) {
|
||||
$this->tempStoreFactory = $temp_store_factory;
|
||||
$this->metadataGenerator = $metadata_generator;
|
||||
$this->editorSelector = $editor_selector;
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container) {
|
||||
return new static(
|
||||
$container->get('user.private_tempstore'),
|
||||
$container->get('quickedit.metadata.generator'),
|
||||
$container->get('quickedit.editor.selector'),
|
||||
$container->get('renderer')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata for a set of fields.
|
||||
*
|
||||
* Given a list of field quick edit IDs as POST parameters, run access checks
|
||||
* on the entity and field level to determine whether the current user may
|
||||
* edit them. Also retrieves other metadata.
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\JsonResponse
|
||||
* The JSON response.
|
||||
*/
|
||||
public function metadata(Request $request) {
|
||||
$fields = $request->request->get('fields');
|
||||
if (!isset($fields)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
$entities = $request->request->get('entities');
|
||||
|
||||
$metadata = array();
|
||||
foreach ($fields as $field) {
|
||||
list($entity_type, $entity_id, $field_name, $langcode, $view_mode) = explode('/', $field);
|
||||
|
||||
// Load the entity.
|
||||
if (!$entity_type || !$this->entityManager()->getDefinition($entity_type)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
$entity = $this->entityManager()->getStorage($entity_type)->load($entity_id);
|
||||
if (!$entity) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// Validate the field name and language.
|
||||
if (!$field_name || !$entity->hasField($field_name)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
if (!$langcode || !$entity->hasTranslation($langcode)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$entity = $entity->getTranslation($langcode);
|
||||
|
||||
// If the entity information for this field is requested, include it.
|
||||
$entity_id = $entity->getEntityTypeId() . '/' . $entity_id;
|
||||
if (is_array($entities) && in_array($entity_id, $entities) && !isset($metadata[$entity_id])) {
|
||||
$metadata[$entity_id] = $this->metadataGenerator->generateEntityMetadata($entity);
|
||||
}
|
||||
|
||||
$metadata[$field] = $this->metadataGenerator->generateFieldMetadata($entity->get($field_name), $view_mode);
|
||||
}
|
||||
|
||||
return new JsonResponse($metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns AJAX commands to load in-place editors' attachments.
|
||||
*
|
||||
* Given a list of in-place editor IDs as POST parameters, render AJAX
|
||||
* commands to load those in-place editors.
|
||||
*
|
||||
* @return \Drupal\Core\Ajax\AjaxResponse
|
||||
* The Ajax response.
|
||||
*/
|
||||
public function attachments(Request $request) {
|
||||
$response = new AjaxResponse();
|
||||
$editors = $request->request->get('editors');
|
||||
if (!isset($editors)) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$response->setAttachments($this->editorSelector->getEditorAttachments($editors));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single field edit form as an Ajax response.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity being edited.
|
||||
* @param string $field_name
|
||||
* The name of the field that is being edited.
|
||||
* @param string $langcode
|
||||
* The name of the language for which the field is being edited.
|
||||
* @param string $view_mode_id
|
||||
* The view mode the field should be rerendered in.
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The current request object containing the search string.
|
||||
*
|
||||
* @return \Drupal\Core\Ajax\AjaxResponse
|
||||
* The Ajax response.
|
||||
*/
|
||||
public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view_mode_id, Request $request) {
|
||||
$response = new AjaxResponse();
|
||||
|
||||
// Replace entity with PrivateTempStore copy if available and not resetting,
|
||||
// init PrivateTempStore copy otherwise.
|
||||
$tempstore_entity = $this->tempStoreFactory->get('quickedit')->get($entity->uuid());
|
||||
if ($tempstore_entity && $request->request->get('reset') !== 'true') {
|
||||
$entity = $tempstore_entity;
|
||||
}
|
||||
else {
|
||||
$this->tempStoreFactory->get('quickedit')->set($entity->uuid(), $entity);
|
||||
}
|
||||
|
||||
$form_state = (new FormState())
|
||||
->set('langcode', $langcode)
|
||||
->disableRedirect()
|
||||
->addBuildInfo('args', [$entity, $field_name]);
|
||||
$form = $this->formBuilder()->buildForm('Drupal\quickedit\Form\QuickEditFieldForm', $form_state);
|
||||
|
||||
if ($form_state->isExecuted()) {
|
||||
// The form submission saved the entity in PrivateTempStore. Return the
|
||||
// updated view of the field from the PrivateTempStore copy.
|
||||
$entity = $this->tempStoreFactory->get('quickedit')->get($entity->uuid());
|
||||
|
||||
// Closure to render the field given a view mode.
|
||||
$render_field_in_view_mode = function ($view_mode_id) use ($entity, $field_name, $langcode) {
|
||||
return $this->renderField($entity, $field_name, $langcode, $view_mode_id);
|
||||
};
|
||||
|
||||
// Re-render the updated field.
|
||||
$output = $render_field_in_view_mode($view_mode_id);
|
||||
|
||||
// Re-render the updated field for other view modes (i.e. for other
|
||||
// instances of the same logical field on the user's page).
|
||||
$other_view_mode_ids = $request->request->get('other_view_modes') ?: array();
|
||||
$other_view_modes = array_map($render_field_in_view_mode, array_combine($other_view_mode_ids, $other_view_mode_ids));
|
||||
|
||||
$response->addCommand(new FieldFormSavedCommand($output, $other_view_modes));
|
||||
}
|
||||
else {
|
||||
$output = $this->renderer->renderRoot($form);
|
||||
// When working with a hidden form, we don't want its CSS/JS to be loaded.
|
||||
if ($request->request->get('nocssjs') !== 'true') {
|
||||
$response->setAttachments($form['#attached']);
|
||||
}
|
||||
$response->addCommand(new FieldFormCommand($output));
|
||||
|
||||
$errors = $form_state->getErrors();
|
||||
if (count($errors)) {
|
||||
$status_messages = array(
|
||||
'#type' => 'status_messages'
|
||||
);
|
||||
$response->addCommand(new FieldFormValidationErrorsCommand($this->renderer->renderRoot($status_messages)));
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a field.
|
||||
*
|
||||
* If the view mode ID is not an Entity Display view mode ID, then the field
|
||||
* was rendered using a custom render pipeline (not the Entity/Field API
|
||||
* render pipeline).
|
||||
*
|
||||
* An example could be Views' render pipeline. In that case, the view mode ID
|
||||
* would probably contain the View's ID, display and the row index.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity being edited.
|
||||
* @param string $field_name
|
||||
* The name of the field that is being edited.
|
||||
* @param string $langcode
|
||||
* The name of the language for which the field is being edited.
|
||||
* @param string $view_mode_id
|
||||
* The view mode the field should be rerendered in. Either an Entity Display
|
||||
* view mode ID, or a custom one. See hook_quickedit_render_field().
|
||||
*
|
||||
* @return string
|
||||
* Rendered HTML.
|
||||
*
|
||||
* @see hook_quickedit_render_field()
|
||||
*/
|
||||
protected function renderField(EntityInterface $entity, $field_name, $langcode, $view_mode_id) {
|
||||
$entity_view_mode_ids = array_keys($this->entityManager()->getViewModes($entity->getEntityTypeId()));
|
||||
if (in_array($view_mode_id, $entity_view_mode_ids)) {
|
||||
$entity = \Drupal::entityManager()->getTranslationFromContext($entity, $langcode);
|
||||
$output = $entity->get($field_name)->view($view_mode_id);
|
||||
}
|
||||
else {
|
||||
// Each part of a custom (non-Entity Display) view mode ID is separated
|
||||
// by a dash; the first part must be the module name.
|
||||
$mode_id_parts = explode('-', $view_mode_id, 2);
|
||||
$module = reset($mode_id_parts);
|
||||
$args = array($entity, $field_name, $view_mode_id, $langcode);
|
||||
$output = $this->moduleHandler()->invoke($module, 'quickedit_render_field', $args);
|
||||
}
|
||||
|
||||
return $this->renderer->renderRoot($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an entity into the database, from PrivateTempStore.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity being edited.
|
||||
*
|
||||
* @return \Drupal\Core\Ajax\AjaxResponse
|
||||
* The Ajax response.
|
||||
*/
|
||||
public function entitySave(EntityInterface $entity) {
|
||||
// Take the entity from PrivateTempStore and save in entity storage.
|
||||
// fieldForm() ensures that the PrivateTempStore copy exists ahead.
|
||||
$tempstore = $this->tempStoreFactory->get('quickedit');
|
||||
$tempstore->get($entity->uuid())->save();
|
||||
$tempstore->delete($entity->uuid());
|
||||
|
||||
// Return information about the entity that allows a front end application
|
||||
// to identify it.
|
||||
$output = array(
|
||||
'entity_type' => $entity->getEntityTypeId(),
|
||||
'entity_id' => $entity->id()
|
||||
);
|
||||
|
||||
// Respond to client that the entity was saved properly.
|
||||
$response = new AjaxResponse();
|
||||
$response->addCommand(new EntitySavedCommand($output));
|
||||
return $response;
|
||||
}
|
||||
|
||||
}
|
158
core/modules/quickedit/src/Tests/EditorSelectionTest.php
Normal file
158
core/modules/quickedit/src/Tests/EditorSelectionTest.php
Normal file
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Tests\EditorSelectionTest.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Tests;
|
||||
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
use Drupal\quickedit\EditorSelector;
|
||||
|
||||
/**
|
||||
* Tests in-place field editor selection.
|
||||
*
|
||||
* @group quickedit
|
||||
*/
|
||||
class EditorSelectionTest extends QuickEditTestBase {
|
||||
|
||||
/**
|
||||
* The manager for editor plugins.
|
||||
*
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $editorManager;
|
||||
|
||||
/**
|
||||
* The editor selector object to be tested.
|
||||
*
|
||||
* @var \Drupal\quickedit\EditorSelectorInterface
|
||||
*/
|
||||
protected $editorSelector;
|
||||
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
|
||||
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in-place editor that Quick Edit selects.
|
||||
*/
|
||||
protected function getSelectedEditor($entity_id, $field_name, $view_mode = 'default') {
|
||||
$entity = entity_load('entity_test', $entity_id, TRUE);
|
||||
$items = $entity->getTranslation(LanguageInterface::LANGCODE_NOT_SPECIFIED)->get($field_name);
|
||||
$options = entity_get_display('entity_test', 'entity_test', $view_mode)->getComponent($field_name);
|
||||
return $this->editorSelector->getEditor($options['type'], $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a string (plain text) field, with cardinality 1 and >1.
|
||||
*/
|
||||
public function testText() {
|
||||
$field_name = 'field_text';
|
||||
$this->createFieldWithStorage(
|
||||
$field_name, 'string', 1, 'Simple text field',
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'string_textfield',
|
||||
array('size' => 42),
|
||||
// 'default' formatter type & settings.
|
||||
'string',
|
||||
array()
|
||||
);
|
||||
|
||||
// Create an entity with values for this text field.
|
||||
$entity = entity_create('entity_test');
|
||||
$entity->{$field_name}->value = 'Hello, world!';
|
||||
$entity->save();
|
||||
|
||||
// With cardinality 1.
|
||||
$this->assertEqual('plain_text', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality 1, the 'plain_text' editor is selected.");
|
||||
|
||||
// With cardinality >1
|
||||
$this->fields->field_text_field_storage->setCardinality(2);
|
||||
$this->fields->field_text_field_storage->save();
|
||||
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality >1, the 'form' editor is selected.");
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a textual field, with text filtering, with cardinality 1 and >1,
|
||||
* always with an Editor plugin present that supports textual fields with text
|
||||
* filtering, but with varying text format compatibility.
|
||||
*/
|
||||
public function testTextWysiwyg() {
|
||||
// Enable edit_test module so that the 'wysiwyg' editor becomes available.
|
||||
$this->enableModules(array('quickedit_test'));
|
||||
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
|
||||
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
|
||||
|
||||
$field_name = 'field_textarea';
|
||||
$this->createFieldWithStorage(
|
||||
$field_name, 'text', 1, 'Long text field',
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'text_textarea',
|
||||
array('size' => 42),
|
||||
// 'default' formatter type & settings.
|
||||
'text_default',
|
||||
array()
|
||||
);
|
||||
|
||||
// Create an entity with values for this text field.
|
||||
$entity = entity_create('entity_test');
|
||||
$entity->{$field_name}->value = 'Hello, world!';
|
||||
$entity->{$field_name}->format = 'filtered_html';
|
||||
$entity->save();
|
||||
|
||||
// Editor selection w/ cardinality 1, text format w/o associated text editor.
|
||||
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality 1, and the filtered_html text format, the 'form' editor is selected.");
|
||||
|
||||
// Editor selection w/ cardinality 1, text format w/ associated text editor.
|
||||
$entity->{$field_name}->format = 'full_html';
|
||||
$entity->save();
|
||||
$this->assertEqual('wysiwyg', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality 1, and the full_html text format, the 'wysiwyg' editor is selected.");
|
||||
|
||||
// Editor selection with text field, cardinality >1.
|
||||
$this->fields->field_textarea_field_storage->setCardinality(2);
|
||||
$this->fields->field_textarea_field_storage->save();
|
||||
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality >1, and both items using the full_html text format, the 'form' editor is selected.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a number field, with cardinality 1 and >1.
|
||||
*/
|
||||
public function testNumber() {
|
||||
$field_name = 'field_nr';
|
||||
$this->createFieldWithStorage(
|
||||
$field_name, 'integer', 1, 'Simple number field',
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'number',
|
||||
array(),
|
||||
// 'default' formatter type & settings.
|
||||
'number_integer',
|
||||
array()
|
||||
);
|
||||
|
||||
// Create an entity with values for this text field.
|
||||
$entity = entity_create('entity_test');
|
||||
$entity->{$field_name}->value = 42;
|
||||
$entity->save();
|
||||
|
||||
// Editor selection with cardinality 1.
|
||||
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality 1, the 'form' editor is selected.");
|
||||
|
||||
// Editor selection with cardinality >1.
|
||||
$this->fields->field_nr_field_storage->setCardinality(2);
|
||||
$this->fields->field_nr_field_storage->save();
|
||||
$this->assertEqual('form', $this->getSelectedEditor($entity->id(), $field_name), "With cardinality >1, the 'form' editor is selected.");
|
||||
}
|
||||
|
||||
}
|
188
core/modules/quickedit/src/Tests/MetadataGeneratorTest.php
Normal file
188
core/modules/quickedit/src/Tests/MetadataGeneratorTest.php
Normal file
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Tests\MetadataGeneratorTest.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Tests;
|
||||
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
use Drupal\quickedit\EditorSelector;
|
||||
use Drupal\quickedit\MetadataGenerator;
|
||||
use Drupal\quickedit\Plugin\InPlaceEditorManager;
|
||||
use Drupal\quickedit_test\MockEditEntityFieldAccessCheck;
|
||||
|
||||
/**
|
||||
* Tests in-place field editing metadata.
|
||||
*
|
||||
* @group quickedit
|
||||
*/
|
||||
class MetadataGeneratorTest extends QuickEditTestBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static $modules = array('quickedit_test');
|
||||
|
||||
/**
|
||||
* The manager for editor plugins.
|
||||
*
|
||||
* @var \Drupal\Component\Plugin\PluginManagerInterface
|
||||
*/
|
||||
protected $editorManager;
|
||||
|
||||
/**
|
||||
* The metadata generator object to be tested.
|
||||
*
|
||||
* @var \Drupal\quickedit\MetadataGeneratorInterface.php
|
||||
*/
|
||||
protected $metadataGenerator;
|
||||
|
||||
/**
|
||||
* The editor selector object to be used by the metadata generator object.
|
||||
*
|
||||
* @var \Drupal\quickedit\EditorSelectorInterface
|
||||
*/
|
||||
protected $editorSelector;
|
||||
|
||||
/**
|
||||
* The access checker object to be used by the metadata generator object.
|
||||
*
|
||||
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface
|
||||
*/
|
||||
protected $accessChecker;
|
||||
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
|
||||
$this->accessChecker = new MockEditEntityFieldAccessCheck();
|
||||
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
|
||||
$this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a simple entity type, with two different simple fields.
|
||||
*/
|
||||
public function testSimpleEntityType() {
|
||||
$field_1_name = 'field_text';
|
||||
$field_1_label = 'Plain text field';
|
||||
$this->createFieldWithStorage(
|
||||
$field_1_name, 'string', 1, $field_1_label,
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'string_textfield',
|
||||
array('size' => 42),
|
||||
// 'default' formatter type & settings.
|
||||
'string',
|
||||
array()
|
||||
);
|
||||
$field_2_name = 'field_nr';
|
||||
$field_2_label = 'Simple number field';
|
||||
$this->createFieldWithStorage(
|
||||
$field_2_name, 'integer', 1, $field_2_label,
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'number',
|
||||
array(),
|
||||
// 'default' formatter type & settings.
|
||||
'number_integer',
|
||||
array()
|
||||
);
|
||||
|
||||
// Create an entity with values for this text field.
|
||||
$entity = entity_create('entity_test');
|
||||
$entity->{$field_1_name}->value = 'Test';
|
||||
$entity->{$field_2_name}->value = 42;
|
||||
$entity->save();
|
||||
$entity = entity_load('entity_test', $entity->id());
|
||||
|
||||
// Verify metadata for field 1.
|
||||
$items_1 = $entity->getTranslation(LanguageInterface::LANGCODE_NOT_SPECIFIED)->get($field_1_name);
|
||||
$metadata_1 = $this->metadataGenerator->generateFieldMetadata($items_1, 'default');
|
||||
$expected_1 = array(
|
||||
'access' => TRUE,
|
||||
'label' => 'Plain text field',
|
||||
'editor' => 'plain_text',
|
||||
'aria' => 'Entity entity_test 1, field Plain text field',
|
||||
);
|
||||
$this->assertEqual($expected_1, $metadata_1, 'The correct metadata is generated for the first field.');
|
||||
|
||||
// Verify metadata for field 2.
|
||||
$items_2 = $entity->getTranslation(LanguageInterface::LANGCODE_NOT_SPECIFIED)->get($field_2_name);
|
||||
$metadata_2 = $this->metadataGenerator->generateFieldMetadata($items_2, 'default');
|
||||
$expected_2 = array(
|
||||
'access' => TRUE,
|
||||
'label' => 'Simple number field',
|
||||
'editor' => 'form',
|
||||
'aria' => 'Entity entity_test 1, field Simple number field',
|
||||
);
|
||||
$this->assertEqual($expected_2, $metadata_2, 'The correct metadata is generated for the second field.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a field whose associated in-place editor generates custom metadata.
|
||||
*/
|
||||
public function testEditorWithCustomMetadata() {
|
||||
$this->installSchema('system', 'url_alias');
|
||||
|
||||
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
|
||||
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
|
||||
$this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager);
|
||||
|
||||
$this->editorManager = $this->container->get('plugin.manager.quickedit.editor');
|
||||
$this->editorSelector = new EditorSelector($this->editorManager, $this->container->get('plugin.manager.field.formatter'));
|
||||
$this->metadataGenerator = new MetadataGenerator($this->accessChecker, $this->editorSelector, $this->editorManager);
|
||||
|
||||
// Create a rich text field.
|
||||
$field_name = 'field_rich';
|
||||
$field_label = 'Rich text field';
|
||||
$this->createFieldWithStorage(
|
||||
$field_name, 'text', 1, $field_label,
|
||||
// Instance settings.
|
||||
array(),
|
||||
// Widget type & settings.
|
||||
'text_textfield',
|
||||
array('size' => 42),
|
||||
// 'default' formatter type & settings.
|
||||
'text_default',
|
||||
array()
|
||||
);
|
||||
|
||||
// Create a text format.
|
||||
$full_html_format = entity_create('filter_format', array(
|
||||
'format' => 'full_html',
|
||||
'name' => 'Full HTML',
|
||||
'weight' => 1,
|
||||
'filters' => array(
|
||||
'filter_htmlcorrector' => array('status' => 1),
|
||||
),
|
||||
));
|
||||
$full_html_format->save();
|
||||
|
||||
// Create an entity with values for this rich text field.
|
||||
$entity = entity_create('entity_test');
|
||||
$entity->{$field_name}->value = 'Test';
|
||||
$entity->{$field_name}->format = 'full_html';
|
||||
$entity->save();
|
||||
$entity = entity_load('entity_test', $entity->id());
|
||||
|
||||
// Verify metadata.
|
||||
$items = $entity->getTranslation(LanguageInterface::LANGCODE_NOT_SPECIFIED)->get($field_name);
|
||||
$metadata = $this->metadataGenerator->generateFieldMetadata($items, 'default');
|
||||
$expected = array(
|
||||
'access' => TRUE,
|
||||
'label' => 'Rich text field',
|
||||
'editor' => 'wysiwyg',
|
||||
'aria' => 'Entity entity_test 1, field Rich text field',
|
||||
'custom' => array(
|
||||
'format' => 'full_html'
|
||||
),
|
||||
);
|
||||
$this->assertEqual($expected, $metadata); //, 'The correct metadata (including custom metadata) is generated.');
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Tests\QuickEditAutocompleteTermTest.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Tests;
|
||||
|
||||
use Drupal\Component\Serialization\Json;
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
use Drupal\entity_reference\Tests\EntityReferenceTestTrait;
|
||||
use Drupal\simpletest\WebTestBase;
|
||||
|
||||
/**
|
||||
* Tests in-place editing of autocomplete tags.
|
||||
*
|
||||
* @group quickedit
|
||||
*/
|
||||
class QuickEditAutocompleteTermTest extends WebTestBase {
|
||||
|
||||
use EntityReferenceTestTrait;
|
||||
|
||||
/**
|
||||
* Modules to enable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $modules = array('node', 'taxonomy', 'quickedit');
|
||||
|
||||
/**
|
||||
* Stores the node used for the tests.
|
||||
*
|
||||
* @var \Drupal\node\NodeInterface
|
||||
*/
|
||||
protected $node;
|
||||
|
||||
/**
|
||||
* Stores the vocabulary used in the tests.
|
||||
*
|
||||
* @var \Drupal\taxonomy\VocabularyInterface
|
||||
*/
|
||||
protected $vocabulary;
|
||||
|
||||
/**
|
||||
* Stores the first term used in the tests.
|
||||
*
|
||||
* @var \Drupal\taxonomy\TermInterface
|
||||
*/
|
||||
protected $term1;
|
||||
|
||||
/**
|
||||
* Stores the second term used in the tests.
|
||||
*
|
||||
* @var \Drupal\taxonomy\TermInterface
|
||||
*/
|
||||
protected $term2;
|
||||
|
||||
/**
|
||||
* Stores the field name for the autocomplete field.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldName;
|
||||
|
||||
/**
|
||||
* An user with permissions to access in-place editor.
|
||||
*
|
||||
* @var \Drupal\user\UserInterface
|
||||
*/
|
||||
protected $editorUser;
|
||||
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
$this->drupalCreateContentType(array(
|
||||
'type' => 'article',
|
||||
));
|
||||
// Create the vocabulary for the tag field.
|
||||
$this->vocabulary = entity_create('taxonomy_vocabulary', [
|
||||
'name' => 'quickedit testing tags',
|
||||
'vid' => 'quickedit_testing_tags',
|
||||
]);
|
||||
$this->vocabulary->save();
|
||||
$this->fieldName = 'field_' . $this->vocabulary->id();
|
||||
|
||||
$handler_settings = array(
|
||||
'target_bundles' => array(
|
||||
$this->vocabulary->id() => $this->vocabulary->id(),
|
||||
),
|
||||
'auto_create' => TRUE,
|
||||
);
|
||||
$this->createEntityReferenceField('node', 'article', $this->fieldName, 'Tags', 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
|
||||
|
||||
entity_get_form_display('node', 'article', 'default')
|
||||
->setComponent($this->fieldName, [
|
||||
'type' => 'entity_reference_autocomplete_tags',
|
||||
'weight' => -4,
|
||||
])
|
||||
->save();
|
||||
|
||||
entity_get_display('node', 'article', 'default')
|
||||
->setComponent($this->fieldName, [
|
||||
'type' => 'entity_reference_label',
|
||||
'weight' => 10,
|
||||
])
|
||||
->save();
|
||||
entity_get_display('node', 'article', 'teaser')
|
||||
->setComponent($this->fieldName, [
|
||||
'type' => 'entity_reference_label',
|
||||
'weight' => 10,
|
||||
])
|
||||
->save();
|
||||
|
||||
$this->term1 = $this->createTerm();
|
||||
$this->term2 = $this->createTerm();
|
||||
|
||||
$node = array();
|
||||
$node['type'] = 'article';
|
||||
$node[$this->fieldName][]['target_id'] = $this->term1->id();
|
||||
$node[$this->fieldName][]['target_id'] = $this->term2->id();
|
||||
$this->node = $this->drupalCreateNode($node);
|
||||
|
||||
$this->editorUser = $this->drupalCreateUser(['access content', 'create article content', 'edit any article content', 'access in-place editing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Quick Edit autocomplete term behavior.
|
||||
*/
|
||||
public function testAutocompleteQuickEdit() {
|
||||
$this->drupalLogin($this->editorUser);
|
||||
|
||||
$quickedit_uri = 'quickedit/form/node/'. $this->node->id() . '/' . $this->fieldName . '/' . $this->node->language()->getId() . '/full';
|
||||
$post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost($quickedit_uri, 'application/vnd.drupal-ajax', $post);
|
||||
$ajax_commands = Json::decode($response);
|
||||
|
||||
// Prepare form values for submission. drupalPostAJAX() is not suitable for
|
||||
// handling pages with JSON responses, so we need our own solution here.
|
||||
$form_tokens_found = preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match) && preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$this->assertTrue($form_tokens_found, 'Form tokens found in output.');
|
||||
|
||||
if ($form_tokens_found) {
|
||||
$post = array(
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
$this->fieldName . '[target_id]' => implode(', ', array($this->term1->getName(), 'new term', $this->term2->getName())),
|
||||
'op' => t('Save'),
|
||||
);
|
||||
|
||||
// Submit field form and check response. Should render back all the terms.
|
||||
$response = $this->drupalPost($quickedit_uri, 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->setRawContent($ajax_commands[0]['data']);
|
||||
$this->assertLink($this->term1->getName());
|
||||
$this->assertLink($this->term2->getName());
|
||||
$this->assertText('new term');
|
||||
$this->assertNoLink('new term');
|
||||
|
||||
// Load the form again, which should now get it back from
|
||||
// PrivateTempStore.
|
||||
$quickedit_uri = 'quickedit/form/node/'. $this->node->id() . '/' . $this->fieldName . '/' . $this->node->language()->getId() . '/full';
|
||||
$post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost($quickedit_uri, 'application/vnd.drupal-ajax', $post);
|
||||
$ajax_commands = Json::decode($response);
|
||||
|
||||
// The AjaxResponse's first command is an InsertCommand which contains
|
||||
// the form to edit the taxonomy term field, it should contain all three
|
||||
// taxonomy terms, including the one that has just been newly created and
|
||||
// which is not yet stored.
|
||||
$this->setRawContent($ajax_commands[0]['data']);
|
||||
$expected = array(
|
||||
$this->term1->getName() . ' (' . $this->term1->id() . ')',
|
||||
'new term',
|
||||
$this->term2->getName() . ' (' . $this->term2->id() . ')',
|
||||
);
|
||||
$this->assertFieldByName($this->fieldName . '[target_id]', implode(', ', $expected));
|
||||
|
||||
// Save the entity.
|
||||
$post = array('nocssjs' => 'true');
|
||||
$response = $this->drupalPostWithFormat('quickedit/entity/node/' . $this->node->id(), 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
|
||||
// The full node display should now link to all entities, with the new
|
||||
// one created in the database as well.
|
||||
$this->drupalGet('node/' . $this->node->id());
|
||||
$this->assertLink($this->term1->getName());
|
||||
$this->assertLink($this->term2->getName());
|
||||
$this->assertLink('new term');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new term with random name and description in $this->vocabulary.
|
||||
*
|
||||
* @return \Drupal\taxonomy\TermInterface
|
||||
* The created taxonomy term.
|
||||
*/
|
||||
protected function createTerm() {
|
||||
$filter_formats = filter_formats();
|
||||
$format = array_pop($filter_formats);
|
||||
$term = entity_create('taxonomy_term', array(
|
||||
'name' => $this->randomMachineName(),
|
||||
'description' => $this->randomMachineName(),
|
||||
// Use the first available text format.
|
||||
'format' => $format->id(),
|
||||
'vid' => $this->vocabulary->id(),
|
||||
'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
|
||||
));
|
||||
$term->save();
|
||||
return $term;
|
||||
}
|
||||
|
||||
}
|
580
core/modules/quickedit/src/Tests/QuickEditLoadingTest.php
Normal file
580
core/modules/quickedit/src/Tests/QuickEditLoadingTest.php
Normal file
|
@ -0,0 +1,580 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Tests\QuickEditLoadingTest.
|
||||
*/
|
||||
|
||||
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;
|
||||
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\node\Entity\Node;
|
||||
use Drupal\node\Entity\NodeType;
|
||||
use Drupal\simpletest\WebTestBase;
|
||||
|
||||
/**
|
||||
* Tests loading of in-place editing functionality and lazy loading of its
|
||||
* in-place editors.
|
||||
*
|
||||
* @group quickedit
|
||||
*/
|
||||
class QuickEditLoadingTest extends WebTestBase {
|
||||
|
||||
/**
|
||||
* Modules to enable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $modules = array(
|
||||
'contextual',
|
||||
'quickedit',
|
||||
'filter',
|
||||
'node',
|
||||
'image',
|
||||
);
|
||||
|
||||
/**
|
||||
* An user with permissions to create and edit articles.
|
||||
*
|
||||
* @var \Drupal\user\UserInterface
|
||||
*/
|
||||
protected $authorUser;
|
||||
|
||||
/**
|
||||
* A author user with permissions to access in-place editor.
|
||||
*
|
||||
* @var \Drupal\user\UserInterface
|
||||
*/
|
||||
protected $editorUser;
|
||||
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
// Create a text format.
|
||||
$filtered_html_format = entity_create('filter_format', array(
|
||||
'format' => 'filtered_html',
|
||||
'name' => 'Filtered HTML',
|
||||
'weight' => 0,
|
||||
'filters' => array(),
|
||||
));
|
||||
$filtered_html_format->save();
|
||||
|
||||
// Create a node type.
|
||||
$this->drupalCreateContentType(array(
|
||||
'type' => 'article',
|
||||
'name' => 'Article',
|
||||
));
|
||||
|
||||
// Create one node of the above node type using the above text format.
|
||||
$this->drupalCreateNode(array(
|
||||
'type' => 'article',
|
||||
'body' => array(
|
||||
0 => array(
|
||||
'value' => '<p>How are you?</p>',
|
||||
'format' => 'filtered_html',
|
||||
)
|
||||
),
|
||||
'revision_log' => $this->randomString(),
|
||||
));
|
||||
|
||||
// Create 2 users, the only difference being the ability to use in-place
|
||||
// editing
|
||||
$basic_permissions = array('access content', 'create article content', 'edit any article content', 'use text format filtered_html', 'access contextual links');
|
||||
$this->authorUser = $this->drupalCreateUser($basic_permissions);
|
||||
$this->editorUser = $this->drupalCreateUser(array_merge($basic_permissions, array('access in-place editing')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the loading of Quick Edit when a user doesn't have access to it.
|
||||
*/
|
||||
public function testUserWithoutPermission() {
|
||||
$this->drupalLogin($this->authorUser);
|
||||
$this->drupalGet('node/1');
|
||||
|
||||
// Library and in-place editors.
|
||||
$this->assertNoRaw('core/modules/quickedit/js/quickedit.js', 'Quick Edit library not loaded.');
|
||||
$this->assertNoRaw('core/modules/quickedit/js/editors/formEditor.js', "'form' in-place editor not loaded.");
|
||||
|
||||
// HTML annotation must always exist (to not break the render cache).
|
||||
$this->assertRaw('data-quickedit-entity-id="node/1"');
|
||||
$this->assertRaw('data-quickedit-field-id="node/1/body/en/full"');
|
||||
|
||||
// Retrieving the metadata should result in an empty 403 response.
|
||||
$post = array('fields[0]' => 'node/1/body/en/full');
|
||||
$response = $this->drupalPostWithFormat(Url::fromRoute('quickedit.metadata'), 'json', $post);
|
||||
$this->assertIdentical('{"message":""}', $response);
|
||||
$this->assertResponse(403);
|
||||
|
||||
// Quick Edit's JavaScript would SearchRankingTestnever hit these endpoints if the metadata
|
||||
// was empty as above, but we need to make sure that malicious users aren't
|
||||
// able to use any of the other endpoints either.
|
||||
$post = array('editors[0]' => 'form') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/attachments', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
|
||||
$this->assertIdentical('{}', $response);
|
||||
$this->assertResponse(403);
|
||||
$post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', '', $post, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
|
||||
$this->assertIdentical('{}', $response);
|
||||
$this->assertResponse(403);
|
||||
$edit = array();
|
||||
$edit['form_id'] = 'quickedit_field_form';
|
||||
$edit['form_token'] = 'xIOzMjuc-PULKsRn_KxFn7xzNk5Bx7XKXLfQfw1qOnA';
|
||||
$edit['form_build_id'] = 'form-kVmovBpyX-SJfTT5kY0pjTV35TV-znor--a64dEnMR8';
|
||||
$edit['body[0][summary]'] = '';
|
||||
$edit['body[0][value]'] = '<p>Malicious content.</p>';
|
||||
$edit['body[0][format]'] = 'filtered_html';
|
||||
$edit['op'] = t('Save');
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', '', $edit, ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
|
||||
$this->assertIdentical('{}', $response);
|
||||
$this->assertResponse(403);
|
||||
$post = array('nocssjs' => 'true');
|
||||
$response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
|
||||
$this->assertIdentical('{"message":""}', $response);
|
||||
$this->assertResponse(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the loading of Quick Edit when a user does have access to it.
|
||||
*
|
||||
* Also ensures lazy loading of in-place editors works.
|
||||
*/
|
||||
public function testUserWithPermission() {
|
||||
$this->drupalLogin($this->editorUser);
|
||||
$this->drupalGet('node/1');
|
||||
|
||||
// Library and in-place editors.
|
||||
$settings = $this->getDrupalSettings();
|
||||
$libraries = explode(',', $settings['ajaxPageState']['libraries']);
|
||||
$this->assertTrue(in_array('quickedit/quickedit', $libraries), 'Quick Edit library loaded.');
|
||||
$this->assertFalse(in_array('quickedit/quickedit.inPlaceEditor.form', $libraries), "'form' in-place editor not loaded.");
|
||||
|
||||
// HTML annotation must always exist (to not break the render cache).
|
||||
$this->assertRaw('data-quickedit-entity-id="node/1"');
|
||||
$this->assertRaw('data-quickedit-field-id="node/1/body/en/full"');
|
||||
|
||||
// There should be only one revision so far.
|
||||
$node = Node::load(1);
|
||||
$vids = \Drupal::entityManager()->getStorage('node')->revisionIds($node);
|
||||
$this->assertIdentical(1, count($vids), 'The node has only one revision.');
|
||||
$original_log = $node->revision_log->value;
|
||||
|
||||
// Retrieving the metadata should result in a 200 JSON response.
|
||||
$htmlPageDrupalSettings = $this->drupalSettings;
|
||||
$post = array('fields[0]' => 'node/1/body/en/full');
|
||||
$response = $this->drupalPostWithFormat('quickedit/metadata', 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
$expected = array(
|
||||
'node/1/body/en/full' => array(
|
||||
'label' => 'Body',
|
||||
'access' => TRUE,
|
||||
'editor' => 'form',
|
||||
'aria' => 'Entity node 1, field Body',
|
||||
)
|
||||
);
|
||||
$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
|
||||
// after a JSON response.
|
||||
$this->drupalSettings = $htmlPageDrupalSettings;
|
||||
|
||||
// Retrieving the attachments should result in a 200 response, containing:
|
||||
// 1. a settings command with useless metadata: AjaxController is dumb
|
||||
// 2. an insert command that loads the required in-place editors
|
||||
$post = array('editors[0]' => 'form') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/attachments', 'application/vnd.drupal-ajax', $post);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(2, count($ajax_commands), 'The attachments HTTP request results in two AJAX commands.');
|
||||
// First command: settings.
|
||||
$this->assertIdentical('settings', $ajax_commands[0]['command'], 'The first AJAX command is a settings command.');
|
||||
// Second command: insert libraries into DOM.
|
||||
$this->assertIdentical('insert', $ajax_commands[1]['command'], 'The second AJAX command is an append command.');
|
||||
$this->assertTrue(in_array('quickedit/quickedit.inPlaceEditor.form', explode(',', $ajax_commands[0]['settings']['ajaxPageState']['libraries'])), 'The quickedit.inPlaceEditor.form library is loaded.');
|
||||
|
||||
// Retrieving the form for this field should result in a 200 response,
|
||||
// containing only a quickeditFieldForm command.
|
||||
$post = array('nocssjs' => 'true', 'reset' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$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.');
|
||||
|
||||
// Prepare form values for submission. drupalPostAjaxForm() is not suitable
|
||||
// for handling pages with JSON responses, so we need our own solution here.
|
||||
$form_tokens_found = preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match) && preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$this->assertTrue($form_tokens_found, 'Form tokens found in output.');
|
||||
|
||||
if ($form_tokens_found) {
|
||||
$edit = array(
|
||||
'body[0][summary]' => '',
|
||||
'body[0][value]' => '<p>Fine thanks.</p>',
|
||||
'body[0][format]' => 'filtered_html',
|
||||
'op' => t('Save'),
|
||||
);
|
||||
$post = array(
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
);
|
||||
$post += $edit + $this->getAjaxPageStatePostData();
|
||||
|
||||
// Submit field form and check response. This should store the updated
|
||||
// entity in PrivateTempStore on the server.
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldFormSaved command.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['data'], 'Fine thanks.'), 'Form value saved and printed back.');
|
||||
$this->assertIdentical($ajax_commands[0]['other_view_modes'], array(), 'Field was not rendered in any other view mode.');
|
||||
|
||||
// Ensure the text on the original node did not change yet.
|
||||
$this->drupalGet('node/1');
|
||||
$this->assertText('How are you?');
|
||||
|
||||
// Save the entity by moving the PrivateTempStore values to entity storage.
|
||||
$post = array('nocssjs' => 'true');
|
||||
$response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The entity submission HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditEntitySaved', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditEntitySaved command.');
|
||||
$this->assertIdentical($ajax_commands[0]['data']['entity_type'], 'node', 'Saved entity is of type node.');
|
||||
$this->assertIdentical($ajax_commands[0]['data']['entity_id'], '1', 'Entity id is 1.');
|
||||
|
||||
// Ensure the text on the original node did change.
|
||||
$this->drupalGet('node/1');
|
||||
$this->assertText('Fine thanks.');
|
||||
|
||||
// Ensure no new revision was created and the log message is unchanged.
|
||||
$node = Node::load(1);
|
||||
$vids = \Drupal::entityManager()->getStorage('node')->revisionIds($node);
|
||||
$this->assertIdentical(1, count($vids), 'The node has only one revision.');
|
||||
$this->assertIdentical($original_log, $node->revision_log->value, 'The revision log message is unchanged.');
|
||||
|
||||
// Now configure this node type to create new revisions automatically,
|
||||
// then again retrieve the field form, fill it, submit it (so it ends up
|
||||
// in PrivateTempStore) and then save the entity. Now there should be two
|
||||
// revisions.
|
||||
$node_type = NodeType::load('article');
|
||||
$node_type->setNewRevision(TRUE);
|
||||
$node_type->save();
|
||||
|
||||
// Retrieve field form.
|
||||
$post = array('nocssjs' => 'true', 'reset' => 'true');
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$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.');
|
||||
|
||||
// Submit field form.
|
||||
preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match);
|
||||
preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$edit['body[0][value]'] = '<p>kthxbye</p>';
|
||||
$post = array(
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
);
|
||||
$post += $edit + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is an quickeditFieldFormSaved command.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['data'], 'kthxbye'), 'Form value saved and printed back.');
|
||||
|
||||
// Save the entity.
|
||||
$post = array('nocssjs' => 'true');
|
||||
$response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands));
|
||||
$this->assertIdentical('quickeditEntitySaved', $ajax_commands[0]['command'], 'The first AJAX command is an quickeditEntitySaved command.');
|
||||
$this->assertEqual($ajax_commands[0]['data'], ['entity_type' => 'node', 'entity_id' => 1], 'Updated entity type and ID returned');
|
||||
|
||||
// Test that a revision was created with the correct log message.
|
||||
$vids = \Drupal::entityManager()->getStorage('node')->revisionIds(Node::load(1));
|
||||
$this->assertIdentical(2, count($vids), 'The node has two revisions.');
|
||||
$revision = node_revision_load($vids[0]);
|
||||
$this->assertIdentical($original_log, $revision->revision_log->value, 'The first revision log message is unchanged.');
|
||||
$revision = node_revision_load($vids[1]);
|
||||
$this->assertIdentical('Updated the <em class="placeholder">Body</em> field through in-place editing.', $revision->revision_log->value, 'The second revision log message was correctly generated by Quick Edit module.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the loading of Quick Edit for the title base field.
|
||||
*/
|
||||
public function testTitleBaseField() {
|
||||
$this->drupalLogin($this->editorUser);
|
||||
$this->drupalGet('node/1');
|
||||
|
||||
// Ensure that the full page title is actually in-place editable
|
||||
$node = Node::load(1);
|
||||
$elements = $this->xpath('//h1/span[@data-quickedit-field-id="node/1/title/en/full" and normalize-space(text())=:title]', array(':title' => $node->label()));
|
||||
$this->assertTrue(!empty($elements), 'Title with data-quickedit-field-id attribute found.');
|
||||
|
||||
// Retrieving the metadata should result in a 200 JSON response.
|
||||
$htmlPageDrupalSettings = $this->drupalSettings;
|
||||
$post = array('fields[0]' => 'node/1/title/en/full');
|
||||
$response = $this->drupalPostWithFormat('quickedit/metadata', 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
$expected = array(
|
||||
'node/1/title/en/full' => array(
|
||||
'label' => 'Title',
|
||||
'access' => TRUE,
|
||||
'editor' => 'plain_text',
|
||||
'aria' => 'Entity node 1, field Title',
|
||||
)
|
||||
);
|
||||
$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
|
||||
// after a JSON response.
|
||||
$this->drupalSettings = $htmlPageDrupalSettings;
|
||||
|
||||
// Retrieving the form for this field should result in a 200 response,
|
||||
// containing only a quickeditFieldForm command.
|
||||
$post = array('nocssjs' => 'true', 'reset' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/title/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$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.');
|
||||
|
||||
// Prepare form values for submission. drupalPostAjaxForm() is not suitable
|
||||
// for handling pages with JSON responses, so we need our own solution
|
||||
// here.
|
||||
$form_tokens_found = preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match) && preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$this->assertTrue($form_tokens_found, 'Form tokens found in output.');
|
||||
|
||||
if ($form_tokens_found) {
|
||||
$edit = array(
|
||||
'title[0][value]' => 'Obligatory question',
|
||||
'op' => t('Save'),
|
||||
);
|
||||
$post = array(
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
);
|
||||
$post += $edit + $this->getAjaxPageStatePostData();
|
||||
|
||||
// Submit field form and check response. This should store the
|
||||
// updated entity in PrivateTempStore on the server.
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/title/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldFormSaved command.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['data'], 'Obligatory question'), 'Form value saved and printed back.');
|
||||
|
||||
// Ensure the text on the original node did not change yet.
|
||||
$this->drupalGet('node/1');
|
||||
$this->assertNoText('Obligatory question');
|
||||
|
||||
// Save the entity by moving the PrivateTempStore values to entity storage.
|
||||
$post = array('nocssjs' => 'true');
|
||||
$response = $this->drupalPostWithFormat('quickedit/entity/' . 'node/1', 'json', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The entity submission HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditEntitySaved', $ajax_commands[0]['command'], 'The first AJAX command is n quickeditEntitySaved command.');
|
||||
$this->assertIdentical($ajax_commands[0]['data']['entity_type'], 'node', 'Saved entity is of type node.');
|
||||
$this->assertIdentical($ajax_commands[0]['data']['entity_id'], '1', 'Entity id is 1.');
|
||||
|
||||
// Ensure the text on the original node did change.
|
||||
$this->drupalGet('node/1');
|
||||
$this->assertText('Obligatory question');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Quick Edit doesn't make pseudo fields or computed fields
|
||||
* editable.
|
||||
*/
|
||||
public function testPseudoFields() {
|
||||
\Drupal::service('module_installer')->install(array('quickedit_test'));
|
||||
|
||||
$this->drupalLogin($this->authorUser);
|
||||
$this->drupalGet('node/1');
|
||||
|
||||
// Check that the data- attribute is not added.
|
||||
$this->assertNoRaw('data-quickedit-field-id="node/1/quickedit_test_pseudo_field/en/default"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Quick Edit doesn't make fields rendered with display options
|
||||
* editable.
|
||||
*/
|
||||
public function testDisplayOptions() {
|
||||
$node = Node::load('1');
|
||||
$display_settings = array(
|
||||
'label' => 'inline',
|
||||
);
|
||||
$build = $node->body->view($display_settings);
|
||||
$output = \Drupal::service('renderer')->renderRoot($build);
|
||||
$this->assertFalse(strpos($output, 'data-quickedit-field-id'), 'data-quickedit-field-id attribute not added when rendering field using dynamic display options.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Quick Edit works with custom render pipelines.
|
||||
*/
|
||||
public function testCustomPipeline() {
|
||||
\Drupal::service('module_installer')->install(array('quickedit_test'));
|
||||
|
||||
$custom_render_url = 'quickedit/form/node/1/body/en/quickedit_test-custom-render-data';
|
||||
$this->drupalLogin($this->editorUser);
|
||||
|
||||
// Request editing to render results with the custom render pipeline.
|
||||
$post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost($custom_render_url, 'application/vnd.drupal-ajax', $post);
|
||||
$ajax_commands = Json::decode($response);
|
||||
|
||||
// Prepare form values for submission. drupalPostAJAX() is not suitable for
|
||||
// handling pages with JSON responses, so we need our own solution here.
|
||||
$form_tokens_found = preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match) && preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$this->assertTrue($form_tokens_found, 'Form tokens found in output.');
|
||||
|
||||
if ($form_tokens_found) {
|
||||
$post = array(
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
'body[0][summary]' => '',
|
||||
'body[0][value]' => '<p>Fine thanks.</p>',
|
||||
'body[0][format]' => 'filtered_html',
|
||||
'op' => t('Save'),
|
||||
);
|
||||
// Assume there is another field on this page, which doesn't use a custom
|
||||
// render pipeline, but the default one, and it uses the "full" view mode.
|
||||
$post += array('other_view_modes[]' => 'full');
|
||||
|
||||
// Submit field form and check response. Should render with the custom
|
||||
// render pipeline.
|
||||
$response = $this->drupalPost($custom_render_url, 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(1, count($ajax_commands), 'The field form HTTP request results in one AJAX command.');
|
||||
$this->assertIdentical('quickeditFieldFormSaved', $ajax_commands[0]['command'], 'The first AJAX command is a quickeditFieldFormSaved command.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['data'], 'Fine thanks.'), 'Form value saved and printed back.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['data'], '<div class="quickedit-test-wrapper">') !== FALSE, 'Custom render pipeline used to render the value.');
|
||||
$this->assertIdentical(array_keys($ajax_commands[0]['other_view_modes']), array('full'), 'Field was also rendered in the "full" view mode.');
|
||||
$this->assertTrue(strpos($ajax_commands[0]['other_view_modes']['full'], 'Fine thanks.'), '"full" version of field contains the form value.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests Quick Edit on a node that was concurrently edited on the full node
|
||||
* form.
|
||||
*/
|
||||
public function testConcurrentEdit() {
|
||||
$this->drupalLogin($this->editorUser);
|
||||
|
||||
$post = array('nocssjs' => 'true') + $this->getAjaxPageStatePostData();
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
|
||||
// Prepare form values for submission. drupalPostAJAX() is not suitable for
|
||||
// handling pages with JSON responses, so we need our own solution here.
|
||||
$form_tokens_found = preg_match('/\sname="form_token" value="([^"]+)"/', $ajax_commands[0]['data'], $token_match) && preg_match('/\sname="form_build_id" value="([^"]+)"/', $ajax_commands[0]['data'], $build_id_match);
|
||||
$this->assertTrue($form_tokens_found, 'Form tokens found in output.');
|
||||
|
||||
if ($form_tokens_found) {
|
||||
$post = array(
|
||||
'nocssjs' => 'true',
|
||||
'form_id' => 'quickedit_field_form',
|
||||
'form_token' => $token_match[1],
|
||||
'form_build_id' => $build_id_match[1],
|
||||
'body[0][summary]' => '',
|
||||
'body[0][value]' => '<p>Fine thanks.</p>',
|
||||
'body[0][format]' => 'filtered_html',
|
||||
'op' => t('Save'),
|
||||
);
|
||||
|
||||
// Save the node on the regular node edit form.
|
||||
$this->drupalPostForm('node/1/edit', array(), t('Save'));
|
||||
// Ensure different save timestamps for field editing.
|
||||
sleep(2);
|
||||
|
||||
// Submit field form and check response. Should throw a validation error
|
||||
// because the node was changed in the meantime.
|
||||
$response = $this->drupalPost('quickedit/form/' . 'node/1/body/en/full', 'application/vnd.drupal-ajax', $post);
|
||||
$this->assertResponse(200);
|
||||
$ajax_commands = Json::decode($response);
|
||||
$this->assertIdentical(2, count($ajax_commands), 'The field form HTTP request results in two AJAX commands.');
|
||||
$this->assertIdentical('quickeditFieldFormValidationErrors', $ajax_commands[1]['command'], 'The second AJAX command is a quickeditFieldFormValidationErrors command.');
|
||||
$this->assertTrue(strpos($ajax_commands[1]['data'], t('The content has either been modified by another user, or you have already submitted modifications. As a result, your changes cannot be saved.')), 'Error message returned to user.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Quick Edit's data- attributes are present for content blocks.
|
||||
*/
|
||||
public function testContentBlock() {
|
||||
\Drupal::service('module_installer')->install(array('block_content'));
|
||||
|
||||
// Create and place a content_block block.
|
||||
$block = BlockContent::create([
|
||||
'info' => $this->randomMachineName(),
|
||||
'type' => 'basic',
|
||||
'langcode' => 'en',
|
||||
]);
|
||||
$block->save();
|
||||
$this->drupalPlaceBlock('block_content:' . $block->uuid());
|
||||
|
||||
// Check that the data- attribute is present.
|
||||
$this->drupalGet('');
|
||||
$this->assertRaw('data-quickedit-entity-id="block_content/1"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that Quick Edit can handle an image field.
|
||||
*/
|
||||
public function testImageField() {
|
||||
// Add an image field to the content type.
|
||||
FieldStorageConfig::create([
|
||||
'field_name' => 'field_image',
|
||||
'type' => 'image',
|
||||
'entity_type' => 'node',
|
||||
])->save();
|
||||
FieldConfig::create([
|
||||
'field_name' => 'field_image',
|
||||
'field_type' => 'image',
|
||||
'label' => t('Image'),
|
||||
'entity_type' => 'node',
|
||||
'bundle' => 'article',
|
||||
])->save();
|
||||
entity_get_form_display('node', 'article', 'default')
|
||||
->setComponent('field_image', [
|
||||
'type' => 'image_image',
|
||||
])
|
||||
->save();
|
||||
|
||||
// Add an image to the node.
|
||||
$this->drupalLogin($this->editorUser);
|
||||
$image = $this->drupalGetTestFiles('image')[0];
|
||||
$this->drupalPostForm('node/1/edit', [
|
||||
'files[field_image_0]' => $image->uri,
|
||||
], t('Upload'));
|
||||
$this->drupalPostForm(NULL, [
|
||||
'field_image[0][alt]' => 'Vivamus aliquet elit',
|
||||
], t('Save'));
|
||||
|
||||
// The image field form should load normally.
|
||||
$response = $this->drupalPost('quickedit/form/node/1/field_image/en/full', 'application/vnd.drupal-ajax', ['nocssjs' => 'true'] + $this->getAjaxPageStatePostData());
|
||||
$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.');
|
||||
}
|
||||
}
|
108
core/modules/quickedit/src/Tests/QuickEditTestBase.php
Normal file
108
core/modules/quickedit/src/Tests/QuickEditTestBase.php
Normal file
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit\Tests\QuickEditTestBase.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit\Tests;
|
||||
|
||||
use Drupal\simpletest\KernelTestBase;
|
||||
|
||||
/**
|
||||
* Base class for testing Quick Edit functionality.
|
||||
*/
|
||||
abstract class QuickEditTestBase extends KernelTestBase {
|
||||
|
||||
/**
|
||||
* Modules to enable.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $modules = array('system', 'entity_test', 'field', 'field_test', 'filter', 'user', 'text', 'quickedit', 'entity_reference');
|
||||
|
||||
/**
|
||||
* Bag of created fields.
|
||||
*
|
||||
* Allows easy access to test field names/IDs/objects via:
|
||||
* - $this->fields->{$field_name}_field_storage
|
||||
* - $this->fields->{$field_name}_instance
|
||||
*
|
||||
* @see \Drupal\quickedit\Tests\QuickEditTestBase::createFieldWithStorage()
|
||||
*
|
||||
* @var \ArrayObject
|
||||
*/
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* Sets the default field storage backend for fields created during tests.
|
||||
*/
|
||||
protected function setUp() {
|
||||
parent::setUp();
|
||||
|
||||
$this->fields = new \ArrayObject(array(), \ArrayObject::ARRAY_AS_PROPS);
|
||||
|
||||
$this->installEntitySchema('user');
|
||||
$this->installEntitySchema('entity_test');
|
||||
$this->installConfig(array('field', 'filter'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a field.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The field name.
|
||||
* @param string $type
|
||||
* The field type.
|
||||
* @param int $cardinality
|
||||
* The field's cardinality.
|
||||
* @param string $label
|
||||
* The field's label (used everywhere: widget label, formatter label).
|
||||
* @param array $field_settings
|
||||
* @param string $widget_type
|
||||
* The widget type.
|
||||
* @param array $widget_settings
|
||||
* The widget settings.
|
||||
* @param string $formatter_type
|
||||
* The formatter type.
|
||||
* @param array $formatter_settings
|
||||
* The formatter settings.
|
||||
*/
|
||||
protected function createFieldWithStorage($field_name, $type, $cardinality, $label, $field_settings, $widget_type, $widget_settings, $formatter_type, $formatter_settings) {
|
||||
$field_storage = $field_name . '_field_storage';
|
||||
$this->fields->$field_storage = entity_create('field_storage_config', array(
|
||||
'field_name' => $field_name,
|
||||
'entity_type' => 'entity_test',
|
||||
'type' => $type,
|
||||
'cardinality' => $cardinality,
|
||||
));
|
||||
$this->fields->$field_storage->save();
|
||||
|
||||
$field = $field_name . '_field';
|
||||
$this->fields->$field = entity_create('field_config', array(
|
||||
'field_storage' => $this->fields->$field_storage,
|
||||
'bundle' => 'entity_test',
|
||||
'label' => $label,
|
||||
'description' => $label,
|
||||
'weight' => mt_rand(0, 127),
|
||||
'settings' => $field_settings,
|
||||
));
|
||||
$this->fields->$field->save();
|
||||
|
||||
entity_get_form_display('entity_test', 'entity_test', 'default')
|
||||
->setComponent($field_name, array(
|
||||
'type' => $widget_type,
|
||||
'settings' => $widget_settings,
|
||||
))
|
||||
->save();
|
||||
|
||||
entity_get_display('entity_test', 'entity_test', 'default')
|
||||
->setComponent($field_name, array(
|
||||
'label' => 'above',
|
||||
'type' => $formatter_type,
|
||||
'settings' => $formatter_settings
|
||||
))
|
||||
->save();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
name: 'Quick Edit test'
|
||||
type: module
|
||||
description: 'Support module for the Quick Edit module tests.'
|
||||
core: 8.x
|
||||
package: Testing
|
||||
version: VERSION
|
52
core/modules/quickedit/tests/modules/quickedit_test.module
Normal file
52
core/modules/quickedit/tests/modules/quickedit_test.module
Normal file
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Helper module for the Quick Edit tests.
|
||||
*/
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
|
||||
/**
|
||||
* Implements hook_entity_view_alter().
|
||||
*/
|
||||
function quickedit_test_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
|
||||
if ($entity->getEntityTypeId() == 'node' && $entity->bundle() == 'article') {
|
||||
$build['pseudo'] = array(
|
||||
'#theme' => 'field',
|
||||
'#title' => 'My pseudo field',
|
||||
'#field_name' => 'quickedit_test_pseudo_field',
|
||||
'#label_display' => 'Label',
|
||||
'#entity_type' => $entity->getEntityTypeId(),
|
||||
'#bundle' => $entity->bundle(),
|
||||
'#language' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
|
||||
'#field_type' => 'pseudo',
|
||||
'#view_mode' => 'default',
|
||||
'#object' => $entity,
|
||||
'#access' => TRUE,
|
||||
'#items' => array(
|
||||
0 => array(
|
||||
'value' => 'pseudo field',
|
||||
),
|
||||
),
|
||||
0 => array(
|
||||
'#markup' => 'pseudo field',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements hook_quickedit_render_field().
|
||||
*/
|
||||
function quickedit_test_quickedit_render_field(EntityInterface $entity, $field_name, $view_mode_id, $langcode) {
|
||||
$entity = \Drupal::entityManager()->getTranslationFromContext($entity, $langcode);
|
||||
return array(
|
||||
'#prefix' => '<div class="quickedit-test-wrapper">',
|
||||
'field' => $entity->get($field_name)->view($view_mode_id),
|
||||
'#suffix' => '</div>',
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit_test\MockEditEntityFieldAccessCheck.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit_test;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\quickedit\Access\EditEntityFieldAccessCheckInterface;
|
||||
|
||||
/**
|
||||
* Access check for editing entity fields.
|
||||
*/
|
||||
class MockEditEntityFieldAccessCheck implements EditEntityFieldAccessCheckInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function accessEditEntityField(EntityInterface $entity, $field_name) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\quickedit_test\Plugin\InPlaceEditor\WysiwygEditor.
|
||||
*/
|
||||
|
||||
namespace Drupal\quickedit_test\Plugin\InPlaceEditor;
|
||||
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\quickedit\Plugin\InPlaceEditorBase;
|
||||
|
||||
/**
|
||||
* Defines the 'wysiwyg' in-place editor.
|
||||
*
|
||||
* @InPlaceEditor(
|
||||
* id = "wysiwyg",
|
||||
* alternativeTo = {"plain_text"}
|
||||
* )
|
||||
*/
|
||||
class WysiwygEditor extends InPlaceEditorBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function isCompatible(FieldItemListInterface $items) {
|
||||
$field_definition = $items->getFieldDefinition();
|
||||
|
||||
// This editor is incompatible with multivalued fields.
|
||||
if ($field_definition->getFieldStorageDefinition()->getCardinality() != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
// This editor is compatible with formatted ("rich") text fields; but only
|
||||
// if there is a currently active text format and that text format is the
|
||||
// 'full_html' text format.
|
||||
elseif (in_array($field_definition->getType(), array('text', 'text_long', 'text_with_summary'), TRUE)) {
|
||||
if ($items[0]->format === 'full_html') {
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMetadata(FieldItemListInterface $items) {
|
||||
$metadata['format'] = $items[0]->format;
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getAttachments() {
|
||||
return array(
|
||||
'library' => array(
|
||||
'quickedit_test/not-existing-wysiwyg',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* @file
|
||||
* Contains \Drupal\Tests\quickedit\Unit\Access\EditEntityFieldAccessCheckTest.
|
||||
*/
|
||||
|
||||
namespace Drupal\Tests\quickedit\Unit\Access;
|
||||
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\quickedit\Access\EditEntityFieldAccessCheck;
|
||||
use Drupal\Tests\UnitTestCase;
|
||||
use Drupal\field\FieldStorageConfigInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
|
||||
/**
|
||||
* @coversDefaultClass \Drupal\quickedit\Access\EditEntityFieldAccessCheck
|
||||
* @group Access
|
||||
* @group quickedit
|
||||
*/
|
||||
class EditEntityFieldAccessCheckTest extends UnitTestCase {
|
||||
|
||||
/**
|
||||
* The tested access checker.
|
||||
*
|
||||
* @var \Drupal\quickedit\Access\EditEntityFieldAccessCheck
|
||||
*/
|
||||
protected $editAccessCheck;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp() {
|
||||
$this->editAccessCheck = new EditEntityFieldAccessCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test data for testAccess().
|
||||
*
|
||||
* @see \Drupal\Tests\edit\Unit\quickedit\Access\EditEntityFieldAccessCheckTest::testAccess()
|
||||
*/
|
||||
public function providerTestAccess() {
|
||||
$editable_entity = $this->createMockEntity();
|
||||
$editable_entity->expects($this->any())
|
||||
->method('access')
|
||||
->will($this->returnValue(AccessResult::allowed()->cachePerPermissions()));
|
||||
|
||||
$non_editable_entity = $this->createMockEntity();
|
||||
$non_editable_entity->expects($this->any())
|
||||
->method('access')
|
||||
->will($this->returnValue(AccessResult::neutral()->cachePerPermissions()));
|
||||
|
||||
$field_storage_with_access = $this->getMockBuilder('Drupal\field\Entity\FieldStorageConfig')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
$field_storage_with_access->expects($this->any())
|
||||
->method('access')
|
||||
->will($this->returnValue(AccessResult::allowed()));
|
||||
$field_storage_without_access = $this->getMockBuilder('Drupal\field\Entity\FieldStorageConfig')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
$field_storage_without_access->expects($this->any())
|
||||
->method('access')
|
||||
->will($this->returnValue(AccessResult::neutral()));
|
||||
|
||||
$data = array();
|
||||
$data[] = array($editable_entity, $field_storage_with_access, AccessResult::allowed()->cachePerPermissions());
|
||||
$data[] = array($non_editable_entity, $field_storage_with_access, AccessResult::neutral()->cachePerPermissions());
|
||||
$data[] = array($editable_entity, $field_storage_without_access, AccessResult::neutral()->cachePerPermissions());
|
||||
$data[] = array($non_editable_entity, $field_storage_without_access, AccessResult::neutral()->cachePerPermissions());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the method for checking access to routes.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* A mocked entity.
|
||||
* @param \Drupal\field\FieldStorageConfigInterface $field_storage
|
||||
* A mocked field storage.
|
||||
* @param bool|null $expected_result
|
||||
* The expected result of the access call.
|
||||
*
|
||||
* @dataProvider providerTestAccess
|
||||
*/
|
||||
public function testAccess(EntityInterface $entity, FieldStorageConfigInterface $field_storage = NULL, $expected_result) {
|
||||
$field_name = 'valid';
|
||||
$entity_with_field = clone $entity;
|
||||
$entity_with_field->expects($this->any())
|
||||
->method('get')
|
||||
->with($field_name)
|
||||
->will($this->returnValue($field_storage));
|
||||
$entity_with_field->expects($this->once())
|
||||
->method('hasTranslation')
|
||||
->with(LanguageInterface::LANGCODE_NOT_SPECIFIED)
|
||||
->will($this->returnValue(TRUE));
|
||||
|
||||
$account = $this->getMock('Drupal\Core\Session\AccountInterface');
|
||||
$access = $this->editAccessCheck->access($entity_with_field, $field_name, LanguageInterface::LANGCODE_NOT_SPECIFIED, $account);
|
||||
$this->assertEquals($expected_result, $access);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests checking access to routes that result in AccessResult::isForbidden().
|
||||
*
|
||||
* @dataProvider providerTestAccessForbidden
|
||||
*/
|
||||
public function testAccessForbidden($field_name, $langcode) {
|
||||
$account = $this->getMock('Drupal\Core\Session\AccountInterface');
|
||||
$entity = $this->createMockEntity();
|
||||
$this->assertEquals(AccessResult::forbidden(), $this->editAccessCheck->access($entity, $field_name, $langcode, $account));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides test data for testAccessForbidden.
|
||||
*/
|
||||
public function providerTestAccessForbidden() {
|
||||
$data = array();
|
||||
// Tests the access method without a field_name.
|
||||
$data[] = array(NULL, LanguageInterface::LANGCODE_NOT_SPECIFIED);
|
||||
// Tests the access method with a non-existent field.
|
||||
$data[] = array('not_valid', LanguageInterface::LANGCODE_NOT_SPECIFIED);
|
||||
// Tests the access method without a langcode.
|
||||
$data[] = array('valid', NULL);
|
||||
// Tests the access method with an invalid langcode.
|
||||
$data[] = array('valid', 'xx-lolspeak');
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a mock entity.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface|\PHPUnit_Framework_MockObject_MockObject
|
||||
*/
|
||||
protected function createMockEntity() {
|
||||
$entity = $this->getMockBuilder('Drupal\entity_test\Entity\EntityTest')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$entity->expects($this->any())
|
||||
->method('hasTranslation')
|
||||
->will($this->returnValueMap(array(
|
||||
array(LanguageInterface::LANGCODE_NOT_SPECIFIED, TRUE),
|
||||
array('xx-lolspeak', FALSE),
|
||||
)));
|
||||
$entity->expects($this->any())
|
||||
->method('hasField')
|
||||
->will($this->returnValueMap(array(
|
||||
array('valid', TRUE),
|
||||
array('not_valid', FALSE),
|
||||
)));
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
}
|
Reference in a new issue