diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e05e2ca..1a279a6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,26 @@ ## Gravity PDF +### 6.14.3 +* ๐งน Housekeeping: WordPress 7.0 Compatibility +* ๐งน Housekeeping: Improved screen-reader support for admin pages +* ๐งน Housekeeping: Remove obsolete Gravity Forms cache workaround +* ๐ Bug: Honor PDF Conditional Logic toggle setting, so disabling it in the UI skips stale conditional rules +* ๐ Bug: Fix Rich Text Editor rendering error when switching PDF templates +* ๐ Bug: Preserve unsaved edits to the Template fields if switching to a PDF template with the same fields (eg. Core / Invoice / Certificate) +* ๐ Bug: Preserve the selected template when adding a new PDF +* ๐ Bug: Fix Gravity Forms 2.6+ version detection on PDF form-settings pages +* ๐ Bug: Fix regression in the Template Manager when an invalid .zip is uploaded +* ๐ Bug: Minor UI fixes in the Template Manager +* ๐ Bug: Fix regression in the Core Fonts installer when a download error occurs +* ๐ Bug: Reset Gravity Wiz Nested Form field IDs after each group in PDFs + ### 6.14.2 -* ๐Bug: Fix PHP warning caused by the GFCommon::get_lead_field_display() API change in Gravity Forms 2.9.29 -* ๐Bug: Fix merge tag rendering issue when switching between PDF templates -* ๐Bug: Fix Font Manager to Font select field syncing issue -* ๐Bug: Fix edge-case issue with the URL signing feature and improve verification/validation checks -* ๐Bug: Rename Chinese language files so it is correctly used when the site language is set to the zh_CN locale. +* ๐ Bug: Fix PHP warning caused by the GFCommon::get_lead_field_display() API change in Gravity Forms 2.9.29 +* ๐ Bug: Fix merge tag rendering issue when switching between PDF templates +* ๐ Bug: Fix Font Manager to Font select field syncing issue +* ๐ Bug: Fix edge-case issue with the URL signing feature and improve verification/validation checks +* ๐ Bug: Rename Chinese language files so it is correctly used when the site language is set to the zh_CN locale. * ๐งน Housekeeping: Improve template caching and performance * ๐งน Housekeeping: Update PHP and Javascript dependencies * ๐งน Housekeeping: Disable asset caching when `SCRIPT_DEBUG` constant enabled @@ -17,24 +31,24 @@ ### 6.14.1 * ๐งน Housekeeping: Add `gfpdf-{$type}` CSS class to the HTML mark-up when a field uses a different input type * ๐งน Housekeeping: Use the field type (not input type) in the `gfpdf_pdf_field_content_{$type}` filter -* ๐Bug: Fix PHP error if another plugin lazy loads the PSR/Log v1 library +* ๐ Bug: Fix PHP error if another plugin lazy loads the PSR/Log v1 library ### 6.14.0 * ๐ Feature: Rotate Gravity PDF log files when Gravity Forms logging is enabled. This prevents the log file getting to large. * ๐ Feature: Add support for using v1, v2, and v3 of the PSR/Log Composer library with Gravity PDF -* ๐Bug: Fix PHP error if a third-party plugin loads PSR/Log v2 or v3 +* ๐ Bug: Fix PHP error if a third-party plugin loads PSR/Log v2 or v3 ### 6.13.5 -* ๐Bug: Ensure background queue uses correct entry data when resending notifications -* ๐Bug: Prevent plugins corrupting PDF data when viewing/downloading (via output buffer) +* ๐ Bug: Ensure background queue uses correct entry data when resending notifications +* ๐ Bug: Prevent plugins corrupting PDF data when viewing/downloading (via output buffer) ### 6.13.4 -* ๐Bug: Resolve PDF View/Download issue if both Event Espresso and LifterLMS plugin are installed +* ๐ Bug: Resolve PDF View/Download issue if both Event Espresso and LifterLMS plugin are installed ### 6.13.3 * ๐ Security: Remove the mPDF and Gravity PDF version numbers in the PDF metadata -* ๐Bug: Resolve PHP error in 6.13.2 upgrade routine if the temporary PDF directory has been incorrectly set to a shared system folder -* ๐Bug: Resolve PHP error if the `page` or `subview` admin URL parameters are arrays +* ๐ Bug: Resolve PHP error in 6.13.2 upgrade routine if the temporary PDF directory has been incorrectly set to a shared system folder +* ๐ Bug: Resolve PHP error if the `page` or `subview` admin URL parameters are arrays ### 6.13.2 * ๐ Bug: Fix plugin build issue preventing the mPDF cache filesystem fix (6.13.0) from working diff --git a/pdf.php b/pdf.php index 5063b01c8..61a569efa 100644 --- a/pdf.php +++ b/pdf.php @@ -1,8 +1,8 @@ notices[] = static function () { /* translators: 1. HTML Anchor Open Tag 2. HTML Anchor Open Tag 3. Html Anchor Close Tag */ return sprintf( esc_html__( '%1$sGravity Forms%3$s is required to use Gravity PDF. %2$sGet more information%3$s.', 'gravity-pdf' ), '', '', '' ); @@ -228,7 +228,7 @@ public function check_gravity_forms() { return false; } - if ( ! version_compare( GFCommon::$version, $this->required_gf_version, '>=' ) ) { + if ( ! version_compare( GFForms::$version, $this->required_gf_version, '>=' ) ) { $this->notices[] = function () { /* translators: 1. HTML Anchor Open Tag 2. HTML Anchor Close Tag 3. Plugin version number 4. Html Anchor Open Tag */ return sprintf( esc_html__( '%1$sGravity Forms%2$s version %3$s or higher is required. %4$sGet more information%2$s.', 'gravity-pdf' ), '', '', $this->required_gf_version, '' ); @@ -532,7 +532,7 @@ public function maybe_display_canonical_plugin_notice() { ] ); - \GFCommon::add_dismissible_message( + GFCommon::add_dismissible_message( $message, 'gravity-pdf-canonical-plugin-notice', 'warning', diff --git a/src/Helper/Fields/Field_Form.php b/src/Helper/Fields/Field_Form.php index 1c2069294..d286221be 100644 --- a/src/Helper/Fields/Field_Form.php +++ b/src/Helper/Fields/Field_Form.php @@ -91,10 +91,10 @@ public function html( $value = '', $label = true ) { $this->field->id = "$field_id-$key"; $html .= parent::html( $markup ); - } - /* Reset the ID back to the original value */ - $this->field->id = $field_id; + /* Reset the ID back to the original value */ + $this->field->id = $field_id; + } return $html; } diff --git a/src/Helper/Helper_Abstract_Fields.php b/src/Helper/Helper_Abstract_Fields.php index 569945ad0..d7c533446 100644 --- a/src/Helper/Helper_Abstract_Fields.php +++ b/src/Helper/Helper_Abstract_Fields.php @@ -4,8 +4,6 @@ use Exception; use GF_Field; -use GFCache; -use GFCommon; use GFFormsModel; use GFPDF\Statics\Kses; @@ -232,20 +230,6 @@ final public function remove_cache() { */ final public function get_value() { - /** - * Gravity Forms' GFCache function was thrashing the database, causing double the amount of time for the field_value() method to run. - * The reason is that the cache was checking against a field value stored in a transient every time `GFFormsModel::get_lead_field_value()` is called. - * We're forcing the cache to skip the extra database lookup and just get the value. - * - * @hack - * @since 4.0 - * @credit Zack Katz (Gravity View author) - * @fixed Gravity Forms 1.9.13.25 - */ - if ( class_exists( 'GFCache' ) && version_compare( GFCommon::$version, '1.9.13.25', '<' ) ) { - GFCache::set( 'GFFormsModel::get_lead_field_value_' . $this->entry['id'] . '_' . $this->field->id, false, false, 0 ); - } - /* * Get the Gravity Forms field value * diff --git a/src/Helper/Helper_Abstract_Pdf_Shortcode.php b/src/Helper/Helper_Abstract_Pdf_Shortcode.php index b8ff35e80..14e9ed4ad 100644 --- a/src/Helper/Helper_Abstract_Pdf_Shortcode.php +++ b/src/Helper/Helper_Abstract_Pdf_Shortcode.php @@ -148,7 +148,7 @@ protected function get_pdf_config( $entry_id, $pdf_id ) { throw new GravityPdfShortcodePdfInactiveException(); } - if ( isset( $settings['conditionalLogic'] ) && ! $this->misc->evaluate_conditional_logic( $settings['conditionalLogic'], $entry ) ) { + if ( ! $this->misc->conditional_logic_passes( $settings, $entry ) ) { throw new GravityPdfShortcodePdfConditionalLogicFailedException(); } diff --git a/src/Helper/Helper_Form.php b/src/Helper/Helper_Form.php index b5541d80c..ab55b28e5 100644 --- a/src/Helper/Helper_Form.php +++ b/src/Helper/Helper_Form.php @@ -4,6 +4,7 @@ use GFAPI; use GFCommon; +use GFForms; use GFFormsModel; use WP_Error; @@ -33,7 +34,7 @@ class Helper_Form extends Helper_Abstract_Form { * @since 4.0 */ public function get_version() { - return GFCommon::$version; + return GFForms::$version; } /** diff --git a/src/Helper/Helper_Misc.php b/src/Helper/Helper_Misc.php index 8e90cf2c8..83ec1fba4 100644 --- a/src/Helper/Helper_Misc.php +++ b/src/Helper/Helper_Misc.php @@ -829,6 +829,39 @@ public function update_deprecated_config( $value ) { return $value; } + /** + * Whether a PDF's saved settings should pass the entry through conditional-logic gating. + * + * The form-settings UI exposes two related fields: the `conditional` toggle (the user-visible + * on/off switch) and the `conditionalLogic` rules array. They can drift out of sync โ the + * toggle gets disabled while the rules array keeps its previous value โ so trusting only + * `conditionalLogic` makes the runtime disagree with what the UI shows. + * + * Returns true when the entry is allowed (toggle off, no rules, or rules pass), false when + * the toggle is on and rules explicitly reject the entry. + * + * The `conditional` key may be absent on very old settings; treat that as "no override, + * fall back to the rules" so legacy behaviour is preserved. + * + * @param array $settings The PDF settings array + * @param array $entry The Gravity Forms entry + * + * @return bool + * + * @since 6.14.3 + */ + public function conditional_logic_passes( $settings, $entry ) { + if ( array_key_exists( 'conditional', $settings ) && empty( $settings['conditional'] ) ) { + return true; + } + + if ( empty( $settings['conditionalLogic'] ) ) { + return true; + } + + return $this->evaluate_conditional_logic( $settings['conditionalLogic'], $entry ); + } + /** * Determine if the logic should show or hide the item * diff --git a/src/Helper/Helper_PDF_List_Table.php b/src/Helper/Helper_PDF_List_Table.php index 158c60a68..7d4767a0d 100644 --- a/src/Helper/Helper_PDF_List_Table.php +++ b/src/Helper/Helper_PDF_List_Table.php @@ -2,6 +2,7 @@ namespace GFPDF\Helper; +use GFForms; use WP_List_Table; /** @@ -223,7 +224,7 @@ public function column_cb( $item ) { $text = __( 'Inactive', 'gravity-pdf' ); } - $gf_less_than_288 = version_compare( \GFCommon::$version, '2.8.8', '<' ); + $gf_less_than_288 = version_compare( GFForms::$version, '2.8.8', '<' ); ?> @@ -289,7 +290,7 @@ public function column_shortcode( $item ) { ob_start(); /* If the current GF version is 2.6 or higher, use the new updated UI for the shortcode button or else use the pre GF 2.5 version. */ - if ( version_compare( '2.6-rc-1', \GFCommon::$version, '<=' ) ): + if ( version_compare( GFForms::$version, '2.6.0', '>=' ) ): ?> =' ) ) { + if ( version_compare( GFForms::$version, '2.6.0', '>=' ) ) { $form_classes .= 'gfpdf-gf-2-6'; } @@ -627,6 +627,7 @@ public function get_template_name_from_current_page() { /* phpcs:enable */ /* If we don't have a specific PDF we'll use the defaults */ + $template = ''; if ( empty( $pid ) || empty( $form_id ) ) { $template = $this->options->get_option( 'default_template', 'zadani' ); } else { @@ -635,8 +636,9 @@ public function get_template_name_from_current_page() { if ( ! is_wp_error( $pdf ) ) { $template = $pdf['template']; - } else { - $template = ''; + } elseif ( ! empty( $_POST['gfpdf_settings']['template'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing + /* in the middle of creating a new PDF, and not yet saved in the DB. Grab from POST data */ + $template = sanitize_html_class( $_POST['gfpdf_settings']['template'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } } diff --git a/src/Model/Model_Mergetags.php b/src/Model/Model_Mergetags.php index e4986088b..62344a813 100644 --- a/src/Model/Model_Mergetags.php +++ b/src/Model/Model_Mergetags.php @@ -182,7 +182,7 @@ public function process_pdf_mergetags( $text, $form, $entry, $url_encode ) { /* Strip tag if config not valid, it isn't active or conditional logic is not met */ if ( is_wp_error( $config ) || $config['active'] !== true - || ( isset( $config['conditionalLogic'] ) && ! $this->misc->evaluate_conditional_logic( $config['conditionalLogic'], $entry ) ) + || ! $this->misc->conditional_logic_passes( $config, $entry ) ) { $error = 'Conditional logic did not pass'; if ( is_wp_error( $config ) ) { diff --git a/src/Model/Model_PDF.php b/src/Model/Model_PDF.php index aee51332d..52259c7c0 100644 --- a/src/Model/Model_PDF.php +++ b/src/Model/Model_PDF.php @@ -417,7 +417,7 @@ public function middle_active( $action, $entry, $settings ) { public function middle_conditional( $action, $entry, $settings ) { if ( ! is_wp_error( $action ) ) { - if ( isset( $settings['conditionalLogic'] ) && ! $this->misc->evaluate_conditional_logic( $settings['conditionalLogic'], $entry ) ) { + if ( ! $this->misc->conditional_logic_passes( $settings, $entry ) ) { return new WP_Error( 'conditional_logic', esc_html__( 'PDF conditional logic requirements have not been met.', 'gravity-pdf' ) ); } } @@ -795,7 +795,7 @@ public function get_active_pdfs( $pdfs, $entry ) { $form = apply_filters( 'gfpdf_current_form_object', $this->gform->get_form( $entry['form_id'] ), $entry, __FUNCTION__ ); foreach ( $pdfs as $pdf ) { - if ( $pdf['active'] && ( empty( $pdf['conditionalLogic'] ) || $this->misc->evaluate_conditional_logic( $pdf['conditionalLogic'], $entry ) ) ) { + if ( $pdf['active'] && $this->misc->conditional_logic_passes( $pdf, $entry ) ) { $filtered[ $pdf['id'] ] = $pdf; } } diff --git a/src/Model/Model_Templates.php b/src/Model/Model_Templates.php index e400d6435..d92456a55 100644 --- a/src/Model/Model_Templates.php +++ b/src/Model/Model_Templates.php @@ -155,7 +155,7 @@ public function ajax_process_uploaded_template() { /* Get the template headers now all the files are in the right location */ $this->templates->flush_template_transient_cache(); - $headers = $this->get_template_info( glob( $unzipped_dir_name . '*.php', GLOB_NOSORT ) ); + $headers = $this->get_template_info( $this->templates->get_all_templates_in_folder( $unzipped_dir_name ) ); /* Fix template path */ $headers = array_map( @@ -356,8 +356,10 @@ public function unzip_and_verify_templates( $zip_path ) { throw new Exception( esc_html( $results->get_error_message() ) ); } - /* Check unzipped templates for a valid v4 header, or v3 string pattern */ - $files = glob( $dir . '*.php', GLOB_NOSORT ); + /* 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 */ + $files = $this->templates->get_all_templates_in_folder( $dir ); if ( ! is_array( $files ) || count( $files ) === 0 ) { throw new Exception( esc_html__( 'No valid PDF template found in Zip archive.', 'gravity-pdf' ) ); diff --git a/src/View/html/GravityForms/settings_field.php b/src/View/html/GravityForms/settings_field.php index 37708a8f7..3ead3aa35 100644 --- a/src/View/html/GravityForms/settings_field.php +++ b/src/View/html/GravityForms/settings_field.php @@ -14,20 +14,43 @@ /** @var $args array */ +$for = $args['callback_args']['type'] !== 'rich_editor' ? + 'gfpdf_settings[' . $args['callback_args']['id'] . ']' : + 'gfpdf_settings_' . $args['callback_args']['id']; + +/* Group complex fields together into a fieldset */ ?> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/View/html/Settings/general.php b/src/View/html/Settings/general.php index 968e176bd..2c47cbdd4 100644 --- a/src/View/html/Settings/general.php +++ b/src/View/html/Settings/general.php @@ -23,7 +23,7 @@ - + diff --git a/src/View/html/Settings/tools.php b/src/View/html/Settings/tools.php index 68060576c..8538af52a 100644 --- a/src/View/html/Settings/tools.php +++ b/src/View/html/Settings/tools.php @@ -23,7 +23,7 @@ - + diff --git a/src/assets/js/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.js b/src/assets/js/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.js new file mode 100644 index 000000000..45e2e323e --- /dev/null +++ b/src/assets/js/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.js @@ -0,0 +1,106 @@ +import $ from 'jquery' + +const TEMPLATE_DROPDOWN_SELECTOR = '#gfpdf_settings\\[template\\]' + +/** + * Capture every recoverable user input inside $container. Returns a flat + * array of entries discriminated by `kind`: + * + * { kind: 'input', name, value, checked? } + * { kind: 'toggle', controls, checked } + * + * For TinyMCE-backed textareas the editor's live content is used when the + * editor is in Visual mode (`editor.hidden === false`). In Code mode the + * textarea itself holds the live edits, so the editor body is ignored. + * + * `.gfpdf-input-toggle` controls (the First Page Header/Footer toggles) + * have no `name` attribute, so they're keyed by the name of the textarea + * inside the container they show/hide โ a stable identity across the + * AJAX-driven HTML swap. + * + * @param {jQuery} $container + * @returns {Array} + */ +export function snapshotFormValues ($container) { + const entries = [] + + $container.find(':input').not(TEMPLATE_DROPDOWN_SELECTOR).each(function () { + const $el = $(this) + const name = $el.attr('name') + if (!name || $el.is(':button, :submit, :reset, [type=file]')) { + return + } + + if ($el.is(':checkbox') || $el.is(':radio')) { + entries.push({ kind: 'input', name, value: $el.val(), checked: $el.prop('checked') }) + return + } + + const id = $el.attr('id') + if ($el.is('textarea') && id && typeof tinyMCE !== 'undefined') { + const editor = tinyMCE.get(id) + if (editor && !editor.hidden) { + entries.push({ kind: 'input', name, value: editor.getContent() }) + return + } + } + + entries.push({ kind: 'input', name, value: $el.val() }) + }) + + $container.find('.gfpdf-input-toggle').each(function () { + const $toggle = $(this) + const controlsName = $toggle.parent().next().find('textarea').first().attr('name') + if (!controlsName) { + return + } + entries.push({ kind: 'toggle', controls: controlsName, checked: $toggle.prop('checked') }) + }) + + return entries +} + +/** + * Re-apply a snapshot captured by snapshotFormValues() to whatever fields + * still exist after the template section's HTML was swapped. Snapshot + * entries with no match in the new HTML are silently dropped. + * + * Restoring a checkbox/radio (or toggle) fires `change` so dependent UI + * driven by `setupToggledFields` slides its conditional panel into the + * right state. + * + * @param {jQuery} $container + * @param {Array} snapshot see snapshotFormValues + */ +export function restoreFormValues ($container, snapshot) { + snapshot.forEach(function (item) { + if (item.kind === 'toggle') { + const $toggle = $container.find('.gfpdf-input-toggle').filter(function () { + return $(this).parent().next().find('textarea').first().attr('name') === item.controls + }) + if (!$toggle.length || $toggle.prop('checked') === item.checked) { + return + } + $toggle.prop('checked', item.checked).trigger('change') + return + } + + const $matches = $container.find(':input[name="' + item.name + '"]') + if (!$matches.length) { + return + } + + if (typeof item.checked === 'boolean') { + const $target = $matches.filter(function () { + return $(this).val() === item.value + }) + if (!$target.length || $target.prop('checked') === item.checked) { + return + } + $target.prop('checked', item.checked).trigger('change') + return + } + + $matches.first().val(item.value) + }) +} diff --git a/src/assets/js/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.js b/src/assets/js/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.js index cd3c28388..d9f84b75f 100644 --- a/src/assets/js/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.js +++ b/src/assets/js/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.js @@ -1,15 +1,11 @@ -import $ from 'jquery' - /** - * Initialises AJAX-loaded wp_editor TinyMCE containers for use - * @param Array editors The DOM element IDs to parse - * @param Object settings The TinyMCE settings to use - * @return void + * Initializes AJAX-loaded wp_editor TinyMCE containers for use + * @param {Array.} editors The DOM element IDs to parse + * @param {Record} settings The TinyMCE settings to use * @since 4.0 */ export function loadTinyMCEEditor (editors, settings) { if (settings != null) { - /* Ensure appropriate settings defaults */ settings.body_class = 'id post-type-post post-status-publish post-format-standard' settings.formats = { alignleft: [ @@ -29,26 +25,56 @@ export function loadTinyMCEEditor (editors, settings) { settings.content_style = 'body#tinymce { max-width: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;}' } - /* Load our new editors */ - $.each(editors, function (index, fullId) { - /* Setup out selector */ - settings.selector = '#' + fullId + const lastEditorTab = getUserSetting('editor') === 'html' ? 'html' : 'tmce' - /* Initialise our editor */ + editors.forEach(function (fullId) { + settings.selector = '#' + fullId tinyMCE.init(settings) - - /* Add our editor to the DOM */ tinyMCE.execCommand('mceAddEditor', false, fullId) - /* Enable WP quick tags */ - if (typeof (QTags) === 'function') { + if (typeof QTags === 'function') { QTags({ id: fullId }) QTags._buttonsInit() - /* remember last tab selected */ - if (typeof switchEditors.switchto === 'function') { - switchEditors.switchto(jQuery('#wp-' + fullId + '-wrap').find('.wp-switch-editor.switch-' + (getUserSetting('editor') === 'html' ? 'html' : 'tmce'))[0]) - } + restoreLastEditorTab(fullId, lastEditorTab) } }) } + +/** + * Restore the user's last-selected editor tab on a freshly mounted wp_editor. + * Deferred via editor.on('init') โ switchEditors.go throws if the iframe isn't built. + * + * @param {string} fullId + * @param {'html'|'tmce'} mode + */ +function restoreLastEditorTab (fullId, mode) { + if (typeof switchEditors === 'undefined' || typeof switchEditors.go !== 'function') { + return + } + + const apply = function () { + try { + /* Clear TinyMCE's default-cursor range so switchEditors.go's findBookmarkedPosition + * short-circuits โ otherwise it schedules a textArea.focus() that scrolls the page. */ + const editor = tinyMCE.get(fullId) + const editorWindow = editor && typeof editor.getWin === 'function' ? editor.getWin() : null + if (editorWindow && typeof editorWindow.getSelection === 'function') { + const selection = editorWindow.getSelection() + if (selection) { + selection.removeAllRanges() + } + } + switchEditors.go(fullId, mode) + } catch (e) { + /* Fall back silently to Visual mode if WP throws โ the user can flip the tab manually. */ + } + } + + const editor = tinyMCE.get(fullId) + if (editor && editor.initialized) { + apply() + } else if (editor && typeof editor.on === 'function') { + editor.on('init', apply) + } +} diff --git a/src/assets/js/admin/settings/common/setupDynamicTemplateFields.js b/src/assets/js/admin/settings/common/setupDynamicTemplateFields.js index c8404a4b0..8e747bba4 100644 --- a/src/assets/js/admin/settings/common/setupDynamicTemplateFields.js +++ b/src/assets/js/admin/settings/common/setupDynamicTemplateFields.js @@ -7,6 +7,10 @@ import { doMergetags } from './dynamicTemplateFields/doMergetags' import { toggleFontAppearance } from '../pdf/toggleFontAppearance' import { insertAfter } from '../../../react/utilities/PdfSettings/addEditButton' import { handleMergeTags } from './handleMergeTags' +import { snapshotFormValues, restoreFormValues } from './dynamicTemplateFields/formValuesSnapshot' + +const TEMPLATE_SECTION_SELECTOR = '#gfpdf-fieldset-gfpdf_form_settings_template' +const TEMPLATE_DROPDOWN_SELECTOR = '#gfpdf_settings\\[template\\]' /** * PDF Templates can assign their own custom settings which can enhance a template @@ -16,12 +20,15 @@ import { handleMergeTags } from './handleMergeTags' */ export function setupDynamicTemplateFields () { /* Add change listener to our template */ - $('#gfpdf_settings\\[template\\]').off('change').on('change', function () { + $(TEMPLATE_DROPDOWN_SELECTOR).off('change').on('change', function () { /* Add spinner */ const $spinner = spinner('gfpdf-spinner-template') $(this).next().after($spinner) + /* Snapshot any unsaved edits so they can be re-applied to matching fields in the new template */ + const formValuesSnapshot = snapshotFormValues($(TEMPLATE_SECTION_SELECTOR)) + const data = { action: 'gfpdf_get_template_fields', nonce: GFPDF.ajaxNonce, @@ -48,7 +55,7 @@ export function setupDynamicTemplateFields () { if (editor !== null) { /* Bug Fix for Firefox - http://www.tinymce.com/develop/bugtracker_view.php?id=3152 */ try { - tinyMCE.remove(editor) + tinyMCE.remove('#' + value) } catch (e) { // empty } @@ -57,22 +64,27 @@ export function setupDynamicTemplateFields () { /* Add floating Add/Edit PDF button */ if (!addEditButton) { - insertAfter($('#gfpdf-fieldset-gfpdf_form_settings_template')[0], $('#gfpdf_pdf_form')[0], '2') + insertAfter($(TEMPLATE_SECTION_SELECTOR)[0], $('#gfpdf_pdf_form')[0], '2') } + const $templateSection = $(TEMPLATE_SECTION_SELECTOR).show() + /* Replace the custom appearance with the AJAX response fields */ - $('#gfpdf-fieldset-gfpdf_form_settings_template') - .show() - .find('.gform-settings-panel__content') - .html(response.fields) + $templateSection.find('.gform-settings-panel__content').html(response.fields) + + /* Re-apply snapshotted values before re-init so TinyMCE & wpColorPicker mount on top of the restored values */ + restoreFormValues($templateSection, formValuesSnapshot) /* Load our new editors */ - loadTinyMCEEditor(response.editors, response.editor_init) + loadTinyMCEEditor( + response.editors ?? [], + response.editor_init ?? {} + ) /* reinitialise new dom elements */ initialiseCommonElements.runElements() doMergetags() - handleMergeTags('#gfpdf-fieldset-gfpdf_form_settings_template') + handleMergeTags(TEMPLATE_SECTION_SELECTOR) gform_initialize_tooltips() } else { /* Remove floating Add/Edit PDF button */ @@ -81,7 +93,7 @@ export function setupDynamicTemplateFields () { } /* Hide our template nav item as there are no fields and clear our the HTML */ - $('#gfpdf-fieldset-gfpdf_form_settings_template') + $(TEMPLATE_SECTION_SELECTOR) .hide() .find('.gform-settings-panel__content') .html('') diff --git a/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js b/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js index e4c44119b..d9324bc23 100644 --- a/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js +++ b/src/assets/js/react/components/FontManager/AddUpdateFontFooter.js @@ -8,6 +8,7 @@ import Spinner from '../Spinner' /* Redux actions */ import { selectFont, deleteFont } from '../../actions/fontManager' import TemplateTooltip from './TemplateTooltip' +import { getTabLocation } from '../../utilities/FontManager/getTabLocation' /** * @package Gravity PDF @@ -150,6 +151,8 @@ export class AddUpdateFontFooter extends Component { const errorFontValidation = (errorAddFont && error.fontValidationError) && error.fontValidationError const fontFileMissing = sprintf(GFPDF.fontFileMissing, '', '') const selectedBoxStyle = (id !== '') && (id === selectedFont) ? ' checked' : ' uncheck' + /* No parent font dropdown on the Tools tab, so the tick has no target */ + const hideSelectFontButton = getTabLocation() === 'tools' /* Display error message for uploading invalid font file */ const displayInvalidFileErrorMessage = errorAddFont && errorFontValidation /* Display generic error messages including missing font file */ @@ -188,7 +191,7 @@ export class AddUpdateFontFooter extends Component { - {id && ( + {id && !hideSelectFontButton && ( this.handleSelectFont(id, selectedFont)} diff --git a/src/assets/js/react/components/FontManager/FontListItems.js b/src/assets/js/react/components/FontManager/FontListItems.js index 29021f680..db172583a 100644 --- a/src/assets/js/react/components/FontManager/FontListItems.js +++ b/src/assets/js/react/components/FontManager/FontListItems.js @@ -9,6 +9,7 @@ import FontListIcon from './FontListIcon' import Spinner from '../Spinner' /* Utilities */ import { toggleUpdateFont } from '../../utilities/FontManager/toggleUpdateFont' +import { getTabLocation } from '../../utilities/FontManager/getTabLocation' /** * @package Gravity PDF @@ -118,7 +119,7 @@ export class FontListItems extends Component { * @since 6.0 */ handleDisableSelectFields = () => { - const tabLocation = window.location.search.substr(window.location.search.lastIndexOf('=') + 1) + const tabLocation = getTabLocation() if (tabLocation === 'tools') { return this.setState({ disableSelectFontName: true }) diff --git a/src/assets/js/react/components/FontManager/FontManager.js b/src/assets/js/react/components/FontManager/FontManager.js index 364a9ea02..46967f0c4 100644 --- a/src/assets/js/react/components/FontManager/FontManager.js +++ b/src/assets/js/react/components/FontManager/FontManager.js @@ -6,6 +6,7 @@ import FontManagerHeader from './FontManagerHeader' import FontManagerBody from './FontManagerBody' import { connect } from 'react-redux' import { associatedFontManagerSelectBox } from '../../utilities/FontManager/associatedFontManagerSelectBox' +import { getTabLocation } from '../../utilities/FontManager/getTabLocation' /** * @package Gravity PDF @@ -68,12 +69,8 @@ export class FontManager extends Component { fontList, selectedFont } = this.props - const tabLocation = window.location.search.substring( - window.location.search.lastIndexOf('=') + 1 - ) - /* When closed, ensure font select box has the latest custom font data */ - if (tabLocation !== 'tools') { + if (getTabLocation() !== 'tools') { return associatedFontManagerSelectBox(fontList, selectedFont) } } diff --git a/src/assets/js/react/components/Template/TemplateUploader.js b/src/assets/js/react/components/Template/TemplateUploader.js index 369dbc3c8..f81604c07 100644 --- a/src/assets/js/react/components/Template/TemplateUploader.js +++ b/src/assets/js/react/components/Template/TemplateUploader.js @@ -207,10 +207,13 @@ export class TemplateUploader extends Component { * * @since 4.1 */ - ajaxFailed = (error) => { - /* Let the user know there was a problem with the upload */ + ajaxFailed = (response) => { + const message = response && response.body && typeof response.body === 'object' && response.body.error + ? response.body.error + : this.props.genericUploadErrorText + this.setState({ - error: (error.response.body && error.response.body.error !== undefined) ? error.response.body.error : this.props.genericUploadErrorText, + error: message, ajax: false }) diff --git a/src/assets/js/react/sagas/coreFonts.js b/src/assets/js/react/sagas/coreFonts.js index 71a57cd9c..d71f50673 100644 --- a/src/assets/js/react/sagas/coreFonts.js +++ b/src/assets/js/react/sagas/coreFonts.js @@ -29,6 +29,11 @@ import { apiGetFilesFromGitHub, apiPostDownloadFonts } from '../api/coreFonts' export function * getFilesFromGitHub () { try { const response = yield call(apiGetFilesFromGitHub) + + if (!response.ok || !response.body) { + throw response + } + yield put(getFilesFromGitHubSuccess(response.body)) } catch (error) { yield put(getFilesFromGitHubFailed(GFPDF.coreFontGithubError)) @@ -65,7 +70,7 @@ export function * getDownloadFonts (chan) { try { const response = yield call(apiPostDownloadFonts, payload) - if (!response.body) { + if (!response.ok || !response.body) { throw response } diff --git a/src/assets/js/react/sagas/templates.js b/src/assets/js/react/sagas/templates.js index 3c07b6de1..621c8d7b0 100644 --- a/src/assets/js/react/sagas/templates.js +++ b/src/assets/js/react/sagas/templates.js @@ -62,9 +62,15 @@ export function * templateProcessing (action) { export function * templateUploadProcessing (action) { try { const response = yield call(apiPostTemplateUploadProcessing, action.payload.file, action.payload.filename) + + if (!response.ok || !response.body || !Array.isArray(response.body.templates)) { + yield put(templateUploadProcessingFailed(response)) + return + } + yield put(templateUploadProcessingSuccess(response)) } catch (error) { - yield put(templateUploadProcessingFailed(error)) + yield put(templateUploadProcessingFailed({ ok: false, body: null })) } } diff --git a/src/assets/js/react/utilities/FontManager/getTabLocation.js b/src/assets/js/react/utilities/FontManager/getTabLocation.js new file mode 100644 index 000000000..79f2b2f25 --- /dev/null +++ b/src/assets/js/react/utilities/FontManager/getTabLocation.js @@ -0,0 +1,24 @@ +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.14.3 + */ + +/** + * Return the value of the final query-string parameter (after the last `=`). + * + * Gravity Forms admin URLs put the active "tab" in different params depending + * on context: `subview=PDF` on the global settings page, `tab=tools` on the + * Tools tab, and a form id at the end of form-level PDF settings. Reading the + * value after the last `=` mirrors that order-dependent convention so callers + * can distinguish global vs form vs Tools without parsing each variant. + * + * @returns {string} the trailing parameter value, or '' when the URL has no query string + */ +export function getTabLocation () { + const { search } = window.location + const lastEquals = search.lastIndexOf('=') + + return lastEquals === -1 ? '' : search.substring(lastEquals + 1) +} diff --git a/src/assets/scss/Component/FontManager/_font-manager.scss b/src/assets/scss/Component/FontManager/_font-manager.scss index 0d3bfa07e..310c1d916 100644 --- a/src/assets/scss/Component/FontManager/_font-manager.scss +++ b/src/assets/scss/Component/FontManager/_font-manager.scss @@ -57,6 +57,19 @@ cursor: pointer; text-decoration: underline; } + + button.link { + background-color: unset; + border: unset; + box-shadow: unset; + text-decoration: underline; + color: base.$link-color; + padding: 0; + + &:hover { + cursor: pointer; + } + } } .font-list-header, @@ -163,127 +176,125 @@ } } - form { - h2 { - font-size: 1.8em; - margin: 0.5rem 0; + h2 { + font-size: 1.8em; + margin: 0.5rem 0; - & + p { - margin: 0; - } + & + p { + margin: 0; } + } - label { - display: block; - margin-top: 1rem; - font-size: 1rem; - font-weight: 600; + label { + display: block; + margin-top: 1rem; + font-size: 1rem; + font-weight: 600; - & + p { - margin: 0.325rem 0; - } + & + p { + margin: 0.325rem 0; } + } - .required { - color: base.$validation-error-color; - font-size: 0.8rem; - } + .required { + color: base.$validation-error-color; + font-size: 0.8rem; + } - #gfpdf-font-files-setting { - display: grid; - grid-template-columns: 50% 50%; - grid-template-rows: 1fr 1fr; - gap: 1rem; - margin: 0.75rem 0; - width: 96%; + #gfpdf-font-files-setting { + display: grid; + grid-template-columns: 50% 50%; + grid-template-rows: 1fr 1fr; + gap: 1rem; + margin: 0.75rem 0; + width: 96%; - a { - color: unset; + a { + color: unset; - &:focus { - box-shadow: unset; - } + &:focus { + box-shadow: unset; } + } - .drop-zone { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - border: 5px dashed base.$lighter-highlight-color; - height: 160px; - transition: all 0.3s ease-out; - - &:hover, - &.active { - background-color: base.$lighter-highlight-color; - border: 5px solid base.$lighter-highlight-color; + .drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 5px dashed base.$lighter-highlight-color; + height: 160px; + transition: all 0.3s ease-out; - .dashicons { - background-color: #ffffff; - color: base.$highlight-color; - } - } + &:hover, + &.active { + background-color: base.$lighter-highlight-color; + border: 5px solid base.$lighter-highlight-color; - &:focus { - outline: none; - border: 5px dashed base.$default-text-color; + .dashicons { + background-color: #ffffff; + color: base.$highlight-color; } + } - &.required { - border: 5px dashed base.$error-color; - - .dashicons { - background-color: base.$error-color; - color: #ffffff; - } + &:focus { + outline: none; + border: 5px dashed base.$default-text-color; + } - &:focus { - border-color: base.$default-text-color; - } - } + &.required { + border: 5px dashed base.$error-color; - &.error { - background-color: base.$validation-error-color; - border-color: base.$validation-error-color; + .dashicons { + background-color: base.$error-color; color: #ffffff; } - input { - display: none; + &:focus { + border-color: base.$default-text-color; } + } - .gfpdf-font-filename { - overflow: hidden; - text-align: center; + &.error { + background-color: base.$validation-error-color; + border-color: base.$validation-error-color; + color: #ffffff; + } + + input { + display: none; + } - &.required { - color: base.$validation-error-color; - } + .gfpdf-font-filename { + overflow: hidden; + text-align: center; + + &.required { + color: base.$validation-error-color; } + } - .dashicons { - width: 70px; - height: 70px; - margin: 0.675rem; - background-color: base.$lighter-highlight-color; - border-radius: 50%; - font-size: 3.2rem; - color: base.$highlight-color; - cursor: pointer; + .dashicons { + width: 70px; + height: 70px; + margin: 0.675rem; + background-color: base.$lighter-highlight-color; + border-radius: 50%; + font-size: 3.2rem; + color: base.$highlight-color; + cursor: pointer; - &:before { - margin-left: -1px; - vertical-align: middle; - } + &:before { + margin-left: -1px; + vertical-align: middle; } + } - .dashicons-trash { - font-size: 2.9rem; + .dashicons-trash { + font-size: 2.9rem; - &:before { - margin-left: 3px; - } + &:before { + margin-left: 3px; } } } diff --git a/src/assets/scss/Component/FontManager/_footer.scss b/src/assets/scss/Component/FontManager/_footer.scss index d55a61859..b559b16f9 100644 --- a/src/assets/scss/Component/FontManager/_footer.scss +++ b/src/assets/scss/Component/FontManager/_footer.scss @@ -34,6 +34,8 @@ .dashicons { width: auto; height: auto; + font-size: 20px; + line-height: 1; } } } @@ -110,6 +112,19 @@ } } + .template-usage-link__button { + background-color: unset; + border: unset; + box-shadow: unset; + text-decoration: underline; + color: base.$link-color; + padding: 0; + + &:hover { + cursor: pointer; + } + } + &.success { color: base.$success-color; } diff --git a/src/assets/scss/Component/HelpTab/_help-tab.scss b/src/assets/scss/Component/HelpTab/_help-tab.scss index 719c160fb..54e3050bb 100644 --- a/src/assets/scss/Component/HelpTab/_help-tab.scss +++ b/src/assets/scss/Component/HelpTab/_help-tab.scss @@ -47,14 +47,14 @@ text-align: center; .button-large { - background: #007cba; + background: var(--wp-admin-theme-color, '#007cba'); border: none; - height: 35px; - line-height: 33px; - padding: 0 17px 9px; + height: 40px; + line-height: 40px; + padding: 0 16px; &:hover { - background: #0071a1; + background: var(--wp-admin-theme-color-darker-10, '#0071a1'); } } } @@ -89,6 +89,8 @@ background: #fff; box-shadow: 0 1px 1px 0 rgba(85, 95, 110, .2); padding: 16px 16px 16px 46px; + min-height: 40px; + line-height: normal; vertical-align: middle; white-space: normal; font-size: inherit; @@ -130,7 +132,6 @@ border: 0; border-radius: 4px 0 0 4px; background-color: hsla(0, 0%, 100%, 0); - padding-top: 16px; width: 46px; height: 100%; vertical-align: middle; @@ -176,7 +177,6 @@ border: 0; background: none; cursor: pointer; - padding-top: 8px; font-size: inherit; -webkit-user-select: none; -moz-user-select: none; @@ -219,25 +219,22 @@ /* Search result */ .search-result:not(:empty) { - padding: 1rem 1rem 0.8rem 1rem; + padding-block: 1rem; .group-name { - color: #3E7DA6; + color: var(--wp-admin-theme-color-darker-20, '#2a31a3'); font-size: 0.85rem; font-weight: 600; line-height: 32px; - margin: 0 -4px; - padding: 8px 4px 0; - top: 0; - z-index: 10; } ul { + margin: 0; + list-style: none; + li { border-radius: 4px; - display: flex; padding-bottom: 4px; - position: relative; a { background-color: #fff; @@ -247,8 +244,18 @@ width: 100%; text-decoration: none; + mark { + color: var(--wp-admin-theme-color-darker-20, '#2a31a3'); + background: none; + } + &:hover { - background-color: #3e7da6; + background-color: var(--wp-admin-theme-color-darker-20, '#2a31a3'); + + mark { + color: #fff; + text-decoration: underline; + } .hit-container { .hit-icon { @@ -266,7 +273,7 @@ .hit-action { color: #fff; - display: flex; + visibility: visible; } } } @@ -312,9 +319,10 @@ .hit-action { align-items: center; color: rgb(150, 159, 175); - display: none; + display: flex; height: 22px; stroke-width: 1.4; + visibility: hidden; width: 22px; } } @@ -323,4 +331,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/assets/scss/Component/_button.scss b/src/assets/scss/Component/_button.scss index 50eab98e9..6dae345f5 100644 --- a/src/assets/scss/Component/_button.scss +++ b/src/assets/scss/Component/_button.scss @@ -2,14 +2,30 @@ #tab_PDF { + .button:not(.wp-editor-wrap .button):not(.wp-picker-container .button) { + @include button.reset; + line-height: 2.95; + + &:not(.primary):hover { + border-color: unset; + background-color: unset; + } + } + /* Template and Font Manager Advanced Button */ - #gpdf-advance-template-selector, #gpdf-advance-font-manager-selector { + #gfpdf-template-container, #gfpdf-font-manager-container { display: inline-block; vertical-align: top; - button { + button, .button { @include button.reset; - line-height: 2.95; + min-height: 32px; + line-height: 2.30769231; + + &.primary:hover { + background: #242748; + border-color: #242748; + } } } } diff --git a/src/assets/scss/Component/_color-picker.scss b/src/assets/scss/Component/_color-picker.scss index b3f04c346..c036e716b 100644 --- a/src/assets/scss/Component/_color-picker.scss +++ b/src/assets/scss/Component/_color-picker.scss @@ -3,13 +3,14 @@ #tab_PDF { .wp-picker-container { - button { + .button { padding: 0 0 0 35px; border-width: 1px; border-style: solid; border-color: rgb(144, 146, 178); border-image: initial; height: auto; + line-height: 2.7; .wp-color-result-text { font-size: 0.875rem; @@ -24,7 +25,8 @@ input[type="text"] { width: 5.25rem; font-size: 1rem; + height: 42px; } } } -} \ No newline at end of file +} diff --git a/src/assets/scss/Component/_core-fonts.scss b/src/assets/scss/Component/_core-fonts.scss index fbb23b1f3..be51475aa 100644 --- a/src/assets/scss/Component/_core-fonts.scss +++ b/src/assets/scss/Component/_core-fonts.scss @@ -22,5 +22,18 @@ .gfpdf-core-font-status-error { color: base.$error-color; } + + .gfpdf-core-font-retry-link { + background-color: unset; + border: unset; + box-shadow: unset; + text-decoration: underline; + color: inherit; + padding: 0; + + &:hover { + cursor: pointer; + } + } } } diff --git a/src/assets/scss/Component/_rich-text.scss b/src/assets/scss/Component/_rich-text.scss index 983e4b305..bfa303248 100644 --- a/src/assets/scss/Component/_rich-text.scss +++ b/src/assets/scss/Component/_rich-text.scss @@ -1,5 +1,3 @@ -@use '../Mixins/button'; - #tab_PDF { /* Rich Text field */ @@ -12,21 +10,4 @@ top: 3rem; } } - - .wp-editor-wrap { - .wp-editor-tools { - button { - @include button.reset; - margin: 0; - } - - .wp-editor-tabs button { - margin-left: 5px; - } - } - - .quicktags-toolbar .button { - @include button.reset; - } - } } diff --git a/src/assets/scss/Component/_template-manager.scss b/src/assets/scss/Component/_template-manager.scss index ff7e00f85..4e937490c 100644 --- a/src/assets/scss/Component/_template-manager.scss +++ b/src/assets/scss/Component/_template-manager.scss @@ -26,6 +26,14 @@ display: block; } + .more-details { + box-sizing: border-box; + } + + &.active .theme-name { + background: #1d2327; + } + .theme-actions { opacity: 1; left: inherit; diff --git a/src/assets/scss/Pages/_pdf-settings.scss b/src/assets/scss/Pages/_pdf-settings.scss index b006e2bec..b5f697543 100644 --- a/src/assets/scss/Pages/_pdf-settings.scss +++ b/src/assets/scss/Pages/_pdf-settings.scss @@ -12,6 +12,10 @@ } /* Merge tag box */ + .gform-settings-input__container--with-merge-tag { + width: 100%; + } + .all-merge-tags { &.textarea { .tooltip-merge-tag { @@ -45,17 +49,7 @@ .gfpdf-gf-2-6 { .gfpdf-settings-field-wrapper { .wp-editor-wrap { - margin-right: 0rem; - } - } - - .wp-media-buttons { - .all-merge-tags { - top: -0.15rem; - } - - .gform-icon--merge-tag { - font-size: 2.25rem; + margin-right: 0; } } } diff --git a/src/assets/scss/_base.scss b/src/assets/scss/_base.scss index d9d1edf11..01eceb173 100644 --- a/src/assets/scss/_base.scss +++ b/src/assets/scss/_base.scss @@ -1,11 +1,11 @@ -$primary-color: #3E7DA6; +$primary-color: #3e7da6; $default-text-color: #242748; -$success-color: #22A753; -$error-color: #DD301D; -$validation-error-color: #B71F1F; +$success-color: #22a753; +$error-color: #dd301d; +$validation-error-color: #b71f1f; -$lighter-highlight-color: #D5D7E9; -$highlight-color: #5B5E80; +$lighter-highlight-color: #d5d7e9; +$highlight-color: #5b5e80; $link-color: #0073aa; diff --git a/src/assets/scss/_reset.scss b/src/assets/scss/_reset.scss index 552f951cf..b7ca76778 100644 --- a/src/assets/scss/_reset.scss +++ b/src/assets/scss/_reset.scss @@ -1,13 +1,13 @@ /* WP left sidebar */ div#adminmenuback { - z-index: 2; + z-index: 2; } -.wp-clearfix:after { - content: unset; +.wp-clearfix::after { + content: unset; } /* Always show up/down arrows for input (number) */ -input[type=number]::-webkit-inner-spin-button { - opacity: 1 +input[type="number"]::-webkit-inner-spin-button { + opacity: 1; } diff --git a/src/assets/scss/gfpdf-styles.scss b/src/assets/scss/gfpdf-styles.scss index 65a1af65e..45e757398 100644 --- a/src/assets/scss/gfpdf-styles.scss +++ b/src/assets/scss/gfpdf-styles.scss @@ -1,53 +1,53 @@ /* Reset default */ -@use 'reset'; +@use "reset"; /* Components */ -@use 'Component/alert'; -@use 'Component/button'; -@use 'Component/color-picker'; -@use 'Component/core-fonts'; -@use 'Component/FontManager/font-manager'; -@use 'Component/FontManager/footer'; -@use 'Component/FontManager/font-manager-skeleton'; -@use 'Component/HelpTab/help-tab'; -@use 'Component/gf-settings-containers'; -@use 'Component/license'; -@use 'Component/multicheck'; -@use 'Component/number'; -@use 'Component/popup'; -@use 'Component/rich-text'; -@use 'Component/search-bar'; -@use 'Component/spinner'; -@use 'Component/submit'; -@use 'Component/template-manager'; -@use 'Component/uninstaller'; -@use 'Component/upload'; -@use 'Component/hidden'; +@use "Component/alert"; +@use "Component/button"; +@use "Component/color-picker"; +@use "Component/core-fonts"; +@use "Component/FontManager/font-manager"; +@use "Component/FontManager/footer"; +@use "Component/FontManager/font-manager-skeleton"; +@use "Component/HelpTab/help-tab"; +@use "Component/gf-settings-containers"; +@use "Component/license"; +@use "Component/multicheck"; +@use "Component/number"; +@use "Component/popup"; +@use "Component/rich-text"; +@use "Component/search-bar"; +@use "Component/spinner"; +@use "Component/submit"; +@use "Component/template-manager"; +@use "Component/uninstaller"; +@use "Component/upload"; +@use "Component/hidden"; /* Pages */ -@use 'Pages/general-settings'; -@use 'Pages/entry-details'; -@use 'Pages/entry-list'; -@use 'Pages/pdf-list'; -@use 'Pages/pdf-settings'; +@use "Pages/general-settings"; +@use "Pages/entry-details"; +@use "Pages/entry-list"; +@use "Pages/pdf-list"; +@use "Pages/pdf-settings"; /* Media queries */ -@use 'MediaQueries/min-961'; -@use 'MediaQueries/min-1200'; +@use "MediaQueries/min-961"; +@use "MediaQueries/min-1200"; /* RTL Components */ -@use 'RTL/Component/FontManager/rtl-font-manager'; -@use 'RTL/Component/TemplateManager/rtl-template-manager'; -@use 'RTL/Component/rtl-spinner'; -@use 'RTL/Component/rtl-upload'; -@use 'RTL/Component/rtl-rich-text'; +@use "RTL/Component/FontManager/rtl-font-manager"; +@use "RTL/Component/TemplateManager/rtl-template-manager"; +@use "RTL/Component/rtl-spinner"; +@use "RTL/Component/rtl-upload"; +@use "RTL/Component/rtl-rich-text"; /* RTL Pages */ -@use 'RTL/rtl'; -@use 'RTL/Pages/rtl-general-settings'; -@use 'RTL/Pages/rtl-pdf-list'; -@use 'RTL/Pages/rtl-pdf-settings'; +@use "RTL/rtl"; +@use "RTL/Pages/rtl-general-settings"; +@use "RTL/Pages/rtl-pdf-list"; +@use "RTL/Pages/rtl-pdf-settings"; /* RTL Media queries */ -@use 'RTL/MediaQueries/rtl-min-961'; -@use 'RTL/MediaQueries/rtl-min-1200'; +@use "RTL/MediaQueries/rtl-min-961"; +@use "RTL/MediaQueries/rtl-min-1200"; diff --git a/src/bootstrap.php b/src/bootstrap.php index 9d9edc444..8a27b7774 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -3,8 +3,7 @@ namespace GFPDF; use GFCommon; -use GFPDF\Controller; -use GFPDF\Helper; +use GFForms; use GFPDF\Helper\Helper_Data; use GFPDF\Helper\Helper_Form; use GFPDF\Helper\Helper_Misc; @@ -12,10 +11,10 @@ use GFPDF\Helper\Helper_Options_Fields; use GFPDF\Helper\Helper_Singleton; use GFPDF\Helper\Helper_Templates; -use GFPDF\Model; -use GFPDF\View; use GFPDF_Core; use GFPDF_Major_Compatibility_Checks; +use GPDFAPI; +use Gravity_Forms\Gravity_Forms\Async\GF_Background_Process; use Psr\Log\LoggerInterface; /* @@ -208,13 +207,13 @@ public function init() { ); /* Load Background Queue classes */ - if ( version_compare( \GFCommon::$version, '2.9.7.2', '>=' ) ) { - if ( ! class_exists( '\Gravity_Forms\Gravity_Forms\Async\GF_Background_Process' ) ) { + if ( version_compare( GFForms::$version, '2.9.7.2', '>=' ) ) { + if ( ! class_exists( GF_Background_Process::class ) ) { require_once GFCommon::get_base_path() . '/includes/async/class-gf-background-process.php'; } if ( ! class_exists( 'GF_Background_Process' ) ) { - class_alias( \Gravity_Forms\Gravity_Forms\Async\GF_Background_Process::class, 'GF_Background_Process', false ); + class_alias( GF_Background_Process::class, 'GF_Background_Process', false ); } } elseif ( ! class_exists( 'WP_Async_Request' ) ) { require_once GFCommon::get_base_path() . '/includes/libraries/wp-async-request.php'; @@ -959,7 +958,7 @@ public function async_pdfs() { */ $gfpdf_settings_sanitize = function ( $new_value, $key ) use ( $queue ) { if ( $key === 'background_processing' ) { - $current_value = \GPDFAPI::get_plugin_option( 'background_processing' ); + $current_value = GPDFAPI::get_plugin_option( 'background_processing' ); if ( $current_value !== $new_value ) { $queue->clear_queue(); } diff --git a/tests/js-unit/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.test.js b/tests/js-unit/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.test.js new file mode 100644 index 000000000..49008c15c --- /dev/null +++ b/tests/js-unit/admin/settings/common/dynamicTemplateFields/formValuesSnapshot.test.js @@ -0,0 +1,512 @@ +import $ from 'jquery' +import { + snapshotFormValues, + restoreFormValues +} from '../../../../../../src/assets/js/admin/settings/common/dynamicTemplateFields/formValuesSnapshot' + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.14.3 + */ + +/* Build a jQuery-wrapped container from raw HTML for use as $container */ +function mountContainer (html) { + const $container = $('').html(html) + $('body').append($container) + return $container +} + +describe('formValuesSnapshot helpers', () => { + afterEach(() => { + $('body').empty() + delete window.tinyMCE + }) + + describe('snapshotFormValues', () => { + it('captures values from text, textarea, and select inputs', () => { + const $container = mountContainer(` + + + Hello world + + 10 + 12 + + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[background_color]', value: '#ff0000' }, + { kind: 'input', name: 'gfpdf_settings[background_image]', value: '/path/to/image.png' }, + { kind: 'input', name: 'gfpdf_settings[notes]', value: 'Hello world' }, + { kind: 'input', name: 'gfpdf_settings[font_size]', value: '12' } + ]) + }) + + it('captures checked + value for checkboxes and radios', () => { + const $container = mountContainer(` + + + + + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[show_form_title]', value: 'Yes', checked: true }, + { kind: 'input', name: 'gfpdf_settings[show_page_names]', value: 'Yes', checked: false }, + { kind: 'input', name: 'gfpdf_settings[orientation]', value: 'portrait', checked: true }, + { kind: 'input', name: 'gfpdf_settings[orientation]', value: 'landscape', checked: false } + ]) + }) + + it('prefers live TinyMCE content when the editor is in Visual mode', () => { + const $container = mountContainer(` + stale textarea body + `) + + window.tinyMCE = { + get: jest.fn((id) => { + if (id !== 'gfpdf_settings_header') return null + return { hidden: false, getContent: () => 'live editor body' } + }) + } + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[header]', value: 'live editor body' } + ]) + }) + + it('reads textarea value when the TinyMCE editor is in Code mode (hidden=true)', () => { + const $container = mountContainer(` + live code-mode body + `) + + window.tinyMCE = { + get: jest.fn(() => ({ hidden: true, getContent: () => 'stale editor html' })) + } + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[footer]', value: 'live code-mode body' } + ]) + }) + + it('falls back to textarea value when TinyMCE has no editor for that id', () => { + const $container = mountContainer(` + raw textarea body + `) + + window.tinyMCE = { get: jest.fn(() => null) } + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[footer]', value: 'raw textarea body' } + ]) + }) + + it('falls back to textarea value when TinyMCE is undefined', () => { + const $container = mountContainer(` + no tinyMCE + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[header]', value: 'no tinyMCE' } + ]) + }) + + it('captures .gfpdf-input-toggle state keyed by the textarea it controls', () => { + const $container = mountContainer(` + First Page Header + + first header body + + First Page Footer + + first footer body + + `) + + const snapshot = snapshotFormValues($container) + const toggleEntries = snapshot.filter(e => e.kind === 'toggle') + + expect(toggleEntries).toEqual([ + { kind: 'toggle', controls: 'gfpdf_settings[first_header]', checked: true }, + { kind: 'toggle', controls: 'gfpdf_settings[first_footer]', checked: false } + ]) + }) + + it('skips the template dropdown so the new (post-change) value is not captured', () => { + const $container = mountContainer(` + + zadani + rubix + + + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[background_color]', value: '#000' } + ]) + }) + + it('skips buttons, submits, and file inputs', () => { + const $container = mountContainer(` + + + + + + Click + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[real_field]', value: 'kept' } + ]) + }) + + it('skips inputs without a name attribute', () => { + const $container = mountContainer(` + + + `) + + expect(snapshotFormValues($container)).toEqual([ + { kind: 'input', name: 'gfpdf_settings[named]', value: 'kept' } + ]) + }) + }) + + describe('restoreFormValues', () => { + it('restores text, textarea, and select values when names match', () => { + const $container = mountContainer(` + + post-swap default + + 10 + 12 + + `) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[background_color]', value: '#ff0000' }, + { kind: 'input', name: 'gfpdf_settings[notes]', value: 'restored body' }, + { kind: 'input', name: 'gfpdf_settings[font_size]', value: '12' } + ]) + + expect($container.find('[name="gfpdf_settings[background_color]"]').val()).toBe('#ff0000') + expect($container.find('[name="gfpdf_settings[notes]"]').val()).toBe('restored body') + expect($container.find('[name="gfpdf_settings[font_size]"]').val()).toBe('12') + }) + + it('drops snapshot entries whose names no longer exist in the new HTML', () => { + const $container = mountContainer(` + + `) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[background_color]', value: '#ff0000' }, + { kind: 'input', name: 'gfpdf_settings[zadani_border_colour]', value: '#0000ff' } + ]) + + expect($container.find('[name="gfpdf_settings[background_color]"]').val()).toBe('#ff0000') + expect($container.find('[name="gfpdf_settings[zadani_border_colour]"]').length).toBe(0) + }) + + it('toggles a checkbox from unchecked to checked and fires change', () => { + const $container = mountContainer(` + + `) + + const onChange = jest.fn() + $container.find('[name="gfpdf_settings[show_form_title]"]').on('change', onChange) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[show_form_title]', value: 'Yes', checked: true } + ]) + + expect($container.find('[name="gfpdf_settings[show_form_title]"]').prop('checked')).toBe(true) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('toggles a checkbox from checked to unchecked and fires change', () => { + const $container = mountContainer(` + + `) + + const onChange = jest.fn() + $container.find('[name="gfpdf_settings[show_form_title]"]').on('change', onChange) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[show_form_title]', value: 'Yes', checked: false } + ]) + + expect($container.find('[name="gfpdf_settings[show_form_title]"]').prop('checked')).toBe(false) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('skips checkbox change when the post-swap state already matches the snapshot', () => { + const $container = mountContainer(` + + `) + + const onChange = jest.fn() + $container.find('[name="gfpdf_settings[show_form_title]"]').on('change', onChange) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[show_form_title]', value: 'Yes', checked: true } + ]) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('switches a radio group selection', () => { + const $container = mountContainer(` + + + `) + + restoreFormValues($container, [ + { kind: 'input', name: 'gfpdf_settings[orientation]', value: 'portrait', checked: false }, + { kind: 'input', name: 'gfpdf_settings[orientation]', value: 'landscape', checked: true } + ]) + + expect($container.find('[name="gfpdf_settings[orientation]"][value="portrait"]').prop('checked')).toBe(false) + expect($container.find('[name="gfpdf_settings[orientation]"][value="landscape"]').prop('checked')).toBe(true) + }) + + it('restores a .gfpdf-input-toggle from unchecked to checked via its controlled textarea name', () => { + const $container = mountContainer(` + First Page Header + + + + `) + + const onChange = jest.fn() + $container.find('.gfpdf-input-toggle').on('change', onChange) + + restoreFormValues($container, [ + { kind: 'toggle', controls: 'gfpdf_settings[first_header]', checked: true } + ]) + + expect($container.find('.gfpdf-input-toggle').prop('checked')).toBe(true) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('skips a toggle entry whose controlled textarea no longer exists in the new HTML', () => { + const $container = mountContainer(` + + `) + + expect(() => restoreFormValues($container, [ + { kind: 'toggle', controls: 'gfpdf_settings[first_header]', checked: true } + ])).not.toThrow() + }) + + it('handles an empty snapshot without throwing', () => { + const $container = mountContainer(` + + `) + + expect(() => restoreFormValues($container, [])).not.toThrow() + expect($container.find('[name="gfpdf_settings[background_color]"]').val()).toBe('#000') + }) + }) + + describe('snapshot โ swap โ restore round-trip', () => { + it('preserves user edits across an HTML swap where field names overlap', () => { + const $container = mountContainer(` + + + old header body + + First Page Header + + + + `) + + /* User edits */ + $container.find('[name="gfpdf_settings[background_color]"]').val('#ff0000') + $container.find('[name="gfpdf_settings[show_form_title]"]').prop('checked', true) + $container.find('[name="gfpdf_settings[header]"]').val('NEW HEADER') + $container.find('[name="gfpdf_settings[zadani_border_colour]"]').val('#abcdef') + $container.find('.gfpdf-input-toggle').prop('checked', true) + $container.find('[name="gfpdf_settings[first_header]"]').val('FIRST HEADER BODY') + + const snapshot = snapshotFormValues($container) + + /* Swap HTML for a different template โ same toggle pattern, no zadani-specific field, new rubix-specific field */ + $container.html(` + + + default rubix header + + First Page Header + + rubix default first header + + `) + + restoreFormValues($container, snapshot) + + expect($container.find('[name="gfpdf_settings[background_color]"]').val()).toBe('#ff0000') + expect($container.find('[name="gfpdf_settings[show_form_title]"]').prop('checked')).toBe(true) + expect($container.find('[name="gfpdf_settings[header]"]').val()).toBe('NEW HEADER') + expect($container.find('.gfpdf-input-toggle').prop('checked')).toBe(true) + expect($container.find('[name="gfpdf_settings[first_header]"]').val()).toBe('FIRST HEADER BODY') + /* Rubix-specific field keeps its server-rendered default โ zadani snapshot doesn't apply to it */ + expect($container.find('[name="gfpdf_settings[rubix_container_background_colour]"]').val()).toBe('#cccccc') + }) + }) + + describe('all standard form field types round-trip', () => { + it('saves and restores every standard HTML form input type', () => { + /* User-edited values per field type โ what we expect to see after the round-trip */ + const edited = { + text: 'edited text', + password: 'edited-pass', + email: 'edited@example.com', + url: 'https://example.com/edited', + tel: '+61400000000', + search: 'edited query', + number: '42', + color: '#ff8800', + date: '2026-05-19', + time: '14:30', + month: '2026-05', + week: '2026-W20', + 'datetime-local': '2026-05-19T14:30', + range: '75', + hidden: 'edited-hidden-token', + textarea: 'edited textarea body', + select_single: 'b', + select_multiple: ['a', 'c'] + } + + const $container = mountContainer(` + + + + + + + + + + + + + + + + default textarea body + + + A + B + C + + + + A + B + C + + + + + + + + + + + `) + + /* Apply user edits */ + Object.entries(edited).forEach(([type, value]) => { + const name = type === 'select_single' || type === 'select_multiple' || type === 'textarea' + ? `gfpdf_settings[${type}]` + : `gfpdf_settings[${type}]` + $container.find(`[name="${name}"]`).val(value) + }) + $container.find('[name="gfpdf_settings[single_checkbox]"]').prop('checked', true) + $container.find('[name="gfpdf_settings[multi_checkbox][]"][value="one"]').prop('checked', false) + $container.find('[name="gfpdf_settings[multi_checkbox][]"][value="two"]').prop('checked', true) + $container.find('[name="gfpdf_settings[multi_checkbox][]"][value="three"]').prop('checked', true) + $container.find('[name="gfpdf_settings[radio]"][value="portrait"]').prop('checked', false) + $container.find('[name="gfpdf_settings[radio]"][value="landscape"]').prop('checked', true) + + /* Snapshot */ + const snapshot = snapshotFormValues($container) + + /* HTML swap โ same field names, defaults restored as if a new template was loaded */ + $container.html(` + + + + + + + + + + + + + + + + default textarea body + + + A + B + C + + + + A + B + C + + + + + + + + + + + `) + + /* Sanity check โ pre-restore values are the defaults, not the edits */ + expect($container.find('[name="gfpdf_settings[text]"]').val()).toBe('default text') + expect($container.find('[name="gfpdf_settings[single_checkbox]"]').prop('checked')).toBe(false) + + restoreFormValues($container, snapshot) + + /* Every text-like field should now hold the edited value */ + Object.entries(edited).forEach(([type, value]) => { + if (type === 'select_multiple') return /* asserted separately below */ + expect($container.find(`[name="gfpdf_settings[${type}]"]`).val()).toBe(value) + }) + + /* select-multiple: snapshot stored an array, restore writes that array back */ + expect($container.find('[name="gfpdf_settings[select_multiple]"]').val()).toEqual(['a', 'c']) + + /* Checkbox: false โ true was captured */ + expect($container.find('[name="gfpdf_settings[single_checkbox]"]').prop('checked')).toBe(true) + + /* Multi-checkbox group: each value flipped independently */ + expect($container.find('[name="gfpdf_settings[multi_checkbox][]"][value="one"]').prop('checked')).toBe(false) + expect($container.find('[name="gfpdf_settings[multi_checkbox][]"][value="two"]').prop('checked')).toBe(true) + expect($container.find('[name="gfpdf_settings[multi_checkbox][]"][value="three"]').prop('checked')).toBe(true) + + /* Radio group: selection moved from portrait โ landscape */ + expect($container.find('[name="gfpdf_settings[radio]"][value="portrait"]').prop('checked')).toBe(false) + expect($container.find('[name="gfpdf_settings[radio]"][value="landscape"]').prop('checked')).toBe(true) + }) + }) +}) diff --git a/tests/js-unit/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.test.js b/tests/js-unit/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.test.js new file mode 100644 index 000000000..e3ebe3e2a --- /dev/null +++ b/tests/js-unit/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor.test.js @@ -0,0 +1,195 @@ +import { loadTinyMCEEditor } from '../../../../../../src/assets/js/admin/settings/common/dynamicTemplateFields/loadTinyMCEEditor' + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2026, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + * @since 6.14.3 + */ + +function setupEditor ({ initialized = true } = {}) { + const removeAllRanges = jest.fn() + const selection = { removeAllRanges } + const win = { getSelection: jest.fn(() => selection) } + const editor = { + initialized, + on: jest.fn(), + getWin: jest.fn(() => win) + } + return { editor, win, selection, removeAllRanges } +} + +describe('loadTinyMCEEditor', () => { + beforeEach(() => { + window.tinyMCE = { + init: jest.fn(), + execCommand: jest.fn(), + get: jest.fn() + } + window.switchEditors = { go: jest.fn() } + window.getUserSetting = jest.fn().mockReturnValue('tmce') + + window.QTags = jest.fn() + window.QTags._buttonsInit = jest.fn() + }) + + afterEach(() => { + delete window.tinyMCE + delete window.switchEditors + delete window.getUserSetting + delete window.QTags + }) + + it('initialises each editor with the supplied settings and registers it with TinyMCE/QTags', () => { + const { editor } = setupEditor() + window.tinyMCE.get.mockReturnValue(editor) + + /* settings is mutated in-place each iteration, so snapshot the selector at call time */ + const observedSelectors = [] + window.tinyMCE.init.mockImplementation((settings) => observedSelectors.push(settings.selector)) + + loadTinyMCEEditor(['editor-one', 'editor-two'], {}) + + expect(observedSelectors).toEqual(['#editor-one', '#editor-two']) + expect(window.tinyMCE.execCommand).toHaveBeenCalledWith('mceAddEditor', false, 'editor-one') + expect(window.tinyMCE.execCommand).toHaveBeenCalledWith('mceAddEditor', false, 'editor-two') + expect(window.QTags).toHaveBeenCalledWith({ id: 'editor-one' }) + expect(window.QTags).toHaveBeenCalledWith({ id: 'editor-two' }) + expect(window.QTags._buttonsInit).toHaveBeenCalledTimes(2) + }) + + it("applies TinyMCE-style defaults (body_class, formats, content_style) when settings is provided", () => { + const { editor } = setupEditor() + window.tinyMCE.get.mockReturnValue(editor) + + const settings = {} + loadTinyMCEEditor(['editor'], settings) + + expect(settings.body_class).toBe('id post-type-post post-status-publish post-format-standard') + expect(settings.formats).toEqual(expect.objectContaining({ + alignleft: expect.any(Array), + aligncenter: expect.any(Array), + alignright: expect.any(Array), + strikethrough: { inline: 'del' } + })) + expect(settings.content_style).toContain('body#tinymce') + }) + + it('does not touch settings when called with settings === null', () => { + window.tinyMCE.get.mockReturnValue(setupEditor().editor) + + expect(() => loadTinyMCEEditor([], null)).not.toThrow() + expect(window.tinyMCE.init).not.toHaveBeenCalled() + }) + + describe('restoring the user\'s last-selected editor tab', () => { + it("switches to Code mode when getUserSetting('editor') is 'html'", () => { + window.getUserSetting.mockReturnValue('html') + const { editor } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).toHaveBeenCalledWith('editor-one', 'html') + }) + + it("switches to Visual mode when getUserSetting('editor') is anything other than 'html'", () => { + window.getUserSetting.mockReturnValue('tinymce') + const { editor } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).toHaveBeenCalledWith('editor-one', 'tmce') + }) + + it('applies the switch immediately when the editor is already initialised', () => { + const { editor } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).toHaveBeenCalledTimes(1) + expect(editor.on).not.toHaveBeenCalled() + }) + + it("defers the switch until TinyMCE fires 'init' when the editor isn't initialised yet", () => { + const { editor } = setupEditor({ initialized: false }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).not.toHaveBeenCalled() + expect(editor.on).toHaveBeenCalledWith('init', expect.any(Function)) + + const [, deferredApply] = editor.on.mock.calls[0] + deferredApply() + + expect(window.switchEditors.go).toHaveBeenCalledWith('editor-one', 'tmce') + }) + + it('clears the iframe window selection before calling switchEditors.go (prevents scroll jump)', () => { + window.getUserSetting.mockReturnValue('html') + const { editor, removeAllRanges } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(removeAllRanges).toHaveBeenCalledTimes(1) + const removeOrder = removeAllRanges.mock.invocationCallOrder[0] + const goOrder = window.switchEditors.go.mock.invocationCallOrder[0] + expect(removeOrder).toBeLessThan(goOrder) + }) + + it('still attempts the switch when the editor exposes no getWin()', () => { + window.getUserSetting.mockReturnValue('html') + const { editor } = setupEditor({ initialized: true }) + delete editor.getWin + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).toHaveBeenCalledWith('editor-one', 'html') + }) + + it('still attempts the switch when the iframe getSelection() returns null', () => { + window.getUserSetting.mockReturnValue('html') + const { editor, win } = setupEditor({ initialized: true }) + win.getSelection = jest.fn(() => null) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).toHaveBeenCalledWith('editor-one', 'html') + }) + + it('swallows exceptions from switchEditors.go so the surrounding template-swap callback keeps running', () => { + const { editor } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + window.switchEditors.go.mockImplementation(() => { + throw new TypeError('iframeElement is undefined') + }) + + expect(() => loadTinyMCEEditor(['editor-one'], {})).not.toThrow() + }) + + it('no-ops when window.switchEditors is unavailable', () => { + delete window.switchEditors + const { editor, removeAllRanges } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + expect(() => loadTinyMCEEditor(['editor-one'], {})).not.toThrow() + expect(removeAllRanges).not.toHaveBeenCalled() + }) + + it('skips restoration entirely when QTags is unavailable', () => { + delete window.QTags + const { editor } = setupEditor({ initialized: true }) + window.tinyMCE.get.mockReturnValue(editor) + + loadTinyMCEEditor(['editor-one'], {}) + + expect(window.switchEditors.go).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/js-unit/react/components/Template/TemplateUploader.test.js b/tests/js-unit/react/components/Template/TemplateUploader.test.js index 4fa1ebdbb..1d443964d 100644 --- a/tests/js-unit/react/components/Template/TemplateUploader.test.js +++ b/tests/js-unit/react/components/Template/TemplateUploader.test.js @@ -171,28 +171,26 @@ describe('Template - TemplateUploader.js', () => { }) test('ajaxFailed() - Show any errors to the user when AJAX request fails for any reason', () => { - let error - error = { - response: { - body: { - error: 'error' - } + let response + response = { + ok: false, + body: { + error: 'error' } } wrapper = shallow( ) - wrapper.instance().ajaxFailed(error) + wrapper.instance().ajaxFailed(response) expect(wrapper.state('error')).toBe('error') expect(wrapper.state('ajax')).toBe(false) expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(1) - error = { - response: { - body: {} - } + response = { + ok: false, + body: {} } wrapper = shallow( @@ -200,11 +198,27 @@ describe('Template - TemplateUploader.js', () => { clearTemplateUploadProcessing={clearTemplateUploadProcessingMock} genericUploadErrorText={'errorText'} /> ) - wrapper.instance().ajaxFailed(error) + wrapper.instance().ajaxFailed(response) expect(wrapper.state('error')).toBe('errorText') expect(wrapper.state('ajax')).toBe(false) expect(clearTemplateUploadProcessingMock.mock.calls.length).toBe(2) + + /* Non-JSON body (e.g. server returns "400") falls back to the generic message */ + response = { + ok: false, + body: 400 + } + + wrapper = shallow( + + ) + wrapper.instance().ajaxFailed(response) + + expect(wrapper.state('error')).toBe('errorText') + expect(wrapper.state('ajax')).toBe(false) }) test('removeMessage() - Remove message from state once the timeout has finished', () => { @@ -259,10 +273,10 @@ describe('Template - TemplateUploader.js', () => { const props = { templateUploadProcessingSuccess: {}, templateUploadProcessingError: { - response: { - body: { - error: 'error' - } + ok: false, + status: 400, + body: { + error: 'error' } } } diff --git a/tests/js-unit/react/sagas/coreFonts.test.js b/tests/js-unit/react/sagas/coreFonts.test.js index a2bdce11f..7461f7763 100644 --- a/tests/js-unit/react/sagas/coreFonts.test.js +++ b/tests/js-unit/react/sagas/coreFonts.test.js @@ -40,6 +40,15 @@ describe('Sagas - coreFonts', () => { payload: GFPDF.coreFontGithubError })) }) + + test('should treat a non-2xx fetch response as a failure even though fetch did not throw', () => { + const failingGen = getFilesFromGitHub() + failingGen.next() + expect(failingGen.next({ ok: false, body: {} }).value).toEqual(put({ + type: GET_FILES_FROM_GITHUB_FAILED, + payload: GFPDF.coreFontGithubError + })) + }) }) describe('watchDownloadFonts()', () => { @@ -68,7 +77,7 @@ describe('Sagas - coreFonts', () => { test('should display the success download message', () => { expect(gen.next().value).toEqual(call(api.apiPostDownloadFonts, payload)) - expect(gen.next({ body: 'test' }).value).toEqual(put(addToConsole(payload, 'success', '[object Object]'))) + expect(gen.next({ ok: true, body: 'test' }).value).toEqual(put(addToConsole(payload, 'success', '[object Object]'))) }) test('should display the error download message', () => { @@ -79,5 +88,17 @@ describe('Sagas - coreFonts', () => { test('should pass to redux store', () => { expect(gen.next().value).toEqual(put(currentDownload())) }) + + test('should treat a non-2xx fetch response as a failure even though fetch did not throw', () => { + const failingGen = getDownloadFonts(channel) + const failingPayload = downloadFontsApiCall('test2.ttf') + + failingGen.next() + failingGen.next(failingPayload) + failingGen.next() + + expect(failingGen.next({ ok: false, body: {} }).value).toEqual(put(addToConsole(failingPayload, 'error', '[object Object]'))) + expect(failingGen.next().value).toEqual(put(addToRetryList(failingPayload))) + }) }) }) diff --git a/tests/js-unit/react/sagas/templates.test.js b/tests/js-unit/react/sagas/templates.test.js index a7653ee6b..e0ac8001f 100644 --- a/tests/js-unit/react/sagas/templates.test.js +++ b/tests/js-unit/react/sagas/templates.test.js @@ -13,6 +13,7 @@ import { TEMPLATE_PROCESSING, TEMPLATE_PROCESSING_FAILED, POST_TEMPLATE_UPLOAD_PROCESSING, + TEMPLATE_UPLOAD_PROCESSING_SUCCESS, TEMPLATE_UPLOAD_PROCESSING_FAILED } from '../../../../src/assets/js/react/actions/templates' import * as api from '../../../../src/assets/js/react/api/templates' @@ -74,17 +75,57 @@ describe('Sagas - templates', () => { }) describe('templateUploadProcessing()', () => { - const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } - const gen = templateUploadProcessing(newaction) - test('should check that saga asks to call the API for templateUploadProcessing', () => { + const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } + const gen = templateUploadProcessing(newaction) + expect(gen.next().value).toEqual(call(api.apiPostTemplateUploadProcessing, newaction.payload.file, newaction.payload.filename)) }) - test('should check that saga handles correctly to the failure of templateUploadProcessing API call', () => { - expect(gen.throw({ error: 'template upload processing failed' }).value).toEqual(put({ + test('should route to success when the API responds with ok and a templates array', () => { + const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } + const gen = templateUploadProcessing(newaction) + gen.next() + + const response = { ok: true, status: 200, body: { templates: [{ id: 'foo' }] } } + expect(gen.next(response).value).toEqual(put({ + type: TEMPLATE_UPLOAD_PROCESSING_SUCCESS, + payload: response + })) + }) + + test('should route to failed when the API responds with a non-ok status', () => { + const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } + const gen = templateUploadProcessing(newaction) + gen.next() + + const response = { ok: false, status: 400, body: { error: 'invalid zip' } } + expect(gen.next(response).value).toEqual(put({ + type: TEMPLATE_UPLOAD_PROCESSING_FAILED, + payload: response + })) + }) + + test('should route to failed when the API response is missing a templates array', () => { + const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } + const gen = templateUploadProcessing(newaction) + gen.next() + + const response = { ok: true, status: 200, body: 400 } + expect(gen.next(response).value).toEqual(put({ + type: TEMPLATE_UPLOAD_PROCESSING_FAILED, + payload: response + })) + }) + + test('should route to failed when the fetch itself throws', () => { + const newaction = { payload: { file: { data: 'test' }, filename: 'test' } } + const gen = templateUploadProcessing(newaction) + gen.next() + + expect(gen.throw({ error: 'network failure' }).value).toEqual(put({ type: TEMPLATE_UPLOAD_PROCESSING_FAILED, - payload: { error: 'template upload processing failed' } + payload: { ok: false, body: null } })) }) }) diff --git a/tests/phpunit/unit-tests/test-gravity-forms.php b/tests/phpunit/unit-tests/test-gravity-forms.php index b235ad9c3..4d48ba0b9 100644 --- a/tests/phpunit/unit-tests/test-gravity-forms.php +++ b/tests/phpunit/unit-tests/test-gravity-forms.php @@ -3,6 +3,7 @@ namespace GFPDF\Tests; use GFCommon; +use GFForms; use GFFormsModel; use PDF_Common; use RGFormsModel; @@ -492,13 +493,13 @@ public function provider_ip_testing() { } /** - * Test that GFCommon::$version will produce + * Test that \GFForms::$version will produce * the expected result. * * @since 3.6 */ public function test_gf_version() { - $version = GFCommon::$version; + $version = GFForms::$version; /* which the version number is a string before we try to match it */ $this->assertEquals( true, is_string( $version ) ); diff --git a/tests/phpunit/unit-tests/test-helper-misc.php b/tests/phpunit/unit-tests/test-helper-misc.php index 1f8c6a6e3..d8a92a790 100644 --- a/tests/phpunit/unit-tests/test-helper-misc.php +++ b/tests/phpunit/unit-tests/test-helper-misc.php @@ -375,6 +375,93 @@ public function test_evaluate_conditional_logic() { $this->assertFalse( $this->misc->evaluate_conditional_logic( $logic, $data['entry'] ) ); } + /** + * Verify conditional_logic_passes() honours the `conditional` toggle as the source of + * truth for whether conditional-logic gating runs. + * + * Regression coverage for the case where the user disables the toggle in the UI but + * a stale `conditionalLogic` rules array remains in the saved settings โ the runtime + * must not reject entries against rules the UI says are off. + * + * @since 6.14.3 + */ + public function test_conditional_logic_passes() { + $data = $this->create_form_and_entries(); + $entry = $data['entry']; + $entry['form_id'] = $data['form']['id']; + + $failing_rules = [ + 'actionType' => 'show', + 'logicType' => 'all', + 'rules' => [ + [ + 'fieldId' => '1', + 'operator' => 'is', + 'value' => 'this-will-never-match', + ], + ], + ]; + + $passing_rules = [ + 'actionType' => 'show', + 'logicType' => 'all', + 'rules' => [ + [ + 'fieldId' => '1', + 'operator' => 'is', + 'value' => 'My Single Line Response', + ], + ], + ]; + + /* Toggle off + stale failing rules โ passes (the bug we are fixing) */ + $this->assertTrue( + $this->misc->conditional_logic_passes( + [ 'conditional' => '', 'conditionalLogic' => $failing_rules ], + $entry + ), + 'Toggle off with stale failing rules must pass โ UI says conditional logic is disabled.' + ); + + /* Toggle off + passing rules โ still passes (toggle short-circuits before evaluation) */ + $this->assertTrue( + $this->misc->conditional_logic_passes( + [ 'conditional' => '', 'conditionalLogic' => $passing_rules ], + $entry + ) + ); + + /* Toggle on + passing rules โ passes */ + $this->assertTrue( + $this->misc->conditional_logic_passes( + [ 'conditional' => '1', 'conditionalLogic' => $passing_rules ], + $entry + ) + ); + + /* Toggle on + failing rules โ fails */ + $this->assertFalse( + $this->misc->conditional_logic_passes( + [ 'conditional' => '1', 'conditionalLogic' => $failing_rules ], + $entry + ) + ); + + /* No conditional logic rules at all โ passes regardless of toggle */ + $this->assertTrue( $this->misc->conditional_logic_passes( [ 'conditional' => '1' ], $entry ) ); + $this->assertTrue( $this->misc->conditional_logic_passes( [], $entry ) ); + + /* Legacy settings (no `conditional` key) fall back to evaluating the rules */ + $this->assertTrue( + $this->misc->conditional_logic_passes( [ 'conditionalLogic' => $passing_rules ], $entry ), + 'Legacy settings without the toggle should evaluate the rules normally.' + ); + $this->assertFalse( + $this->misc->conditional_logic_passes( [ 'conditionalLogic' => $failing_rules ], $entry ), + 'Legacy settings without the toggle should still reject entries against failing rules.' + ); + } + /** * Ensure we correctly return an appropriate class name based on the file path given * diff --git a/tests/phpunit/unit-tests/test-pre-checks.php b/tests/phpunit/unit-tests/test-pre-checks.php index 22139b579..44ac1e07d 100644 --- a/tests/phpunit/unit-tests/test-pre-checks.php +++ b/tests/phpunit/unit-tests/test-pre-checks.php @@ -2,7 +2,7 @@ namespace GFPDF\Tests; -use GFCommon; +use GFForms; use GFPDF_Major_Compatibility_Checks; use WP_UnitTestCase; @@ -106,7 +106,7 @@ public function test_check_wordpress( $min_version, $test_wp_version, $expected */ public function test_check_gravityforms( $min_version, $test_gf_version, $expected ) { /* set up our current Gravity Forms version and the min version */ - GFCommon::$version = $test_gf_version; + GFForms::$version = $test_gf_version; $this->gravitypdf->required_gf_version = $min_version; /* run our test */ diff --git a/tests/phpunit/unit-tests/test-templates.php b/tests/phpunit/unit-tests/test-templates.php index 7cfa13232..9441a2acf 100644 --- a/tests/phpunit/unit-tests/test-templates.php +++ b/tests/phpunit/unit-tests/test-templates.php @@ -228,68 +228,80 @@ public function provider_get_unzipped_dir_name() { public function test_unzip_and_verify_templates() { global $gfpdf; - /* Check an error is thrown if trying to unzip a zip file */ - try { - $this->model->unzip_and_verify_templates( 'test.txt' ); - } catch ( Exception $e ) { - //do nothing - } - - $this->assertEquals( 'Incompatible Archive.', $e->getMessage() ); - unset( $e ); - - /* Create empty archive and check an exception is thrown for no PDF templates found */ - $test_file = $gfpdf->data->template_tmp_location . 'test-archive.zip'; + /* uniqid() prevents collisions with state leaked from earlier runs */ + $test_file = $gfpdf->data->template_tmp_location . 'test-archive-' . uniqid() . '.zip'; $test_dir = $this->model->get_unzipped_dir_name( $test_file ); - $zip = new ZipArchive(); - $zip->open( $test_file, ZipArchive::CREATE ); - $zip->addFromString( 'tmp', '' ); - $zip->close(); - - try { - $this->model->unzip_and_verify_templates( $test_file ); - } catch ( Exception $e ) { - //do nothing - } - - $this->assertEquals( 'No valid PDF template found in Zip archive.', $e->getMessage() ); - unset( $e ); - - unlink( $test_file ); - $gfpdf->misc->rmdir( $test_dir ); - - /* Zip up two of the core PDF template files and check no exceptions are thrown */ - $zip = new ZipArchive(); - $zip->open( $test_file, ZipArchive::CREATE ); - $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/zadani.php', 'zadani.php' ); - $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/rubix.php', 'rubix.php' ); - $zip->close(); - - try { - $this->model->unzip_and_verify_templates( $test_file ); - } catch ( Exception $e ) { - //do nothing - } - - $this->assertFalse( isset( $e ) ); - - /* Add an invalid filename to the zip and verify an error occurs */ - $zip->open( $test_file ); - $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/zadani.php', 'zad@!@#$%^&*().php' ); - $zip->close(); + /* A cached "Legacy" group for the unzipped path would short-circuit the + v4 header check and falsely throw "not a valid PDF Template" */ + $gfpdf->templates->flush_template_transient_cache(); try { - $this->model->unzip_and_verify_templates( $test_file ); - } catch ( Exception $e ) { - //do nothing + /* Check an error is thrown if trying to unzip a zip file */ + try { + $this->model->unzip_and_verify_templates( 'test.txt' ); + } catch ( Exception $e ) { + //do nothing + } + + $this->assertEquals( 'Incompatible Archive.', $e->getMessage() ); + unset( $e ); + + /* Create empty archive and check an exception is thrown for no PDF templates found */ + $zip = new ZipArchive(); + $zip->open( $test_file, ZipArchive::CREATE ); + $zip->addFromString( 'tmp', '' ); + $zip->close(); + + try { + $this->model->unzip_and_verify_templates( $test_file ); + } catch ( Exception $e ) { + //do nothing + } + + $this->assertEquals( 'No valid PDF template found in Zip archive.', $e->getMessage() ); + unset( $e ); + + unlink( $test_file ); + $gfpdf->misc->rmdir( $test_dir ); + + /* Zip up two of the core PDF template files and check no exceptions are thrown */ + $zip = new ZipArchive(); + $zip->open( $test_file, ZipArchive::CREATE ); + $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/zadani.php', 'zadani.php' ); + $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/rubix.php', 'rubix.php' ); + $zip->close(); + + try { + $this->model->unzip_and_verify_templates( $test_file ); + } catch ( Exception $e ) { + //do nothing + } + + $this->assertFalse( + isset( $e ), + 'Expected no exception when unzipping a valid template archive, got: ' . ( isset( $e ) ? $e->getMessage() : '' ) + ); + + /* Add an invalid filename to the zip and verify an error occurs */ + $zip->open( $test_file ); + $zip->addFile( PDF_PLUGIN_DIR . 'src/templates/zadani.php', 'zad@!@#$%^&*().php' ); + $zip->close(); + + try { + $this->model->unzip_and_verify_templates( $test_file ); + } catch ( Exception $e ) { + //do nothing + } + + $this->assertStringContainsString( 'contains invalid characters.', $e->getMessage() ); + } finally { + if ( file_exists( $test_file ) ) { + unlink( $test_file ); + } + $gfpdf->misc->rmdir( $test_dir ); + $gfpdf->templates->flush_template_transient_cache(); } - - $this->assertStringContainsString( 'contains invalid characters.', $e->getMessage() ); - - /* Cleanup */ - unlink( $test_file ); - $gfpdf->misc->rmdir( $test_dir ); } /**
live editor body