PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 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 All 45 releases
tablepress / admin / js / edit / header-bar.js

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

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