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