Chris@0
|
1 /**
|
Chris@0
|
2 * @file
|
Chris@0
|
3 * Attaches behavior for the Quick Edit module.
|
Chris@0
|
4 *
|
Chris@0
|
5 * Everything happens asynchronously, to allow for:
|
Chris@0
|
6 * - dynamically rendered contextual links
|
Chris@0
|
7 * - asynchronously retrieved (and cached) per-field in-place editing metadata
|
Chris@0
|
8 * - asynchronous setup of in-place editable field and "Quick edit" link.
|
Chris@0
|
9 *
|
Chris@0
|
10 * To achieve this, there are several queues:
|
Chris@0
|
11 * - fieldsMetadataQueue: fields whose metadata still needs to be fetched.
|
Chris@0
|
12 * - fieldsAvailableQueue: queue of fields whose metadata is known, and for
|
Chris@0
|
13 * which it has been confirmed that the user has permission to edit them.
|
Chris@0
|
14 * However, FieldModels will only be created for them once there's a
|
Chris@0
|
15 * contextual link for their entity: when it's possible to initiate editing.
|
Chris@0
|
16 * - contextualLinksQueue: queue of contextual links on entities for which it
|
Chris@0
|
17 * is not yet known whether the user has permission to edit at >=1 of them.
|
Chris@0
|
18 */
|
Chris@0
|
19
|
Chris@0
|
20 (function ($, _, Backbone, Drupal, drupalSettings, JSON, storage) {
|
Chris@0
|
21 const options = $.extend(drupalSettings.quickedit,
|
Chris@0
|
22 // Merge strings on top of drupalSettings so that they are not mutable.
|
Chris@0
|
23 {
|
Chris@0
|
24 strings: {
|
Chris@0
|
25 quickEdit: Drupal.t('Quick edit'),
|
Chris@0
|
26 },
|
Chris@0
|
27 },
|
Chris@0
|
28 );
|
Chris@0
|
29
|
Chris@0
|
30 /**
|
Chris@0
|
31 * Tracks fields without metadata. Contains objects with the following keys:
|
Chris@0
|
32 * - DOM el
|
Chris@0
|
33 * - String fieldID
|
Chris@0
|
34 * - String entityID
|
Chris@0
|
35 */
|
Chris@0
|
36 let fieldsMetadataQueue = [];
|
Chris@0
|
37
|
Chris@0
|
38 /**
|
Chris@0
|
39 * Tracks fields ready for use. Contains objects with the following keys:
|
Chris@0
|
40 * - DOM el
|
Chris@0
|
41 * - String fieldID
|
Chris@0
|
42 * - String entityID
|
Chris@0
|
43 */
|
Chris@0
|
44 let fieldsAvailableQueue = [];
|
Chris@0
|
45
|
Chris@0
|
46 /**
|
Chris@0
|
47 * Tracks contextual links on entities. Contains objects with the following
|
Chris@0
|
48 * keys:
|
Chris@0
|
49 * - String entityID
|
Chris@0
|
50 * - DOM el
|
Chris@0
|
51 * - DOM region
|
Chris@0
|
52 */
|
Chris@0
|
53 let contextualLinksQueue = [];
|
Chris@0
|
54
|
Chris@0
|
55 /**
|
Chris@0
|
56 * Tracks how many instances exist for each unique entity. Contains key-value
|
Chris@0
|
57 * pairs:
|
Chris@0
|
58 * - String entityID
|
Chris@0
|
59 * - Number count
|
Chris@0
|
60 */
|
Chris@0
|
61 const entityInstancesTracker = {};
|
Chris@0
|
62
|
Chris@0
|
63 /**
|
Chris@0
|
64 *
|
Chris@0
|
65 * @type {Drupal~behavior}
|
Chris@0
|
66 */
|
Chris@0
|
67 Drupal.behaviors.quickedit = {
|
Chris@0
|
68 attach(context) {
|
Chris@0
|
69 // Initialize the Quick Edit app once per page load.
|
Chris@0
|
70 $('body').once('quickedit-init').each(initQuickEdit);
|
Chris@0
|
71
|
Chris@0
|
72 // Find all in-place editable fields, if any.
|
Chris@0
|
73 const $fields = $(context).find('[data-quickedit-field-id]').once('quickedit');
|
Chris@0
|
74 if ($fields.length === 0) {
|
Chris@0
|
75 return;
|
Chris@0
|
76 }
|
Chris@0
|
77
|
Chris@0
|
78 // Process each entity element: identical entities that appear multiple
|
Chris@0
|
79 // times will get a numeric identifier, starting at 0.
|
Chris@0
|
80 $(context).find('[data-quickedit-entity-id]').once('quickedit').each((index, entityElement) => {
|
Chris@0
|
81 processEntity(entityElement);
|
Chris@0
|
82 });
|
Chris@0
|
83
|
Chris@0
|
84 // Process each field element: queue to be used or to fetch metadata.
|
Chris@0
|
85 // When a field is being rerendered after editing, it will be processed
|
Chris@0
|
86 // immediately. New fields will be unable to be processed immediately,
|
Chris@0
|
87 // but will instead be queued to have their metadata fetched, which occurs
|
Chris@0
|
88 // below in fetchMissingMetaData().
|
Chris@0
|
89 $fields.each((index, fieldElement) => {
|
Chris@0
|
90 processField(fieldElement);
|
Chris@0
|
91 });
|
Chris@0
|
92
|
Chris@0
|
93 // Entities and fields on the page have been detected, try to set up the
|
Chris@0
|
94 // contextual links for those entities that already have the necessary
|
Chris@0
|
95 // meta- data in the client-side cache.
|
Chris@0
|
96 contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
|
Chris@0
|
97
|
Chris@0
|
98 // Fetch metadata for any fields that are queued to retrieve it.
|
Chris@0
|
99 fetchMissingMetadata((fieldElementsWithFreshMetadata) => {
|
Chris@0
|
100 // Metadata has been fetched, reprocess fields whose metadata was
|
Chris@0
|
101 // missing.
|
Chris@0
|
102 _.each(fieldElementsWithFreshMetadata, processField);
|
Chris@0
|
103
|
Chris@0
|
104 // Metadata has been fetched, try to set up more contextual links now.
|
Chris@0
|
105 contextualLinksQueue = _.filter(contextualLinksQueue, contextualLink => !initializeEntityContextualLink(contextualLink));
|
Chris@0
|
106 });
|
Chris@0
|
107 },
|
Chris@0
|
108 detach(context, settings, trigger) {
|
Chris@0
|
109 if (trigger === 'unload') {
|
Chris@0
|
110 deleteContainedModelsAndQueues($(context));
|
Chris@0
|
111 }
|
Chris@0
|
112 },
|
Chris@0
|
113 };
|
Chris@0
|
114
|
Chris@0
|
115 /**
|
Chris@0
|
116 *
|
Chris@0
|
117 * @namespace
|
Chris@0
|
118 */
|
Chris@0
|
119 Drupal.quickedit = {
|
Chris@0
|
120
|
Chris@0
|
121 /**
|
Chris@0
|
122 * A {@link Drupal.quickedit.AppView} instance.
|
Chris@0
|
123 */
|
Chris@0
|
124 app: null,
|
Chris@0
|
125
|
Chris@0
|
126 /**
|
Chris@0
|
127 * @type {object}
|
Chris@0
|
128 *
|
Chris@0
|
129 * @prop {Array.<Drupal.quickedit.EntityModel>} entities
|
Chris@0
|
130 * @prop {Array.<Drupal.quickedit.FieldModel>} fields
|
Chris@0
|
131 */
|
Chris@0
|
132 collections: {
|
Chris@0
|
133 // All in-place editable entities (Drupal.quickedit.EntityModel) on the
|
Chris@0
|
134 // page.
|
Chris@0
|
135 entities: null,
|
Chris@0
|
136 // All in-place editable fields (Drupal.quickedit.FieldModel) on the page.
|
Chris@0
|
137 fields: null,
|
Chris@0
|
138 },
|
Chris@0
|
139
|
Chris@0
|
140 /**
|
Chris@0
|
141 * In-place editors will register themselves in this object.
|
Chris@0
|
142 *
|
Chris@0
|
143 * @namespace
|
Chris@0
|
144 */
|
Chris@0
|
145 editors: {},
|
Chris@0
|
146
|
Chris@0
|
147 /**
|
Chris@0
|
148 * Per-field metadata that indicates whether in-place editing is allowed,
|
Chris@0
|
149 * which in-place editor should be used, etc.
|
Chris@0
|
150 *
|
Chris@0
|
151 * @namespace
|
Chris@0
|
152 */
|
Chris@0
|
153 metadata: {
|
Chris@0
|
154
|
Chris@0
|
155 /**
|
Chris@0
|
156 * Check if a field exists in storage.
|
Chris@0
|
157 *
|
Chris@0
|
158 * @param {string} fieldID
|
Chris@0
|
159 * The field id to check.
|
Chris@0
|
160 *
|
Chris@0
|
161 * @return {bool}
|
Chris@0
|
162 * Whether it was found or not.
|
Chris@0
|
163 */
|
Chris@0
|
164 has(fieldID) {
|
Chris@0
|
165 return storage.getItem(this._prefixFieldID(fieldID)) !== null;
|
Chris@0
|
166 },
|
Chris@0
|
167
|
Chris@0
|
168 /**
|
Chris@0
|
169 * Add metadata to a field id.
|
Chris@0
|
170 *
|
Chris@0
|
171 * @param {string} fieldID
|
Chris@0
|
172 * The field ID to add data to.
|
Chris@0
|
173 * @param {object} metadata
|
Chris@0
|
174 * Metadata to add.
|
Chris@0
|
175 */
|
Chris@0
|
176 add(fieldID, metadata) {
|
Chris@0
|
177 storage.setItem(this._prefixFieldID(fieldID), JSON.stringify(metadata));
|
Chris@0
|
178 },
|
Chris@0
|
179
|
Chris@0
|
180 /**
|
Chris@0
|
181 * Get a key from a field id.
|
Chris@0
|
182 *
|
Chris@0
|
183 * @param {string} fieldID
|
Chris@0
|
184 * The field ID to check.
|
Chris@0
|
185 * @param {string} [key]
|
Chris@0
|
186 * The key to check. If empty, will return all metadata.
|
Chris@0
|
187 *
|
Chris@0
|
188 * @return {object|*}
|
Chris@0
|
189 * The value for the key, if defined. Otherwise will return all metadata
|
Chris@0
|
190 * for the specified field id.
|
Chris@0
|
191 *
|
Chris@0
|
192 */
|
Chris@0
|
193 get(fieldID, key) {
|
Chris@0
|
194 const metadata = JSON.parse(storage.getItem(this._prefixFieldID(fieldID)));
|
Chris@0
|
195 return (typeof key === 'undefined') ? metadata : metadata[key];
|
Chris@0
|
196 },
|
Chris@0
|
197
|
Chris@0
|
198 /**
|
Chris@0
|
199 * Prefix the field id.
|
Chris@0
|
200 *
|
Chris@0
|
201 * @param {string} fieldID
|
Chris@0
|
202 * The field id to prefix.
|
Chris@0
|
203 *
|
Chris@0
|
204 * @return {string}
|
Chris@0
|
205 * A prefixed field id.
|
Chris@0
|
206 */
|
Chris@0
|
207 _prefixFieldID(fieldID) {
|
Chris@0
|
208 return `Drupal.quickedit.metadata.${fieldID}`;
|
Chris@0
|
209 },
|
Chris@0
|
210
|
Chris@0
|
211 /**
|
Chris@0
|
212 * Unprefix the field id.
|
Chris@0
|
213 *
|
Chris@0
|
214 * @param {string} fieldID
|
Chris@0
|
215 * The field id to unprefix.
|
Chris@0
|
216 *
|
Chris@0
|
217 * @return {string}
|
Chris@0
|
218 * An unprefixed field id.
|
Chris@0
|
219 */
|
Chris@0
|
220 _unprefixFieldID(fieldID) {
|
Chris@0
|
221 // Strip "Drupal.quickedit.metadata.", which is 26 characters long.
|
Chris@0
|
222 return fieldID.substring(26);
|
Chris@0
|
223 },
|
Chris@0
|
224
|
Chris@0
|
225 /**
|
Chris@0
|
226 * Intersection calculation.
|
Chris@0
|
227 *
|
Chris@0
|
228 * @param {Array} fieldIDs
|
Chris@0
|
229 * An array of field ids to compare to prefix field id.
|
Chris@0
|
230 *
|
Chris@0
|
231 * @return {Array}
|
Chris@0
|
232 * The intersection found.
|
Chris@0
|
233 */
|
Chris@0
|
234 intersection(fieldIDs) {
|
Chris@0
|
235 const prefixedFieldIDs = _.map(fieldIDs, this._prefixFieldID);
|
Chris@0
|
236 const intersection = _.intersection(prefixedFieldIDs, _.keys(sessionStorage));
|
Chris@0
|
237 return _.map(intersection, this._unprefixFieldID);
|
Chris@0
|
238 },
|
Chris@0
|
239 },
|
Chris@0
|
240 };
|
Chris@0
|
241
|
Chris@0
|
242 // Clear the Quick Edit metadata cache whenever the current user's set of
|
Chris@0
|
243 // permissions changes.
|
Chris@0
|
244 const permissionsHashKey = Drupal.quickedit.metadata._prefixFieldID('permissionsHash');
|
Chris@0
|
245 const permissionsHashValue = storage.getItem(permissionsHashKey);
|
Chris@0
|
246 const permissionsHash = drupalSettings.user.permissionsHash;
|
Chris@0
|
247 if (permissionsHashValue !== permissionsHash) {
|
Chris@0
|
248 if (typeof permissionsHash === 'string') {
|
Chris@0
|
249 _.chain(storage).keys().each((key) => {
|
Chris@0
|
250 if (key.substring(0, 26) === 'Drupal.quickedit.metadata.') {
|
Chris@0
|
251 storage.removeItem(key);
|
Chris@0
|
252 }
|
Chris@0
|
253 });
|
Chris@0
|
254 }
|
Chris@0
|
255 storage.setItem(permissionsHashKey, permissionsHash);
|
Chris@0
|
256 }
|
Chris@0
|
257
|
Chris@0
|
258 /**
|
Chris@0
|
259 * Detect contextual links on entities annotated by quickedit.
|
Chris@0
|
260 *
|
Chris@0
|
261 * Queue contextual links to be processed.
|
Chris@0
|
262 *
|
Chris@0
|
263 * @param {jQuery.Event} event
|
Chris@0
|
264 * The `drupalContextualLinkAdded` event.
|
Chris@0
|
265 * @param {object} data
|
Chris@0
|
266 * An object containing the data relevant to the event.
|
Chris@0
|
267 *
|
Chris@0
|
268 * @listens event:drupalContextualLinkAdded
|
Chris@0
|
269 */
|
Chris@0
|
270 $(document).on('drupalContextualLinkAdded', (event, data) => {
|
Chris@0
|
271 if (data.$region.is('[data-quickedit-entity-id]')) {
|
Chris@0
|
272 // If the contextual link is cached on the client side, an entity instance
|
Chris@0
|
273 // will not yet have been assigned. So assign one.
|
Chris@0
|
274 if (!data.$region.is('[data-quickedit-entity-instance-id]')) {
|
Chris@0
|
275 data.$region.once('quickedit');
|
Chris@0
|
276 processEntity(data.$region.get(0));
|
Chris@0
|
277 }
|
Chris@0
|
278 const contextualLink = {
|
Chris@0
|
279 entityID: data.$region.attr('data-quickedit-entity-id'),
|
Chris@0
|
280 entityInstanceID: data.$region.attr('data-quickedit-entity-instance-id'),
|
Chris@0
|
281 el: data.$el[0],
|
Chris@0
|
282 region: data.$region[0],
|
Chris@0
|
283 };
|
Chris@0
|
284 // Set up contextual links for this, otherwise queue it to be set up
|
Chris@0
|
285 // later.
|
Chris@0
|
286 if (!initializeEntityContextualLink(contextualLink)) {
|
Chris@0
|
287 contextualLinksQueue.push(contextualLink);
|
Chris@0
|
288 }
|
Chris@0
|
289 }
|
Chris@0
|
290 });
|
Chris@0
|
291
|
Chris@0
|
292 /**
|
Chris@0
|
293 * Extracts the entity ID from a field ID.
|
Chris@0
|
294 *
|
Chris@0
|
295 * @param {string} fieldID
|
Chris@0
|
296 * A field ID: a string of the format
|
Chris@0
|
297 * `<entity type>/<id>/<field name>/<language>/<view mode>`.
|
Chris@0
|
298 *
|
Chris@0
|
299 * @return {string}
|
Chris@0
|
300 * An entity ID: a string of the format `<entity type>/<id>`.
|
Chris@0
|
301 */
|
Chris@0
|
302 function extractEntityID(fieldID) {
|
Chris@0
|
303 return fieldID.split('/').slice(0, 2).join('/');
|
Chris@0
|
304 }
|
Chris@0
|
305
|
Chris@0
|
306 /**
|
Chris@0
|
307 * Initialize the Quick Edit app.
|
Chris@0
|
308 *
|
Chris@0
|
309 * @param {HTMLElement} bodyElement
|
Chris@0
|
310 * This document's body element.
|
Chris@0
|
311 */
|
Chris@0
|
312 function initQuickEdit(bodyElement) {
|
Chris@0
|
313 Drupal.quickedit.collections.entities = new Drupal.quickedit.EntityCollection();
|
Chris@0
|
314 Drupal.quickedit.collections.fields = new Drupal.quickedit.FieldCollection();
|
Chris@0
|
315
|
Chris@0
|
316 // Instantiate AppModel (application state) and AppView, which is the
|
Chris@0
|
317 // controller of the whole in-place editing experience.
|
Chris@0
|
318 Drupal.quickedit.app = new Drupal.quickedit.AppView({
|
Chris@0
|
319 el: bodyElement,
|
Chris@0
|
320 model: new Drupal.quickedit.AppModel(),
|
Chris@0
|
321 entitiesCollection: Drupal.quickedit.collections.entities,
|
Chris@0
|
322 fieldsCollection: Drupal.quickedit.collections.fields,
|
Chris@0
|
323 });
|
Chris@0
|
324 }
|
Chris@0
|
325
|
Chris@0
|
326 /**
|
Chris@0
|
327 * Assigns the entity an instance ID.
|
Chris@0
|
328 *
|
Chris@0
|
329 * @param {HTMLElement} entityElement
|
Chris@0
|
330 * A Drupal Entity API entity's DOM element with a data-quickedit-entity-id
|
Chris@0
|
331 * attribute.
|
Chris@0
|
332 */
|
Chris@0
|
333 function processEntity(entityElement) {
|
Chris@0
|
334 const entityID = entityElement.getAttribute('data-quickedit-entity-id');
|
Chris@0
|
335 if (!entityInstancesTracker.hasOwnProperty(entityID)) {
|
Chris@0
|
336 entityInstancesTracker[entityID] = 0;
|
Chris@0
|
337 }
|
Chris@0
|
338 else {
|
Chris@0
|
339 entityInstancesTracker[entityID]++;
|
Chris@0
|
340 }
|
Chris@0
|
341
|
Chris@0
|
342 // Set the calculated entity instance ID for this element.
|
Chris@0
|
343 const entityInstanceID = entityInstancesTracker[entityID];
|
Chris@0
|
344 entityElement.setAttribute('data-quickedit-entity-instance-id', entityInstanceID);
|
Chris@0
|
345 }
|
Chris@0
|
346
|
Chris@0
|
347 /**
|
Chris@0
|
348 * Fetch the field's metadata; queue or initialize it (if EntityModel exists).
|
Chris@0
|
349 *
|
Chris@0
|
350 * @param {HTMLElement} fieldElement
|
Chris@0
|
351 * A Drupal Field API field's DOM element with a data-quickedit-field-id
|
Chris@0
|
352 * attribute.
|
Chris@0
|
353 */
|
Chris@0
|
354 function processField(fieldElement) {
|
Chris@0
|
355 const metadata = Drupal.quickedit.metadata;
|
Chris@0
|
356 const fieldID = fieldElement.getAttribute('data-quickedit-field-id');
|
Chris@0
|
357 const entityID = extractEntityID(fieldID);
|
Chris@0
|
358 // Figure out the instance ID by looking at the ancestor
|
Chris@0
|
359 // [data-quickedit-entity-id] element's data-quickedit-entity-instance-id
|
Chris@0
|
360 // attribute.
|
Chris@0
|
361 const entityElementSelector = `[data-quickedit-entity-id="${entityID}"]`;
|
Chris@0
|
362 const $entityElement = $(entityElementSelector);
|
Chris@0
|
363
|
Chris@0
|
364 // If there are no elements returned from `entityElementSelector`
|
Chris@0
|
365 // throw an error. Check the browser console for this message.
|
Chris@0
|
366 if (!$entityElement.length) {
|
Chris@14
|
367 throw new Error(`Quick Edit could not associate the rendered entity field markup (with [data-quickedit-field-id="${fieldID}"]) with the corresponding rendered entity markup: no parent DOM node found with [data-quickedit-entity-id="${entityID}"]. This is typically caused by the theme's template for this entity type forgetting to print the attributes.`);
|
Chris@0
|
368 }
|
Chris@0
|
369 let entityElement = $(fieldElement).closest($entityElement);
|
Chris@0
|
370
|
Chris@0
|
371 // In the case of a full entity view page, the entity title is rendered
|
Chris@0
|
372 // outside of "the entity DOM node": it's rendered as the page title. So in
|
Chris@0
|
373 // this case, we find the lowest common parent element (deepest in the tree)
|
Chris@0
|
374 // and consider that the entity element.
|
Chris@0
|
375 if (entityElement.length === 0) {
|
Chris@0
|
376 const $lowestCommonParent = $entityElement.parents().has(fieldElement).first();
|
Chris@0
|
377 entityElement = $lowestCommonParent.find($entityElement);
|
Chris@0
|
378 }
|
Chris@0
|
379 const entityInstanceID = entityElement
|
Chris@0
|
380 .get(0)
|
Chris@0
|
381 .getAttribute('data-quickedit-entity-instance-id');
|
Chris@0
|
382
|
Chris@0
|
383 // Early-return if metadata for this field is missing.
|
Chris@0
|
384 if (!metadata.has(fieldID)) {
|
Chris@0
|
385 fieldsMetadataQueue.push({
|
Chris@0
|
386 el: fieldElement,
|
Chris@0
|
387 fieldID,
|
Chris@0
|
388 entityID,
|
Chris@0
|
389 entityInstanceID,
|
Chris@0
|
390 });
|
Chris@0
|
391 return;
|
Chris@0
|
392 }
|
Chris@0
|
393 // Early-return if the user is not allowed to in-place edit this field.
|
Chris@0
|
394 if (metadata.get(fieldID, 'access') !== true) {
|
Chris@0
|
395 return;
|
Chris@0
|
396 }
|
Chris@0
|
397
|
Chris@0
|
398 // If an EntityModel for this field already exists (and hence also a "Quick
|
Chris@0
|
399 // edit" contextual link), then initialize it immediately.
|
Chris@0
|
400 if (Drupal.quickedit.collections.entities.findWhere({ entityID, entityInstanceID })) {
|
Chris@0
|
401 initializeField(fieldElement, fieldID, entityID, entityInstanceID);
|
Chris@0
|
402 }
|
Chris@0
|
403 // Otherwise: queue the field. It is now available to be set up when its
|
Chris@0
|
404 // corresponding entity becomes in-place editable.
|
Chris@0
|
405 else {
|
Chris@0
|
406 fieldsAvailableQueue.push({ el: fieldElement, fieldID, entityID, entityInstanceID });
|
Chris@0
|
407 }
|
Chris@0
|
408 }
|
Chris@0
|
409
|
Chris@0
|
410 /**
|
Chris@0
|
411 * Initialize a field; create FieldModel.
|
Chris@0
|
412 *
|
Chris@0
|
413 * @param {HTMLElement} fieldElement
|
Chris@0
|
414 * The field's DOM element.
|
Chris@0
|
415 * @param {string} fieldID
|
Chris@0
|
416 * The field's ID.
|
Chris@0
|
417 * @param {string} entityID
|
Chris@0
|
418 * The field's entity's ID.
|
Chris@0
|
419 * @param {string} entityInstanceID
|
Chris@0
|
420 * The field's entity's instance ID.
|
Chris@0
|
421 */
|
Chris@0
|
422 function initializeField(fieldElement, fieldID, entityID, entityInstanceID) {
|
Chris@0
|
423 const entity = Drupal.quickedit.collections.entities.findWhere({
|
Chris@0
|
424 entityID,
|
Chris@0
|
425 entityInstanceID,
|
Chris@0
|
426 });
|
Chris@0
|
427
|
Chris@0
|
428 $(fieldElement).addClass('quickedit-field');
|
Chris@0
|
429
|
Chris@0
|
430 // The FieldModel stores the state of an in-place editable entity field.
|
Chris@0
|
431 const field = new Drupal.quickedit.FieldModel({
|
Chris@0
|
432 el: fieldElement,
|
Chris@0
|
433 fieldID,
|
Chris@0
|
434 id: `${fieldID}[${entity.get('entityInstanceID')}]`,
|
Chris@0
|
435 entity,
|
Chris@0
|
436 metadata: Drupal.quickedit.metadata.get(fieldID),
|
Chris@0
|
437 acceptStateChange: _.bind(Drupal.quickedit.app.acceptEditorStateChange, Drupal.quickedit.app),
|
Chris@0
|
438 });
|
Chris@0
|
439
|
Chris@0
|
440 // Track all fields on the page.
|
Chris@0
|
441 Drupal.quickedit.collections.fields.add(field);
|
Chris@0
|
442 }
|
Chris@0
|
443
|
Chris@0
|
444 /**
|
Chris@0
|
445 * Fetches metadata for fields whose metadata is missing.
|
Chris@0
|
446 *
|
Chris@0
|
447 * Fields whose metadata is missing are tracked at fieldsMetadataQueue.
|
Chris@0
|
448 *
|
Chris@0
|
449 * @param {function} callback
|
Chris@0
|
450 * A callback function that receives field elements whose metadata will just
|
Chris@0
|
451 * have been fetched.
|
Chris@0
|
452 */
|
Chris@0
|
453 function fetchMissingMetadata(callback) {
|
Chris@0
|
454 if (fieldsMetadataQueue.length) {
|
Chris@0
|
455 const fieldIDs = _.pluck(fieldsMetadataQueue, 'fieldID');
|
Chris@0
|
456 const fieldElementsWithoutMetadata = _.pluck(fieldsMetadataQueue, 'el');
|
Chris@0
|
457 let entityIDs = _.uniq(_.pluck(fieldsMetadataQueue, 'entityID'), true);
|
Chris@0
|
458 // Ensure we only request entityIDs for which we don't have metadata yet.
|
Chris@0
|
459 entityIDs = _.difference(entityIDs, Drupal.quickedit.metadata.intersection(entityIDs));
|
Chris@0
|
460 fieldsMetadataQueue = [];
|
Chris@0
|
461
|
Chris@0
|
462 $.ajax({
|
Chris@0
|
463 url: Drupal.url('quickedit/metadata'),
|
Chris@0
|
464 type: 'POST',
|
Chris@0
|
465 data: {
|
Chris@0
|
466 'fields[]': fieldIDs,
|
Chris@0
|
467 'entities[]': entityIDs,
|
Chris@0
|
468 },
|
Chris@0
|
469 dataType: 'json',
|
Chris@0
|
470 success(results) {
|
Chris@0
|
471 // Store the metadata.
|
Chris@0
|
472 _.each(results, (fieldMetadata, fieldID) => {
|
Chris@0
|
473 Drupal.quickedit.metadata.add(fieldID, fieldMetadata);
|
Chris@0
|
474 });
|
Chris@0
|
475
|
Chris@0
|
476 callback(fieldElementsWithoutMetadata);
|
Chris@0
|
477 },
|
Chris@0
|
478 });
|
Chris@0
|
479 }
|
Chris@0
|
480 }
|
Chris@0
|
481
|
Chris@0
|
482 /**
|
Chris@0
|
483 * Loads missing in-place editor's attachments (JavaScript and CSS files).
|
Chris@0
|
484 *
|
Chris@0
|
485 * Missing in-place editors are those whose fields are actively being used on
|
Chris@0
|
486 * the page but don't have.
|
Chris@0
|
487 *
|
Chris@0
|
488 * @param {function} callback
|
Chris@0
|
489 * Callback function to be called when the missing in-place editors (if any)
|
Chris@0
|
490 * have been inserted into the DOM. i.e. they may still be loading.
|
Chris@0
|
491 */
|
Chris@0
|
492 function loadMissingEditors(callback) {
|
Chris@0
|
493 const loadedEditors = _.keys(Drupal.quickedit.editors);
|
Chris@0
|
494 let missingEditors = [];
|
Chris@0
|
495 Drupal.quickedit.collections.fields.each((fieldModel) => {
|
Chris@0
|
496 const metadata = Drupal.quickedit.metadata.get(fieldModel.get('fieldID'));
|
Chris@0
|
497 if (metadata.access && _.indexOf(loadedEditors, metadata.editor) === -1) {
|
Chris@0
|
498 missingEditors.push(metadata.editor);
|
Chris@0
|
499 // Set a stub, to prevent subsequent calls to loadMissingEditors() from
|
Chris@0
|
500 // loading the same in-place editor again. Loading an in-place editor
|
Chris@0
|
501 // requires talking to a server, to download its JavaScript, then
|
Chris@0
|
502 // executing its JavaScript, and only then its Drupal.quickedit.editors
|
Chris@0
|
503 // entry will be set.
|
Chris@0
|
504 Drupal.quickedit.editors[metadata.editor] = false;
|
Chris@0
|
505 }
|
Chris@0
|
506 });
|
Chris@0
|
507 missingEditors = _.uniq(missingEditors);
|
Chris@0
|
508 if (missingEditors.length === 0) {
|
Chris@0
|
509 callback();
|
Chris@0
|
510 return;
|
Chris@0
|
511 }
|
Chris@0
|
512
|
Chris@0
|
513 // @see https://www.drupal.org/node/2029999.
|
Chris@0
|
514 // Create a Drupal.Ajax instance to load the form.
|
Chris@0
|
515 const loadEditorsAjax = Drupal.ajax({
|
Chris@0
|
516 url: Drupal.url('quickedit/attachments'),
|
Chris@0
|
517 submit: { 'editors[]': missingEditors },
|
Chris@0
|
518 });
|
Chris@0
|
519 // Implement a scoped insert AJAX command: calls the callback after all AJAX
|
Chris@0
|
520 // command functions have been executed (hence the deferred calling).
|
Chris@0
|
521 const realInsert = Drupal.AjaxCommands.prototype.insert;
|
Chris@0
|
522 loadEditorsAjax.commands.insert = function (ajax, response, status) {
|
Chris@0
|
523 _.defer(callback);
|
Chris@0
|
524 realInsert(ajax, response, status);
|
Chris@0
|
525 };
|
Chris@0
|
526 // Trigger the AJAX request, which will should return AJAX commands to
|
Chris@0
|
527 // insert any missing attachments.
|
Chris@0
|
528 loadEditorsAjax.execute();
|
Chris@0
|
529 }
|
Chris@0
|
530
|
Chris@0
|
531 /**
|
Chris@0
|
532 * Attempts to set up a "Quick edit" link and corresponding EntityModel.
|
Chris@0
|
533 *
|
Chris@0
|
534 * @param {object} contextualLink
|
Chris@0
|
535 * An object with the following properties:
|
Chris@0
|
536 * - String entityID: a Quick Edit entity identifier, e.g. "node/1" or
|
Chris@0
|
537 * "block_content/5".
|
Chris@0
|
538 * - String entityInstanceID: a Quick Edit entity instance identifier,
|
Chris@0
|
539 * e.g. 0, 1 or n (depending on whether it's the first, second, or n+1st
|
Chris@0
|
540 * instance of this entity).
|
Chris@0
|
541 * - DOM el: element pointing to the contextual links placeholder for this
|
Chris@0
|
542 * entity.
|
Chris@0
|
543 * - DOM region: element pointing to the contextual region of this entity.
|
Chris@0
|
544 *
|
Chris@0
|
545 * @return {bool}
|
Chris@0
|
546 * Returns true when a contextual the given contextual link metadata can be
|
Chris@0
|
547 * removed from the queue (either because the contextual link has been set
|
Chris@0
|
548 * up or because it is certain that in-place editing is not allowed for any
|
Chris@0
|
549 * of its fields). Returns false otherwise.
|
Chris@0
|
550 */
|
Chris@0
|
551 function initializeEntityContextualLink(contextualLink) {
|
Chris@0
|
552 const metadata = Drupal.quickedit.metadata;
|
Chris@0
|
553 // Check if the user has permission to edit at least one of them.
|
Chris@0
|
554 function hasFieldWithPermission(fieldIDs) {
|
Chris@0
|
555 for (let i = 0; i < fieldIDs.length; i++) {
|
Chris@0
|
556 const fieldID = fieldIDs[i];
|
Chris@0
|
557 if (metadata.get(fieldID, 'access') === true) {
|
Chris@0
|
558 return true;
|
Chris@0
|
559 }
|
Chris@0
|
560 }
|
Chris@0
|
561 return false;
|
Chris@0
|
562 }
|
Chris@0
|
563
|
Chris@0
|
564 // Checks if the metadata for all given field IDs exists.
|
Chris@0
|
565 function allMetadataExists(fieldIDs) {
|
Chris@0
|
566 return fieldIDs.length === metadata.intersection(fieldIDs).length;
|
Chris@0
|
567 }
|
Chris@0
|
568
|
Chris@0
|
569 // Find all fields for this entity instance and collect their field IDs.
|
Chris@0
|
570 const fields = _.where(fieldsAvailableQueue, {
|
Chris@0
|
571 entityID: contextualLink.entityID,
|
Chris@0
|
572 entityInstanceID: contextualLink.entityInstanceID,
|
Chris@0
|
573 });
|
Chris@0
|
574 const fieldIDs = _.pluck(fields, 'fieldID');
|
Chris@0
|
575
|
Chris@0
|
576 // No fields found yet.
|
Chris@0
|
577 if (fieldIDs.length === 0) {
|
Chris@0
|
578 return false;
|
Chris@0
|
579 }
|
Chris@0
|
580 // The entity for the given contextual link contains at least one field that
|
Chris@0
|
581 // the current user may edit in-place; instantiate EntityModel,
|
Chris@0
|
582 // EntityDecorationView and ContextualLinkView.
|
Chris@0
|
583 else if (hasFieldWithPermission(fieldIDs)) {
|
Chris@0
|
584 const entityModel = new Drupal.quickedit.EntityModel({
|
Chris@0
|
585 el: contextualLink.region,
|
Chris@0
|
586 entityID: contextualLink.entityID,
|
Chris@0
|
587 entityInstanceID: contextualLink.entityInstanceID,
|
Chris@0
|
588 id: `${contextualLink.entityID}[${contextualLink.entityInstanceID}]`,
|
Chris@0
|
589 label: Drupal.quickedit.metadata.get(contextualLink.entityID, 'label'),
|
Chris@0
|
590 });
|
Chris@0
|
591 Drupal.quickedit.collections.entities.add(entityModel);
|
Chris@0
|
592 // Create an EntityDecorationView associated with the root DOM node of the
|
Chris@0
|
593 // entity.
|
Chris@0
|
594 const entityDecorationView = new Drupal.quickedit.EntityDecorationView({
|
Chris@0
|
595 el: contextualLink.region,
|
Chris@0
|
596 model: entityModel,
|
Chris@0
|
597 });
|
Chris@0
|
598 entityModel.set('entityDecorationView', entityDecorationView);
|
Chris@0
|
599
|
Chris@0
|
600 // Initialize all queued fields within this entity (creates FieldModels).
|
Chris@0
|
601 _.each(fields, (field) => {
|
Chris@0
|
602 initializeField(field.el, field.fieldID, contextualLink.entityID, contextualLink.entityInstanceID);
|
Chris@0
|
603 });
|
Chris@0
|
604 fieldsAvailableQueue = _.difference(fieldsAvailableQueue, fields);
|
Chris@0
|
605
|
Chris@0
|
606 // Initialization should only be called once. Use Underscore's once method
|
Chris@0
|
607 // to get a one-time use version of the function.
|
Chris@0
|
608 const initContextualLink = _.once(() => {
|
Chris@0
|
609 const $links = $(contextualLink.el).find('.contextual-links');
|
Chris@0
|
610 const contextualLinkView = new Drupal.quickedit.ContextualLinkView($.extend({
|
Chris@0
|
611 el: $('<li class="quickedit"><a href="" role="button" aria-pressed="false"></a></li>').prependTo($links),
|
Chris@0
|
612 model: entityModel,
|
Chris@0
|
613 appModel: Drupal.quickedit.app.model,
|
Chris@0
|
614 }, options));
|
Chris@0
|
615 entityModel.set('contextualLinkView', contextualLinkView);
|
Chris@0
|
616 });
|
Chris@0
|
617
|
Chris@0
|
618 // Set up ContextualLinkView after loading any missing in-place editors.
|
Chris@0
|
619 loadMissingEditors(initContextualLink);
|
Chris@0
|
620
|
Chris@0
|
621 return true;
|
Chris@0
|
622 }
|
Chris@0
|
623 // There was not at least one field that the current user may edit in-place,
|
Chris@0
|
624 // even though the metadata for all fields within this entity is available.
|
Chris@0
|
625 else if (allMetadataExists(fieldIDs)) {
|
Chris@0
|
626 return true;
|
Chris@0
|
627 }
|
Chris@0
|
628
|
Chris@0
|
629 return false;
|
Chris@0
|
630 }
|
Chris@0
|
631
|
Chris@0
|
632 /**
|
Chris@0
|
633 * Delete models and queue items that are contained within a given context.
|
Chris@0
|
634 *
|
Chris@0
|
635 * Deletes any contained EntityModels (plus their associated FieldModels and
|
Chris@0
|
636 * ContextualLinkView) and FieldModels, as well as the corresponding queues.
|
Chris@0
|
637 *
|
Chris@0
|
638 * After EntityModels, FieldModels must also be deleted, because it is
|
Chris@0
|
639 * possible in Drupal for a field DOM element to exist outside of the entity
|
Chris@0
|
640 * DOM element, e.g. when viewing the full node, the title of the node is not
|
Chris@0
|
641 * rendered within the node (the entity) but as the page title.
|
Chris@0
|
642 *
|
Chris@0
|
643 * Note: this will not delete an entity that is actively being in-place
|
Chris@0
|
644 * edited.
|
Chris@0
|
645 *
|
Chris@0
|
646 * @param {jQuery} $context
|
Chris@0
|
647 * The context within which to delete.
|
Chris@0
|
648 */
|
Chris@0
|
649 function deleteContainedModelsAndQueues($context) {
|
Chris@0
|
650 $context.find('[data-quickedit-entity-id]').addBack('[data-quickedit-entity-id]').each((index, entityElement) => {
|
Chris@0
|
651 // Delete entity model.
|
Chris@0
|
652 const entityModel = Drupal.quickedit.collections.entities.findWhere({ el: entityElement });
|
Chris@0
|
653 if (entityModel) {
|
Chris@0
|
654 const contextualLinkView = entityModel.get('contextualLinkView');
|
Chris@0
|
655 contextualLinkView.undelegateEvents();
|
Chris@0
|
656 contextualLinkView.remove();
|
Chris@0
|
657 // Remove the EntityDecorationView.
|
Chris@0
|
658 entityModel.get('entityDecorationView').remove();
|
Chris@0
|
659 // Destroy the EntityModel; this will also destroy its FieldModels.
|
Chris@0
|
660 entityModel.destroy();
|
Chris@0
|
661 }
|
Chris@0
|
662
|
Chris@0
|
663 // Filter queue.
|
Chris@0
|
664 function hasOtherRegion(contextualLink) {
|
Chris@0
|
665 return contextualLink.region !== entityElement;
|
Chris@0
|
666 }
|
Chris@0
|
667
|
Chris@0
|
668 contextualLinksQueue = _.filter(contextualLinksQueue, hasOtherRegion);
|
Chris@0
|
669 });
|
Chris@0
|
670
|
Chris@0
|
671 $context.find('[data-quickedit-field-id]').addBack('[data-quickedit-field-id]').each((index, fieldElement) => {
|
Chris@0
|
672 // Delete field models.
|
Chris@0
|
673 Drupal.quickedit.collections.fields.chain()
|
Chris@0
|
674 .filter(fieldModel => fieldModel.get('el') === fieldElement)
|
Chris@0
|
675 .invoke('destroy');
|
Chris@0
|
676
|
Chris@0
|
677 // Filter queues.
|
Chris@0
|
678 function hasOtherFieldElement(field) {
|
Chris@0
|
679 return field.el !== fieldElement;
|
Chris@0
|
680 }
|
Chris@0
|
681
|
Chris@0
|
682 fieldsMetadataQueue = _.filter(fieldsMetadataQueue, hasOtherFieldElement);
|
Chris@0
|
683 fieldsAvailableQueue = _.filter(fieldsAvailableQueue, hasOtherFieldElement);
|
Chris@0
|
684 });
|
Chris@0
|
685 }
|
Chris@0
|
686 }(jQuery, _, Backbone, Drupal, drupalSettings, window.JSON, window.sessionStorage));
|