PluginProbe
TablePress – Tables in WordPress made easy / 3.3
TablePress – Tables in WordPress made easy v3.3
3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 2.4.4 All 44 releases
tablepress / admin / js / edit / header-bar.js

header-bar.js in TablePress – Tables in WordPress made easy 3.3, at admin/js/edit/header-bar.js

571 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * JavaScript code for the "Edit" section integration of the "Header Bar".
3 *
4 * @package TablePress
5 * @subpackage Views JavaScript
6 * @author Tobias Bäthge
7 * @since 3.3.0
8 */
9
10 /**
11 * WordPress dependencies.
12 */
13 import { useCallback, useEffect, useRef, useState } from 'react';
14 import {
15 Button,
16 DropdownMenu,
17 __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
18 Icon,
19 KeyboardShortcuts,
20 MenuGroup,
21 MenuItem,
22 Modal,
23 Spinner,
24 Tooltip,
25 __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis
26 withNotices,
27 } from '@wordpress/components';
28 import { useCopyToClipboard } from '@wordpress/compose';
29 import { useDispatch } from '@wordpress/data';
30 import { applyFilters } from '@wordpress/hooks';
31 import { __, _n, sprintf } from '@wordpress/i18n';
32 import { arrowUp, blockTable, copy, download, info, moreVertical, pencil, settings, shortcode, siteLogo, trash } from '@wordpress/icons';
33 import { displayShortcut, shortcutAriaLabel } from '@wordpress/keycodes';
34 import { store as noticesStore } from '@wordpress/notices';
35
36 /**
37 * Internal dependencies.
38 */
39 import { initializeReactComponentInPortal } from '../common/react-loader';
40 import processAjaxRequest from '../common/ajax-request';
41 import { Notifications } from '../common/notifications';
42 import { TablePressIcon, TablePressIconSimple } from '../../img/tablepress-icon';
43
44 /**
45 * Saves the table changes to the server.
46 *
47 * @param {Object} props Function parameters.
48 * @param {Object} props.screenData Screen data.
49 * @param {Function} props.updateScreenData Callback to update the screen data.
50 * @param {Object} props.tableOptions Table options.
51 * @param {Object} props.tableMeta Table meta data.
52 * @param {Function} props.updateTableMeta Callback to update the table meta.
53 * @param {Function} props.noticeOperations Callbacks for working with notices.
54 * @param {Object} props.noticesStoreDispatch Dispatch function for the notices store. (Optional, only for notices created by keyboard shortcuts.)
55 */
56 const saveTableChanges = ( { screenData, updateScreenData, tableOptions, tableMeta, updateTableMeta, noticeOperations, noticesStoreDispatch } ) => {
57 // Validate input fields.
58 if ( ! applyFilters( 'tablepress.optionsValidateFields', true, tableOptions ) ) {
59 return;
60 }
61
62 // Collect information about hidden rows and columns.
63 tp.helpers.visibility.update();
64
65 // Prepare the data for the AJAX request.
66 const requestData = {
67 action: 'tablepress_save_table',
68 _ajax_nonce: tp.nonces.edit_table,
69 tablepress: {
70 id: tp.table.id,
71 new_id: tableMeta.id,
72 name: tableMeta.name,
73 description: tableMeta.description,
74 data: JSON.stringify( tp.editor.options.data ),
75 options: JSON.stringify( tableOptions ),
76 visibility: JSON.stringify( tp.table.visibility ),
77 number: {
78 rows: tp.editor.options.data.length,
79 columns: tp.editor.options.columns.length,
80 },
81 },
82 };
83
84 /**
85 * Callback for handling specifics of a successful save on this screen.
86 *
87 * @param {Object} data
88 */
89 const onSuccessfulRequest = ( data ) => {
90 // Saving was successful, so the original ID has changed to the (maybe) new ID -> we need to adjust all occurrences.
91 if ( tp.table.id !== data.table_id && window?.history?.pushState ) {
92 // Update URL, but only if the table ID changed, to not get dummy entries in the browser history.
93 window.history.pushState( '', '', window.location.href.replace( /table_id=[a-zA-Z0-9_-]+/gi, `table_id=${ data.table_id }` ) );
94 }
95
96 tp.table.id = data.table_id;
97
98 updateTableMeta( {
99 id: data.table_id,
100 lastModified: data.last_modified,
101 lastEditor: data.last_editor,
102 } );
103
104 // Update the nonces.
105 [ 'copy', 'delete', 'edit', 'preview' ].forEach( action => {
106 tp.nonces[ `${action}_table` ] = data[ `new_${action}_nonce` ];
107 } );
108
109 // Update the "Export", "Copy", "Delete", and "Preview" URLs in the screen data.
110 const updatedUrls = {};
111 updatedUrls.exportUrl = screenData.exportUrl.replace( /table_id=[a-zA-Z0-9_-]+/g, `table_id=${ data.table_id }` );
112 [ 'copy', 'delete', 'preview' ].forEach( ( action ) => {
113 updatedUrls[ `${ action }Url` ] = screenData[ `${ action }Url` ]
114 .replace( /item=[a-zA-Z0-9_-]+/g, `item=${ data.table_id }` ) // Updates both the "item" and the "return_item" parameters.
115 .replace( /&_wpnonce=[a-zA-Z0-9]+/g, `&_wpnonce=${ tp.nonces[ `${ action }_table` ] }` );
116 } );
117 updateScreenData( updatedUrls );
118
119 tp.helpers.unsaved_changes.unset();
120
121 const actionMessages = {};
122 actionMessages.success_save = __( 'The table was saved successfully.', 'tablepress' );
123 actionMessages.success_save_success_id_change = actionMessages.success_save + ' ' + __( 'The table ID was changed.', 'tablepress' );
124 actionMessages.success_save_error_id_change = actionMessages.success_save + ' ' + __( 'The table ID could not be changed, probably because the new ID is already in use!', 'tablepress' );
125
126 if ( 'success_save_error_id_change' === data.message && data.error_details ) {
127 const errorIntroduction = __( 'These errors were encountered:', 'tablepress' );
128 actionMessages.success_save_error_id_change = `<p>${ actionMessages.success_save_error_id_change }</p><p>${ errorIntroduction }</p><pre>${ data.error_details }</pre><p>`;
129 }
130
131 const notice = {
132 status: ( data.message.includes( 'error' ) ) ? 'error' : 'success',
133 content: actionMessages[ data.message ],
134 type: ( data.message.includes( 'error' ) ) ? 'notice' : 'snackbar',
135 };
136
137 return { notice };
138 };
139
140 const setBusyState = ( isBusy ) => updateScreenData( { isSaving: isBusy } );
141
142 processAjaxRequest( { requestData, onSuccessfulRequest, setBusyState, noticeOperations, noticesStoreDispatch } );
143 };
144
145 /**
146 * Shows a preview of the table.
147 *
148 * @param {Object} props Function parameters.
149 * @param {Function} props.updateScreenData Callback to update the screen data.
150 * @param {Object} props.tableOptions Table options.
151 * @param {Object} props.tableMeta Table meta data.
152 * @param {Function} props.noticeOperations Callbacks for working with notices.
153 */
154 const showPreview = ( { updateScreenData, tableOptions, tableMeta, noticeOperations } ) => {
155 // For tables without unsaved changes, directly show an externally rendered table from a URL in an iframe in a Modal.
156 if ( ! tp.made_changes ) {
157 updateScreenData( {
158 previewIsOpen: true,
159 previewSrcDoc: '',
160 } );
161 return;
162 }
163
164 /* For tables with unsaved changes, get the table preview HTML code for the iframe via AJAX. */
165
166 // Update information about hidden rows and columns.
167 tp.helpers.visibility.update();
168
169 // Prepare the data for the AJAX request.
170 const requestData = {
171 action: 'tablepress_preview_table',
172 _ajax_nonce: tp.nonces.preview_table,
173 tablepress: {
174 id: tp.table.id,
175 new_id: tableMeta.id,
176 name: tableMeta.name,
177 description: tableMeta.description,
178 data: JSON.stringify( tp.editor.options.data ),
179 options: JSON.stringify( tableOptions ),
180 visibility: JSON.stringify( tp.table.visibility ),
181 number: {
182 rows: tp.editor.options.data.length,
183 columns: tp.editor.options.columns.length,
184 },
185 },
186 };
187
188 /**
189 * Callback for handling specifics of a successful save on this screen.
190 *
191 * @param {Object} data
192 */
193 const onSuccessfulRequest = ( data ) => {
194 updateScreenData( {
195 previewIsOpen: true,
196 previewSrcDoc: `<!DOCTYPE html><html><head>${ data.head_html }</head><body>${ data.body_html }</body></html>`,
197 } );
198
199 return { notice: null };
200 };
201
202 const setBusyState = ( isBusy ) => updateScreenData( { previewIsLoading: isBusy } );
203
204 processAjaxRequest( { requestData, onSuccessfulRequest, setBusyState, noticeOperations } );
205 };
206
207 /**
208 * Returns the Confirm Delete Modal component's JSX markup.
209 *
210 * @param {Object} props Component props.
211 * @param {string} props.title Title of the modal.
212 * @param {string} props.deleteUrl URL to delete the table.
213 * @param {Function} props.closeConfirmDeleteModal Callback to close the Confirm Delete modal.
214 * @return {Object} Confirm Delete Modal component.
215 */
216 const ConfirmDeleteModal = ( { title, deleteUrl, closeConfirmDeleteModal } ) => {
217 const cancelButtonRef = useRef();
218 const deleteButtonRef = useRef();
219
220 const handleEnter = useCallback(
221 ( event ) => {
222 // Avoid triggering the action when a button is focused, as this can cause a double submission.
223 const isCancelOrDeleteButton =
224 event.target === cancelButtonRef.current ||
225 event.target === deleteButtonRef.current;
226
227 if ( ! isCancelOrDeleteButton && 'Enter' === event.key ) {
228 deleteButtonRef.current.click();
229 }
230 },
231 [],
232 );
233
234 return (
235 <Modal
236 size="medium"
237 icon={ <Icon icon={ TablePressIcon } size="36" style={ { display: 'flex', marginRight: '1rem' } } /> }
238 title={ title }
239 isDismissible={ false }
240 onKeyDown={ handleEnter }
241 onRequestClose={ closeConfirmDeleteModal }
242 >
243 <VStack spacing={ 8 }>
244 <span>
245 { _n( 'Do you really want to delete this table?', 'Do you really want to delete these tables?', 1, 'tablepress' ) }
246 <br />
247 { __( 'Deleting a table is permanent and can not be undone!', 'tablepress' ) }
248 </span>
249 <HStack alignment="right">
250 <Button
251 ref={ cancelButtonRef }
252 variant="tertiary"
253 text={ __( 'Cancel', 'tablepress' ) }
254 onClick={ closeConfirmDeleteModal }
255 />
256 <Button
257 ref={ deleteButtonRef }
258 variant="primary"
259 isDestructive={ true }
260 href={ deleteUrl }
261 text={ __( 'Delete', 'tablepress' ) }
262 onClick={ () => {
263 // Prevent onunload warning, by calling the unset method.
264 tp.helpers.unsaved_changes.unset();
265 closeConfirmDeleteModal();
266 } }
267 />
268 </HStack>
269 </VStack>
270 </Modal>
271 );
272 };
273
274 const HeaderBar = ( { noticeOperations, noticeUI, screenData, updateScreenData, tableOptions, tableMeta, updateTableMeta, features } ) => {
275 const noticesStoreDispatch = useDispatch( noticesStore );
276 const { createSuccessNotice } = noticesStoreDispatch;
277 const [ confirmDeleteModalIsOpen, setConfirmDeleteModalIsOpen ] = useState( false );
278
279 // Copy shortcode to clipboard.
280 const shortcodeText = `[${ tp.table.shortcode } id=${ tableMeta.id } /]`;
281 const copyShortcodeButtonRef = useCopyToClipboard( shortcodeText, () => {
282 createSuccessNotice(
283 __( 'Copied Shortcode to clipboard.', 'tablepress' ),
284 {
285 type: 'snackbar',
286 icon: <Icon icon={ TablePressIconSimple } />,
287 }
288 );
289 } );
290
291 const scrollToTop = () => window.scrollTo( { top: 0, behavior: 'smooth' } );
292
293 const featureModules = Object.keys( tp?.modules ?? {} )
294 .filter( ( module ) => features.includes( module ) )
295 .map( ( module ) => ( {
296 module,
297 name: tp.modules[ module ].name,
298 } ) )
299 .sort( ( a, b ) => a.name.localeCompare( b.name ) );
300
301 return (
302 <VStack>
303 <HStack style={ { maxWidth: '1000px' } }>
304 <HStack alignment="left" spacing={ 3 } expanded={ false }>
305 { /* Left side: Quick Navigation, Table ID, Table name */ }
306 <DropdownMenu
307 icon={ TablePressIconSimple }
308 label={ __( 'Quick navigation', 'tablepress' ) }
309 open={ screenData.quickNavigationDropdownIsOpen }
310 onToggle={ ( newQuickNavigationDropdownIsOpen ) => updateScreenData( { quickNavigationDropdownIsOpen: newQuickNavigationDropdownIsOpen } ) }
311 toggleProps={ {
312 onDoubleClick: scrollToTop, /* Scroll to Top on Double-click. (Double-click will also close the dropdown.) */
313 shortcut: {
314 ariaLabel: shortcutAriaLabel.primary( 'j' ),
315 display: displayShortcut.primary( 'j' ),
316 },
317 } }
318 >
319 { ( { onClose: closeMenu } ) => (
320 <>
321 <MenuGroup>
322 <MenuItem
323 icon={ arrowUp }
324 onClick={ () => {
325 scrollToTop();
326 closeMenu();
327 } }
328 >
329 { __( 'Scroll to Top', 'tablepress' ) }
330 </MenuItem>
331 </MenuGroup>
332 <MenuGroup label={ featureModules.length > 0 && __( 'Common', 'tablepress' ) }>
333 {
334 [
335 { section: 'table-information', label: __( 'Table Information', 'tablepress' ), icon: info },
336 { section: 'table-data', label: __( 'Table Content', 'tablepress' ), icon: blockTable },
337 { section: 'table-manipulation', label: __( 'Table Manipulation', 'tablepress' ), icon: pencil },
338 { section: 'table-options', label: __( 'Table Options', 'tablepress' ), icon: settings },
339 { section: 'datatables-features', label: __( 'Table Features for Site Visitors', 'tablepress' ), icon: siteLogo },
340 ].map( ( { section, label, icon } ) => (
341 <MenuItem
342 key={ section }
343 icon={ icon }
344 onClick={ () => {
345 document.getElementById( `tablepress_edit-${section}` ).scrollIntoView( { behavior: 'smooth', block: 'start' } );
346 closeMenu();
347 } }
348 >
349 { label }
350 </MenuItem>
351 ) )
352 }
353 </MenuGroup>
354 { featureModules.length > 0 && (
355 <MenuGroup label={ __( 'Feature Modules', 'tablepress' ) }>
356 { featureModules.map( ( { module } ) => (
357 <MenuItem
358 key={ module }
359 onClick={ () => {
360 document.getElementById( `tablepress_edit-${module}` ).scrollIntoView( { behavior: 'smooth', block: 'start' } );
361 closeMenu();
362 } }
363 >
364 { tp.modules[ module ].name }
365 </MenuItem>
366 ) ) }
367 </MenuGroup>
368 ) }
369 </>
370 ) }
371 </DropdownMenu>
372 <span
373 className="tablepress-header-table-id"
374 style={ { flexShrink: 0 } }>
375 { sprintf( __( 'ID: %s', 'tablepress' ), tableMeta.id ) }
376 </span>
377 <Tooltip text={ sprintf( __( 'Table Name: %s', 'tablepress' ), tableMeta.name ) }>
378 <h2 className="tablepress-header-table-name">
379 { tableMeta.name }
380 </h2>
381 </Tooltip>
382 </HStack>
383 <HStack alignment="right" spacing={ 3 } expanded={ false } style={ { flexShrink: 0 } }>
384 { /* Right side: Spinner, Preview, Save Changes, Dropdown */ }
385 {
386 ( screenData.isSaving || screenData.previewIsLoading ) && (
387 <Spinner style={ { margin: 0 } } />
388 )
389 }
390 {
391 tp.screenOptions.currentUserCanPreviewTable && (
392 <Button
393 variant="secondary"
394 href={ screenData.previewUrl }
395 text={ __( 'Preview', 'tablepress' ) }
396 shortcut={ screenData.previewIsLoading ? undefined :
397 {
398 ariaLabel: shortcutAriaLabel.primary( 'p' ),
399 display: displayShortcut.primary( 'p' ),
400 }
401 }
402 isBusy={ screenData.previewIsLoading }
403 disabled={ screenData.previewIsLoading }
404 accessibleWhenDisabled={ true }
405 onClick={ ( event ) => {
406 // Open the Preview Modal if the button is clicked, except if an unmodified preview in a new tab is requested.
407 if ( tp.made_changes || ! ( event.ctrlKey || event.metaKey || event.shiftKey ) ) {
408 event.preventDefault();
409 showPreview( { updateScreenData, tableOptions, tableMeta, noticeOperations } );
410 }
411 } }
412 />
413 )
414 }
415 <Button
416 variant="primary"
417 text={ __( 'Save Changes', 'tablepress' ) }
418 shortcut={ screenData.isSaving ? undefined :
419 {
420 ariaLabel: shortcutAriaLabel.primary( 's' ),
421 display: displayShortcut.primary( 's' ),
422 }
423 }
424 isBusy={ screenData.isSaving }
425 disabled={ screenData.isSaving }
426 accessibleWhenDisabled={ true }
427 onClick={ () => saveTableChanges( { screenData, updateScreenData, tableOptions, tableMeta, updateTableMeta, noticeOperations, noticesStoreDispatch } ) }
428 />
429 <DropdownMenu
430 icon={ moreVertical }
431 label={ __( 'More actions', 'tablepress' ) }
432 >
433 { ( { onClose: closeMenu } ) => (
434 <>
435 <MenuGroup>
436 <MenuItem
437 ref={ copyShortcodeButtonRef }
438 icon={ shortcode }
439 info={ shortcodeText }
440 onClick={ () => closeMenu() }
441 >
442 { __( 'Copy Shortcode', 'tablepress' ) }
443 </MenuItem>
444 </MenuGroup>
445 { ( tp.screenOptions.currentUserCanCopyTable || tp.screenOptions.currentUserCanExportTable ) && (
446 <MenuGroup>
447 { tp.screenOptions.currentUserCanCopyTable && (
448 <MenuItem
449 icon={ copy }
450 href={ screenData.copyUrl }
451 >
452 { __( 'Copy Table', 'tablepress' ) }
453 </MenuItem>
454 ) }
455 { tp.screenOptions.currentUserCanExportTable && (
456 <MenuItem
457 icon={ download }
458 href={ screenData.exportUrl }
459 >
460 { __( 'Export Table', 'tablepress' ) }
461 </MenuItem>
462 ) }
463 </MenuGroup>
464 ) }
465 { tp.screenOptions.currentUserCanDeleteTable && (
466 <MenuGroup>
467 <MenuItem
468 icon={ trash }
469 isDestructive={ true }
470 href={ screenData.deleteUrl }
471 onClick={ ( event ) => {
472 setConfirmDeleteModalIsOpen( true );
473 event.preventDefault();
474 closeMenu();
475 } }
476 >
477 { __( 'Delete Table', 'tablepress' ) }
478 </MenuItem>
479 </MenuGroup>
480 ) }
481 </>
482 ) }
483 </DropdownMenu>
484 </HStack>
485 </HStack>
486 { noticeUI }
487 { confirmDeleteModalIsOpen && (
488 <ConfirmDeleteModal
489 title={ sprintf( __( 'Delete “%1$s” (ID %2$s)', 'tablepress' ), tableMeta.name, tableMeta.id ) }
490 deleteUrl={ screenData.deleteUrl }
491 closeConfirmDeleteModal={ () => setConfirmDeleteModalIsOpen( false ) }
492 />
493 ) }
494 </VStack>
495 );
496 };
497
498 // A copy of the section to be used for the bottom buttons, with keyboard shortcuts.
499 const HeaderBarWithKeyboardShortcutsWithNotices = withNotices( ( props ) => {
500 const noticesStoreDispatch = useDispatch( noticesStore );
501
502 const saveChangesCallback = ( event ) => {
503 event.preventDefault();
504 // Blur the focussed element to make sure that all `change` and `blur` events were triggered.
505 document.activeElement.blur(); // eslint-disable-line @wordpress/no-global-active-element
506 // Don't save changes directly, but trigger a save on the next render.
507 props.updateScreenData( { triggerSaveChanges: true } );
508 };
509
510 /*
511 * Save changes when the `triggerSaveChanges` screen data option is set.
512 * This ensures that the save function receives the latest data, after all `change` and `blur` events were triggered.
513 */
514 useEffect( () => {
515 if ( props.screenData.triggerSaveChanges ) {
516 props.updateScreenData( { triggerSaveChanges: false } );
517 saveTableChanges( { ...props, noticesStoreDispatch } );
518 }
519 }, [ props, noticesStoreDispatch ] );
520
521 const openNavigationDropdownCallback = ( event ) => {
522 event.preventDefault();
523 props.updateScreenData( { quickNavigationDropdownIsOpen: ! props.screenData.quickNavigationDropdownIsOpen } );
524 };
525
526 const shortcuts = {
527 'mod+s': saveChangesCallback,
528 'mod+j': openNavigationDropdownCallback,
529 };
530
531 if ( tp.screenOptions.currentUserCanPreviewTable ) {
532 const showPreviewCallback = ( event ) => {
533 event.preventDefault();
534 // Blur the focussed element to make sure that all `change` and `blur` events were triggered.
535 document.activeElement.blur(); // eslint-disable-line @wordpress/no-global-active-element
536 // Don't load the preview directly, but trigger it on the next render.
537 props.updateScreenData( { triggerPreview: true } );
538 };
539
540 /*
541 * Trigger the preview when the `triggerPreview` screen data option is set.
542 * This ensures that the save function receives the latest data, after all `change` and `blur` events were triggered.
543 */
544 useEffect( () => {
545 if ( props.screenData.triggerPreview ) {
546 props.updateScreenData( { triggerPreview: false } );
547 showPreview( props );
548 }
549 }, [ props ] );
550
551 shortcuts[ 'mod+p' ] = showPreviewCallback;
552 }
553
554 return (
555 <>
556 <HeaderBar { ...props } />
557 <KeyboardShortcuts
558 bindGlobal={ true }
559 shortcuts={ shortcuts }
560 />
561 <Notifications />
562 </>
563 );
564 } );
565
566 initializeReactComponentInPortal(
567 'header-bar',
568 'edit',
569 HeaderBarWithKeyboardShortcutsWithNotices,
570 );
571