diff --git a/assets/js/ordering/pointers.js b/assets/js/ordering/pointers.js index 0dd26583a..a1bd9ef3b 100644 --- a/assets/js/ordering/pointers.js +++ b/assets/js/ordering/pointers.js @@ -7,7 +7,7 @@ import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; * WordPress dependencies. */ import apiFetch from '@wordpress/api-fetch'; -import { Component, Fragment } from '@wordpress/element'; +import { useState, useEffect, useMemo, useCallback, Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -17,134 +17,142 @@ import { pluck, debounce } from '../utils/helpers'; apiFetch.use(apiFetch.createRootURLMiddleware(window.epOrdering.restApiRoot)); -export class Pointers extends Component { - titleInput = null; - - publishButton = null; - - debouncedDefaultResults = debounce(() => { - this.getDefaultResults(); - }, 200); - - doSearch = debounce(() => { - const { searchText, searchResults } = this.state; - const searchTerm = searchText; - - // Set loading state - searchResults[searchTerm] = false; - this.setState({ searchResults }); - - apiFetch({ - path: `/elasticpress/v1/pointer_search?s=${searchTerm}`, - }).then((result) => { - searchResults[searchTerm] = result; - - this.setState({ searchResults }); - }); - }, 200); - - debouncedHandleTitleChange = debounce(() => { - this.handleTitleChange(); - }, 200); - - /** - * Initializes the component with initial state set by WP - * - * @param {object} props Component props - */ - constructor(props) { - super(props); - - // We need to know the title of the page and react to changes since this is the query we search for - this.titleInput = document.getElementById('title'); - this.publishButton = document.getElementById('publish'); - - this.state = { - pointers: window.epOrdering.pointers, - posts: window.epOrdering.posts, - title: this.titleInput.value, - defaultResults: {}, - searchText: '', - searchResults: {}, - removedPointers: [], - }; - } - - componentDidMount() { - this.titleInput.addEventListener('keyup', this.debouncedHandleTitleChange); - - const { title } = this.state; - - if (title?.length > 0) { - this.getDefaultResults(); - } - - this.updatePublishButtonState(); - } - - componentDidUpdate() { - this.updatePublishButtonState(); - } - - componentWillUnmount() { - this.titleInput.removeEventListener('keyup', this.debouncedHandleTitleChange); - } +export const Pointers = () => { + const [pointers, setPointers] = useState(window.epOrdering.pointers); + const [posts, setPosts] = useState(window.epOrdering.posts); + const [title, setTitle] = useState(document.getElementById('title')?.value || ''); + const [defaultResults, setDefaultResults] = useState({}); + const [searchText, setSearchText] = useState(''); + const [searchResultsData, setSearchResultsData] = useState({}); + const [removedPointers, setRemovedPointers] = useState([]); /** * Updates the publish button disabled state based on loading status. */ - updatePublishButtonState = () => { - const { title, defaultResults } = this.state; + const updatePublishButtonState = useCallback(() => { + const publishButton = document.getElementById('publish'); + if (!publishButton) { + return; + } const isLoading = title.length === 0 || !defaultResults[title]; + publishButton.disabled = isLoading; + }, [title, defaultResults]); + + useEffect(() => { + updatePublishButtonState(); + }, [updatePublishButtonState]); + + const getDefaultResults = useCallback( + (searchTerm) => { + apiFetch({ + path: `/elasticpress/v1/pointer_preview?s=${searchTerm}`, + }).then((result) => { + setDefaultResults((prevDefaultResults) => ({ + ...prevDefaultResults, + [searchTerm]: result, + })); + }); + }, + [], // No dependencies needed as we use functional state update + ); + + const debouncedDefaultResults = useMemo( + () => + debounce((searchTerm) => { + getDefaultResults(searchTerm); + }, 200), + [getDefaultResults], + ); + + const handleTitleChange = useCallback(() => { + const titleInput = document.getElementById('title'); + if (!titleInput) { + return; + } + const newTitle = titleInput.value; + setTitle(newTitle); + debouncedDefaultResults(newTitle); + }, [debouncedDefaultResults]); + + const debouncedHandleTitleChange = useMemo( + () => + debounce(() => { + handleTitleChange(); + }, 200), + [handleTitleChange], + ); + + useEffect(() => { + const titleInput = document.getElementById('title'); + if (titleInput) { + titleInput.addEventListener('keyup', debouncedHandleTitleChange); + } - this.publishButton.disabled = isLoading; - }; - - handleTitleChange = () => { - this.setState({ title: this.titleInput.value }); - this.debouncedDefaultResults(); - }; - - getDefaultResults = () => { - const { title: searchTerm } = this.state; - - apiFetch({ - path: `/elasticpress/v1/pointer_preview?s=${searchTerm}`, - }).then((result) => { - const { defaultResults } = this.state; + if (title?.length > 0) { + getDefaultResults(title); + } - defaultResults[searchTerm] = result; + updatePublishButtonState(); - this.setState({ defaultResults }); - }); + return () => { + if (titleInput) { + titleInput.removeEventListener('keyup', debouncedHandleTitleChange); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // We only want this to run on mount + + const doSearch = useMemo( + () => + debounce((searchTerm) => { + // Set loading state + setSearchResultsData((prev) => ({ + ...prev, + [searchTerm]: false, + })); + + apiFetch({ + path: `/elasticpress/v1/pointer_search?s=${searchTerm}`, + }).then((result) => { + setSearchResultsData((prev) => ({ + ...prev, + [searchTerm]: result, + })); + }); + }, 200), + [], + ); + + const removePointer = (pointer) => { + const newRemovedPointers = [...removedPointers]; + newRemovedPointers.push(pointer.ID); + setRemovedPointers(newRemovedPointers); + + const pointerIndex = pointers.indexOf(pointer); + if (pointerIndex > -1) { + const newPointers = [...pointers]; + delete newPointers[pointerIndex]; + setPointers(newPointers.filter((item) => item !== null)); + } }; - removePointer = (pointer) => { - let { pointers } = this.state; - const { removedPointers } = this.state; - - delete pointers[pointers.indexOf(pointer)]; - pointers = pointers.filter((item) => item !== null); - removedPointers.push(pointer.ID); - - this.setState({ pointers }); - }; + const getMergedPosts = () => { + if (!defaultResults[title]) { + return []; + } - getMergedPosts = () => { - let { pointers } = this.state; - const { title, defaultResults } = this.state; let merged = defaultResults[title].slice(); - pointers = pointers.sort((a, b) => { + const sortedPointers = [...pointers].sort((a, b) => { return a.order > b.order ? 1 : -1; }); - const pointersIds = pluck(pointers, 'ID'); + const pointersIds = pluck(sortedPointers, 'ID'); // Remove all custom pointers from the default results merged = merged.filter((item) => pointersIds.indexOf(item.ID) === -1); // Insert pointers into their proper location - pointers.forEach((pointer) => { + sortedPointers.forEach((pointer) => { merged.splice(parseInt(pointer.order, 10) - 1, 0, pointer); }); @@ -156,8 +164,7 @@ export class Pointers extends Component { * * @returns {number|false} The available position */ - getNextAvailablePosition = () => { - const { pointers } = this.state; + const getNextAvailablePosition = () => { const availablePositions = {}; for (let i = 1; i <= window.epOrdering.postsPerPage; i++) { @@ -182,16 +189,17 @@ export class Pointers extends Component { * * @param {object} post Post object */ - addPointer = (post) => { + const addPointer = (post) => { const id = post.ID; - const { posts, pointers } = this.state; if (!posts[id]) { - posts[id] = post; - this.setState({ posts }); + setPosts((prevPosts) => ({ + ...prevPosts, + [id]: post, + })); } - const position = this.getNextAvailablePosition(); + const position = getNextAvailablePosition(); if (!position) { /* eslint-disable no-alert */ @@ -202,13 +210,14 @@ export class Pointers extends Component { return; } - pointers.push({ + const newPointers = [...pointers]; + newPointers.push({ ID: id, order: position, type: 'custom-result', }); - this.setState({ pointers }); + setPointers(newPointers); }; /** @@ -219,13 +228,13 @@ export class Pointers extends Component { * * @param {object} result Dragged object */ - onDragComplete = (result) => { + const onDragComplete = (result) => { // dropped outside the list if (!result.destination) { return; } - const items = this.getMergedPosts(); + const items = getMergedPosts(); // Offsetting indexes when over posts per page to account for the non-sortable notice const ppp = parseInt(window.epOrdering.postsPerPage, 10); @@ -240,12 +249,12 @@ export class Pointers extends Component { items.splice(endIndex, 0, removed); // Now _all_ the items are in order - grab the pointers and set the new positions to state - const pointers = []; + const newPointers = []; items.forEach((item, index) => { - // Reordering an existing pointer or adding a default post to the pointers array + // Reordering an existing pointer or adding a default post to the newPointers array if (item.order || Number(item.ID) === Number(result.draggableId)) { - pointers.push({ + newPointers.push({ ID: item.ID, order: index + 1, type: item?.type || 'reordered', @@ -253,17 +262,16 @@ export class Pointers extends Component { } }); - this.setState({ pointers }); + setPointers(newPointers); }; - searchResults = (searchResults) => { - const { searchText } = this.state; - + const renderSearchResults = (results) => { if (searchText === '') { return null; } - if (searchResults === false) { + // Check explicitly for false (loading state) + if (results === false) { return (
- {__( - 'Enter your search query above to preview the results.', - 'elasticpress', - )} -
-{__('Enter your search query above to preview the results.', 'elasticpress')}
+