PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.3 3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 All 107 releases
wp-parsely / src / content-helper / editor-sidebar / related-posts / component.tsx

component.tsx in Parse.ly 3.24.2, at src/content-helper/editor-sidebar/related-posts/component.tsx

399 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 import {
5 __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
6 SelectControl,
7 } from '@wordpress/components';
8 import { useDebounce } from '@wordpress/compose';
9 import { useDispatch, useSelect } from '@wordpress/data';
10 import { useEffect, useState } from '@wordpress/element';
11 import { __ } from '@wordpress/i18n';
12
13 /**
14 * Internal dependencies
15 */
16 import { GutenbergFunction } from '../../../@types/gutenberg/types';
17 import { Telemetry } from '../../../js/telemetry/telemetry';
18 import { ContentHelperError } from '../../common/content-helper-error';
19 import { SidebarSettings, useSettings } from '../../common/settings';
20 import {
21 Metric,
22 Period,
23 PostFilterType,
24 PostFilters,
25 getMetricDescription,
26 getPeriodDescription,
27 isInEnum,
28 } from '../../common/utils/constants';
29 import { PostData } from '../../common/utils/post';
30 import { SidebarPostData, SidebarPostDataCategory } from '../editor-sidebar';
31 import { RelatedPostsFilterSettings } from './component-filter-settings';
32 import { RelatedPostItem } from './component-item';
33 import { usePostData } from './hooks';
34 import { RelatedPostsProvider } from './provider';
35 import './related-posts.scss';
36 import { RelatedPostsStore } from './store';
37
38 /**
39 * The maximum number of retries for fetching the related posts.
40 *
41 * @since 3.20.0 Renamed from FETCH_RETRIES to MAX_FETCH_RETRIES.
42 */
43 const MAX_FETCH_RETRIES = 1;
44
45 /**
46 * The Related Posts panel in the Editor Sidebar.
47 *
48 * @since 3.14.0
49 */
50 export const RelatedPostsPanel = (): React.JSX.Element => {
51 const { settings, setSettings } = useSettings<SidebarSettings>();
52
53 const period = settings.RelatedPosts.Period;
54 const metric = settings.RelatedPosts.Metric;
55
56 const [ postData, setPostData ] = useState<SidebarPostData>( {
57 authors: [], categories: [], tags: [],
58 } );
59
60 /**
61 * Returns the current Post's ID, tags and categories.
62 *
63 * @since 3.11.0
64 * @since 3.14.0 Moved from `editor-sidebar.tsx`.
65 * @since 3.14.3 Moved to a custom usePostData hook in `hooks.ts`.
66 */
67 const {
68 authors,
69 categories,
70 tags,
71 isReady: isPostDataReady,
72 } = usePostData();
73
74 /**
75 * Validates that the passed value is an array of Users or Taxonomies, with
76 * at least one item.
77 *
78 * @since 3.14.4
79 *
80 * @param {unknown} value The value to be validated.
81 *
82 * @return {boolean} Whether validation succeeded.
83 */
84 const isArrayOfUsersOrTaxonomies = ( value: unknown ): boolean => {
85 if ( ! Array.isArray( value ) || value.length === 0 ) {
86 return false;
87 }
88
89 // Every array item should have the following required properties.
90 return value.every( ( item ) => {
91 return 'name' in item && 'id' in item && 'slug' in item &&
92 'description' in item && 'link' in item;
93 } );
94 };
95
96 useEffect( () => {
97 if ( ! isPostDataReady ) {
98 return;
99 }
100
101 /**
102 * Returns the name properties present in the passed value, or an empty
103 * array if any errors occur.
104 *
105 * @since 3.14.4
106 *
107 * @param {unknown} value The value to be processed.
108 *
109 * @return {string[]} The names extracted from the value.
110 */
111 const extractNamesAsArray = ( value: unknown ): string[] => {
112 if ( ! isArrayOfUsersOrTaxonomies( value ) ) {
113 return [];
114 }
115
116 const array = value as Array<{ name: string }>;
117 return array.map( ( item ) => item.name );
118 };
119
120 /**
121 * Returns the name and slug properties present in the passed value, or
122 * an empty array if any errors occur.
123 *
124 * @since 3.18.0
125 *
126 * @param {unknown} value The value to be processed.
127 *
128 * @return {SidebarPostDataCategory[]} The categories data extracted from the value.
129 */
130 const extractCategoriesDataAsArray = ( value: unknown ): SidebarPostDataCategory[] => {
131 if ( ! isArrayOfUsersOrTaxonomies( value ) ) {
132 return [];
133 }
134
135 const array = value as SidebarPostDataCategory[];
136 return array.map( ( item ) => ( { name: item.name, slug: item.slug } ) );
137 };
138
139 setPostData( {
140 // Pass the data through validation, as `usePostData()` could return
141 // unexpected results.
142 authors: extractNamesAsArray( authors ),
143 categories: extractCategoriesDataAsArray( categories ),
144 tags: extractNamesAsArray( tags ),
145 } );
146 }, [ authors, categories, tags, isPostDataReady ] );
147
148 const {
149 firstRun,
150 loading,
151 posts,
152 filters,
153 } = useSelect( ( select ) => {
154 const { isLoading, getPosts, getFilters, isFirstRun } = select( RelatedPostsStore );
155 return {
156 firstRun: isFirstRun(),
157 loading: isLoading(),
158 posts: getPosts(),
159 filters: getFilters(),
160 };
161 }, [] );
162
163 const {
164 setFirstRun,
165 setLoading,
166 setPosts,
167 setFilters,
168 } = useDispatch( RelatedPostsStore );
169
170 const [ error, setError ] = useState<ContentHelperError>();
171
172 const [ postContent, setPostContent ] = useState<string|undefined>( undefined );
173 const debouncedSetPostContent = useDebounce( setPostContent, 1000 );
174 useSelect( ( select ) => {
175 if ( typeof jest === 'undefined' ) {
176 const { getEditedPostContent } = select( 'core/editor' ) as GutenbergFunction;
177 debouncedSetPostContent( getEditedPostContent() );
178 } else {
179 // It would be better to mock this in the Content Intelligence
180 // structure test.
181 debouncedSetPostContent( 'Jest test is running' );
182 }
183 }, [ debouncedSetPostContent ] );
184
185 /**
186 * Updates the metric setting.
187 *
188 * @since 3.14.0
189 *
190 * @param {string} selection The new metric.
191 */
192 const onMetricChange = ( selection: string ) => {
193 if ( isInEnum( selection, Metric ) ) {
194 const updatedMetric = selection as Metric;
195
196 setSettings( {
197 RelatedPosts: {
198 ...settings.RelatedPosts,
199 Metric: updatedMetric,
200 },
201 } );
202 Telemetry.trackEvent( 'related_posts_metric_changed', { metric: updatedMetric } );
203
204 fetchPosts( period, updatedMetric, filters, MAX_FETCH_RETRIES );
205 }
206 };
207
208 /**
209 * Updates the period setting.
210 *
211 * @since 3.14.0
212 *
213 * @param {string} selection The new period.
214 */
215 const onPeriodChange = ( selection: string ) => {
216 if ( isInEnum( selection, Period ) ) {
217 const updatedPeriod = selection as Period;
218
219 setSettings( {
220 RelatedPosts: {
221 ...settings.RelatedPosts,
222 Period: updatedPeriod,
223 },
224 } );
225 Telemetry.trackEvent( 'related_posts_period_changed', { period: updatedPeriod } );
226
227 fetchPosts( updatedPeriod, metric, filters, MAX_FETCH_RETRIES );
228 }
229 };
230
231 /**
232 * Fetches the related posts.
233 *
234 * @since 3.4.0
235 * @since 3.18.0 Added `fetchPeriod`, `fetchMetric`, and `fetchFilters` parameters.
236 *
237 * @param {Period} fetchPeriod The period for which to fetch data.
238 * @param {Metric} fetchMetric The metric to sort by.
239 * @param {PostFilters} fetchFilters The filters to use in the request.
240 * @param {number} retries The max number of retries to perform in case of failure(s).
241 */
242 const fetchPosts = async (
243 fetchPeriod: Period, fetchMetric: Metric, fetchFilters: PostFilters, retries: number
244 ) : Promise<void> => {
245 setLoading( true );
246
247 RelatedPostsProvider.getInstance().getRelatedPosts( fetchPeriod, fetchMetric, fetchFilters )
248 .then( ( result ): void => {
249 setPosts( result );
250 setLoading( false );
251 } )
252 .catch( async ( err ) => {
253 if ( retries > 0 && err.retryFetch ) {
254 await new Promise( ( r ) => setTimeout( r, 500 ) );
255 await fetchPosts( fetchPeriod, fetchMetric, fetchFilters, retries - 1 );
256 } else {
257 setLoading( false );
258 setError( err );
259 setPosts( [] );
260 }
261 } );
262 };
263
264 if ( firstRun ) {
265 // Run initial fetch when the component is mounted.
266 fetchPosts( period, metric, filters, MAX_FETCH_RETRIES );
267 setFirstRun( false );
268 }
269
270 /**
271 * Updates the filters value.
272 *
273 * @since 3.11.0
274 *
275 * @param {PostFilters} newValue The new filters value.
276 * @param {PostFilterType} filterType The type of filter being changed.
277 */
278 const updateFilters = (
279 newValue: string | null | undefined,
280 filterType: PostFilterType
281 ): void => {
282 if ( null === newValue || undefined === newValue ) {
283 newValue = '';
284 }
285
286 let updatedFilters;
287
288 if ( PostFilterType.Tag === filterType ) {
289 let values: string[] = [];
290
291 if ( '' !== newValue ) {
292 values = newValue.split( ',' ).map( ( tag ) => tag.trim() );
293 }
294
295 updatedFilters = { ...filters, tags: values };
296 } else {
297 updatedFilters = { ...filters, [ filterType ]: newValue };
298 }
299
300 setFilters( updatedFilters );
301 fetchPosts( period, metric, updatedFilters, MAX_FETCH_RETRIES );
302 };
303
304 // No filter data could be retrieved. Prevent the component from rendering.
305 if ( postData.authors.length === 0 && postData.categories.length === 0 &&
306 postData.tags.length === 0 && isPostDataReady
307 ) {
308 return (
309 <div className="wp-parsely-related-posts">
310 <div className="related-posts-body">
311 { __(
312 'Error: No author, section, or tags could be found for this post.',
313 'wp-parsely'
314 ) }
315 </div>
316 </div>
317 );
318 }
319
320 return (
321 <div className="wp-parsely-related-posts">
322 <div className="related-posts-description">
323 { __( 'Find top-performing related posts.', 'wp-parsely' ) }
324 </div>
325 <div className="related-posts-body">
326 <div className="related-posts-settings">
327 <SelectControl
328 size="__unstable-large"
329 onChange={ ( value ) => onMetricChange( value ) }
330 prefix={
331 <InputControlPrefixWrapper>{ __( 'Metric:', 'wp-parsely' ) }</InputControlPrefixWrapper>
332 }
333 value={ metric }
334 >
335 { Object.values( Metric ).map( ( value ) => (
336 <option key={ value } value={ value }>
337 { getMetricDescription( value ) }
338 </option>
339 ) ) }
340 </SelectControl>
341 <SelectControl
342 size="__unstable-large"
343 value={ period }
344 prefix={
345 <InputControlPrefixWrapper>{ __( 'Period:', 'wp-parsely' ) } </InputControlPrefixWrapper>
346 }
347 onChange={ ( selection ) => onPeriodChange( selection ) }
348 >
349 { Object.values( Period ).map( ( value ) => (
350 <option key={ value } value={ value }>
351 { getPeriodDescription( value ) }
352 </option>
353 ) ) }
354 </SelectControl>
355 </div>
356 {
357 <RelatedPostsFilterSettings
358 label={ __( 'Filter by', 'wp-parsely' ) }
359 filters={ filters }
360 onFiltersChange={ updateFilters }
361 postData={ postData }
362 />
363 }
364
365 <div className="related-posts-wrapper">
366 { error && (
367 error.Message()
368 ) }
369 { loading && (
370 <div
371 className="related-posts-loading-message"
372 data-testid="parsely-related-posts-loading-message"
373 >
374 { __( 'Loading…', 'wp-parsely' ) }
375 </div>
376 ) }
377 { ! firstRun && ! loading && ! error && posts.length === 0 && (
378 <div className="related-posts-empty">
379 { __( 'No related posts found.', 'wp-parsely' ) }
380 </div>
381 ) }
382 { ! loading && posts.length > 0 && (
383 <div className="related-posts-list">
384 { posts.map( ( post: PostData ) => (
385 <RelatedPostItem
386 key={ post.id }
387 metric={ metric }
388 post={ post }
389 postContent={ postContent }
390 />
391 ) ) }
392 </div>
393 ) }
394 </div>
395 </div>
396 </div>
397 );
398 };
399