PluginProbe
ElasticPress / 5.3.5
ElasticPress v5.3.5
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / assets / js / ordering / pointers.js

pointers.js in ElasticPress 5.3.5, at assets/js/ordering/pointers.js

526 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * External dependencies.
3 */
4 import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
5
6 /**
7 * WordPress dependencies.
8 */
9 import apiFetch from '@wordpress/api-fetch';
10 import { Component, Fragment } from '@wordpress/element';
11 import { __ } from '@wordpress/i18n';
12
13 /**
14 * Internal dependencies.
15 */
16 import { pluck, debounce } from '../utils/helpers';
17
18 apiFetch.use(apiFetch.createRootURLMiddleware(window.epOrdering.restApiRoot));
19
20 export class Pointers extends Component {
21 titleInput = null;
22
23 publishButton = null;
24
25 debouncedDefaultResults = debounce(() => {
26 this.getDefaultResults();
27 }, 200);
28
29 doSearch = debounce(() => {
30 const { searchText, searchResults } = this.state;
31 const searchTerm = searchText;
32
33 // Set loading state
34 searchResults[searchTerm] = false;
35 this.setState({ searchResults });
36
37 apiFetch({
38 path: `/elasticpress/v1/pointer_search?s=${searchTerm}`,
39 }).then((result) => {
40 searchResults[searchTerm] = result;
41
42 this.setState({ searchResults });
43 });
44 }, 200);
45
46 debouncedHandleTitleChange = debounce(() => {
47 this.handleTitleChange();
48 }, 200);
49
50 /**
51 * Initializes the component with initial state set by WP
52 *
53 * @param {object} props Component props
54 */
55 constructor(props) {
56 super(props);
57
58 // We need to know the title of the page and react to changes since this is the query we search for
59 this.titleInput = document.getElementById('title');
60 this.publishButton = document.getElementById('publish');
61
62 this.state = {
63 pointers: window.epOrdering.pointers,
64 posts: window.epOrdering.posts,
65 title: this.titleInput.value,
66 defaultResults: {},
67 searchText: '',
68 searchResults: {},
69 removedPointers: [],
70 };
71 }
72
73 componentDidMount() {
74 this.titleInput.addEventListener('keyup', this.debouncedHandleTitleChange);
75
76 const { title } = this.state;
77
78 if (title?.length > 0) {
79 this.getDefaultResults();
80 }
81
82 this.updatePublishButtonState();
83 }
84
85 componentDidUpdate() {
86 this.updatePublishButtonState();
87 }
88
89 componentWillUnmount() {
90 this.titleInput.removeEventListener('keyup', this.debouncedHandleTitleChange);
91 }
92
93 /**
94 * Updates the publish button disabled state based on loading status.
95 */
96 updatePublishButtonState = () => {
97 const { title, defaultResults } = this.state;
98 const isLoading = title.length === 0 || !defaultResults[title];
99
100 this.publishButton.disabled = isLoading;
101 };
102
103 handleTitleChange = () => {
104 this.setState({ title: this.titleInput.value });
105 this.debouncedDefaultResults();
106 };
107
108 getDefaultResults = () => {
109 const { title: searchTerm } = this.state;
110
111 apiFetch({
112 path: `/elasticpress/v1/pointer_preview?s=${searchTerm}`,
113 }).then((result) => {
114 const { defaultResults } = this.state;
115
116 defaultResults[searchTerm] = result;
117
118 this.setState({ defaultResults });
119 });
120 };
121
122 removePointer = (pointer) => {
123 let { pointers } = this.state;
124 const { removedPointers } = this.state;
125
126 delete pointers[pointers.indexOf(pointer)];
127 pointers = pointers.filter((item) => item !== null);
128 removedPointers.push(pointer.ID);
129
130 this.setState({ pointers });
131 };
132
133 getMergedPosts = () => {
134 let { pointers } = this.state;
135 const { title, defaultResults } = this.state;
136 let merged = defaultResults[title].slice();
137
138 pointers = pointers.sort((a, b) => {
139 return a.order > b.order ? 1 : -1;
140 });
141 const pointersIds = pluck(pointers, 'ID');
142
143 // Remove all custom pointers from the default results
144 merged = merged.filter((item) => pointersIds.indexOf(item.ID) === -1);
145
146 // Insert pointers into their proper location
147 pointers.forEach((pointer) => {
148 merged.splice(parseInt(pointer.order, 10) - 1, 0, pointer);
149 });
150
151 return merged;
152 };
153
154 /**
155 * Gets the next available position for a pointer
156 *
157 * @returns {number|false} The available position
158 */
159 getNextAvailablePosition = () => {
160 const { pointers } = this.state;
161 const availablePositions = {};
162
163 for (let i = 1; i <= window.epOrdering.postsPerPage; i++) {
164 availablePositions[i] = true;
165 }
166
167 pointers.forEach((item) => {
168 delete availablePositions[item.order];
169 });
170
171 const keys = Object.keys(availablePositions);
172
173 if (keys.length === 0) {
174 return false;
175 }
176
177 return parseInt(keys[0], 10);
178 };
179
180 /**
181 * Adds a new pointer. We place the new pointer at the highest available position
182 *
183 * @param {object} post Post object
184 */
185 addPointer = (post) => {
186 const id = post.ID;
187 const { posts, pointers } = this.state;
188
189 if (!posts[id]) {
190 posts[id] = post;
191 this.setState({ posts });
192 }
193
194 const position = this.getNextAvailablePosition();
195
196 if (!position) {
197 /* eslint-disable no-alert */
198 window.alert(
199 __('You have added the maximum number of custom results.', 'elasticpress'),
200 );
201 /* eslint-enable no-alert */
202 return;
203 }
204
205 pointers.push({
206 ID: id,
207 order: position,
208 type: 'custom-result',
209 });
210
211 this.setState({ pointers });
212 };
213
214 /**
215 * Callback when drag/drop is complete.
216 *
217 * Only the pointers are able to be dragged around, so all we need to do is increase any pointer by one that is
218 * either at the current position or greater
219 *
220 * @param {object} result Dragged object
221 */
222 onDragComplete = (result) => {
223 // dropped outside the list
224 if (!result.destination) {
225 return;
226 }
227
228 const items = this.getMergedPosts();
229
230 // Offsetting indexes when over posts per page to account for the non-sortable notice
231 const ppp = parseInt(window.epOrdering.postsPerPage, 10);
232 const startIndex =
233 result.source.index >= ppp ? result.source.index - 1 : result.source.index;
234 const endIndex =
235 result.destination.index > ppp
236 ? result.destination.index - 1
237 : result.destination.index;
238
239 const [removed] = items.splice(startIndex, 1);
240 items.splice(endIndex, 0, removed);
241
242 // Now _all_ the items are in order - grab the pointers and set the new positions to state
243 const pointers = [];
244
245 items.forEach((item, index) => {
246 // Reordering an existing pointer or adding a default post to the pointers array
247 if (item.order || Number(item.ID) === Number(result.draggableId)) {
248 pointers.push({
249 ID: item.ID,
250 order: index + 1,
251 type: item?.type || 'reordered',
252 });
253 }
254 });
255
256 this.setState({ pointers });
257 };
258
259 searchResults = (searchResults) => {
260 const { searchText } = this.state;
261
262 if (searchText === '') {
263 return null;
264 }
265
266 if (searchResults === false) {
267 return (
268 <div className="loading">
269 <div className="spinner is-active" />
270 Loading...
271 </div>
272 );
273 }
274
275 if (searchResults.length === 0) {
276 return <div className="no-results">{__('No results found.', 'elasticpress')}</div>;
277 }
278
279 return searchResults.map((result) => {
280 return (
281 <div className="pointer-result" key={result.ID}>
282 <span className="title">{result.post_title}</span>
283 <span
284 role="button"
285 tabIndex="0"
286 className="dashicons dashicons-plus add-pointer"
287 onClick={(event) => {
288 event.preventDefault();
289 this.addPointer(result);
290 }}
291 onKeyDown={(event) => {
292 event.preventDefault();
293 this.addPointer(result);
294 }}
295 >
296 <span className="screen-reader-text">{__('Add Post', 'elasticpress')}</span>
297 </span>
298 </div>
299 );
300 });
301 };
302
303 /**
304 * Renders the component
305 *
306 * @returns {*} The component
307 */
308 render() {
309 const {
310 posts,
311 defaultResults,
312 title,
313 pointers,
314 removedPointers,
315 searchText,
316 searchResults: searchResultsFromState,
317 } = this.state;
318
319 if (title.length === 0) {
320 return (
321 <div className="new-post">
322 <p>
323 {__(
324 'Enter your search query above to preview the results.',
325 'elasticpress',
326 )}
327 </p>
328 </div>
329 );
330 }
331
332 if (!defaultResults[title]) {
333 return (
334 <div className="loading">
335 <div className="spinner is-active" />
336 <span>{__('Loading Result Preview…', 'elasticpress')}</span>
337 </div>
338 );
339 }
340
341 // We need to reference these by ID later
342 const defaultResultsById = {};
343 defaultResults[title].forEach((item) => {
344 defaultResultsById[item.ID] = item;
345 });
346
347 const mergedPosts = this.getMergedPosts();
348 const renderedIds = pluck(pointers, 'ID');
349
350 const searchResults = searchResultsFromState[searchText]
351 ? searchResultsFromState[searchText].filter(
352 (item) => renderedIds.indexOf(item.ID) === -1,
353 )
354 : false;
355
356 return (
357 <div>
358 <input type="hidden" name="search-ordering-nonce" value={window.epOrdering.nonce} />
359 <input type="hidden" name="ordered_posts" value={JSON.stringify(pointers)} />
360 <DragDropContext onDragEnd={this.onDragComplete}>
361 <Droppable droppableId="droppable">
362 {(provided) => (
363 <div
364 className="pointers"
365 {...provided.droppableProps}
366 ref={provided.innerRef}
367 >
368 {mergedPosts.map((item, index) => {
369 const draggableIndex =
370 parseInt(window.epOrdering.postsPerPage, 10) <= index
371 ? index + 1
372 : index;
373
374 const isRemoved = removedPointers.includes(item.ID);
375
376 let { title } = item;
377 if (undefined === title) {
378 title =
379 undefined !== posts[item.ID]
380 ? posts[item.ID].post_title
381 : defaultResultsById[item.ID].post_title;
382 }
383
384 // Determine if this result is part of default search results or not
385 const itemType = item?.type || 'reordered';
386 const tooltipText =
387 itemType === 'reordered'
388 ? __('Return to original position', 'elasticpress')
389 : __(
390 'Remove custom result from results list',
391 'elasticpress',
392 );
393
394 return (
395 <Fragment key={item.ID}>
396 {parseInt(window.epOrdering.postsPerPage, 10) ===
397 index && (
398 <Draggable
399 key="divider"
400 draggableId="divider"
401 index={index}
402 isDragDisabled={false}
403 >
404 {(component) => (
405 <div
406 className={`next-page-notice ${index}`}
407 ref={component.innerRef}
408 {...component.draggableProps}
409 {...component.dragHandleProps}
410 >
411 <span>
412 {__(
413 'The following posts have been displaced to the next page of search results.',
414 'elasticpress',
415 )}
416 </span>
417 </div>
418 )}
419 </Draggable>
420 )}
421
422 <Draggable
423 key={item.ID}
424 draggableId={String(item.ID)}
425 index={draggableIndex}
426 >
427 {(provided2) => (
428 <div
429 className={`pointer ${draggableIndex} ${
430 isRemoved ? 'removed' : ''
431 }`}
432 ref={provided2.innerRef}
433 {...provided2.draggableProps}
434 >
435 {item.order && itemType === 'reordered' && (
436 <span className="pointer-type">RD</span>
437 )}
438 {item.order && itemType !== 'reordered' && (
439 <span className="pointer-type">CR</span>
440 )}
441 <strong className="title">{title}</strong>
442 <div className="pointer-actions">
443 {item.order && (
444 <span
445 role="button"
446 tabIndex="0"
447 title={tooltipText}
448 className="dashicons dashicons-undo delete-pointer"
449 onClick={(event) => {
450 event.preventDefault();
451 this.removePointer(item);
452 }}
453 onKeyDown={(event) => {
454 event.preventDefault();
455 this.removePointer(item);
456 }}
457 >
458 <span className="screen-reader-text">
459 Remove Post
460 </span>
461 </span>
462 )}
463 <span
464 className="dashicons dashicons-menu handle"
465 {...provided2.dragHandleProps}
466 title={__(
467 'Drag post up or down to reposition',
468 'elasticpress',
469 )}
470 />
471 </div>
472 </div>
473 )}
474 </Draggable>
475 </Fragment>
476 );
477 })}
478 {provided.placeholder}
479 </div>
480 )}
481 </Droppable>
482 </DragDropContext>
483
484 <div className="legend">
485 <div className="legend-item">
486 <span className="pointer-type">CR</span>
487 <span className="type-description">
488 {__('Custom Result (manually added to list)', 'elasticpress')}
489 </span>
490 </div>
491 <div className="legend-item">
492 <span className="pointer-type">RD</span>
493 <span className="type-description">
494 {__(
495 'Reordered Default (originally in results, but repositioned)',
496 'elasticpress',
497 )}
498 </span>
499 </div>
500 </div>
501
502 <div className="pointer-search">
503 <h2 className="section-title">{__('Add to results', 'elasticpress')}</h2>
504
505 <div className="search-wrapper">
506 <div className="input-wrap">
507 <input
508 type="text"
509 className="widefat search-pointers"
510 placeholder="Search for Post"
511 value={searchText}
512 onChange={(e) => {
513 this.setState({ searchText: e.target.value });
514 this.doSearch();
515 }}
516 />
517 </div>
518
519 <div className="pointer-results">{this.searchResults(searchResults)}</div>
520 </div>
521 </div>
522 </div>
523 );
524 }
525 }
526