| 1 |
/** |
| 2 |
* JavaScript code for the "Import Screen" component. |
| 3 |
* |
| 4 |
* @package TablePress |
| 5 |
* @subpackage Import Screen |
| 6 |
* @author Tobias Bäthge |
| 7 |
* @since 2.2.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
/* globals tp */ |
| 11 |
|
| 12 |
/** |
| 13 |
* WordPress dependencies. |
| 14 |
*/ |
| 15 |
import { useEffect, useRef, useState } from 'react'; |
| 16 |
import { __, _n, _x, sprintf } from '@wordpress/i18n'; |
| 17 |
|
| 18 |
// Details for the available import sources. |
| 19 |
const importSources = { |
| 20 |
'file-upload': { |
| 21 |
label: __( 'File Upload', 'tablepress' ), |
| 22 |
instruction: __( 'Select files', 'tablepress' ), |
| 23 |
}, |
| 24 |
url: { |
| 25 |
label: __( 'URL', 'tablepress' ), |
| 26 |
instruction: __( 'File URL', 'tablepress' ), |
| 27 |
}, |
| 28 |
server: { |
| 29 |
label: __( 'File on server', 'tablepress' ), |
| 30 |
instruction: __( 'Server Path to file', 'tablepress' ), |
| 31 |
}, |
| 32 |
'form-field': { |
| 33 |
label: __( 'Manual Input', 'tablepress' ), |
| 34 |
instruction: __( 'Import data', 'tablepress' ), |
| 35 |
}, |
| 36 |
}; |
| 37 |
if ( ! tp.import.showImportSourceServer ) { |
| 38 |
delete importSources.server; |
| 39 |
} |
| 40 |
|
| 41 |
// Number of tables. |
| 42 |
const tablesCount = Object.keys( tp.import.tables ).length; |
| 43 |
|
| 44 |
// The <option> entries for the dropdown do not depend on the state, so they can be created once. |
| 45 |
const tablesSelectOptions = Object.entries( tp.import.tables ).map( ( [ tableId, tableName ] ) => { |
| 46 |
if ( '' === tableName.trim() ) { |
| 47 |
tableName = __( '(no name)', 'tablepress' ); |
| 48 |
} |
| 49 |
const optionText = sprintf( __( 'ID %1$s: %2$s', 'tablepress' ), tableId, tableName ); |
| 50 |
return <option key={ tableId } value={ tableId }>{ optionText }</option>; |
| 51 |
} ); |
| 52 |
|
| 53 |
/** |
| 54 |
* Returns the "Import Screen" component's JSX markup. |
| 55 |
* |
| 56 |
* @return {Object} Import Screen component. |
| 57 |
*/ |
| 58 |
const ImportScreen = () => { |
| 59 |
const [ screenData, setScreenData ] = useState( { |
| 60 |
importSource: tp.import.importSource, |
| 61 |
importType: tp.import.importType, |
| 62 |
importFileUpload: [], |
| 63 |
importUrl: tp.import.importUrl, |
| 64 |
importServer: tp.import.importServer, |
| 65 |
importFormField: tp.import.importFormField, |
| 66 |
importExistingTable: tp.import.importExistingTable, |
| 67 |
validationHighlighting: false, |
| 68 |
} ); |
| 69 |
|
| 70 |
/** |
| 71 |
* Handles screen data state changes. |
| 72 |
* |
| 73 |
* @param {string} item Configuration item name. |
| 74 |
* @param {string|null} value Value for configuration item. |
| 75 |
*/ |
| 76 |
const updateScreenData = ( item, value ) => { |
| 77 |
const newScreenData = { |
| 78 |
...screenData, |
| 79 |
validationHighlighting: false, // Reset with every UI state change. |
| 80 |
[ item ]: value, |
| 81 |
}; |
| 82 |
setScreenData( newScreenData ); |
| 83 |
}; |
| 84 |
|
| 85 |
// References to DOM elements. |
| 86 |
const importServerInput = useRef( null ); |
| 87 |
const appendReplaceDropdown = useRef( null ); |
| 88 |
const fileUploadDropzone = useRef( null ); |
| 89 |
|
| 90 |
// Initialize the jSuites dropdown when the component is mounted. |
| 91 |
useEffect( () => { |
| 92 |
jSuites.dropdown( appendReplaceDropdown.current, { |
| 93 |
autocomplete: true, |
| 94 |
placeholder: __( '— Select or type —', 'tablepress' ), |
| 95 |
onchange: ( element, index, oldValue, newValue ) => { |
| 96 |
// Directly update the state with an updater function, as the state is otherwise reset. |
| 97 |
setScreenData( ( newScreenData ) => ( { |
| 98 |
...newScreenData, |
| 99 |
validationHighlighting: false, |
| 100 |
importExistingTable: newValue, |
| 101 |
} ) ); |
| 102 |
}, |
| 103 |
} ); |
| 104 |
}, [] ); |
| 105 |
|
| 106 |
// Update the validation highlighting (using APIs and DOM elements outside of the React components) when the state changes. |
| 107 |
useEffect( () => { |
| 108 |
document.getElementById( 'tablepress_import-import-form' ).classList.toggle( 'no-validation-highlighting', ! screenData.validationHighlighting ); |
| 109 |
if ( ! screenData.validationHighlighting ) { |
| 110 |
importServerInput.current?.setCustomValidity( '' ); |
| 111 |
appendReplaceDropdown.current.previousElementSibling.querySelector( '.jdropdown-header' )?.setCustomValidity( '' ); |
| 112 |
} |
| 113 |
}, [ screenData.validationHighlighting ] ); |
| 114 |
|
| 115 |
// Determine calculated state variables to avoid repeating calculations. |
| 116 |
const fileUploadMultipleFilesChosen = ( |
| 117 |
'file-upload' === screenData.importSource |
| 118 |
&& ( |
| 119 |
1 < screenData.importFileUpload.length |
| 120 |
|| ( |
| 121 |
1 === screenData.importFileUpload.length |
| 122 |
&& screenData.importFileUpload[0].name.endsWith( '.zip' ) |
| 123 |
) |
| 124 |
) |
| 125 |
); |
| 126 |
const appendReplaceDropdownDisabled = ( |
| 127 |
0 === tablesCount |
| 128 |
|| 'add' === screenData.importType |
| 129 |
|| fileUploadMultipleFilesChosen |
| 130 |
); |
| 131 |
|
| 132 |
// Disable the artificial dropdown (not inserted by React) via a class, as it can not use :disabled. |
| 133 |
useEffect( () => { |
| 134 |
appendReplaceDropdown.current.previousElementSibling.classList.toggle( 'disabled', appendReplaceDropdownDisabled ); |
| 135 |
}, [ appendReplaceDropdownDisabled ] ); |
| 136 |
|
| 137 |
return ( |
| 138 |
<table className="tablepress-postbox-table fixed"> |
| 139 |
<tbody> |
| 140 |
<tr> |
| 141 |
<th className="column-1" scope="row" id="import-source-header"> |
| 142 |
{ __( 'Import Source', 'tablepress' ) }: |
| 143 |
</th> |
| 144 |
<td className="column-2"> |
| 145 |
{ |
| 146 |
Object.entries( importSources ) .map( ( [ importSource, importSourceData ] ) => ( |
| 147 |
<label |
| 148 |
key={ importSource } |
| 149 |
htmlFor={ `tables-import-source-${ importSource }` } |
| 150 |
> |
| 151 |
<input |
| 152 |
name="import[source]" |
| 153 |
id={ `tables-import-source-${ importSource }` } |
| 154 |
type="radio" |
| 155 |
aria-labelledby="import-source-header" |
| 156 |
value={ importSource } |
| 157 |
checked={ importSource === screenData.importSource } |
| 158 |
onChange={ ( event ) => updateScreenData( 'importSource', event.target.value ) } |
| 159 |
/> { importSourceData.label } |
| 160 |
</label> |
| 161 |
) ) |
| 162 |
} |
| 163 |
</td> |
| 164 |
</tr> |
| 165 |
<tr className="top-border bottom-border"> |
| 166 |
<th className="column-1 top-align" scope="row"> |
| 167 |
<label htmlFor={ `tables-import-${ screenData.importSource }` }> |
| 168 |
{ importSources[ screenData.importSource ].instruction }: |
| 169 |
</label> |
| 170 |
</th> |
| 171 |
<td className="column-2"> |
| 172 |
{ |
| 173 |
/* |
| 174 |
* Always add the "File Upload" UI to the DOM, but hide it using `style="display: none;"` below. |
| 175 |
* This ensures that the <input type="file"> field works, as that is "uncontrolled" in React, and setting its value (files) is not possible. |
| 176 |
*/ |
| 177 |
} |
| 178 |
<div |
| 179 |
className="file-upload-area" |
| 180 |
style={ { |
| 181 |
display: ( 'file-upload' === screenData.importSource ) ? 'block' : 'none', |
| 182 |
} } |
| 183 |
> |
| 184 |
<input |
| 185 |
name="import_file_upload[]" |
| 186 |
id="tables-import-file-upload" |
| 187 |
type="file" |
| 188 |
multiple |
| 189 |
required={ 'file-upload' === screenData.importSource } |
| 190 |
onChange={ ( event ) => ( event.target.files && updateScreenData( 'importFileUpload', event.target.files ) ) } |
| 191 |
onDragEnter={ () => fileUploadDropzone.current.classList.add( 'dragover' ) } |
| 192 |
onDragLeave={ () => fileUploadDropzone.current.classList.remove( 'dragover' ) } |
| 193 |
/> |
| 194 |
<div |
| 195 |
ref={ fileUploadDropzone } |
| 196 |
className="dropzone" |
| 197 |
> |
| 198 |
<span> |
| 199 |
{ 0 === screenData.importFileUpload.length && __( 'Click to select files, or drag them here.', 'tablepress' ) } |
| 200 |
{ 0 < screenData.importFileUpload.length && sprintf( _n( 'You have selected %1$d file:', 'You have selected %1$d files:', screenData.importFileUpload.length, 'tablepress' ), screenData.importFileUpload.length ) } |
| 201 |
</span> |
| 202 |
{ |
| 203 |
[ ...screenData.importFileUpload ].map( ( file ) => |
| 204 |
<span key={ file.name }>{ file.name }</span> |
| 205 |
) |
| 206 |
} |
| 207 |
</div> |
| 208 |
</div> |
| 209 |
{ 'url' === screenData.importSource && |
| 210 |
<input |
| 211 |
type="url" |
| 212 |
name="import[url]" |
| 213 |
id="tables-import-url" |
| 214 |
className="large-text code" |
| 215 |
required={ true } |
| 216 |
value={ screenData.importUrl } |
| 217 |
onChange={ ( event ) => updateScreenData( 'importUrl', event.target.value ) } |
| 218 |
/> |
| 219 |
} |
| 220 |
{ tp.import.showImportSourceServer && 'server' === screenData.importSource && |
| 221 |
<input |
| 222 |
ref={ importServerInput } |
| 223 |
type="text" |
| 224 |
name="import[server]" |
| 225 |
id="tables-import-server" |
| 226 |
className="large-text code" |
| 227 |
required={ true } |
| 228 |
value={ screenData.importServer } |
| 229 |
onChange={ ( event ) => updateScreenData( 'importServer', event.target.value ) } |
| 230 |
/> |
| 231 |
} |
| 232 |
{ 'form-field' === screenData.importSource && |
| 233 |
<textarea |
| 234 |
name="import[form-field]" |
| 235 |
id="tables-import-form-field" |
| 236 |
rows="15" |
| 237 |
cols="40" |
| 238 |
className="large-text code" |
| 239 |
required={ true } |
| 240 |
value={ screenData.importFormField } |
| 241 |
onChange={ ( event ) => updateScreenData( 'importFormField', event.target.value ) } |
| 242 |
/> |
| 243 |
} |
| 244 |
{ tp.import.zipSupportAvailable && 'form-field' !== screenData.importSource && |
| 245 |
<span className="description"> |
| 246 |
{ __( 'You can also import multiple tables by placing them in a ZIP file.', 'tablepress' ) } |
| 247 |
</span> |
| 248 |
} |
| 249 |
</td> |
| 250 |
</tr> |
| 251 |
<tr className="top-border"> |
| 252 |
<th className="column-1" scope="row" id="import-type-header"> |
| 253 |
{ __( 'Add, Replace, or Append?', 'tablepress' ) }: |
| 254 |
</th> |
| 255 |
<td className="column-2"> |
| 256 |
<label htmlFor="tables-import-type-add"> |
| 257 |
<input |
| 258 |
name="import[type]" |
| 259 |
id="tables-import-type-add" |
| 260 |
type="radio" |
| 261 |
aria-labelledby="import-type-header" |
| 262 |
value="add" |
| 263 |
checked={ 'add' === screenData.importType || 0 === tablesCount } |
| 264 |
onChange={ ( event ) => updateScreenData( 'importType', event.target.value ) } |
| 265 |
/> { __( 'Add as new table', 'tablepress' ) } |
| 266 |
</label> |
| 267 |
<label htmlFor="tables-import-type-replace"> |
| 268 |
<input |
| 269 |
name="import[type]" |
| 270 |
id="tables-import-type-replace" |
| 271 |
type="radio" |
| 272 |
aria-labelledby="import-type-header" |
| 273 |
value="replace" |
| 274 |
disabled={ 0 === tablesCount } |
| 275 |
checked={ 'replace' === screenData.importType } |
| 276 |
onChange={ ( event ) => updateScreenData( 'importType', event.target.value ) } |
| 277 |
/> { __( 'Replace existing table', 'tablepress' ) } |
| 278 |
</label> |
| 279 |
<label htmlFor="tables-import-type-append"> |
| 280 |
<input |
| 281 |
name="import[type]" |
| 282 |
id="tables-import-type-append" |
| 283 |
type="radio" |
| 284 |
aria-labelledby="import-type-header" |
| 285 |
value="append" |
| 286 |
disabled={ 0 === tablesCount } |
| 287 |
checked={ 'append' === screenData.importType } |
| 288 |
onChange={ ( event ) => updateScreenData( 'importType', event.target.value ) } |
| 289 |
/> { __( 'Append rows to existing table', 'tablepress' ) } |
| 290 |
</label> |
| 291 |
</td> |
| 292 |
</tr> |
| 293 |
<tr className="top-border bottom-border"> |
| 294 |
<th className="column-1" scope="row"> |
| 295 |
<label htmlFor="tables-import-existing-table"> |
| 296 |
{ __( 'Table to replace or append to', 'tablepress' ) }: |
| 297 |
</label> |
| 298 |
</th> |
| 299 |
<td className="column-2"> |
| 300 |
<select |
| 301 |
ref={ appendReplaceDropdown } |
| 302 |
id="tables-import-existing-table" |
| 303 |
name="import[existing_table]" |
| 304 |
disabled={ appendReplaceDropdownDisabled } |
| 305 |
value={ screenData.importExistingTable } |
| 306 |
> |
| 307 |
<option value=""> |
| 308 |
{ |
| 309 |
' ' // Use a space as an empty string will be printed as ` ` by jSuites. |
| 310 |
} |
| 311 |
</option> |
| 312 |
{ tablesSelectOptions } |
| 313 |
</select> |
| 314 |
</td> |
| 315 |
</tr> |
| 316 |
<tr className="top-border"> |
| 317 |
<td className="column-1"></td> |
| 318 |
<td className="column-2"> |
| 319 |
<input |
| 320 |
type="hidden" |
| 321 |
name="import[legacy_import]" |
| 322 |
value={ tp.import.legacyImport } |
| 323 |
/> |
| 324 |
<input |
| 325 |
type="submit" |
| 326 |
value={ _x( 'Import', 'button', 'tablepress' ) } |
| 327 |
className="button button-primary button-large" |
| 328 |
id="import-submit-button" |
| 329 |
onClick={ () => { |
| 330 |
// Show validation :invalid CSS pseudo-selector highlighting. |
| 331 |
updateScreenData( 'validationHighlighting', true ); |
| 332 |
|
| 333 |
// When importing from the server, the value must have been changed from the default (normally ABSPATH). |
| 334 |
if ( 'server' === screenData.importSource && tp.import.importServer === screenData.importServer ) { |
| 335 |
importServerInput.current.setCustomValidity( __( 'You must specify a path to a file on the server.', 'tablepress' ) ); |
| 336 |
} |
| 337 |
|
| 338 |
// If the table selection dropdown for replace or append is enabled, a table must be selected. |
| 339 |
if ( ! appendReplaceDropdownDisabled && '' === screenData.importExistingTable ) { |
| 340 |
// Use the jSuites dropdown input field, as the actual <select> is hidden. |
| 341 |
appendReplaceDropdown.current.previousElementSibling.querySelector( '.jdropdown-header' ).setCustomValidity( __( 'You must select a table.', 'tablepress' ) ); |
| 342 |
} |
| 343 |
} } |
| 344 |
/> |
| 345 |
</td> |
| 346 |
</tr> |
| 347 |
</tbody> |
| 348 |
</table> |
| 349 |
); |
| 350 |
}; |
| 351 |
|
| 352 |
export default ImportScreen; |
| 353 |
|