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 / features / apps / features.js

features.js in ElasticPress 5.3.5, at assets/js/features/apps/features.js

484 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * External dependencies.
3 */
4 // eslint-disable-next-line import/no-extraneous-dependencies
5 import { Route, Routes, Navigate, HashRouter, useParams, useNavigate } from 'react-router-dom';
6
7 /**
8 * WordPress dependencies.
9 */
10 import { Button, Flex, FlexItem, Notice, Panel, PanelBody } from '@wordpress/components';
11 import { useMemo, useState, WPElement } from '@wordpress/element';
12 import { __ } from '@wordpress/i18n';
13
14 /**
15 * Internal dependencies.
16 */
17 import { useSettingsScreen } from '../../settings-screen';
18 import { syncUrl, syncNonce, featureGroups } from '../config';
19 import { useFeatureSettings } from '../provider';
20 import Feature from '../components/feature';
21
22 /**
23 * Styles.
24 */
25 import '../style.css';
26
27 /**
28 * Navigation Tab Component for features
29 *
30 * @param {object} props Component props
31 * @param {string} props.title Feature title
32 * @param {string} props.to URL to navigate to
33 * @param {boolean} props.isActive Whether this tab is active
34 * @returns {WPElement} Tab component
35 */
36 const NavigationTab = ({ title, to, isActive }) => {
37 const navigate = useNavigate();
38 return (
39 <button
40 className={`ep-dashboard-tab ${isActive ? 'is-active' : ''}`}
41 aria-current={isActive ? 'page' : undefined}
42 onClick={() => navigate(to)}
43 type="button"
44 id={`title-${title}-to-${to}`}
45 >
46 {title}
47 </button>
48 );
49 };
50
51 /**
52 * Group navigation component that determines which group is active based on the current feature
53 *
54 * @param {object} props Component props
55 * @param {Array} props.groupedFeatures Grouped features data
56 * @param {string} props.activeFeature Currently active feature slug
57 * @returns {WPElement} Group navigation component
58 */
59 const GroupNavigation = ({ groupedFeatures, activeFeature }) => {
60 // Find which group contains the active feature
61 const activeGroup = groupedFeatures.find((group) =>
62 group.features.some((feature) => feature.slug === activeFeature),
63 );
64
65 const activeGroupSlug = activeGroup?.groupSlug || groupedFeatures[0]?.groupSlug;
66
67 return (
68 <div className="ep-dashboard-outer-tabs">
69 <div className="ep-dashboard-tabs-nav">
70 {groupedFeatures.map(({ groupSlug, title, features }) => (
71 <NavigationTab
72 key={groupSlug}
73 slug={groupSlug}
74 title={title}
75 to={`/${features[0]?.slug || ''}`}
76 isActive={groupSlug === activeGroupSlug}
77 />
78 ))}
79 </div>
80 </div>
81 );
82 };
83
84 /**
85 * Feature navigation component for features within a group
86 *
87 * @param {object} props Component props
88 * @param {Array} props.groupedFeatures Grouped features data
89 * @param {string} props.activeFeature Currently active feature
90 * @returns {WPElement} Feature navigation component
91 */
92 const FeatureNavigation = ({ groupedFeatures, activeFeature }) => {
93 // Find which group contains the active feature
94 const currentGroup = groupedFeatures.find((group) =>
95 group.features.some((feature) => feature.slug === activeFeature),
96 );
97
98 if (!currentGroup) {
99 return null;
100 }
101
102 return (
103 <Panel className="ep-dashboard-panel">
104 <PanelBody>
105 <div className="ep-dashboard-tabs">
106 <div className="ep-dashboard-tabs-nav">
107 {currentGroup.features.map(({ slug, shortTitle, title }) => (
108 <NavigationTab
109 key={slug}
110 slug={slug}
111 title={shortTitle || title || slug}
112 to={`/${slug}`}
113 isActive={activeFeature === slug}
114 />
115 ))}
116 </div>
117 </div>
118 </PanelBody>
119 </Panel>
120 );
121 };
122
123 /**
124 * Feature settings dashboard app content, using react-router-dom for navigation.
125 *
126 * @returns {WPElement} Feature Settings component
127 */
128 const FeatureSettingsContent = () => {
129 const { createNotice } = useSettingsScreen();
130 const {
131 features,
132 isBusy,
133 isModified,
134 isSyncing,
135 isSyncRequired,
136 resetSettings,
137 saveSettings,
138 setIsSyncing,
139 } = useFeatureSettings();
140
141 // Get feature from URL parameters
142 const { feature } = useParams();
143
144 /**
145 * URL to start a sync.
146 */
147 const syncNowUrl = useMemo(() => {
148 const url = new URL(syncUrl);
149 url.searchParams.append('do_sync', 'features');
150 url.searchParams.append('ep_sync_nonce', syncNonce);
151 return url.toString();
152 }, []);
153
154 /**
155 * Generic error notice.
156 */
157 const errorNotice = __('Could not save feature settings. Please try again.', 'elasticpress');
158
159 /**
160 * Action when a sync is in progress.
161 */
162 const isSyncingActions = [
163 {
164 url: syncUrl,
165 label: __('View sync status', 'elasticpress'),
166 },
167 ];
168
169 /**
170 * Notice when a sync is in progress.
171 */
172 const isSyncingNotice = __('Cannot save settings while a sync is in progress.', 'elasticpress');
173
174 /**
175 * Reset notice.
176 */
177 const resetNotice = __('Changes to feature settings discarded.', 'elasticpress');
178
179 /**
180 * Action when syncing later.
181 */
182 const syncLaterActions = [
183 {
184 url: syncNowUrl,
185 label: __('Sync', 'elasticpress'),
186 },
187 ];
188
189 /**
190 * Prompt when syncing later.
191 */
192 const syncLaterConfirm = __(
193 'If you choose to sync later some settings changes may not take effect until the sync is performed. Save and sync later?',
194 'elasticpress',
195 );
196
197 /**
198 * Prompt when syncing now.
199 */
200 const syncNowConfirm = __(
201 'Saving these settings will begin re-syncing your content. Save and sync now?',
202 'elasticpress',
203 );
204
205 /**
206 * Notice when syncing now.
207 */
208 const syncNowNotice = __('Feature settings saved. Starting sync…', 'elasticpress');
209
210 /**
211 * Success notice.
212 */
213 const successNotice = __('Feature settings saved.', 'elasticpress');
214
215 /**
216 * Whether the user has chosen to sync later when saving.
217 */
218 const [willSyncLater, setWillSyncLater] = useState(false);
219
220 /**
221 * Group visible features by their group property using centralized featureGroups
222 */
223 const groupedFeatures = useMemo(() => {
224 const groupSlugs = Object.keys(featureGroups || {});
225
226 // Map group slugs to group info (label, slug)
227 const groups = groupSlugs.map((slug) => ({
228 title: featureGroups[slug].label,
229 groupSlug: slug,
230 features: [],
231 }));
232
233 // Map for quick lookup
234 const groupMap = groups.reduce((acc, group) => {
235 acc[group.groupSlug] = group;
236 return acc;
237 }, {});
238
239 // Features with a valid group
240 features.forEach((feature) => {
241 if (feature.isVisible && feature.group && featureGroups[feature.group]) {
242 groupMap[feature.group].features.push(feature);
243 }
244 });
245
246 // Remove empty groups
247 const nonEmptyGroups = groups.filter((g) => g.features.length > 0);
248
249 // Features with no group or unknown group
250 const otherFeatures = features.filter(
251 (f) => f.isVisible && (!f.group || !featureGroups[f.group]),
252 );
253
254 if (otherFeatures.length > 0) {
255 nonEmptyGroups.push({
256 title: __('Other', 'elasticpress'),
257 groupSlug: 'other',
258 features: otherFeatures,
259 });
260 }
261
262 return nonEmptyGroups;
263 }, [features]);
264
265 /**
266 * Error handler.
267 *
268 * @param {Error} e Error object.
269 */
270 const onError = (e) => {
271 if (e.data === 'is_syncing') {
272 createNotice('error', isSyncingNotice, {
273 actions: isSyncingActions,
274 });
275 setIsSyncing(true);
276 return;
277 }
278
279 const errorMessage = `${__(
280 'ElasticPress: Could not save feature settings.',
281 'elasticpress',
282 )}\n${e.message}`;
283
284 console.error(errorMessage); // eslint-disable-line no-console
285
286 createNotice('error', errorNotice);
287 };
288
289 /**
290 * Form submission event handler.
291 *
292 * @param {Event} event Submit event.
293 * @returns {void}
294 */
295 const onSubmit = async (event) => {
296 event.preventDefault();
297
298 if (isSyncRequired) {
299 // eslint-disable-next-line no-alert
300 if (!window.confirm(syncNowConfirm)) {
301 return;
302 }
303 }
304
305 setWillSyncLater(false);
306
307 try {
308 await saveSettings();
309
310 if (isSyncRequired) {
311 createNotice('success', syncNowNotice);
312
313 // Use window.location for full page redirect to sync URL
314 window.location = syncNowUrl;
315 } else {
316 createNotice('success', successNotice);
317 }
318 } catch (e) {
319 onError(e);
320 }
321 };
322
323 /**
324 * Save and sync later button click event.
325 *
326 * @returns {void}
327 */
328 const onClickSyncLater = async () => {
329 // eslint-disable-next-line no-alert
330 if (!window.confirm(syncLaterConfirm)) {
331 return;
332 }
333
334 setWillSyncLater(true);
335
336 try {
337 await saveSettings(false);
338
339 createNotice('success', successNotice, {
340 actions: syncLaterActions,
341 });
342 } catch (e) {
343 onError(e);
344 }
345 };
346
347 /**
348 * Form reset event handler.
349 *
350 * @param {Event} event Reset event.
351 * @returns {void}
352 */
353 const onReset = (event) => {
354 event.preventDefault();
355
356 resetSettings();
357
358 createNotice('success', resetNotice);
359 };
360
361 // Find which group contains the active feature
362 const currentGroup =
363 groupedFeatures.find((group) => group.features.some((feat) => feat.slug === feature))
364 ?.title || null;
365
366 // If we can't find the current group, use the first one
367 const activeGroup = currentGroup || groupedFeatures[0];
368
369 return (
370 <form onReset={onReset} onSubmit={onSubmit}>
371 {isSyncing ? (
372 <Notice actions={isSyncingActions} isDismissible={false} status="warning">
373 {isSyncingNotice}
374 </Notice>
375 ) : null}
376 <div className="form-grid">
377 {/* Group Navigation */}
378 <GroupNavigation groupedFeatures={groupedFeatures} activeFeature={feature} />
379
380 <div className="group-content" id={`${activeGroup}-view`}>
381 {/* Feature Navigation for the current group */}
382 <FeatureNavigation groupedFeatures={groupedFeatures} activeFeature={feature} />
383
384 {/* Feature Content based on route parameters */}
385 <div className="ep-dashboard-content" id={`${feature}-view`}>
386 {feature ? (
387 <Feature feature={feature} />
388 ) : (
389 <Notice status="info" isDismissible={false}>
390 {__('Select a feature above.', 'elasticpress')}
391 </Notice>
392 )}
393 </div>
394 </div>
395 </div>
396 {isSyncing && (
397 <Notice actions={isSyncingActions} isDismissible={false} status="warning">
398 {isSyncingNotice}
399 </Notice>
400 )}
401 <Flex justify="start">
402 <FlexItem>
403 <Button
404 disabled={isBusy || isSyncing}
405 isBusy={isBusy && !willSyncLater}
406 type="submit"
407 variant="primary"
408 >
409 {isSyncRequired
410 ? __('Save and sync now', 'elasticpress')
411 : __('Save changes', 'elasticpress')}
412 </Button>
413 </FlexItem>
414 {isSyncRequired ? (
415 <FlexItem>
416 <Button
417 disabled={isBusy || isSyncing}
418 isBusy={isBusy && willSyncLater}
419 onClick={onClickSyncLater}
420 type="button"
421 variant="secondary"
422 >
423 {__('Save and sync later', 'elasticpress')}
424 </Button>
425 </FlexItem>
426 ) : null}
427 {isModified ? (
428 <FlexItem>
429 <Button disabled={isBusy} type="reset" variant="tertiary">
430 {__('Discard changes', 'elasticpress')}
431 </Button>
432 </FlexItem>
433 ) : null}
434 </Flex>
435 </form>
436 );
437 };
438
439 /**
440 * Root layout component with HashRouter configuration
441 *
442 * @returns {WPElement} Root component with routing
443 */
444 export default () => {
445 const { features } = useFeatureSettings();
446
447 // Determine default routes for redirects
448 const defaultRouteInfo = useMemo(() => {
449 const visibleFeatures = features.filter((f) => f.isVisible);
450
451 // Get the first visible feature as default
452 const defaultFeature = visibleFeatures[0]?.slug || '';
453 const hasFeatures = visibleFeatures.length > 0;
454
455 return { defaultFeature, hasFeatures };
456 }, [features]);
457
458 return (
459 <HashRouter>
460 <Routes>
461 {/* Main route for displaying feature settings with specific feature */}
462 <Route path="/:feature" element={<FeatureSettingsContent />} />
463
464 {/* Default redirect when accessing root or invalid URLs */}
465 {defaultRouteInfo.hasFeatures ? (
466 <Route
467 path="*"
468 element={<Navigate to={`/${defaultRouteInfo.defaultFeature}`} replace />}
469 />
470 ) : (
471 <Route
472 path="*"
473 element={
474 <Notice status="info" isDismissible={false}>
475 {__('No features available.', 'elasticpress')}
476 </Notice>
477 }
478 />
479 )}
480 </Routes>
481 </HashRouter>
482 );
483 };
484