diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d5a36f2..9004c1732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Gravity PDF +### 6.17.0 +* ๐ŸŽ‰ Feature: Install multiple PDF templates at once by selecting or dropping several zip files together +* ๐ŸŽ‰ Feature: Drop PDF template zip files anywhere in the Template Manager window to install them +* ๐ŸŽ‰ Feature: Accept PDF template zips with the templates nested inside a folder, which is what you get when re-zipping a Safari download +* ๐Ÿ”’ Security: Harden PDF template and custom font uploads by upgrading the bundled upload library to 4.0 +* ๐Ÿงน Housekeeping: Raise the PDF template upload limit from 10MB to 32MB, capped by the server's own upload limit +* ๐Ÿ’ป Developer: Add `gfpdf_template_max_upload_size` filter to change the maximum PDF template zip size + ### 6.16.0 * ๐ŸŽ‰ Feature: Gravity Forms 3.0 compatibility * ๐ŸŽ‰ Feature: Support the Gravity Forms 3.0 International (formatted) phone number format, displayed with the country and dial code in front of the number diff --git a/composer.json b/composer.json index 888c85c3d..d36793176 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,11 @@ "url": "https://github.com/GravityPDF/url-signer" }, + { + "type": "vcs", + "url": "https://github.com/GravityPDF/Upload" + }, + { "type": "composer", "url": "https://composer.gravity.io", @@ -38,7 +43,7 @@ "spatie/url-signer": "^1.1", "mpdf/qrcode": "^1.0", "gravitypdf/querypath": "^4.0", - "gravitypdf/upload": "^3.0" + "gravitypdf/upload": "dev-hardening" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", diff --git a/composer.lock b/composer.lock index 6fb032d60..f7a83d2a7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "45c670075d119a64c3e7b101b78db661", + "content-hash": "e2f695d52575759449906f3ee12c5ada", "packages": [ { "name": "gravitypdf/querypath", @@ -72,16 +72,16 @@ }, { "name": "gravitypdf/upload", - "version": "3.1.0", + "version": "dev-hardening", "source": { "type": "git", "url": "https://github.com/GravityPDF/Upload.git", - "reference": "7fff246f86d9b8f6277a0561ce28d2c229633e49" + "reference": "a3e2bc758266efe0ac377562f9b1ceb78d863bf5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GravityPDF/Upload/zipball/7fff246f86d9b8f6277a0561ce28d2c229633e49", - "reference": "7fff246f86d9b8f6277a0561ce28d2c229633e49", + "url": "https://api.github.com/repos/GravityPDF/Upload/zipball/a3e2bc758266efe0ac377562f9b1ceb78d863bf5", + "reference": "a3e2bc758266efe0ac377562f9b1ceb78d863bf5", "shasum": "" }, "require": { @@ -94,16 +94,35 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/phpstan": "^1.8", - "squizlabs/php_codesniffer": "*", + "squizlabs/php_codesniffer": "^4.0", "yoast/phpunit-polyfills": "^1.0" }, + "suggest": { + "ext-mbstring": "Truncates sanitized filenames on a character boundary and guarantees they are valid UTF-8" + }, "type": "library", "autoload": { "psr-4": { "GravityPdf\\Upload\\": "src/Upload" } }, - "notification-url": "https://packagist.org/downloads/", + "scripts": { + "check-syntax": [ + "@php ./vendor/bin/parallel-lint --exclude .git --exclude vendor ." + ], + "lint": [ + "@php ./vendor/bin/phpcs --standard=PSR12 ./src/ ./tests/" + ], + "lint:fix": [ + "@php ./vendor/bin/phpcbf --standard=PSR12 ./src/ ./tests/" + ], + "phpstan": [ + "@php ./vendor/bin/phpstan analyse -l 9 src tests" + ], + "phpunit": [ + "@php ./vendor/bin/phpunit" + ] + }, "license": [ "MIT" ], @@ -129,7 +148,7 @@ "issues": "https://github.com/gravitypdf/upload/issues", "source": "https://github.com/gravitypdf/upload" }, - "time": "2026-03-11T04:07:56+00:00" + "time": "2026-08-04T01:33:18+00:00" }, { "name": "league/uri", @@ -4895,6 +4914,7 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "gravitypdf/upload": 20, "mpdf/mpdf": 20, "roave/security-advisories": 20 }, diff --git a/src/Helper/Fonts/LocalFile.php b/src/Helper/Fonts/LocalFile.php index e78268ca5..e89d615e1 100644 --- a/src/Helper/Fonts/LocalFile.php +++ b/src/Helper/Fonts/LocalFile.php @@ -26,6 +26,10 @@ class LocalFile extends File { * @since 6.4 */ public function isValid(): bool { + /* Reset rather than append, matching the parent: upload() calls isValid() again, so the + `if ( ! $file->isValid() ) {โ€ฆ} $file->upload();` pattern would otherwise double every error */ + $this->errors = $this->constructorErrors; + foreach ( $this->objects as $fileInfo ) { $this->applyCallback( 'beforeValidationCallback', $fileInfo ); foreach ( $this->validations as $validation ) { diff --git a/src/Helper/Helper_Data.php b/src/Helper/Helper_Data.php index a1cd4f762..08168a961 100644 --- a/src/Helper/Helper_Data.php +++ b/src/Helper/Helper_Data.php @@ -214,6 +214,7 @@ public function addon_license_responses( $addon_name ) { public function get_localised_script_data( Helper_Abstract_Options $options, Helper_Abstract_Form $gform ) { $custom_fonts = array_values( $options->get_custom_fonts() ); + $max_upload_size = Helper_Templates::get_max_upload_size(); $user_data = get_userdata( get_current_user_id() ); $user_capabilities = is_object( $user_data ) ? $user_data->allcaps : []; $user_capabilities = is_super_admin() ? [ 'administrator' => true ] : $user_capabilities; @@ -282,7 +283,11 @@ public function get_localised_script_data( Helper_Abstract_Options $options, Hel 'showPreviousTemplate' => esc_html__( 'Show previous template', 'gravity-pdf' ), 'showNextTemplate' => esc_html__( 'Show next template', 'gravity-pdf' ), 'uploadInvalidNotZipFile' => esc_html__( 'Upload is not a valid template. Upload a .zip file.', 'gravity-pdf' ), - 'uploadInvalidExceedsFileSizeLimit' => esc_html__( 'Upload exceeds the 10MB limit.', 'gravity-pdf' ), + /* translators: %s: the maximum upload size e.g. "32 MB" */ + 'uploadInvalidExceedsFileSizeLimit' => sprintf( esc_html__( 'Upload exceeds the %s limit.', 'gravity-pdf' ), size_format( $max_upload_size ) ), + 'templateUploadMaxSize' => $max_upload_size, + 'templateUploadDropzone' => esc_html__( 'Drop PDF template .zip files anywhere to install', 'gravity-pdf' ), + 'templateUploadInProgress' => esc_html__( 'Installing PDF template(s)...', 'gravity-pdf' ), 'templateSuccessfullyInstalled' => esc_html__( 'Template successfully installed', 'gravity-pdf' ), 'templateSuccessfullyUpdated' => esc_html__( 'Template successfully updated', 'gravity-pdf' ), 'templateSuccessfullyInstalledUpdated' => esc_html__( 'PDF Template(s) Successfully Installed / Updated', 'gravity-pdf' ), diff --git a/src/Helper/Helper_Templates.php b/src/Helper/Helper_Templates.php index 7a86d4722..b763d4e2e 100644 --- a/src/Helper/Helper_Templates.php +++ b/src/Helper/Helper_Templates.php @@ -27,6 +27,20 @@ */ class Helper_Templates { + /** + * The largest PDF template zip we'll accept, in bytes + * + * @since 6.17 + */ + public const MAX_UPLOAD_SIZE = 32 * MB_IN_BYTES; + + /** + * How many nested wrapper directories we'll look through to find the PDF templates + * + * @since 6.17 + */ + private const MAX_NESTED_DIRECTORIES = 3; + /** * Holds our log class * @@ -492,23 +506,92 @@ public function get_core_pdf_templates() { * @return array */ public function get_all_templates_in_folder( $folder ) { - try { - $dir = new \FilesystemIterator( $folder ); - $files = new \CallbackFilterIterator( + return $this->list_folder( + $folder, + function ( $current ) { + return ! $current->isDir() && $current->getExtension() === 'php'; + } + ); + } + + /** + * The maximum size, in bytes, of a PDF template zip we'll accept + * + * Clamped to the server's own upload ceiling. A POST larger than `post_max_size` is discarded by PHP + * before the request reaches us, which surfaces as a nonce failure instead of a size error. + * + * @return int + * + * @since 6.17 + */ + public static function get_max_upload_size() { + $server_limit = (int) wp_max_upload_size(); + $max_size = $server_limit > 0 ? min( static::MAX_UPLOAD_SIZE, $server_limit ) : static::MAX_UPLOAD_SIZE; + + return (int) apply_filters( 'gfpdf_template_max_upload_size', $max_size ); + } + + /** + * Find the directory holding the PDF templates inside an extracted zip + * + * Safari auto-extracts template zips on download. When the resulting folder is re-zipped the PHP files + * sit one (or more) levels deep, so we descend through single-directory wrappers until we find them. + * + * @param string $dir The directory the zip was extracted to + * + * @return string The directory containing the PDF templates, with a trailing slash + * + * @since 6.17 + */ + public function get_template_root_dir( $dir ) { + $dir = trailingslashit( $dir ); + + for ( $depth = 0; $depth < static::MAX_NESTED_DIRECTORIES; $depth++ ) { + if ( count( $this->get_all_templates_in_folder( $dir ) ) > 0 ) { + return $dir; + } + + /* Hidden directories are tooling leftovers (.git, .idea), not the template we're looking for */ + $subdirectories = $this->list_folder( $dir, function ( $current ) { - return ! $current->isDir() && $current->getExtension() === 'php'; + return $current->isDir() && $current->getFilename()[0] !== '.'; } ); - $templates = []; - foreach ( $files as $file ) { - $templates[] = $file->getPathname(); + /* Anything other than a single wrapper directory is ambiguous, so leave the path alone */ + if ( count( $subdirectories ) !== 1 ) { + return $dir; + } + + $dir = trailingslashit( $subdirectories[0] ); + } + + return $dir; + } + + /** + * Get the full paths of everything in a folder that matches a filter + * + * @param string $folder + * @param callable $filter Receives a SplFileInfo and returns whether to include it + * + * @return string[] + * + * @since 6.17 + */ + protected function list_folder( $folder, callable $filter ) { + try { + $matches = []; + + foreach ( new \CallbackFilterIterator( new \FilesystemIterator( $folder ), $filter ) as $item ) { + $matches[] = $item->getPathname(); } - return $templates; + return $matches; } catch ( \Exception $e ) { $this->log->error( $e->getMessage() ); + return []; } } diff --git a/src/Model/Model_Templates.php b/src/Model/Model_Templates.php index 3c84324b1..a92f58bbc 100644 --- a/src/Model/Model_Templates.php +++ b/src/Model/Model_Templates.php @@ -10,7 +10,7 @@ use GFPDF_Vendor\GravityPdf\Upload\File; use GFPDF_Vendor\GravityPdf\Upload\Storage\FileSystem; use GFPDF_Vendor\GravityPdf\Upload\Validation\Extension; -use GFPDF_Vendor\GravityPdf\Upload\Validation\Mimetype; +use GFPDF_Vendor\GravityPdf\Upload\Validation\FileType; use GFPDF_Vendor\GravityPdf\Upload\Validation\Size; use GPDFAPI; use GFPDF_Vendor\Psr\Log\LoggerInterface; @@ -124,7 +124,7 @@ public function ajax_process_uploaded_template() { /* Unzip and check the PDF templates look valid */ try { - $this->unzip_and_verify_templates( $zip_path ); + $unzipped_dir_name = $this->unzip_and_verify_templates( $zip_path ); } catch ( Exception $e ) { $this->cleanup_template_files( $zip_path ); @@ -149,8 +149,7 @@ public function ajax_process_uploaded_template() { } /* Copy all the files to the active PDF working directory */ - $unzipped_dir_name = $this->get_unzipped_dir_name( $zip_path ); - $template_path = $this->templates->get_template_path(); + $template_path = $this->templates->get_template_path(); $results = $this->misc->copyr( $unzipped_dir_name, $template_path ); @@ -303,22 +302,19 @@ public function move_template_to_tmp_dir( File $file ) { /* Validate our uploaded file and move to the PDF tmp directory for further processing */ $file->setName( uniqid() ); + /* FileType requires the extension and the sniffed contents to describe the same format, where + Extension and Mimetype side by side check two independent allow-lists. It sniffs, so it needs + fileinfo โ€” that should be loaded by default, but where it isn't, Extension on its own beats + refusing every upload. */ $file->addValidations( [ - new Extension( 'zip' ), - new Size( '10240K' ), /* allow 10MB upload โ€“ accounts for fonts, PDF and PHP files */ + new Size( Helper_Templates::get_max_upload_size() ), + extension_loaded( 'fileinfo' ) + ? new FileType( 'zip', [ 'application/zip', 'application/octet-stream' ] ) + : new Extension( 'zip' ), ] ); - /* Do a check to ensure fileinfo is loaded. It should be loaded by default but in some cases this isn't so */ - if ( extension_loaded( 'fileinfo' ) ) { - $file->addValidations( - [ - new Mimetype( [ 'application/zip', 'application/octet-stream' ] ), - ] - ); - } - $file->upload(); return $this->data->template_tmp_location . $file->getNameWithExtension(); @@ -342,9 +338,12 @@ public function get_unzipped_dir_name( $zip_path ) { * * @param string $zip_path The full path to the zip file * + * @return string The directory the PDF templates were found in + * * @throws Exception Thrown if a PDF template file isn't valid * * @since 4.1 + * @since 6.17 Returns the directory the PDF templates were found in */ public function unzip_and_verify_templates( $zip_path ) { $this->enable_wp_filesystem(); @@ -357,6 +356,8 @@ public function unzip_and_verify_templates( $zip_path ) { throw new Exception( esc_html( $results->get_error_message() ) ); } + $dir = $this->templates->get_template_root_dir( $dir ); + /* Check unzipped templates for a valid v4 header, or v3 string pattern. Avoid glob() here โ€” it can return a stale (empty) listing when called immediately after unzip_file() writes via the WP_Filesystem abstraction */ @@ -367,6 +368,8 @@ public function unzip_and_verify_templates( $zip_path ) { } $this->check_for_valid_pdf_templates( $files ); + + return $dir; } /** diff --git a/src/assets/js/react/actions/templates.js b/src/assets/js/react/actions/templates.js index 81e4ece53..c3171907c 100644 --- a/src/assets/js/react/actions/templates.js +++ b/src/assets/js/react/actions/templates.js @@ -239,15 +239,17 @@ export const postTemplateUploadProcessing = (file, filename) => { * Fires request template to our endpoint for processing * * @param { Object } response + * @param { string } filename The zip the response belongs to * * @return {{ type: string, payload: Object }} action object * * @since 5.2 + * @since 6.17 Added the `filename` parameter so concurrent uploads can be told apart */ -export const templateUploadProcessingSuccess = (response) => { +export const templateUploadProcessingSuccess = (response, filename = '') => { return { type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, - payload: response, + payload: { ...response, filename }, }; }; @@ -255,20 +257,22 @@ export const templateUploadProcessingSuccess = (response) => { * Fires Update/Show error * * @param { Object } error + * @param { string } filename The zip the error belongs to * * @return {{ type: string, payload: Object }} action object * * @since 5.2 + * @since 6.17 Added the `filename` parameter so concurrent uploads can be told apart */ -export const templateUploadProcessingFailed = (error) => { +export const templateUploadProcessingFailed = (error, filename = '') => { return { type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: error, + payload: { ...error, filename }, }; }; /** - * Fires to clear/reset data for templateUploadProcessingSuccess and templateUploadProcessingFailed + * Fires to clear/reset the results of the current batch of template uploads * * @return {{ type: string }} action object * diff --git a/src/assets/js/react/components/Template/TemplateList.js b/src/assets/js/react/components/Template/TemplateList.js index 4467a4ce3..55c580991 100644 --- a/src/assets/js/react/components/Template/TemplateList.js +++ b/src/assets/js/react/components/Template/TemplateList.js @@ -8,6 +8,7 @@ import TemplateListItem from './TemplateListItem'; import TemplateSearch from './TemplateSearch'; import TemplateHeaderTitle from './TemplateHeaderTitle'; import TemplateUploader from './TemplateUploader'; +import TemplateUploaderTile from './TemplateUploaderTile'; /* Selectors */ import getTemplates from '../../selectors/getTemplates'; /* Helpers */ @@ -39,8 +40,6 @@ export class TemplateList extends Component { templates: PropTypes.array, templateDetailsText: PropTypes.string, activateText: PropTypes.string, - ajaxUrl: PropTypes.string, - ajaxNonce: PropTypes.string, addTemplateText: PropTypes.string, genericUploadErrorText: PropTypes.string, filenameErrorText: PropTypes.string, @@ -49,6 +48,9 @@ export class TemplateList extends Component { installUpdatedText: PropTypes.string, templateSuccessfullyInstalledUpdated: PropTypes.string, templateInstallInstructions: PropTypes.string, + dropzoneText: PropTypes.string, + uploadInProgressText: PropTypes.string, + maxFileSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; /** @@ -60,7 +62,7 @@ export class TemplateList extends Component { GFPDF.userCapabilities.gravityforms_edit_settings || false; - return ( + const templateManager = ( + )} ); + + if (!hasUserPrivs) { + return templateManager; + } + + /* Wrap the whole Template Manager so a zip can be dropped anywhere in the window */ + return ( + + {templateManager} + + ); } } diff --git a/src/assets/js/react/components/Template/TemplateUploader.js b/src/assets/js/react/components/Template/TemplateUploader.js index c19152d67..1cfc02b56 100644 --- a/src/assets/js/react/components/Template/TemplateUploader.js +++ b/src/assets/js/react/components/Template/TemplateUploader.js @@ -2,10 +2,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import classNames from 'classnames'; import Dropzone from 'react-dropzone'; /* Components */ import ShowMessage from '../ShowMessage'; +import { TemplateUploaderContext } from './TemplateUploaderContext'; /* Redux actions */ import { addTemplate, @@ -17,12 +17,18 @@ import { /** * Handles the uploading of new PDF templates to the server * + * Wraps the entire Template Manager so a zip can be dropped anywhere in the window, and shares the file + * picker with the "Add New Template" tile via context + * * @package Gravity PDF * @copyright Copyright (c) 2026, Blue Liquid Designs * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License * @since 4.1 */ +/* The fallback ceiling if the server didn't tell us its own (see Helper_Templates::MAX_UPLOAD_SIZE) */ +export const DEFAULT_MAX_FILE_SIZE = 32 * 1024 * 1024; + /** * React Component * @@ -33,6 +39,7 @@ export class TemplateUploader extends Component { * @since 4.1 */ static propTypes = { + children: PropTypes.node, genericUploadErrorText: PropTypes.string, addTemplateText: PropTypes.string, filenameErrorText: PropTypes.string, @@ -41,55 +48,76 @@ export class TemplateUploader extends Component { installUpdatedText: PropTypes.string, templateSuccessfullyInstalledUpdated: PropTypes.string, templateInstallInstructions: PropTypes.string, + dropzoneText: PropTypes.string, + uploadInProgressText: PropTypes.string, + /* wp_localize_script() stringifies scalars, so this arrives from PHP as a string */ + maxFileSize: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), addNewTemplate: PropTypes.func, updateTemplateParam: PropTypes.func, postTemplateUploadProcessing: PropTypes.func, clearTemplateUploadProcessing: PropTypes.func, templates: PropTypes.array, - templateUploadProcessingSuccess: PropTypes.object, - templateUploadProcessingError: PropTypes.object, + templateUploadResults: PropTypes.array, + }; + + /** + * @since 6.17 + */ + static defaultProps = { + templateUploadResults: [], }; /** * Setup internal component state that doesn't need to be in Redux * - * @return {{ajax: boolean, error: string, message: string}} + * `total` and `completed` track the current batch of uploads so results can be drained from the + * Redux store exactly once, even when several land in the same render + * + * @return {{ + * errors: Array, + * showSuccess: boolean, + * total: number, + * completed: number + * }} initial state * * @since 4.1 */ state = { - ajax: false, - error: '', - message: '', + errors: [], + showSuccess: false, + total: 0, + completed: 0, }; /** - * If component did update, fires appropriate function based on Redux store data + * Whether the current batch of uploads is still in flight + * + * @return { boolean } True until every file dispatched by handleOndrop() has reported back + * + * @since 6.17 + */ + get isUploading() { + return this.state.completed < this.state.total; + } + + /** + * Drain any upload results that arrived since the last render * * @param { Object } prevProps * * @since 4.1 */ componentDidUpdate(prevProps) { - const { - templateUploadProcessingSuccess, - templateUploadProcessingError, - } = this.props; + const { templateUploadResults } = this.props; - if ( - prevProps.templateUploadProcessingSuccess !== - templateUploadProcessingSuccess && - templateUploadProcessingSuccess?.templates?.length > 0 - ) { - this.ajaxSuccess(templateUploadProcessingSuccess); + if (prevProps.templateUploadResults === templateUploadResults) { + return; } - if ( - prevProps.templateUploadProcessingError !== - templateUploadProcessingError && - Object.keys(templateUploadProcessingError).length > 0 - ) { - this.ajaxFailed(templateUploadProcessingError); + const fresh = templateUploadResults.slice(this.state.completed); + + if (fresh.length > 0) { + this.processResults(fresh); } } @@ -101,89 +129,116 @@ export class TemplateUploader extends Component { * @since 4.1 */ handleOndrop = (acceptedFiles) => { - /* Handle file upload and pass in an nonce!!! */ - if (acceptedFiles instanceof Array && acceptedFiles.length > 0) { - acceptedFiles.forEach((file) => { - const filename = file.name; - - /* Do validation */ - if ( - !this.checkFilename(filename) || - !this.checkFilesize(file.size) - ) { - return; - } - - /* Add our loader */ - this.setState({ - ajax: true, - error: '', - message: '', - }); - - /* POST the PDF template to our endpoint for processing */ - this.props.postTemplateUploadProcessing(file, filename); - }); + if (acceptedFiles.length === 0) { + return; } + + const errors = []; + const valid = []; + + acceptedFiles.forEach((file) => { + const error = this.validateFile(file); + + if (error !== '') { + errors.push({ filename: file.name, message: error }); + return; + } + + valid.push(file); + }); + + /* Discard the previous batch's results before we start counting this one */ + this.props.clearTemplateUploadProcessing(); + + this.setState({ + errors, + showSuccess: false, + total: valid.length, + completed: 0, + }); + + valid.forEach((file) => + this.props.postTemplateUploadProcessing(file, file.name) + ); }; /** - * Checks if the uploaded file has a .zip extension - * We do this instead of mime type checking as it doesn't work in all browsers + * Check a file is something we're willing to send to the server * - * @param { string } name + * The extension is checked instead of the mime type, which isn't reported reliably by all browsers * - * @return { boolean } conditional value + * @param { Object } file * - * @since 4.1 + * @return { string } The problem with the file, or an empty string when it's valid + * + * @since 6.17 */ - checkFilename = (name) => { - if (name.substr(name.length - 4) !== '.zip') { - /* Tell use about incorrect file type */ - this.setState({ - error: this.props.filenameErrorText, - }); + validateFile = (file) => { + if (file.name.substr(file.name.length - 4) !== '.zip') { + return this.props.filenameErrorText; + } + + /* wp_localize_script() stringifies the server's limit, and may not have sent one at all */ + const maxFileSize = + parseInt(this.props.maxFileSize, 10) || DEFAULT_MAX_FILE_SIZE; - return false; + if (file.size > maxFileSize) { + return this.props.filesizeErrorText; } - return true; + return ''; }; /** - * Checks if the file size is larger than 5MB + * Apply a batch of upload results to our Redux store and the on-screen messages * - * @param { number } size File size in bytes + * @param { Array } results * - * @return { boolean } conditional value - * - * @since 4.1 + * @since 6.17 */ - checkFilesize = (size) => { - /* Check the file is no larger than 10MB (convert from bytes to KB) */ - if (size / 1024 > 10240) { - /* Tell use about incorrect file type */ - this.setState({ - error: this.props.filesizeErrorText, - }); + processResults = (results) => { + const errors = []; + let installed = 0; + + results.forEach((result) => { + if (!result.success) { + errors.push({ + filename: result.filename, + message: + result.message || this.props.genericUploadErrorText, + }); - return false; - } + return; + } + + this.addTemplatesToStore(result.templates); + installed++; + }); + + const completed = this.state.completed + results.length; + const done = completed >= this.state.total; + + /* Latch the success message on, so a later failure in the batch can't hide it */ + this.setState((prevState) => ({ + completed, + errors: [...prevState.errors, ...errors], + showSuccess: prevState.showSuccess || installed > 0, + })); - return true; + if (done) { + this.props.clearTemplateUploadProcessing(); + } }; /** * Update our Redux store with the new PDF template details - * If our upload AJAX call to the server passed this function gets fired * - * @param { Object } response + * @param { Array } templates * * @since 4.1 */ - ajaxSuccess = (response) => { - /* Update our Redux Store with the new template(s) */ - response.templates.forEach((template) => { + addTemplatesToStore = (templates) => { + templates.forEach((template) => { /* Check if template already in the list before adding to our store */ const matched = this.props.templates.find((item) => { return item.id === template.id; @@ -201,112 +256,116 @@ export class TemplateUploader extends Component { ); } }); - - /* Mark as success and stop AJAX spinner */ - this.setState({ - ajax: false, - message: this.props.templateSuccessfullyInstalledUpdated, - }); - - /* Clean/Reset our Redux Store state for templateUploadProcessing */ - this.props.clearTemplateUploadProcessing(); }; /** - * Show any errors to the user when AJAX request fails for any reason - * - * @param { Object } error + * Remove message from state once the timeout has finished * * @since 4.1 */ - ajaxFailed = (error) => { - /* Let the user know there was a problem with the upload */ + removeMessage = () => { this.setState({ - error: error?.message || this.props.genericUploadErrorText, - ajax: false, + showSuccess: false, }); - - /* Clean/Reset our Redux Store state for templateUploadProcessing */ - this.props.clearTemplateUploadProcessing(); }; /** - * Remove message from state once the timeout has finished + * The upload progress, errors and success message, pinned to the foot of the Template Manager so + * they're seen no matter where the template list is scrolled to * - * @since 4.1 + * @since 6.17 */ - removeMessage = () => { - this.setState({ - message: '', - }); - }; + renderStatus() { + const { errors, showSuccess } = this.state; + const uploading = this.isUploading; + + if (!uploading && errors.length === 0 && !showSuccess) { + return null; + } + + return ( +
+ {uploading && ( + + )} + + {errors.map((error, index) => ( + + ))} + + {showSuccess && ( + + )} +
+ ); + } /** * @since 4.1 */ render() { + const { + children, + dropzoneText, + addTemplateText, + templateInstallInstructions, + } = this.props; + return ( -
- - {({ getRootProps, getInputProps, isDragActive }) => { - return ( + {({ getRootProps, getInputProps, isDragActive, open }) => ( +
+ + + + {children} + + + {this.renderStatus()} + + {isDragActive && ( + )} +
+ )} +
); } } @@ -319,8 +378,7 @@ export class TemplateUploader extends Component { * * @return {{ * templates: Array, - * templateUploadProcessingSuccess: Object, - * templateUploadProcessingError: Object + * templateUploadResults: Array * }} mapped state * * @since 5.2 @@ -328,10 +386,7 @@ export class TemplateUploader extends Component { const mapStateToProps = (state) => { return { templates: state.template.list, - templateUploadProcessingSuccess: - state.template.templateUploadProcessingSuccess, - templateUploadProcessingError: - state.template.templateUploadProcessingError, + templateUploadResults: state.template.templateUploadResults, }; }; diff --git a/src/assets/js/react/components/Template/TemplateUploaderContext.js b/src/assets/js/react/components/Template/TemplateUploaderContext.js new file mode 100644 index 000000000..f2b68eb9c --- /dev/null +++ b/src/assets/js/react/components/Template/TemplateUploaderContext.js @@ -0,0 +1,13 @@ +/* Dependencies */ +import { createContext } from 'react'; + +/** + * Shares the file picker and upload status from , which wraps the entire Template + * Manager, with the "Add New Template" tile rendered deep inside the template list + * + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.17 + */ +export const TemplateUploaderContext = createContext({}); diff --git a/src/assets/js/react/components/Template/TemplateUploaderTile.js b/src/assets/js/react/components/Template/TemplateUploaderTile.js new file mode 100644 index 000000000..05cc718e2 --- /dev/null +++ b/src/assets/js/react/components/Template/TemplateUploaderTile.js @@ -0,0 +1,58 @@ +/* Dependencies */ +import React, { useContext } from 'react'; +/* Components */ +import { TemplateUploaderContext } from './TemplateUploaderContext'; + +/** + * The "Add New Template" tile at the end of the template list. Opens the file picker owned by + * , which doubles as a drop target covering the whole Template Manager + * + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.17 + */ + +/** + * React Component + * + * @since 6.17 + */ +const TemplateUploaderTile = () => { + const { open, ajax, addTemplateText, templateInstallInstructions } = + useContext(TemplateUploaderContext); + + const handleClick = (e) => { + e.preventDefault(); + open(); + }; + + return ( +
+ +
+ +
+ +

{addTemplateText}

+
+ +
+ {templateInstallInstructions} +
+
+ ); +}; + +export default TemplateUploaderTile; diff --git a/src/assets/js/react/reducers/templateReducer.js b/src/assets/js/react/reducers/templateReducer.js index 041c0c0f6..e49c50440 100644 --- a/src/assets/js/react/reducers/templateReducer.js +++ b/src/assets/js/react/reducers/templateReducer.js @@ -24,15 +24,22 @@ import { * @since 4.1 */ +/** + * @typedef { Object } TemplateUploadResult + * @property { boolean } success - whether the zip was installed + * @property { string } filename - the zip the result belongs to + * @property { Array } templates - the installed templates (successful uploads only) + * @property { string } message - the reason for the failure (failed uploads only) + */ + /** * @typedef { Object } TemplateReducerState - * @property { Array } list - list of GFPDF prebuilt templates - * @property { Object } activeTemplate - current template used - * @property { string } search - filter keyword value - * @property { string } updateSelectBoxText - state of select box text - * @property { string } templateProcessing - state of template processed - * @property { Object } templateUploadProcessingSuccess - state when upload is successful - * @property { Object } templateUploadProcessingError - state when upload is not successful + * @property { Array } list - list of GFPDF prebuilt templates + * @property { Object } activeTemplate - current template used + * @property { string } search - filter keyword value + * @property { string } updateSelectBoxText - state of select box text + * @property { string } templateProcessing - state of template processed + * @property { Array } templateUploadResults - results of the current upload batch */ /** @@ -48,8 +55,7 @@ export const initialState = { search: '', updateSelectBoxText: '', templateProcessing: '', - templateUploadProcessingSuccess: {}, - templateUploadProcessingError: {}, + templateUploadResults: [], }; /** @@ -173,37 +179,43 @@ export default function (state = initialState, action) { }; /** - * Update with the new PDF template details + * Record a successfully-installed zip. Results are appended (never replaced) so a batch of + * uploads finishing in the same render can't overwrite each other * * @since 5.2 */ case TEMPLATE_UPLOAD_PROCESSING_SUCCESS: return { ...state, - templateUploadProcessingSuccess: action.payload, + templateUploadResults: [ + ...state.templateUploadResults, + { ...action.payload, success: true }, + ], }; /** - * Update/Show error + * Record a zip that failed to install * * @since 5.2 */ case TEMPLATE_UPLOAD_PROCESSING_FAILED: return { ...state, - templateUploadProcessingError: action.payload, + templateUploadResults: [ + ...state.templateUploadResults, + { ...action.payload, success: false }, + ], }; /** - * Clear/reset state of templateUploadProcessingSuccess & templateUploadProcessingError + * Clear/reset the results of the current upload batch * * @since 5.2 */ case CLEAR_TEMPLATE_UPLOAD_PROCESSING: return { ...state, - templateUploadProcessingSuccess: {}, - templateUploadProcessingError: {}, + templateUploadResults: [], }; } diff --git a/src/assets/js/react/router/templateRouter.js b/src/assets/js/react/router/templateRouter.js index 4539dc467..439994ca4 100644 --- a/src/assets/js/react/router/templateRouter.js +++ b/src/assets/js/react/router/templateRouter.js @@ -45,8 +45,6 @@ export const Routes = () => ( exact element={ ( templateInstallInstructions={ GFPDF.templateInstallInstructions } + dropzoneText={GFPDF.templateUploadDropzone} + uploadInProgressText={ + GFPDF.templateUploadInProgress + } + maxFileSize={GFPDF.templateUploadMaxSize} /> } /> diff --git a/src/assets/js/react/sagas/templates.js b/src/assets/js/react/sagas/templates.js index 62718eb84..ede2abd27 100644 --- a/src/assets/js/react/sagas/templates.js +++ b/src/assets/js/react/sagas/templates.js @@ -1,5 +1,5 @@ /* Dependencies */ -import { takeLatest, call, put } from 'redux-saga/effects'; +import { takeLatest, takeEvery, call, put } from 'redux-saga/effects'; /* Redux action types & actions */ import { updateSelectBoxSuccess, @@ -64,11 +64,13 @@ export function* templateProcessing(action) { * @since 5.2 */ export function* templateUploadProcessing(action) { + const { file, filename } = action.payload; + try { const response = yield call( apiPostTemplateUploadProcessing, - action.payload.file, - action.payload.filename + file, + filename ); if ( @@ -77,24 +79,30 @@ export function* templateUploadProcessing(action) { !Array.isArray(response.body.templates) ) { yield put( - templateUploadProcessingFailed({ - message: - response.body && - typeof response.body === 'object' && - response.body.error - ? response.body.error - : '', - }) + templateUploadProcessingFailed( + { + message: + response.body && + typeof response.body === 'object' && + response.body.error + ? response.body.error + : '', + }, + filename + ) ); return; } - yield put(templateUploadProcessingSuccess(response.body)); + yield put(templateUploadProcessingSuccess(response.body, filename)); } catch (error) { yield put( - templateUploadProcessingFailed({ - message: error.message, - }) + templateUploadProcessingFailed( + { + message: error.message, + }, + filename + ) ); } } @@ -120,8 +128,10 @@ export function* watchTemplateProcessing() { /** * Watcher Saga watchTemplateProcessing for templateUploadProcessing() * + * Uses takeEvery so every zip in a multi-file drop is uploaded โ€” takeLatest cancelled all but the last + * * @since 5.2 */ export function* watchpostTemplateUploadProcessing() { - yield takeLatest(POST_TEMPLATE_UPLOAD_PROCESSING, templateUploadProcessing); + yield takeEvery(POST_TEMPLATE_UPLOAD_PROCESSING, templateUploadProcessing); } diff --git a/src/assets/scss/Component/_template-manager.scss b/src/assets/scss/Component/_template-manager.scss index 4e937490c..d95bbdf2f 100644 --- a/src/assets/scss/Component/_template-manager.scss +++ b/src/assets/scss/Component/_template-manager.scss @@ -1,5 +1,17 @@ @use '../base'; +/* Shared by the notices inline in the template list and the ones in the upload status toast */ +%gfpdf-template-notice { + border-color: base.$success-color; + + &.error { + border-color: base.$error-color; + color: #000; + margin: 0; + padding: 1px 12px; + } +} + #tab_PDF { #gfpdf_settings\[template\], #gfpdf_settings\[default_template\] { margin: 0 0.2rem 0.2rem 0; @@ -80,7 +92,6 @@ animation: gfpdfspin 1s infinite linear; } - &.gfpdf-dropzone-active .doing-ajax span:after, .doing-ajax:hover span:after, .doing-ajax:focus span:after { color: #FFF; @@ -88,26 +99,75 @@ } } - .gfpdf-dropzone { - .dropzone { - .gfpdf-template-install-instructions { - font-size: 85%; - text-align: center; - margin-top: 5px; - } - } + .gfpdf-dropzone .gfpdf-template-install-instructions { + font-size: 85%; + text-align: center; + margin-top: 5px; } .notice { - border-color: base.$success-color; + @extend %gfpdf-template-notice; + } + } + } +} - &.error { - border-color: base.$error-color; - color: #000; - margin: 0; - padding: 1px 12px; - } +/* The whole Template Manager doubles as a drop target for new template zips */ +.theme-overlay { + .gfpdf-dropzone-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 10001; /* Over .theme-wrap, which core puts at 10000 */ + display: flex; + background: rgba(0, 0, 0, 0.75); + pointer-events: none; + + &__message { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 20px; + border: 4px dashed #FFF; + border-radius: 8px; + color: #FFF; + text-align: center; + + .dashicons { + width: 48px; + height: 48px; + font-size: 48px; + } + + p { + margin: 10px 0 0; + font-size: 18px; + font-weight: 600; } } } + + /* Toast at the foot of the modal, so results are seen wherever the template list is scrolled to */ + .gfpdf-dropzone-status { + position: fixed; + bottom: 5%; + left: 50%; + z-index: 10002; + width: 400px; + max-width: 90%; + transform: translateX(-50%); + + .notice { + @extend %gfpdf-template-notice; + + margin: 0 0 5px; + padding: 1px 12px; + background: #FFF; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + } } diff --git a/tests/js-unit/react/actions/templates.test.js b/tests/js-unit/react/actions/templates.test.js index caa008ada..2be82dc16 100644 --- a/tests/js-unit/react/actions/templates.test.js +++ b/tests/js-unit/react/actions/templates.test.js @@ -131,18 +131,24 @@ describe('Actions - templates', () => { test('templateUploadProcessingSuccess - check if it returns the correct action', () => { data = { success: { data: 'success' } }; - results = templateUploadProcessingSuccess(data); + results = templateUploadProcessingSuccess(data, 'template.zip'); expect(results.type).toEqual(TEMPLATE_UPLOAD_PROCESSING_SUCCESS); - expect(results.payload).toEqual({ success: { data: 'success' } }); + expect(results.payload).toEqual({ + success: { data: 'success' }, + filename: 'template.zip', + }); }); test('templateUploadProcessingFailed - check if it returns the correct action', () => { data = { error: { error: 'error' } }; - results = templateUploadProcessingFailed(data); + results = templateUploadProcessingFailed(data, 'template.zip'); expect(results.type).toEqual(TEMPLATE_UPLOAD_PROCESSING_FAILED); - expect(results.payload).toEqual({ error: { error: 'error' } }); + expect(results.payload).toEqual({ + error: { error: 'error' }, + filename: 'template.zip', + }); }); test('clearTemplateUploadProcessing - check if it returns the correct action', () => { diff --git a/tests/js-unit/react/components/Template/TemplateList.test.js b/tests/js-unit/react/components/Template/TemplateList.test.js index 76891fa5b..d7270ef2e 100644 --- a/tests/js-unit/react/components/Template/TemplateList.test.js +++ b/tests/js-unit/react/components/Template/TemplateList.test.js @@ -40,15 +40,17 @@ describe('Template - TemplateList.js', () => { }); test('renders component', () => { - const newWrapper = shallow( - - ).dive(); - const component = findByTestAttr( - newWrapper, - 'component-templateHeaderTitle' + const header = findByTestAttr(wrapper, 'component-templateList').prop( + 'header' ); - expect(component.length).toBe(1); + expect(header.props['data-test']).toBe('component-templateHeaderTitle'); + }); + + test('renders component', () => { + expect( + findByTestAttr(wrapper, 'component-templateUploaderTile').length + ).toBe(1); }); test('renders component', () => { diff --git a/tests/js-unit/react/components/Template/TemplateUploader.test.js b/tests/js-unit/react/components/Template/TemplateUploader.test.js index 3d8eaf818..aa3a8a7e0 100644 --- a/tests/js-unit/react/components/Template/TemplateUploader.test.js +++ b/tests/js-unit/react/components/Template/TemplateUploader.test.js @@ -3,9 +3,19 @@ import { shallow, mount } from 'enzyme'; import { storeFactory, findByTestAttr } from '../../testUtils'; import ConnectedTemplateUploader, { TemplateUploader, + DEFAULT_MAX_FILE_SIZE, mapDispatchToProps, } from '../../../../../src/assets/js/react/components/Template/TemplateUploader'; +const file = (name, size = 1137334) => ({ + lastModified: 1552267520000, + name, + path: name, + size, + type: 'application/zip', + webkitRelativePath: '', +}); + describe('Template - TemplateUploader.js', () => { let wrapper; let component; @@ -14,6 +24,19 @@ describe('Template - TemplateUploader.js', () => { const clearTemplateUploadProcessingMock = jest.fn(); const updateTemplateParamMock = jest.fn(); + const setupUploader = (props = {}) => + shallow( + + ); + + beforeEach(() => jest.clearAllMocks()); + describe('Check for redux properties', () => { const setup = (state = {}) => { const store = storeFactory(state); @@ -25,8 +48,6 @@ describe('Template - TemplateUploader.js', () => { }; const dispatch = jest.fn(); - setup(); - test('has access to `list` state', () => { wrapper = setup(); const templatesProp = wrapper.instance().props.templates; @@ -34,30 +55,19 @@ describe('Template - TemplateUploader.js', () => { expect(templatesProp).toBeInstanceOf(Array); }); - test('has access to `templateUploadProcessingSuccess` state', () => { - wrapper = setup({ - template: { templateUploadProcessingSuccess: { test: 'test' } }, - }); - const templateUploadProcessingSuccessProp = - wrapper.instance().props.templateUploadProcessingSuccess; - - expect(templateUploadProcessingSuccessProp).toBeInstanceOf(Object); - expect(templateUploadProcessingSuccessProp).toEqual({ - test: 'test', - }); - }); - - test('has access to `templateUploadProcessingError` state', () => { + test('has access to `templateUploadResults` state', () => { wrapper = setup({ - template: { templateUploadProcessingError: { error: 'test' } }, + template: { + templateUploadResults: [ + { filename: 'one.zip', success: true, templates: [] }, + ], + }, }); - const templateUploadProcessingErrorProp = - wrapper.instance().props.templateUploadProcessingError; + const results = wrapper.instance().props.templateUploadResults; - expect(templateUploadProcessingErrorProp).toBeInstanceOf(Object); - expect(templateUploadProcessingErrorProp).toEqual({ - error: 'test', - }); + expect(results).toEqual([ + { filename: 'one.zip', success: true, templates: [] }, + ]); }); test('check for mapDispatchToProps addNewTemplate()', () => { @@ -94,253 +104,298 @@ describe('Template - TemplateUploader.js', () => { }); describe('Component functions', () => { - test('handleOndrop() - Manages the template file upload', () => { - const acceptedFiles = [ - { - lastModified: 1552267520000, - name: 'gpdf-cellulose-1.4.0.zip', - path: 'gpdf-cellulose-1.4.0.zip', - size: 1137334, - type: 'application/zip', - webkitRelativePath: '', - }, - ]; - - wrapper = shallow( - - ); - wrapper.instance().handleOndrop(acceptedFiles); + test('handleOndrop() - uploads every zip that was dropped', () => { + wrapper = setupUploader(); + wrapper + .instance() + .handleOndrop([ + file('gpdf-cellulose-1.4.0.zip'), + file('gpdf-blueprint-1.0.0.zip'), + file('gpdf-flow-2.0.0.zip'), + ]); + + expect(wrapper.instance().isUploading).toBe(true); + expect(wrapper.state('total')).toBe(3); + expect(wrapper.state('completed')).toBe(0); + expect(wrapper.state('errors')).toEqual([]); + expect(postTemplateUploadProcessingMock.mock.calls.length).toBe(3); + }); - expect(wrapper.state('ajax')).toBe(true); - expect(wrapper.state('error')).toBe(''); - expect(wrapper.state('message')).toBe(''); + test('handleOndrop() - reports each invalid file and uploads the rest', () => { + wrapper = setupUploader({ + filenameErrorText: 'notZip', + filesizeErrorText: 'tooBig', + }); + wrapper + .instance() + .handleOndrop([ + file('gpdf-cellulose-1.4.0.zip'), + file('not-a-template.txt'), + file('huge.zip', DEFAULT_MAX_FILE_SIZE + 1), + ]); + + expect(wrapper.state('errors')).toEqual([ + { filename: 'not-a-template.txt', message: 'notZip' }, + { filename: 'huge.zip', message: 'tooBig' }, + ]); + expect(wrapper.state('total')).toBe(1); expect(postTemplateUploadProcessingMock.mock.calls.length).toBe(1); }); - test('checkFilename() - Checks if the uploaded file has a .zip extension', () => { - let name; - name = 'gpdf-cellulose-1.4.0.zip'; - - wrapper = shallow( - - ); - - expect(wrapper.instance().checkFilename(name)).toBe(true); + test('handleOndrop() - ignores an empty drop', () => { + wrapper = setupUploader(); + wrapper.instance().handleOndrop([]); - name = 'gpdf-cellulose-1.4.0'; + expect(wrapper.instance().isUploading).toBe(false); + expect(postTemplateUploadProcessingMock.mock.calls.length).toBe(0); + }); - wrapper.instance().checkFilename(name); + test('validateFile() - only accepts a .zip extension', () => { + wrapper = setupUploader({ filenameErrorText: 'notZip' }); - expect(wrapper.state('error')).toBe('errorText'); - expect(wrapper.instance().checkFilename(name)).toBe(false); + expect( + wrapper.instance().validateFile(file('gpdf-cellulose.zip')) + ).toBe(''); + expect( + wrapper.instance().validateFile(file('gpdf-cellulose.tar')) + ).toBe('notZip'); }); - test('checkFilesize() - Checks if the file size is larger than 5MB', () => { - let size; - size = 1137334; + test('validateFile() - rejects anything over the upload limit', () => { + wrapper = setupUploader({ + filesizeErrorText: 'tooBig', + maxFileSize: 1000, + }); - wrapper = shallow( - + expect(wrapper.instance().validateFile(file('a.zip', 1000))).toBe( + '' ); + expect(wrapper.instance().validateFile(file('a.zip', 1001))).toBe( + 'tooBig' + ); + }); - expect(wrapper.instance().checkFilesize(size)).toBe(true); + test('validateFile() - accepts the stringified limit wp_localize_script() produces', () => { + wrapper = setupUploader({ + filesizeErrorText: 'tooBig', + maxFileSize: '1000', + }); - size = 999999999; + expect(wrapper.instance().validateFile(file('a.zip', 1000))).toBe( + '' + ); + expect(wrapper.instance().validateFile(file('a.zip', 1001))).toBe( + 'tooBig' + ); + }); - wrapper.instance().checkFilesize(size); - expect(wrapper.state('error')).toBe('errorText'); - expect(wrapper.instance().checkFilesize(size)).toBe(false); + test('validateFile() - falls back to the default limit when the server sent none', () => { + wrapper = setupUploader({ filesizeErrorText: 'tooBig' }); + + expect( + wrapper + .instance() + .validateFile(file('a.zip', DEFAULT_MAX_FILE_SIZE)) + ).toBe(''); + expect( + wrapper + .instance() + .validateFile(file('a.zip', DEFAULT_MAX_FILE_SIZE + 1)) + ).toBe('tooBig'); }); - test('ajaxSuccess() - Update our Redux store with the new PDF template details', () => { - let response; + test('addTemplatesToStore() - adds new templates and flags existing ones as updated', () => { const templates = [ { template: 'Blank Slate', id: 'blank-slate' }, - { template: 'Focus Gravity', id: 'focus-gravity' }, { template: 'Rubix', id: 'rubix' }, - { template: 'Zadani', id: 'zadani' }, ]; - response = { - templates: [{ template: 'Cellulose', id: 'gpdf-cellulose' }], - }; - - wrapper = shallow( - - ); - wrapper.instance().ajaxSuccess(response); - expect(addNewTemplateMock.mock.calls.length).toBe(1); - expect(wrapper.state('ajax')).toBe(false); - expect(wrapper.state('message')).toBe('successText'); - expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(1); + wrapper = setupUploader({ + templates, + addNewTemplate: addNewTemplateMock, + updateTemplateParam: updateTemplateParamMock, + installSuccessText: 'installed', + installUpdatedText: 'updated', + }); - response = { templates: [{ template: 'Rubix', id: 'rubix' }] }; - - wrapper = shallow( - - ); - wrapper.instance().ajaxSuccess(response); + wrapper.instance().addTemplatesToStore([ + { template: 'Cellulose', id: 'gpdf-cellulose' }, + { template: 'Rubix', id: 'rubix' }, + ]); expect(addNewTemplateMock.mock.calls.length).toBe(1); - expect(wrapper.state('ajax')).toBe(false); - expect(wrapper.state('message')).toBe('successText'); - expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(2); - }); - - test('ajaxFailed() - Show any errors to the user when AJAX request fails for any reason', () => { - let error; - error = { - message: 'error', - }; - - wrapper = shallow( - + expect(addNewTemplateMock.mock.calls[0][0].message).toBe( + 'installed' ); - wrapper.instance().ajaxFailed(error); - - expect(wrapper.state('error')).toBe('error'); - expect(wrapper.state('ajax')).toBe(false); - expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(1); - - error = { - response: { - body: {}, - }, - }; - - wrapper = shallow( - - ); - wrapper.instance().ajaxFailed(error); - - expect(wrapper.state('error')).toBe('errorText'); - expect(wrapper.state('ajax')).toBe(false); - expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(2); + expect(updateTemplateParamMock.mock.calls[0]).toEqual([ + 'rubix', + 'message', + 'updated', + ]); }); test('removeMessage() - Remove message from state once the timeout has finished', () => { - wrapper = shallow(); + wrapper = setupUploader(); + wrapper.setState({ showSuccess: true }); wrapper.instance().removeMessage(); - expect(wrapper.state('message')).toBe(''); + expect(wrapper.state('showSuccess')).toBe(false); }); }); describe('Run Lifecycle methods', () => { - test('componentDidUpdate() - Fires appropriate function based on Redux store data (success)', () => { - const props = { - templateUploadProcessingSuccess: { - templates: [ - { - template: 'Cellulose', - id: 'gpdf-cellulose', - }, - ], + const templates = [{ template: 'Rubix', id: 'rubix' }]; + + const setupBatch = (total, props = {}) => { + const uploader = setupUploader({ + templates, + addNewTemplate: addNewTemplateMock, + updateTemplateParam: updateTemplateParamMock, + templateSuccessfullyInstalledUpdated: 'successText', + genericUploadErrorText: 'genericError', + templateUploadResults: [], + ...props, + }); + + uploader.setState({ total, completed: 0 }); + + return uploader; + }; + + /* The shallow renderer doesn't run lifecycle hooks, so drive componentDidUpdate ourselves */ + const applyResults = (uploader, results) => { + const prevProps = { ...uploader.instance().props }; + uploader.setProps({ templateUploadResults: results }); + uploader.instance().componentDidUpdate(prevProps); + }; + + test('componentDidUpdate() - keeps the spinner up until every upload in the batch reports back', () => { + wrapper = setupBatch(2); + + applyResults(wrapper, [ + { + success: true, + filename: 'one.zip', + templates: [{ template: 'Cellulose', id: 'cellulose' }], }, - templateUploadProcessingError: {}, - }; - const prevProps = { - templateUploadProcessingSuccess: {}, - templateUploadProcessingError: {}, - }; - const templates = [ - { template: 'Blank Slate', id: 'blank-slate' }, - { template: 'Focus Gravity', id: 'focus-gravity' }, - { template: 'Rubix', id: 'rubix' }, - { template: 'Zadani', id: 'zadani' }, - ]; - wrapper = shallow( - - ); - const ajaxSuccess = jest.spyOn(wrapper.instance(), 'ajaxSuccess'); - wrapper.instance().componentDidUpdate(prevProps); + ]); - expect(ajaxSuccess).toHaveBeenCalledTimes(1); + expect(wrapper.state('completed')).toBe(1); + expect(wrapper.instance().isUploading).toBe(true); + expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(0); + + applyResults(wrapper, [ + { + success: true, + filename: 'one.zip', + templates: [{ template: 'Cellulose', id: 'cellulose' }], + }, + { + success: true, + filename: 'two.zip', + templates: [{ template: 'Flow', id: 'flow' }], + }, + ]); + + expect(wrapper.state('completed')).toBe(2); + expect(wrapper.instance().isUploading).toBe(false); + expect(wrapper.state('showSuccess')).toBe(true); + expect(addNewTemplateMock.mock.calls.length).toBe(2); + expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(1); }); - test('componentDidUpdate() - Fires appropriate function based on Redux store data (error)', () => { - const props = { - templateUploadProcessingSuccess: {}, - templateUploadProcessingError: { - response: { - body: { - error: 'error', - }, - }, + test('componentDidUpdate() - handles several results landing in the same render', () => { + wrapper = setupBatch(2); + + applyResults(wrapper, [ + { success: true, filename: 'one.zip', templates: [] }, + { success: false, filename: 'two.zip', message: 'boom' }, + ]); + + expect(wrapper.state('completed')).toBe(2); + expect(wrapper.instance().isUploading).toBe(false); + expect(wrapper.state('errors')).toEqual([ + { filename: 'two.zip', message: 'boom' }, + ]); + expect(wrapper.state('showSuccess')).toBe(true); + }); + + test('componentDidUpdate() - still reports success when the last zip in the batch fails', () => { + wrapper = setupBatch(2); + + applyResults(wrapper, [ + { + success: true, + filename: 'one.zip', + templates: [{ template: 'Cellulose', id: 'cellulose' }], }, - }; - const prevProps = { - templateUploadProcessingSuccess: {}, - templateUploadProcessingError: {}, - }; - wrapper = shallow( - - ); - const ajaxFailed = jest.spyOn(wrapper.instance(), 'ajaxFailed'); - wrapper.instance().componentDidUpdate(prevProps); + ]); - expect(ajaxFailed).toHaveBeenCalledTimes(1); + applyResults(wrapper, [ + { success: true, filename: 'one.zip', templates: [] }, + { success: false, filename: 'two.zip', message: 'boom' }, + ]); + + expect(wrapper.state('showSuccess')).toBe(true); + expect(wrapper.state('errors')).toEqual([ + { filename: 'two.zip', message: 'boom' }, + ]); }); - }); - test('renders component', () => { - wrapper = shallow(); - component = findByTestAttr(wrapper, 'component-templateUploader'); + test('componentDidUpdate() - falls back to the generic error when the server gave no reason', () => { + wrapper = setupBatch(1); - expect(component.length).toBe(1); + applyResults(wrapper, [ + { success: false, filename: 'one.zip', message: '' }, + ]); + + expect(wrapper.state('errors')).toEqual([ + { filename: 'one.zip', message: 'genericError' }, + ]); + expect(wrapper.state('showSuccess')).toBe(false); + expect(wrapper.instance().isUploading).toBe(false); + }); + + test('componentDidUpdate() - ignores the store being cleared', () => { + wrapper = setupBatch(1); + wrapper.setState({ completed: 1 }); + + applyResults(wrapper, []); + + expect(wrapper.state('completed')).toBe(1); + expect(addNewTemplateMock.mock.calls.length).toBe(0); + }); }); - test('renders component', () => { + test('renders component', () => { wrapper = shallow(); component = findByTestAttr(wrapper, 'component-dropzone'); expect(component.length).toBe(1); }); - test("renders component if state.error !== ''", async () => { + test('renders component for each error in state.errors', async () => { wrapper = mount(); - React.act(() => wrapper.setState({ error: 'errorText' })); + React.act(() => + wrapper.setState({ + errors: [ + { filename: 'one.zip', message: 'errorText' }, + { filename: 'two.zip', message: 'errorText' }, + ], + }) + ); component = findByTestAttr(wrapper, 'component-stateError-showMessage'); - expect(component.length).toBe(1); + expect(component.length).toBe(2); + expect(component.first().prop('text')).toBe('one.zip: errorText'); + }); + + test('renders the upload progress notice while a batch is in flight', async () => { + wrapper = mount(); + React.act(() => wrapper.setState({ total: 1, completed: 0 })); + + expect( + findByTestAttr(wrapper, 'component-templateUploaderStatus').text() + ).toContain('uploading'); }); }); diff --git a/tests/js-unit/react/components/Template/TemplateUploaderTile.test.js b/tests/js-unit/react/components/Template/TemplateUploaderTile.test.js new file mode 100644 index 000000000..8b406e168 --- /dev/null +++ b/tests/js-unit/react/components/Template/TemplateUploaderTile.test.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { findByTestAttr } from '../../testUtils'; +import TemplateUploaderTile from '../../../../../src/assets/js/react/components/Template/TemplateUploaderTile'; +import { TemplateUploaderContext } from '../../../../../src/assets/js/react/components/Template/TemplateUploaderContext'; + +describe('Template - TemplateUploaderTile.js', () => { + const setup = (context = {}) => + mount( + + + + ); + + test('renders the tile with the text supplied by ', () => { + const wrapper = setup(); + + expect( + findByTestAttr(wrapper, 'component-templateUploaderTile').length + ).toBe(1); + expect(wrapper.find('h2.theme-name').text()).toBe('Add New Template'); + expect( + wrapper.find('.gfpdf-template-install-instructions').text() + ).toBe('instructions'); + }); + + test('opens the file picker owned by instead of following the link', () => { + const open = jest.fn(); + const preventDefault = jest.fn(); + const wrapper = setup({ open }); + + wrapper.find('a').simulate('click', { preventDefault }); + + expect(open).toHaveBeenCalledTimes(1); + expect(preventDefault).toHaveBeenCalledTimes(1); + }); + + test('shows the spinner while an upload is in flight', () => { + expect(setup({ ajax: true }).find('a').hasClass('doing-ajax')).toBe( + true + ); + expect(setup().find('a').hasClass('doing-ajax')).toBe(false); + }); +}); diff --git a/tests/js-unit/react/reducers/templateReducer.test.js b/tests/js-unit/react/reducers/templateReducer.test.js index 82271f7f5..c51da171f 100644 --- a/tests/js-unit/react/reducers/templateReducer.test.js +++ b/tests/js-unit/react/reducers/templateReducer.test.js @@ -182,60 +182,61 @@ describe('Reducers - templateReducer', () => { }); describe('TEMPLATE_UPLOAD_PROCESSING_SUCCESS', () => { - test('check the correct state gets returned when this action runs', () => { - const test = { data: 'test' }; - const newtest = { newtest: 'new-test' }; + test('appends each result so concurrent uploads do not overwrite each other', () => { newState = reducer(initialState, { type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, - payload: test, + payload: { filename: 'one.zip', templates: [] }, }); - expect(newState.templateUploadProcessingSuccess).toBe(test); - newState = reducer(newState, { type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, - payload: newtest, + payload: { filename: 'two.zip', templates: [] }, }); - expect(newState.templateUploadProcessingSuccess).toBe(newtest); + expect(newState.templateUploadResults).toEqual([ + { filename: 'one.zip', templates: [], success: true }, + { filename: 'two.zip', templates: [], success: true }, + ]); }); }); describe('TEMPLATE_UPLOAD_PROCESSING_FAILED', () => { - test('check the correct state gets returned when this action runs', () => { - const error = { error: 'error' }; - const newerror = { newerror: 'newerror' }; + test('appends each result alongside any successful uploads', () => { newState = reducer(initialState, { - type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: error, + type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, + payload: { filename: 'one.zip', templates: [] }, }); - expect(newState.templateUploadProcessingError).toBe(error); - newState = reducer(newState, { type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: newerror, + payload: { filename: 'two.zip', message: 'error' }, }); - expect(newState.templateUploadProcessingError).toBe(newerror); + expect(newState.templateUploadResults).toEqual([ + { filename: 'one.zip', templates: [], success: true }, + { filename: 'two.zip', message: 'error', success: false }, + ]); }); }); describe('CLEAR_TEMPLATE_UPLOAD_PROCESSING', () => { test('check the correct state gets returned when this action runs', () => { newState = reducer(initialState, { + type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, + payload: { filename: 'one.zip', templates: [] }, + }); + + newState = reducer(newState, { type: CLEAR_TEMPLATE_UPLOAD_PROCESSING, }); - expect(newState.templateUploadProcessingSuccess).toEqual({}); - expect(newState.templateUploadProcessingError).toEqual({}); + expect(newState.templateUploadResults).toEqual([]); newState = reducer(newState, { type: CLEAR_TEMPLATE_UPLOAD_PROCESSING, }); - expect(newState.templateUploadProcessingSuccess).toEqual({}); - expect(newState.templateUploadProcessingError).toEqual({}); + expect(newState.templateUploadResults).toEqual([]); }); }); diff --git a/tests/js-unit/react/sagas/templates.test.js b/tests/js-unit/react/sagas/templates.test.js index 33c58f3a5..bd899f098 100644 --- a/tests/js-unit/react/sagas/templates.test.js +++ b/tests/js-unit/react/sagas/templates.test.js @@ -1,4 +1,4 @@ -import { call, put, takeLatest } from 'redux-saga/effects'; +import { call, put, takeLatest, takeEvery } from 'redux-saga/effects'; import { watchUpdateSelectBox, watchTemplateProcessing, @@ -78,9 +78,9 @@ describe('Sagas - templates', () => { describe('watchpostTemplateUploadProcessing()', () => { const gen = watchpostTemplateUploadProcessing(); - test('should check the watcher to loads up the templateUploadProcessing function and call POST_TEMPLATE_UPLOAD_PROCESSING action', () => { + test('should take every POST_TEMPLATE_UPLOAD_PROCESSING action so multi-file drops all upload', () => { expect(gen.next().value).toEqual( - takeLatest( + takeEvery( POST_TEMPLATE_UPLOAD_PROCESSING, templateUploadProcessing ) @@ -119,7 +119,7 @@ describe('Sagas - templates', () => { expect(gen.next(response).value).toEqual( put({ type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: { message: 'invalid zip' }, + payload: { message: 'invalid zip', filename: 'test' }, }) ); }); @@ -139,7 +139,7 @@ describe('Sagas - templates', () => { expect(gen.next(response).value).toEqual( put({ type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, - payload: response.body, + payload: { ...response.body, filename: 'test' }, }) ); }); @@ -155,7 +155,7 @@ describe('Sagas - templates', () => { expect(gen.next(response).value).toEqual( put({ type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: { message: '' }, + payload: { message: '', filename: 'test' }, }) ); }); @@ -170,7 +170,7 @@ describe('Sagas - templates', () => { expect(gen.throw({ message: 'network failure' }).value).toEqual( put({ type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: { message: 'network failure' }, + payload: { message: 'network failure', filename: 'test' }, }) ); }); diff --git a/tests/phpunit/integration/Helper/Fonts/Test_LocalFile.php b/tests/phpunit/integration/Helper/Fonts/Test_LocalFile.php index 7a57241f9..53e88ded9 100644 --- a/tests/phpunit/integration/Helper/Fonts/Test_LocalFile.php +++ b/tests/phpunit/integration/Helper/Fonts/Test_LocalFile.php @@ -86,6 +86,62 @@ public function validate( FileInfoInterface $file ): void { $this->assertStringContainsString( 'not a real font', $errors[0] ); } + /** + * LocalFile::isValid() resets its errors per call, so repeated calls don't accumulate duplicates. + * + * The override reimplements the parent's loop, so it needs the parent's reset of its own โ€” + * upload() calls isValid() again, which would otherwise report every error twice. + */ + public function test_is_valid_resets_errors_between_calls(): void { + $this->tmp_file = tempnam( sys_get_temp_dir(), 'gfpdf_test_' ) . '.ttf'; + touch( $this->tmp_file ); + + $_FILES['font'] = [ + 'tmp_name' => $this->tmp_file, + 'name' => 'mybadfile.ttf', + 'error' => UPLOAD_ERR_OK, + ]; + + $storage = new LocalFilesystem( sys_get_temp_dir() ); + $file = new LocalFile( 'font', $storage ); + + $file->addValidation( + new class implements ValidationInterface { + public function validate( FileInfoInterface $file ): void { + throw new UploadException( 'not a real font' ); + } + } + ); + + $this->assertFalse( $file->isValid() ); + $this->assertCount( 1, $file->getErrors() ); + + $this->assertFalse( $file->isValid() ); + $this->assertCount( 1, $file->getErrors() ); + } + + /** + * Errors recorded while reading $_FILES survive the reset. + * + * LocalFile skips the is-uploaded-file check, so a transfer-level failure is all it can report. + */ + public function test_is_valid_keeps_constructor_errors(): void { + $_FILES['font'] = [ + 'tmp_name' => '', + 'name' => 'too-big.ttf', + 'error' => UPLOAD_ERR_FORM_SIZE, + ]; + + $storage = new LocalFilesystem( sys_get_temp_dir() ); + $file = new LocalFile( 'font', $storage ); + + $this->assertCount( 1, $file->getErrors() ); + + $this->assertFalse( $file->isValid() ); + $this->assertCount( 1, $file->getErrors() ); + $this->assertStringContainsString( 'too-big', $file->getErrors()[0] ); + } + /** * LocalFile::isValid() returns true when all validations pass. */ diff --git a/tests/phpunit/integration/Model/Test_Templates.php b/tests/phpunit/integration/Model/Test_Templates.php index bce41bc11..ecbe96dd7 100644 --- a/tests/phpunit/integration/Model/Test_Templates.php +++ b/tests/phpunit/integration/Model/Test_Templates.php @@ -7,6 +7,7 @@ use GFPDF\Controller\Controller_Templates; use GFPDF\Helper\Fonts\LocalFile; use GFPDF\Helper\Fonts\LocalFilesystem; +use GFPDF\Helper\Helper_Templates; use GFPDF\Model\Model_Templates; use GFPDF_Vendor\GravityPdf\Upload\Exception as UploadException; use GFPDF\Tests\Integration\TestCase; @@ -179,6 +180,50 @@ public function test_move_template_to_tmp_dir() { @unlink( $path ); } + /** + * The upload has to be a zip by extension *and* by contents + * + * Validation\FileType pairs the two, where Extension and Mimetype side by side checked two + * independent allow-lists and so accepted a file whose extension and contents disagreed. + * + * @since 6.17 + */ + public function test_move_template_to_tmp_dir_requires_the_contents_to_match_the_extension() { + global $gfpdf; + + /* A font wearing a .zip extension: the extension is allowed, the contents are not */ + $disguised = $gfpdf->data->template_tmp_location . 'disguised.zip'; + copy( PDF_PLUGIN_DIR . 'tools/phpunit/data/fonts/DejaVuSans.ttf', $disguised ); + + $_FILES['template'] = [ + 'name' => 'disguised.zip', + 'tmp_name' => $disguised, + 'error' => UPLOAD_ERR_OK, + ]; + + try { + $this->model->move_template_to_tmp_dir( $this->getFileStub() ); + $this->fail( 'Expected the disguised font to be refused.' ); + } catch ( UploadException $e ) { + $this->assertSame( 'File validation failed', $e->getMessage() ); + } + + /* A real zip still passes */ + $real = $this->make_zip( [ 'tmp' => '' ] ); + + $_FILES['template']['name'] = basename( $real ); + $_FILES['template']['tmp_name'] = $real; + + $path = $this->model->move_template_to_tmp_dir( $this->getFileStub() ); + + $this->assertFileExists( $path ); + + /* Cleanup */ + @unlink( $disguised ); + @unlink( $real ); + @unlink( $path ); + } + /** * Get if we get the expected results @@ -296,6 +341,140 @@ public function test_unzip_and_verify_templates() { } } + /** + * Safari auto-extracts template zips on download. Re-zipping the resulting folder buries the PDF + * templates a directory deep, which we need to see through. + * + * @since 6.17 + */ + public function test_unzip_and_verify_templates_handles_rezipped_folder() { + global $gfpdf; + + $gfpdf->templates->flush_template_transient_cache(); + + $zip = $this->make_zip( + [ + 'my-template/zadani.php' => PDF_PLUGIN_DIR . 'src/templates/zadani.php', + 'my-template/images/logo.txt' => 'not a template', + ] + ); + + try { + $dir = $this->model->unzip_and_verify_templates( $zip ); + + $this->assertSame( $this->model->get_unzipped_dir_name( $zip ) . 'my-template/', $dir ); + $this->assertFileExists( $dir . 'zadani.php' ); + } finally { + @unlink( $zip ); /* phpcs:ignore */ + $gfpdf->misc->rmdir( $this->model->get_unzipped_dir_name( $zip ) ); + $gfpdf->templates->flush_template_transient_cache(); + } + } + + /** + * @param string $expected Path relative to the extracted directory + * @param array $entries Zip contents + * + * @since 6.17 + * + * @dataProvider provider_get_template_root_dir + */ + public function test_get_template_root_dir( string $expected, array $entries ) { + global $gfpdf; + + $zip = $this->make_zip( $entries ); + + add_filter( 'filesystem_method', $direct = fn() => 'direct' ); + WP_Filesystem(); + + try { + $dir = $this->model->get_unzipped_dir_name( $zip ); + unzip_file( $zip, $dir ); + + $this->assertSame( $dir . $expected, $gfpdf->templates->get_template_root_dir( $dir ) ); + } finally { + remove_filter( 'filesystem_method', $direct ); + @unlink( $zip ); /* phpcs:ignore */ + $gfpdf->misc->rmdir( $this->model->get_unzipped_dir_name( $zip ) ); + } + } + + /** + * @return array + * + * @since 6.17 + */ + public function provider_get_template_root_dir(): array { + $zadani = PDF_PLUGIN_DIR . 'src/templates/zadani.php'; + + return [ + 'templates at the top level' => [ + '', + [ 'zadani.php' => $zadani ], + ], + + 'wrapped in a single folder' => [ + 'my-template/', + [ 'my-template/zadani.php' => $zadani ], + ], + + /* A Finder-compressed folder โ€” unzip_file() drops the root __MACOSX, leaving one wrapper */ + 'macOS-compressed folder' => [ + 'my-template/', + [ + 'my-template/zadani.php' => $zadani, + '__MACOSX/my-template/._zadani.php' => 'apple double', + ], + ], + + 'wrapped twice' => [ + 'outer/inner/', + [ 'outer/inner/zadani.php' => $zadani ], + ], + + 'hidden folders are skipped' => [ + 'my-template/', + [ + 'my-template/zadani.php' => $zadani, + '.git/HEAD' => 'ref: refs/heads/main', + ], + ], + + 'ambiguous, so left alone' => [ + '', + [ + 'one/zadani.php' => $zadani, + 'two/rubix.php' => PDF_PLUGIN_DIR . 'src/templates/rubix.php', + ], + ], + + 'assets only, so left alone' => [ + 'images/', + [ 'images/logo.txt' => 'not a template' ], + ], + ]; + } + + /** + * @since 6.17 + */ + public function test_get_max_upload_size() { + $this->assertSame( 32 * MB_IN_BYTES, Helper_Templates::MAX_UPLOAD_SIZE ); + + /* Never offer to accept more than the server itself will */ + add_filter( 'upload_size_limit', $tiny = fn() => MB_IN_BYTES ); + $this->assertSame( MB_IN_BYTES, Helper_Templates::get_max_upload_size() ); + remove_filter( 'upload_size_limit', $tiny ); + + add_filter( 'upload_size_limit', $huge = fn() => 512 * MB_IN_BYTES ); + $this->assertSame( 32 * MB_IN_BYTES, Helper_Templates::get_max_upload_size() ); + remove_filter( 'upload_size_limit', $huge ); + + add_filter( 'gfpdf_template_max_upload_size', $override = fn() => 5 * MB_IN_BYTES ); + $this->assertSame( 5 * MB_IN_BYTES, Helper_Templates::get_max_upload_size() ); + remove_filter( 'gfpdf_template_max_upload_size', $override ); + } + /** Build a zip at a unique tmp path; entries map archive-name => file path (added via addFile) or raw content string (addFromString). */ private function make_zip( array $entries ): string { global $gfpdf; diff --git a/tests/playwright/permalinks/managers/template-manager.spec.ts b/tests/playwright/permalinks/managers/template-manager.spec.ts index 8cd329d35..d882083b8 100644 --- a/tests/playwright/permalinks/managers/template-manager.spec.ts +++ b/tests/playwright/permalinks/managers/template-manager.spec.ts @@ -89,8 +89,7 @@ test.describe('Template Manager', () => { // Upload await page - .locator('.gfpdf-dropzone') - .locator('input[type="file"]') + .locator('.gfpdf-template-dropzone input[type="file"]') .setInputFiles( path.join(resourcesPath, 'template', 'test-template.zip') ); @@ -123,6 +122,77 @@ test.describe('Template Manager', () => { ).not.toBeVisible(); }); + test('should install every zip in a multi-file selection', async ({ + page, + }) => { + await pdf.navigateToNewFormPdf(form.id); + await page + .getByRole('button', { name: 'Manage PDF Templates' }) + .click(); + + // Fixtures used by no other test โ€” they all share one WordPress instance + await page + .locator('.gfpdf-template-dropzone input[type="file"]') + .setInputFiles([ + path.join(resourcesPath, 'template', 'bulk-sample.zip'), + path.join(resourcesPath, 'template', 'bulk-sample-two.zip'), + ]); + + await expect( + page.locator('.theme[data-slug="bulk-sample"]') + ).toBeVisible(); + await expect( + page.locator('.theme[data-slug="bulk-sample-two"]') + ).toBeVisible(); + }); + + test('should install a template zip that was re-zipped from an extracted folder', async ({ + page, + }) => { + await pdf.navigateToNewFormPdf(form.id); + await page + .getByRole('button', { name: 'Manage PDF Templates' }) + .click(); + + // Safari auto-extracts downloads, so users re-zip the folder and the templates end up nested + await page + .locator('.gfpdf-template-dropzone input[type="file"]') + .setInputFiles( + path.join(resourcesPath, 'template', 'rezipped-sample.zip') + ); + + await expect( + page.locator('.theme[data-slug="rezipped-sample"]') + ).toBeVisible(); + }); + + test('should treat the whole Template Manager window as a drop target', async ({ + page, + }) => { + await pdf.navigateToNewFormPdf(form.id); + await page + .getByRole('button', { name: 'Manage PDF Templates' }) + .click(); + + const overlay = page.locator('.gfpdf-dropzone-overlay'); + await expect(overlay).toBeHidden(); + + // Drag a zip over the backdrop, which sits well outside the old "Add New Template" tile + await page.locator('.theme-backdrop').dispatchEvent('dragenter', { + dataTransfer: await page.evaluateHandle(() => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add( + new File(['zip'], 'template.zip', { + type: 'application/zip', + }) + ); + return dataTransfer; + }), + }); + + await expect(overlay).toBeVisible(); + }); + test('should be able to close template manager popup button', async ({ page, }) => { diff --git a/tools/playwright/data/template/bulk-sample-two.zip b/tools/playwright/data/template/bulk-sample-two.zip new file mode 100644 index 000000000..af8dbbf5f Binary files /dev/null and b/tools/playwright/data/template/bulk-sample-two.zip differ diff --git a/tools/playwright/data/template/bulk-sample.zip b/tools/playwright/data/template/bulk-sample.zip new file mode 100644 index 000000000..b91663606 Binary files /dev/null and b/tools/playwright/data/template/bulk-sample.zip differ diff --git a/tools/playwright/data/template/rezipped-sample.zip b/tools/playwright/data/template/rezipped-sample.zip new file mode 100644 index 000000000..f4afbf8fd Binary files /dev/null and b/tools/playwright/data/template/rezipped-sample.zip differ