Chris@0
|
1 /**
|
Chris@0
|
2 * @file
|
Chris@0
|
3 * A Backbone Model for the state of an in-place editable entity in the DOM.
|
Chris@0
|
4 */
|
Chris@0
|
5
|
Chris@17
|
6 (function(_, $, Backbone, Drupal) {
|
Chris@17
|
7 Drupal.quickedit.EntityModel = Drupal.quickedit.BaseModel.extend(
|
Chris@17
|
8 /** @lends Drupal.quickedit.EntityModel# */ {
|
Chris@17
|
9 /**
|
Chris@17
|
10 * @type {object}
|
Chris@17
|
11 */
|
Chris@17
|
12 defaults: /** @lends Drupal.quickedit.EntityModel# */ {
|
Chris@17
|
13 /**
|
Chris@17
|
14 * The DOM element that represents this entity.
|
Chris@17
|
15 *
|
Chris@17
|
16 * It may seem bizarre to have a DOM element in a Backbone Model, but we
|
Chris@17
|
17 * need to be able to map entities in the DOM to EntityModels in memory.
|
Chris@17
|
18 *
|
Chris@17
|
19 * @type {HTMLElement}
|
Chris@17
|
20 */
|
Chris@17
|
21 el: null,
|
Chris@0
|
22
|
Chris@17
|
23 /**
|
Chris@17
|
24 * An entity ID, of the form `<entity type>/<entity ID>`
|
Chris@17
|
25 *
|
Chris@17
|
26 * @example
|
Chris@17
|
27 * "node/1"
|
Chris@17
|
28 *
|
Chris@17
|
29 * @type {string}
|
Chris@17
|
30 */
|
Chris@17
|
31 entityID: null,
|
Chris@17
|
32
|
Chris@17
|
33 /**
|
Chris@17
|
34 * An entity instance ID.
|
Chris@17
|
35 *
|
Chris@17
|
36 * The first instance of a specific entity (i.e. with a given entity ID)
|
Chris@17
|
37 * is assigned 0, the second 1, and so on.
|
Chris@17
|
38 *
|
Chris@17
|
39 * @type {number}
|
Chris@17
|
40 */
|
Chris@17
|
41 entityInstanceID: null,
|
Chris@17
|
42
|
Chris@17
|
43 /**
|
Chris@17
|
44 * The unique ID of this entity instance on the page, of the form
|
Chris@17
|
45 * `<entity type>/<entity ID>[entity instance ID]`
|
Chris@17
|
46 *
|
Chris@17
|
47 * @example
|
Chris@17
|
48 * "node/1[0]"
|
Chris@17
|
49 *
|
Chris@17
|
50 * @type {string}
|
Chris@17
|
51 */
|
Chris@17
|
52 id: null,
|
Chris@17
|
53
|
Chris@17
|
54 /**
|
Chris@17
|
55 * The label of the entity.
|
Chris@17
|
56 *
|
Chris@17
|
57 * @type {string}
|
Chris@17
|
58 */
|
Chris@17
|
59 label: null,
|
Chris@17
|
60
|
Chris@17
|
61 /**
|
Chris@17
|
62 * A FieldCollection for all fields of the entity.
|
Chris@17
|
63 *
|
Chris@17
|
64 * @type {Drupal.quickedit.FieldCollection}
|
Chris@17
|
65 *
|
Chris@17
|
66 * @see Drupal.quickedit.FieldCollection
|
Chris@17
|
67 */
|
Chris@17
|
68 fields: null,
|
Chris@17
|
69
|
Chris@17
|
70 // The attributes below are stateful. The ones above will never change
|
Chris@17
|
71 // during the life of a EntityModel instance.
|
Chris@17
|
72
|
Chris@17
|
73 /**
|
Chris@17
|
74 * Indicates whether this entity is currently being edited in-place.
|
Chris@17
|
75 *
|
Chris@17
|
76 * @type {bool}
|
Chris@17
|
77 */
|
Chris@17
|
78 isActive: false,
|
Chris@17
|
79
|
Chris@17
|
80 /**
|
Chris@17
|
81 * Whether one or more fields are already been stored in PrivateTempStore.
|
Chris@17
|
82 *
|
Chris@17
|
83 * @type {bool}
|
Chris@17
|
84 */
|
Chris@17
|
85 inTempStore: false,
|
Chris@17
|
86
|
Chris@17
|
87 /**
|
Chris@17
|
88 * Indicates whether a "Save" button is necessary or not.
|
Chris@17
|
89 *
|
Chris@17
|
90 * Whether one or more fields have already been stored in PrivateTempStore
|
Chris@17
|
91 * *or* the field that's currently being edited is in the 'changed' or a
|
Chris@17
|
92 * later state.
|
Chris@17
|
93 *
|
Chris@17
|
94 * @type {bool}
|
Chris@17
|
95 */
|
Chris@17
|
96 isDirty: false,
|
Chris@17
|
97
|
Chris@17
|
98 /**
|
Chris@17
|
99 * Whether the request to the server has been made to commit this entity.
|
Chris@17
|
100 *
|
Chris@17
|
101 * Used to prevent multiple such requests.
|
Chris@17
|
102 *
|
Chris@17
|
103 * @type {bool}
|
Chris@17
|
104 */
|
Chris@17
|
105 isCommitting: false,
|
Chris@17
|
106
|
Chris@17
|
107 /**
|
Chris@17
|
108 * The current processing state of an entity.
|
Chris@17
|
109 *
|
Chris@17
|
110 * @type {string}
|
Chris@17
|
111 */
|
Chris@17
|
112 state: 'closed',
|
Chris@17
|
113
|
Chris@17
|
114 /**
|
Chris@17
|
115 * IDs of fields whose new values have been stored in PrivateTempStore.
|
Chris@17
|
116 *
|
Chris@17
|
117 * We must store this on the EntityModel as well (even though it already
|
Chris@17
|
118 * is on the FieldModel) because when a field is rerendered, its
|
Chris@17
|
119 * FieldModel is destroyed and this allows us to transition it back to
|
Chris@17
|
120 * the proper state.
|
Chris@17
|
121 *
|
Chris@17
|
122 * @type {Array.<string>}
|
Chris@17
|
123 */
|
Chris@17
|
124 fieldsInTempStore: [],
|
Chris@17
|
125
|
Chris@17
|
126 /**
|
Chris@17
|
127 * A flag the tells the application that this EntityModel must be reloaded
|
Chris@17
|
128 * in order to restore the original values to its fields in the client.
|
Chris@17
|
129 *
|
Chris@17
|
130 * @type {bool}
|
Chris@17
|
131 */
|
Chris@17
|
132 reload: false,
|
Chris@17
|
133 },
|
Chris@0
|
134
|
Chris@0
|
135 /**
|
Chris@17
|
136 * @constructs
|
Chris@0
|
137 *
|
Chris@17
|
138 * @augments Drupal.quickedit.BaseModel
|
Chris@0
|
139 */
|
Chris@17
|
140 initialize() {
|
Chris@17
|
141 this.set('fields', new Drupal.quickedit.FieldCollection());
|
Chris@17
|
142
|
Chris@17
|
143 // Respond to entity state changes.
|
Chris@17
|
144 this.listenTo(this, 'change:state', this.stateChange);
|
Chris@17
|
145
|
Chris@17
|
146 // The state of the entity is largely dependent on the state of its
|
Chris@17
|
147 // fields.
|
Chris@17
|
148 this.listenTo(
|
Chris@17
|
149 this.get('fields'),
|
Chris@17
|
150 'change:state',
|
Chris@17
|
151 this.fieldStateChange,
|
Chris@17
|
152 );
|
Chris@17
|
153
|
Chris@17
|
154 // Call Drupal.quickedit.BaseModel's initialize() method.
|
Chris@17
|
155 Drupal.quickedit.BaseModel.prototype.initialize.call(this);
|
Chris@17
|
156 },
|
Chris@0
|
157
|
Chris@0
|
158 /**
|
Chris@17
|
159 * Updates FieldModels' states when an EntityModel change occurs.
|
Chris@0
|
160 *
|
Chris@17
|
161 * @param {Drupal.quickedit.EntityModel} entityModel
|
Chris@17
|
162 * The entity model
|
Chris@17
|
163 * @param {string} state
|
Chris@17
|
164 * The state of the associated entity. One of
|
Chris@17
|
165 * {@link Drupal.quickedit.EntityModel.states}.
|
Chris@17
|
166 * @param {object} options
|
Chris@17
|
167 * Options for the entity model.
|
Chris@0
|
168 */
|
Chris@17
|
169 stateChange(entityModel, state, options) {
|
Chris@17
|
170 const to = state;
|
Chris@17
|
171 switch (to) {
|
Chris@17
|
172 case 'closed':
|
Chris@17
|
173 this.set({
|
Chris@17
|
174 isActive: false,
|
Chris@17
|
175 inTempStore: false,
|
Chris@17
|
176 isDirty: false,
|
Chris@17
|
177 });
|
Chris@17
|
178 break;
|
Chris@17
|
179
|
Chris@17
|
180 case 'launching':
|
Chris@17
|
181 break;
|
Chris@17
|
182
|
Chris@17
|
183 case 'opening':
|
Chris@17
|
184 // Set the fields to candidate state.
|
Chris@17
|
185 entityModel.get('fields').each(fieldModel => {
|
Chris@17
|
186 fieldModel.set('state', 'candidate', options);
|
Chris@17
|
187 });
|
Chris@17
|
188 break;
|
Chris@17
|
189
|
Chris@17
|
190 case 'opened':
|
Chris@17
|
191 // The entity is now ready for editing!
|
Chris@17
|
192 this.set('isActive', true);
|
Chris@17
|
193 break;
|
Chris@17
|
194
|
Chris@17
|
195 case 'committing': {
|
Chris@17
|
196 // The user indicated they want to save the entity.
|
Chris@17
|
197 const fields = this.get('fields');
|
Chris@17
|
198 // For fields that are in an active state, transition them to
|
Chris@17
|
199 // candidate.
|
Chris@17
|
200 fields
|
Chris@17
|
201 .chain()
|
Chris@17
|
202 .filter(
|
Chris@17
|
203 fieldModel =>
|
Chris@17
|
204 _.intersection([fieldModel.get('state')], ['active']).length,
|
Chris@17
|
205 )
|
Chris@17
|
206 .each(fieldModel => {
|
Chris@17
|
207 fieldModel.set('state', 'candidate');
|
Chris@17
|
208 });
|
Chris@17
|
209 // For fields that are in a changed state, field values must first be
|
Chris@17
|
210 // stored in PrivateTempStore.
|
Chris@17
|
211 fields
|
Chris@17
|
212 .chain()
|
Chris@17
|
213 .filter(
|
Chris@17
|
214 fieldModel =>
|
Chris@17
|
215 _.intersection(
|
Chris@17
|
216 [fieldModel.get('state')],
|
Chris@17
|
217 Drupal.quickedit.app.changedFieldStates,
|
Chris@17
|
218 ).length,
|
Chris@17
|
219 )
|
Chris@17
|
220 .each(fieldModel => {
|
Chris@17
|
221 fieldModel.set('state', 'saving');
|
Chris@17
|
222 });
|
Chris@17
|
223 break;
|
Chris@17
|
224 }
|
Chris@17
|
225
|
Chris@17
|
226 case 'deactivating': {
|
Chris@17
|
227 const changedFields = this.get('fields').filter(
|
Chris@17
|
228 fieldModel =>
|
Chris@17
|
229 _.intersection(
|
Chris@17
|
230 [fieldModel.get('state')],
|
Chris@17
|
231 ['changed', 'invalid'],
|
Chris@17
|
232 ).length,
|
Chris@17
|
233 );
|
Chris@17
|
234 // If the entity contains unconfirmed or unsaved changes, return the
|
Chris@17
|
235 // entity to an opened state and ask the user if they would like to
|
Chris@17
|
236 // save the changes or discard the changes.
|
Chris@17
|
237 // 1. One of the fields is in a changed state. The changed field
|
Chris@17
|
238 // might just be a change in the client or it might have been saved
|
Chris@17
|
239 // to tempstore.
|
Chris@17
|
240 // 2. The saved flag is empty and the confirmed flag is empty. If
|
Chris@17
|
241 // the entity has been saved to the server, the fields changed in
|
Chris@17
|
242 // the client are irrelevant. If the changes are confirmed, then
|
Chris@17
|
243 // proceed to set the fields to candidate state.
|
Chris@17
|
244 if (
|
Chris@17
|
245 (changedFields.length || this.get('fieldsInTempStore').length) &&
|
Chris@17
|
246 (!options.saved && !options.confirmed)
|
Chris@17
|
247 ) {
|
Chris@17
|
248 // Cancel deactivation until the user confirms save or discard.
|
Chris@17
|
249 this.set('state', 'opened', { confirming: true });
|
Chris@17
|
250 // An action in reaction to state change must be deferred.
|
Chris@17
|
251 _.defer(() => {
|
Chris@17
|
252 Drupal.quickedit.app.confirmEntityDeactivation(entityModel);
|
Chris@17
|
253 });
|
Chris@17
|
254 } else {
|
Chris@17
|
255 const invalidFields = this.get('fields').filter(
|
Chris@17
|
256 fieldModel =>
|
Chris@17
|
257 _.intersection([fieldModel.get('state')], ['invalid']).length,
|
Chris@17
|
258 );
|
Chris@17
|
259 // Indicate if this EntityModel needs to be reloaded in order to
|
Chris@17
|
260 // restore the original values of its fields.
|
Chris@17
|
261 entityModel.set(
|
Chris@17
|
262 'reload',
|
Chris@17
|
263 this.get('fieldsInTempStore').length || invalidFields.length,
|
Chris@17
|
264 );
|
Chris@17
|
265 // Set all fields to the 'candidate' state. A changed field may have
|
Chris@17
|
266 // to go through confirmation first.
|
Chris@17
|
267 entityModel.get('fields').each(fieldModel => {
|
Chris@17
|
268 // If the field is already in the candidate state, trigger a
|
Chris@17
|
269 // change event so that the entityModel can move to the next state
|
Chris@17
|
270 // in deactivation.
|
Chris@17
|
271 if (
|
Chris@17
|
272 _.intersection(
|
Chris@17
|
273 [fieldModel.get('state')],
|
Chris@17
|
274 ['candidate', 'highlighted'],
|
Chris@17
|
275 ).length
|
Chris@17
|
276 ) {
|
Chris@17
|
277 fieldModel.trigger(
|
Chris@17
|
278 'change:state',
|
Chris@17
|
279 fieldModel,
|
Chris@17
|
280 fieldModel.get('state'),
|
Chris@17
|
281 options,
|
Chris@17
|
282 );
|
Chris@17
|
283 } else {
|
Chris@17
|
284 fieldModel.set('state', 'candidate', options);
|
Chris@17
|
285 }
|
Chris@17
|
286 });
|
Chris@17
|
287 }
|
Chris@17
|
288 break;
|
Chris@17
|
289 }
|
Chris@17
|
290
|
Chris@17
|
291 case 'closing':
|
Chris@17
|
292 // Set all fields to the 'inactive' state.
|
Chris@17
|
293 options.reason = 'stop';
|
Chris@17
|
294 this.get('fields').each(fieldModel => {
|
Chris@17
|
295 fieldModel.set(
|
Chris@17
|
296 {
|
Chris@17
|
297 inTempStore: false,
|
Chris@17
|
298 state: 'inactive',
|
Chris@17
|
299 },
|
Chris@17
|
300 options,
|
Chris@17
|
301 );
|
Chris@17
|
302 });
|
Chris@17
|
303 break;
|
Chris@17
|
304 }
|
Chris@17
|
305 },
|
Chris@0
|
306
|
Chris@0
|
307 /**
|
Chris@17
|
308 * Updates a Field and Entity model's "inTempStore" when appropriate.
|
Chris@0
|
309 *
|
Chris@17
|
310 * Helper function.
|
Chris@0
|
311 *
|
Chris@17
|
312 * @param {Drupal.quickedit.EntityModel} entityModel
|
Chris@17
|
313 * The model of the entity for which a field's state attribute has
|
Chris@17
|
314 * changed.
|
Chris@17
|
315 * @param {Drupal.quickedit.FieldModel} fieldModel
|
Chris@17
|
316 * The model of the field whose state attribute has changed.
|
Chris@17
|
317 *
|
Chris@17
|
318 * @see Drupal.quickedit.EntityModel#fieldStateChange
|
Chris@0
|
319 */
|
Chris@17
|
320 _updateInTempStoreAttributes(entityModel, fieldModel) {
|
Chris@17
|
321 const current = fieldModel.get('state');
|
Chris@17
|
322 const previous = fieldModel.previous('state');
|
Chris@17
|
323 let fieldsInTempStore = entityModel.get('fieldsInTempStore');
|
Chris@17
|
324 // If the fieldModel changed to the 'saved' state: remember that this
|
Chris@17
|
325 // field was saved to PrivateTempStore.
|
Chris@17
|
326 if (current === 'saved') {
|
Chris@17
|
327 // Mark the entity as saved in PrivateTempStore, so that we can pass the
|
Chris@17
|
328 // proper "reset PrivateTempStore" boolean value when communicating with
|
Chris@17
|
329 // the server.
|
Chris@17
|
330 entityModel.set('inTempStore', true);
|
Chris@17
|
331 // Mark the field as saved in PrivateTempStore, so that visual
|
Chris@17
|
332 // indicators signifying just that may be rendered.
|
Chris@17
|
333 fieldModel.set('inTempStore', true);
|
Chris@17
|
334 // Remember that this field is in PrivateTempStore, restore when
|
Chris@17
|
335 // rerendered.
|
Chris@17
|
336 fieldsInTempStore.push(fieldModel.get('fieldID'));
|
Chris@17
|
337 fieldsInTempStore = _.uniq(fieldsInTempStore);
|
Chris@17
|
338 entityModel.set('fieldsInTempStore', fieldsInTempStore);
|
Chris@17
|
339 }
|
Chris@17
|
340 // If the fieldModel changed to the 'candidate' state from the
|
Chris@17
|
341 // 'inactive' state, then this is a field for this entity that got
|
Chris@17
|
342 // rerendered. Restore its previous 'inTempStore' attribute value.
|
Chris@17
|
343 else if (current === 'candidate' && previous === 'inactive') {
|
Chris@17
|
344 fieldModel.set(
|
Chris@17
|
345 'inTempStore',
|
Chris@17
|
346 _.intersection([fieldModel.get('fieldID')], fieldsInTempStore)
|
Chris@17
|
347 .length > 0,
|
Chris@17
|
348 );
|
Chris@17
|
349 }
|
Chris@17
|
350 },
|
Chris@0
|
351
|
Chris@0
|
352 /**
|
Chris@17
|
353 * Reacts to state changes in this entity's fields.
|
Chris@0
|
354 *
|
Chris@17
|
355 * @param {Drupal.quickedit.FieldModel} fieldModel
|
Chris@17
|
356 * The model of the field whose state attribute changed.
|
Chris@17
|
357 * @param {string} state
|
Chris@17
|
358 * The state of the associated field. One of
|
Chris@17
|
359 * {@link Drupal.quickedit.FieldModel.states}.
|
Chris@0
|
360 */
|
Chris@17
|
361 fieldStateChange(fieldModel, state) {
|
Chris@17
|
362 const entityModel = this;
|
Chris@17
|
363 const fieldState = state;
|
Chris@17
|
364 // Switch on the entityModel state.
|
Chris@17
|
365 // The EntityModel responds to FieldModel state changes as a function of
|
Chris@17
|
366 // its state. For example, a field switching back to 'candidate' state
|
Chris@17
|
367 // when its entity is in the 'opened' state has no effect on the entity.
|
Chris@17
|
368 // But that same switch back to 'candidate' state of a field when the
|
Chris@17
|
369 // entity is in the 'committing' state might allow the entity to proceed
|
Chris@17
|
370 // with the commit flow.
|
Chris@17
|
371 switch (this.get('state')) {
|
Chris@17
|
372 case 'closed':
|
Chris@17
|
373 case 'launching':
|
Chris@17
|
374 // It should be impossible to reach these: fields can't change state
|
Chris@17
|
375 // while the entity is closed or still launching.
|
Chris@17
|
376 break;
|
Chris@17
|
377
|
Chris@17
|
378 case 'opening':
|
Chris@17
|
379 // We must change the entity to the 'opened' state, but it must first
|
Chris@17
|
380 // be confirmed that all of its fieldModels have transitioned to the
|
Chris@17
|
381 // 'candidate' state.
|
Chris@17
|
382 // We do this here, because this is called every time a fieldModel
|
Chris@17
|
383 // changes state, hence each time this is called, we get closer to the
|
Chris@17
|
384 // goal of having all fieldModels in the 'candidate' state.
|
Chris@17
|
385 // A state change in reaction to another state change must be
|
Chris@17
|
386 // deferred.
|
Chris@17
|
387 _.defer(() => {
|
Chris@17
|
388 entityModel.set('state', 'opened', {
|
Chris@17
|
389 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
|
Chris@17
|
390 });
|
Chris@17
|
391 });
|
Chris@17
|
392 break;
|
Chris@17
|
393
|
Chris@17
|
394 case 'opened':
|
Chris@17
|
395 // Set the isDirty attribute when appropriate so that it is known when
|
Chris@17
|
396 // to display the "Save" button in the entity toolbar.
|
Chris@17
|
397 // Note that once a field has been changed, there's no way to discard
|
Chris@17
|
398 // that change, hence it will have to be saved into PrivateTempStore,
|
Chris@17
|
399 // or the in-place editing of this field will have to be stopped
|
Chris@17
|
400 // completely. In other words: once any field enters the 'changed'
|
Chris@17
|
401 // field, then for the remainder of the in-place editing session, the
|
Chris@17
|
402 // entity is by definition dirty.
|
Chris@17
|
403 if (fieldState === 'changed') {
|
Chris@17
|
404 entityModel.set('isDirty', true);
|
Chris@17
|
405 } else {
|
Chris@17
|
406 this._updateInTempStoreAttributes(entityModel, fieldModel);
|
Chris@17
|
407 }
|
Chris@17
|
408 break;
|
Chris@17
|
409
|
Chris@17
|
410 case 'committing': {
|
Chris@17
|
411 // If the field save returned a validation error, set the state of the
|
Chris@17
|
412 // entity back to 'opened'.
|
Chris@17
|
413 if (fieldState === 'invalid') {
|
Chris@17
|
414 // A state change in reaction to another state change must be
|
Chris@17
|
415 // deferred.
|
Chris@17
|
416 _.defer(() => {
|
Chris@17
|
417 entityModel.set('state', 'opened', { reason: 'invalid' });
|
Chris@17
|
418 });
|
Chris@17
|
419 } else {
|
Chris@17
|
420 this._updateInTempStoreAttributes(entityModel, fieldModel);
|
Chris@17
|
421 }
|
Chris@17
|
422
|
Chris@17
|
423 // Attempt to save the entity. If the entity's fields are not yet all
|
Chris@17
|
424 // in a ready state, the save will not be processed.
|
Chris@17
|
425 const options = {
|
Chris@17
|
426 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
|
Chris@17
|
427 };
|
Chris@17
|
428 if (entityModel.set('isCommitting', true, options)) {
|
Chris@17
|
429 entityModel.save({
|
Chris@17
|
430 success() {
|
Chris@17
|
431 entityModel.set(
|
Chris@17
|
432 {
|
Chris@17
|
433 state: 'deactivating',
|
Chris@17
|
434 isCommitting: false,
|
Chris@17
|
435 },
|
Chris@17
|
436 { saved: true },
|
Chris@17
|
437 );
|
Chris@17
|
438 },
|
Chris@17
|
439 error() {
|
Chris@17
|
440 // Reset the "isCommitting" mutex.
|
Chris@17
|
441 entityModel.set('isCommitting', false);
|
Chris@17
|
442 // Change the state back to "opened", to allow the user to hit
|
Chris@17
|
443 // the "Save" button again.
|
Chris@17
|
444 entityModel.set('state', 'opened', {
|
Chris@17
|
445 reason: 'networkerror',
|
Chris@17
|
446 });
|
Chris@17
|
447 // Show a modal to inform the user of the network error.
|
Chris@17
|
448 const message = Drupal.t(
|
Chris@17
|
449 '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.',
|
Chris@17
|
450 { '@entity-title': entityModel.get('label') },
|
Chris@17
|
451 );
|
Chris@17
|
452 Drupal.quickedit.util.networkErrorModal(
|
Chris@17
|
453 Drupal.t('Network problem!'),
|
Chris@17
|
454 message,
|
Chris@17
|
455 );
|
Chris@17
|
456 },
|
Chris@17
|
457 });
|
Chris@17
|
458 }
|
Chris@17
|
459 break;
|
Chris@17
|
460 }
|
Chris@17
|
461
|
Chris@17
|
462 case 'deactivating':
|
Chris@17
|
463 // When setting the entity to 'closing', require that all fieldModels
|
Chris@17
|
464 // are in either the 'candidate' or 'highlighted' state.
|
Chris@17
|
465 // A state change in reaction to another state change must be
|
Chris@17
|
466 // deferred.
|
Chris@17
|
467 _.defer(() => {
|
Chris@17
|
468 entityModel.set('state', 'closing', {
|
Chris@17
|
469 'accept-field-states': Drupal.quickedit.app.readyFieldStates,
|
Chris@17
|
470 });
|
Chris@17
|
471 });
|
Chris@17
|
472 break;
|
Chris@17
|
473
|
Chris@17
|
474 case 'closing':
|
Chris@17
|
475 // When setting the entity to 'closed', require that all fieldModels
|
Chris@17
|
476 // are in the 'inactive' state.
|
Chris@17
|
477 // A state change in reaction to another state change must be
|
Chris@17
|
478 // deferred.
|
Chris@17
|
479 _.defer(() => {
|
Chris@17
|
480 entityModel.set('state', 'closed', {
|
Chris@17
|
481 'accept-field-states': ['inactive'],
|
Chris@17
|
482 });
|
Chris@17
|
483 });
|
Chris@17
|
484 break;
|
Chris@17
|
485 }
|
Chris@17
|
486 },
|
Chris@0
|
487
|
Chris@0
|
488 /**
|
Chris@17
|
489 * Fires an AJAX request to the REST save URL for an entity.
|
Chris@0
|
490 *
|
Chris@17
|
491 * @param {object} options
|
Chris@17
|
492 * An object of options that contains:
|
Chris@17
|
493 * @param {function} [options.success]
|
Chris@17
|
494 * A function to invoke if the entity is successfully saved.
|
Chris@0
|
495 */
|
Chris@17
|
496 save(options) {
|
Chris@17
|
497 const entityModel = this;
|
Chris@17
|
498
|
Chris@17
|
499 // Create a Drupal.ajax instance to save the entity.
|
Chris@17
|
500 const entitySaverAjax = Drupal.ajax({
|
Chris@17
|
501 url: Drupal.url(`quickedit/entity/${entityModel.get('entityID')}`),
|
Chris@17
|
502 error() {
|
Chris@17
|
503 // Let the Drupal.quickedit.EntityModel Backbone model's error()
|
Chris@17
|
504 // method handle errors.
|
Chris@17
|
505 options.error.call(entityModel);
|
Chris@17
|
506 },
|
Chris@17
|
507 });
|
Chris@17
|
508 // Entity saved successfully.
|
Chris@17
|
509 entitySaverAjax.commands.quickeditEntitySaved = function(
|
Chris@17
|
510 ajax,
|
Chris@17
|
511 response,
|
Chris@17
|
512 status,
|
Chris@17
|
513 ) {
|
Chris@17
|
514 // All fields have been moved from PrivateTempStore to permanent
|
Chris@17
|
515 // storage, update the "inTempStore" attribute on FieldModels, on the
|
Chris@17
|
516 // EntityModel and clear EntityModel's "fieldInTempStore" attribute.
|
Chris@17
|
517 entityModel.get('fields').each(fieldModel => {
|
Chris@17
|
518 fieldModel.set('inTempStore', false);
|
Chris@17
|
519 });
|
Chris@17
|
520 entityModel.set('inTempStore', false);
|
Chris@17
|
521 entityModel.set('fieldsInTempStore', []);
|
Chris@17
|
522
|
Chris@17
|
523 // Invoke the optional success callback.
|
Chris@17
|
524 if (options.success) {
|
Chris@17
|
525 options.success.call(entityModel);
|
Chris@17
|
526 }
|
Chris@17
|
527 };
|
Chris@17
|
528 // Trigger the AJAX request, which will will return the
|
Chris@17
|
529 // quickeditEntitySaved AJAX command to which we then react.
|
Chris@17
|
530 entitySaverAjax.execute();
|
Chris@17
|
531 },
|
Chris@0
|
532
|
Chris@0
|
533 /**
|
Chris@17
|
534 * Validate the entity model.
|
Chris@0
|
535 *
|
Chris@17
|
536 * @param {object} attrs
|
Chris@17
|
537 * The attributes changes in the save or set call.
|
Chris@17
|
538 * @param {object} options
|
Chris@17
|
539 * An object with the following option:
|
Chris@17
|
540 * @param {string} [options.reason]
|
Chris@17
|
541 * A string that conveys a particular reason to allow for an exceptional
|
Chris@17
|
542 * state change.
|
Chris@17
|
543 * @param {Array} options.accept-field-states
|
Chris@17
|
544 * An array of strings that represent field states that the entities must
|
Chris@17
|
545 * be in to validate. For example, if `accept-field-states` is
|
Chris@17
|
546 * `['candidate', 'highlighted']`, then all the fields of the entity must
|
Chris@17
|
547 * be in either of these two states for the save or set call to
|
Chris@17
|
548 * validate and proceed.
|
Chris@0
|
549 *
|
Chris@17
|
550 * @return {string}
|
Chris@17
|
551 * A string to say something about the state of the entity model.
|
Chris@0
|
552 */
|
Chris@17
|
553 validate(attrs, options) {
|
Chris@17
|
554 const acceptedFieldStates = options['accept-field-states'] || [];
|
Chris@0
|
555
|
Chris@17
|
556 // Validate state change.
|
Chris@17
|
557 const currentState = this.get('state');
|
Chris@17
|
558 const nextState = attrs.state;
|
Chris@17
|
559 if (currentState !== nextState) {
|
Chris@17
|
560 // Ensure it's a valid state.
|
Chris@17
|
561 if (_.indexOf(this.constructor.states, nextState) === -1) {
|
Chris@17
|
562 return `"${nextState}" is an invalid state`;
|
Chris@17
|
563 }
|
Chris@17
|
564
|
Chris@17
|
565 // Ensure it's a state change that is allowed.
|
Chris@17
|
566 // Check if the acceptStateChange function accepts it.
|
Chris@17
|
567 if (!this._acceptStateChange(currentState, nextState, options)) {
|
Chris@17
|
568 return 'state change not accepted';
|
Chris@17
|
569 }
|
Chris@17
|
570 // If that function accepts it, then ensure all fields are also in an
|
Chris@17
|
571 // acceptable state.
|
Chris@17
|
572 if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
|
Chris@17
|
573 return 'state change not accepted because fields are not in acceptable state';
|
Chris@17
|
574 }
|
Chris@17
|
575 }
|
Chris@17
|
576
|
Chris@17
|
577 // Validate setting isCommitting = true.
|
Chris@17
|
578 const currentIsCommitting = this.get('isCommitting');
|
Chris@17
|
579 const nextIsCommitting = attrs.isCommitting;
|
Chris@17
|
580 if (currentIsCommitting === false && nextIsCommitting === true) {
|
Chris@17
|
581 if (!this._fieldsHaveAcceptableStates(acceptedFieldStates)) {
|
Chris@17
|
582 return 'isCommitting change not accepted because fields are not in acceptable state';
|
Chris@17
|
583 }
|
Chris@17
|
584 } else if (currentIsCommitting === true && nextIsCommitting === true) {
|
Chris@17
|
585 return 'isCommitting is a mutex, hence only changes are allowed';
|
Chris@17
|
586 }
|
Chris@17
|
587 },
|
Chris@0
|
588
|
Chris@0
|
589 /**
|
Chris@17
|
590 * Checks if a state change can be accepted.
|
Chris@0
|
591 *
|
Chris@17
|
592 * @param {string} from
|
Chris@17
|
593 * From state.
|
Chris@17
|
594 * @param {string} to
|
Chris@17
|
595 * To state.
|
Chris@17
|
596 * @param {object} context
|
Chris@17
|
597 * Context for the check.
|
Chris@17
|
598 * @param {string} context.reason
|
Chris@17
|
599 * The reason for the state change.
|
Chris@17
|
600 * @param {bool} context.confirming
|
Chris@17
|
601 * Whether context is confirming or not.
|
Chris@17
|
602 *
|
Chris@17
|
603 * @return {bool}
|
Chris@17
|
604 * Whether the state change is accepted or not.
|
Chris@17
|
605 *
|
Chris@17
|
606 * @see Drupal.quickedit.AppView#acceptEditorStateChange
|
Chris@0
|
607 */
|
Chris@17
|
608 _acceptStateChange(from, to, context) {
|
Chris@17
|
609 let accept = true;
|
Chris@17
|
610
|
Chris@17
|
611 // In general, enforce the states sequence. Disallow going back from a
|
Chris@17
|
612 // "later" state to an "earlier" state, except in explicitly allowed
|
Chris@17
|
613 // cases.
|
Chris@17
|
614 if (!this.constructor.followsStateSequence(from, to)) {
|
Chris@17
|
615 accept = false;
|
Chris@17
|
616
|
Chris@17
|
617 // Allow: closing -> closed.
|
Chris@17
|
618 // Necessary to stop editing an entity.
|
Chris@17
|
619 if (from === 'closing' && to === 'closed') {
|
Chris@17
|
620 accept = true;
|
Chris@17
|
621 }
|
Chris@17
|
622 // Allow: committing -> opened.
|
Chris@17
|
623 // Necessary to be able to correct an invalid field, or to hit the
|
Chris@17
|
624 // "Save" button again after a server/network error.
|
Chris@17
|
625 else if (
|
Chris@17
|
626 from === 'committing' &&
|
Chris@17
|
627 to === 'opened' &&
|
Chris@17
|
628 context.reason &&
|
Chris@17
|
629 (context.reason === 'invalid' || context.reason === 'networkerror')
|
Chris@17
|
630 ) {
|
Chris@17
|
631 accept = true;
|
Chris@17
|
632 }
|
Chris@17
|
633 // Allow: deactivating -> opened.
|
Chris@17
|
634 // Necessary to be able to confirm changes with the user.
|
Chris@17
|
635 else if (
|
Chris@17
|
636 from === 'deactivating' &&
|
Chris@17
|
637 to === 'opened' &&
|
Chris@17
|
638 context.confirming
|
Chris@17
|
639 ) {
|
Chris@17
|
640 accept = true;
|
Chris@17
|
641 }
|
Chris@17
|
642 // Allow: opened -> deactivating.
|
Chris@17
|
643 // Necessary to be able to stop editing.
|
Chris@17
|
644 else if (
|
Chris@17
|
645 from === 'opened' &&
|
Chris@17
|
646 to === 'deactivating' &&
|
Chris@17
|
647 context.confirmed
|
Chris@17
|
648 ) {
|
Chris@17
|
649 accept = true;
|
Chris@17
|
650 }
|
Chris@17
|
651 }
|
Chris@17
|
652
|
Chris@17
|
653 return accept;
|
Chris@17
|
654 },
|
Chris@0
|
655
|
Chris@0
|
656 /**
|
Chris@17
|
657 * Checks if fields have acceptable states.
|
Chris@0
|
658 *
|
Chris@17
|
659 * @param {Array} acceptedFieldStates
|
Chris@17
|
660 * An array of acceptable field states to check for.
|
Chris@17
|
661 *
|
Chris@17
|
662 * @return {bool}
|
Chris@17
|
663 * Whether the fields have an acceptable state.
|
Chris@17
|
664 *
|
Chris@17
|
665 * @see Drupal.quickedit.EntityModel#validate
|
Chris@0
|
666 */
|
Chris@17
|
667 _fieldsHaveAcceptableStates(acceptedFieldStates) {
|
Chris@17
|
668 let accept = true;
|
Chris@17
|
669
|
Chris@17
|
670 // If no acceptable field states are provided, assume all field states are
|
Chris@17
|
671 // acceptable. We want to let validation pass as a default and only
|
Chris@17
|
672 // check validity on calls to set that explicitly request it.
|
Chris@17
|
673 if (acceptedFieldStates.length > 0) {
|
Chris@17
|
674 const fieldStates = this.get('fields').pluck('state') || [];
|
Chris@17
|
675 // If not all fields are in one of the accepted field states, then we
|
Chris@17
|
676 // still can't allow this state change.
|
Chris@17
|
677 if (_.difference(fieldStates, acceptedFieldStates).length) {
|
Chris@17
|
678 accept = false;
|
Chris@17
|
679 }
|
Chris@17
|
680 }
|
Chris@17
|
681
|
Chris@17
|
682 return accept;
|
Chris@17
|
683 },
|
Chris@0
|
684
|
Chris@0
|
685 /**
|
Chris@17
|
686 * Destroys the entity model.
|
Chris@0
|
687 *
|
Chris@17
|
688 * @param {object} options
|
Chris@17
|
689 * Options for the entity model.
|
Chris@0
|
690 */
|
Chris@17
|
691 destroy(options) {
|
Chris@17
|
692 Drupal.quickedit.BaseModel.prototype.destroy.call(this, options);
|
Chris@17
|
693
|
Chris@17
|
694 this.stopListening();
|
Chris@17
|
695
|
Chris@17
|
696 // Destroy all fields of this entity.
|
Chris@17
|
697 this.get('fields').reset();
|
Chris@17
|
698 },
|
Chris@0
|
699
|
Chris@0
|
700 /**
|
Chris@17
|
701 * @inheritdoc
|
Chris@0
|
702 */
|
Chris@17
|
703 sync() {
|
Chris@17
|
704 // We don't use REST updates to sync.
|
Chris@17
|
705 },
|
Chris@17
|
706 },
|
Chris@17
|
707 /** @lends Drupal.quickedit.EntityModel */ {
|
Chris@0
|
708 /**
|
Chris@17
|
709 * Sequence of all possible states an entity can be in during quickediting.
|
Chris@0
|
710 *
|
Chris@0
|
711 * @type {Array.<string>}
|
Chris@0
|
712 */
|
Chris@17
|
713 states: [
|
Chris@17
|
714 // Initial state, like field's 'inactive' OR the user has just finished
|
Chris@17
|
715 // in-place editing this entity.
|
Chris@17
|
716 // - Trigger: none (initial) or EntityModel (finished).
|
Chris@17
|
717 // - Expected behavior: (when not initial state): tear down
|
Chris@17
|
718 // EntityToolbarView, in-place editors and related views.
|
Chris@17
|
719 'closed',
|
Chris@17
|
720 // User has activated in-place editing of this entity.
|
Chris@17
|
721 // - Trigger: user.
|
Chris@17
|
722 // - Expected behavior: the EntityToolbarView is gets set up, in-place
|
Chris@17
|
723 // editors (EditorViews) and related views for this entity's fields are
|
Chris@17
|
724 // set up. Upon completion of those, the state is changed to 'opening'.
|
Chris@17
|
725 'launching',
|
Chris@17
|
726 // Launching has finished.
|
Chris@17
|
727 // - Trigger: application.
|
Chris@17
|
728 // - Guarantees: in-place editors ready for use, all entity and field
|
Chris@17
|
729 // views have been set up, all fields are in the 'inactive' state.
|
Chris@17
|
730 // - Expected behavior: all fields are changed to the 'candidate' state
|
Chris@17
|
731 // and once this is completed, the entity state will be changed to
|
Chris@17
|
732 // 'opened'.
|
Chris@17
|
733 'opening',
|
Chris@17
|
734 // Opening has finished.
|
Chris@17
|
735 // - Trigger: EntityModel.
|
Chris@17
|
736 // - Guarantees: see 'opening', all fields are in the 'candidate' state.
|
Chris@17
|
737 // - Expected behavior: the user is able to actually use in-place editing.
|
Chris@17
|
738 'opened',
|
Chris@17
|
739 // User has clicked the 'Save' button (and has thus changed at least one
|
Chris@17
|
740 // field).
|
Chris@17
|
741 // - Trigger: user.
|
Chris@17
|
742 // - Guarantees: see 'opened', plus: either a changed field is in
|
Chris@17
|
743 // PrivateTempStore, or the user has just modified a field without
|
Chris@17
|
744 // activating (switching to) another field.
|
Chris@17
|
745 // - Expected behavior: 1) if any of the fields are not yet in
|
Chris@17
|
746 // PrivateTempStore, save them to PrivateTempStore, 2) if then any of
|
Chris@17
|
747 // the fields has the 'invalid' state, then change the entity state back
|
Chris@17
|
748 // to 'opened', otherwise: save the entity by committing it from
|
Chris@17
|
749 // PrivateTempStore into permanent storage.
|
Chris@17
|
750 'committing',
|
Chris@17
|
751 // User has clicked the 'Close' button, or has clicked the 'Save' button
|
Chris@17
|
752 // and that was successfully completed.
|
Chris@17
|
753 // - Trigger: user or EntityModel.
|
Chris@17
|
754 // - Guarantees: when having clicked 'Close' hardly any: fields may be in
|
Chris@17
|
755 // a variety of states; when having clicked 'Save': all fields are in
|
Chris@17
|
756 // the 'candidate' state.
|
Chris@17
|
757 // - Expected behavior: transition all fields to the 'candidate' state,
|
Chris@17
|
758 // possibly requiring confirmation in the case of having clicked
|
Chris@17
|
759 // 'Close'.
|
Chris@17
|
760 'deactivating',
|
Chris@17
|
761 // Deactivation has been completed.
|
Chris@17
|
762 // - Trigger: EntityModel.
|
Chris@17
|
763 // - Guarantees: all fields are in the 'candidate' state.
|
Chris@17
|
764 // - Expected behavior: change all fields to the 'inactive' state.
|
Chris@17
|
765 'closing',
|
Chris@17
|
766 ],
|
Chris@0
|
767
|
Chris@0
|
768 /**
|
Chris@17
|
769 * Indicates whether the 'from' state comes before the 'to' state.
|
Chris@0
|
770 *
|
Chris@17
|
771 * @param {string} from
|
Chris@17
|
772 * One of {@link Drupal.quickedit.EntityModel.states}.
|
Chris@17
|
773 * @param {string} to
|
Chris@17
|
774 * One of {@link Drupal.quickedit.EntityModel.states}.
|
Chris@17
|
775 *
|
Chris@17
|
776 * @return {bool}
|
Chris@17
|
777 * Whether the 'from' state comes before the 'to' state.
|
Chris@0
|
778 */
|
Chris@17
|
779 followsStateSequence(from, to) {
|
Chris@17
|
780 return _.indexOf(this.states, from) < _.indexOf(this.states, to);
|
Chris@17
|
781 },
|
Chris@0
|
782 },
|
Chris@17
|
783 );
|
Chris@0
|
784
|
Chris@0
|
785 /**
|
Chris@0
|
786 * @constructor
|
Chris@0
|
787 *
|
Chris@0
|
788 * @augments Backbone.Collection
|
Chris@0
|
789 */
|
Chris@17
|
790 Drupal.quickedit.EntityCollection = Backbone.Collection.extend(
|
Chris@17
|
791 /** @lends Drupal.quickedit.EntityCollection# */ {
|
Chris@17
|
792 /**
|
Chris@17
|
793 * @type {Drupal.quickedit.EntityModel}
|
Chris@17
|
794 */
|
Chris@17
|
795 model: Drupal.quickedit.EntityModel,
|
Chris@17
|
796 },
|
Chris@17
|
797 );
|
Chris@17
|
798 })(_, jQuery, Backbone, Drupal);
|