From 3433a80ae3da32a36e9525cf7b5ba679c4bcd908 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Fri, 5 Jun 2026 13:12:20 +1000 Subject: [PATCH] feat: add PDF Preview to Form PDF Settings Adds a "Preview PDF" button to the Form PDF Settings page that renders a live PDF from the current (unsaved) settings via a dedicated gravity-pdf/v1 form//preview REST endpoint. Parked as a draft until it's ready for prime-time. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Helper/Helper_Data.php | 1 + src/Rest/Rest_Pdf_Preview.php | 136 +++++++++++++ src/View/html/FormSettings/add_edit.php | 5 + src/assets/js/react/api/preview.js | 94 +++++++++ src/assets/js/react/gfpdf-main.js | 6 +- .../utilities/PdfSettings/formSettings.js | 180 ++++++++++++++++++ .../utilities/PdfSettings/previewButton.js | 67 +++++++ src/assets/js/react/utilities/download.js | 22 +++ src/bootstrap.php | 4 + tests/phpunit/integration/Rest/Test_Rest.php | 2 +- .../Rest/Test_Rest_Pdf_Preview.php | 157 +++++++++++++++ 11 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 src/Rest/Rest_Pdf_Preview.php create mode 100644 src/assets/js/react/api/preview.js create mode 100644 src/assets/js/react/utilities/PdfSettings/formSettings.js create mode 100644 src/assets/js/react/utilities/PdfSettings/previewButton.js create mode 100644 src/assets/js/react/utilities/download.js create mode 100644 tests/phpunit/integration/Rest/Test_Rest_Pdf_Preview.php diff --git a/src/Helper/Helper_Data.php b/src/Helper/Helper_Data.php index 3a62694ab..8d908d0b2 100644 --- a/src/Helper/Helper_Data.php +++ b/src/Helper/Helper_Data.php @@ -271,6 +271,7 @@ public function get_localised_script_data( Helper_Abstract_Options $options, Hel 'searchResultHeadingText' => esc_html__( 'Gravity PDF Documentation', 'gravity-pdf' ), 'noResultText' => esc_html__( "It doesn't look like there are any topics related to your issue.", 'gravity-pdf' ), 'getSearchResultError' => esc_html__( 'An error occurred. Please try again', 'gravity-pdf' ), + 'getPreviewResultError' => esc_html__( 'An error occurred. Please try again', 'gravity-pdf' ), /* translators: %s: minimum required Gravity PDF version number */ 'requiresGravityPdfVersion' => esc_html__( 'Requires Gravity PDF v%s', 'gravity-pdf' ), diff --git a/src/Rest/Rest_Pdf_Preview.php b/src/Rest/Rest_Pdf_Preview.php new file mode 100644 index 000000000..f7e334767 --- /dev/null +++ b/src/Rest/Rest_Pdf_Preview.php @@ -0,0 +1,136 @@ + self::API_BASE . '/(?P
[\d]+)/preview', + ]; + + /** + * Registers the routes for this endpoint + * + * @return void + * @since 7.0 + */ + public function register_routes() { + + register_rest_route( + static::NAMESPACE, + static::$endpoints['pdf-settings-preview'], + [ + 'args' => [ + 'form' => [ + 'description' => __( 'The unique identifier for the Gravity Forms form.', 'gravity-pdf' ), + 'type' => 'integer', + 'required' => true, + 'validate_callback' => [ $this, 'check_form_is_valid' ], + ], + + 'entry' => [ + 'description' => __( 'The unique identifier for the Gravity Forms entry.', 'gravity-pdf' ), + 'type' => 'integer', + 'required' => false, + 'validate_callback' => [ $this, 'check_entry_is_valid' ], + ], + ], + + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'create_item' ], + 'permission_callback' => [ $this, 'create_item_permissions_check' ], + 'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ), + ], + + 'schema' => [ $this, 'get_public_item_schema' ], + ], + true + ); + } + + /** + * Take the current PDF settings and generate a PDF Preview + * + * @param \WP_REST_Request $request + * + * @return \WP_Error|null + * + * @since 7.0 + */ + public function create_item( $request ) { + $form = $this->gform->get_form( $request->get_param( 'form' ) ); + $entry = $this->get_entry( $request, $form ); + + /* Prepare request for previewing */ + $request->set_param( 'context', 'edit' ); + $request->set_param( 'password', '' ); + + $pdf_settings = $this->prepare_item_for_database( $request ); + $pdf_settings['id'] = uniqid( 'review' ); + + /* Generate the PDF */ + /** @var Model_PDF $pdf_model */ + $pdf_model = \GPDFAPI::get_pdf_class( 'model' ); + + $pdf_path = $pdf_model->generate_and_save_pdf( $entry, $pdf_settings ); + if ( is_wp_error( $pdf_path ) ) { + return $pdf_path; + } + + /* Sends the PDF to browser, or return WP_Error */ + return $pdf_model->send_pdf_to_browser( $pdf_path ); + } + + /** + * Get a Gravity Forms Entry object + * + * @param \WP_REST_Request $request + * @param array $form + * + * @return array|\WP_Error + */ + protected function get_entry( $request, $form ) { + /* user requested a specific entry to preview */ + if ( $request->get_param( 'entry' ) ) { + return $this->gform->get_entry( $request->get_param( 'entry' ) ); + } + + /* try to get the last form submission */ + $latest_entry = \GFAPI::get_entries( + $form['id'], + [ 'status' => 'active' ], + null, + [ 'page_size' => 1 ] + ); + + if ( ! is_wp_error( $latest_entry ) && isset( $latest_entry[0] ) ) { + return $latest_entry[0]; + } + + /* fallback to a blank entry */ + + return \GFFormsModel::create_lead( $form ); + } +} diff --git a/src/View/html/FormSettings/add_edit.php b/src/View/html/FormSettings/add_edit.php index 1083b02d2..ad4ce17a6 100644 --- a/src/View/html/FormSettings/add_edit.php +++ b/src/View/html/FormSettings/add_edit.php @@ -60,6 +60,11 @@ class="gform_settings_form "> name="submit" value="" class="button primary large" /> + +
diff --git a/src/assets/js/react/api/preview.js b/src/assets/js/react/api/preview.js new file mode 100644 index 000000000..99ce60def --- /dev/null +++ b/src/assets/js/react/api/preview.js @@ -0,0 +1,94 @@ +import { api } from './api'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2024, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + */ + +/** + * A cache of template schema data, grouped by form + * @type {Object} + */ +const templateSchema = {}; + +/** + * Get template schema data + * + * @param {number} formId + * @param {string} template + * @return {Object} Template schema. + * + * @since 7.0 + */ +export async function getTemplateSchema(formId, template) { + // add formId key to cache + if (!templateSchema[formId]) { + templateSchema[formId] = {}; + } + + // return cached schema + if (templateSchema[formId][template]) { + return templateSchema[formId][template]; + } + + const url = + GFPDF.restUrl + + 'form/' + + encodeURIComponent(formId) + + '/schema/?template=' + + encodeURIComponent(template); + const response = await api(url, { + method: 'GET', + headers: { + 'X-WP-Nonce': GFPDF.restNonce, + }, + }); + + try { + if (!response.ok) { + throw new Error(await response.json()); + } + + templateSchema[formId][template] = await response.json(); + + return templateSchema[formId][template]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +} + +/** + * Generate a PDF Preview using the defined PDF settings + * + * @param {FormData} formData + * @return {Blob|null} The rendered PDF, or null on failure. + * + * @since 7.0 + */ +export async function getPdfPreview(formData) { + const url = + GFPDF.restUrl + + 'form/' + + encodeURIComponent(formData.get('form')) + + '/preview'; + const response = await api(url, { + method: 'POST', + headers: { + 'X-WP-Nonce': GFPDF.restNonce, + }, + body: formData, + }); + + try { + if (!response.ok) { + throw new Error(await response.json()); + } + + return await response.blob(); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } +} diff --git a/src/assets/js/react/gfpdf-main.js b/src/assets/js/react/gfpdf-main.js index 866ead87c..137707899 100644 --- a/src/assets/js/react/gfpdf-main.js +++ b/src/assets/js/react/gfpdf-main.js @@ -8,6 +8,7 @@ import helpBootstrap from './bootstrap/helpBootstrap'; /* Utilities */ import { actionToolbar } from './utilities/PdfSettings/actionToolbar'; import shortcodeButton from './utilities/PdfList/shortcodeButton'; +import previewButton from './utilities/PdfSettings/previewButton'; import unsavedChangesWarning from './utilities/PdfSettings/unsavedChangesWarning'; /* Sass Styling */ import '../../scss/gfpdf-styles.scss'; @@ -94,7 +95,10 @@ $(function () { /* Adding / Updating form PDF settings */ if (pdfSettingsForm) { - /* Initialize additional add/update buttons on PDF setting panels */ + /* Initialize the PDF Preview button */ + previewButton(); + + /* Initialize additional add/update/preview buttons on PDF setting panels */ actionToolbar(pdfSettingFieldSets, pdfSettingsForm); /* Watch for unsaved changes */ diff --git a/src/assets/js/react/utilities/PdfSettings/formSettings.js b/src/assets/js/react/utilities/PdfSettings/formSettings.js new file mode 100644 index 000000000..0b2760991 --- /dev/null +++ b/src/assets/js/react/utilities/PdfSettings/formSettings.js @@ -0,0 +1,180 @@ +import $ from 'jquery'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2024, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + */ + +/** + * Prepare form inputs for use with Gravity PDF Form Settings API + * + * @param {Object} schema + * @return {FormData} Serialised PDF settings. + * + * @since 7.0 + */ +export function getCurrentPdfSettingsForApi(schema) { + let formData = new FormData(); + + // loop over the schema and get the values + for (const [key, property] of Object.entries(schema.properties)) { + // ignore readonly fields + if (property.readonly) { + continue; + } + + // if not matching DOM node, skip + const propertyNodes = document.querySelectorAll( + '[name^="gfpdf_settings[' + key + ']"]' + ); + if (propertyNodes.length === 0) { + continue; + } + + // loop over matching HTML tags + for (const input of propertyNodes.values()) { + // add field data based on schema and input types + switch (property.type) { + case 'boolean': + formData.set(key, input.checked); + break; + + case 'array': + // handle individual checkboxes + if (input.type === 'checkbox' && input.checked) { + formData.append(key + '[]', input.value); + } + + // Add all checked multiselect options + if (input.nodeName === 'SELECT' && input.multiple) { + for (const option of input + .querySelectorAll('option:checked') + .values()) { + formData.append(key + '[]', option.value); + } + } + break; + + case 'number': + case 'integer': + formData.set(key, input.value); + + break; + + case 'string': + // skip checkbox or radio fields that are not checked + if ( + ['checkbox', 'radio'].includes(input.type) && + !input.checked + ) { + continue; + } + + // if field should be a hex color and is empty + if ( + property.format === 'hex-color' && + input.value.length === 0 + ) { + continue; + } + + formData.set(key, input.value); + + break; + } + } + } + + /** + * Manipulate the formData before sending to the PDF Preview API + * + * @param {FormData} formData The constructed Previewer API PDF settings + * @param {Object} schema Valid Preview API schema for the current template + * + * @since 7.0 + */ + formData = gform.applyFilters('gfpdf_preview_settings', formData, schema); + + return formData; +} + +/** + * Handle custom Paper Size for Preview API + */ +gform.addFilter('gfpdf_preview_settings', (formData, schema) => { + // Fix custom paper size (the API uses a structured object instead of an array) + if ( + !schema.properties.custom_pdf_size || + formData.get('pdf_size') !== 'CUSTOM' + ) { + return formData; + } + + formData.set( + 'custom_pdf_size[width]', + document.getElementById('gfpdf_settings[custom_pdf_size]_width').value + ); + formData.set( + 'custom_pdf_size[height]', + document.getElementById('gfpdf_settings[custom_pdf_size]_height').value + ); + formData.set( + 'custom_pdf_size[unit]', + document + .getElementById('gfpdf_settings[custom_pdf_size]_measurement') + .value.replace('millimeters', 'mm') + .replace('inches', 'in') + ); + + return formData; +}); + +/** + * Turn off conditional logic for PDF preview + */ +// eslint-disable-next-line no-unused-vars -- filter signature requires schema arg. +gform.addFilter('gfpdf_preview_settings', (formData, schema) => { + formData.delete('conditional'); + formData.delete('conditionalLogic'); + + return formData; +}); + +/** + * Unset the Label / Filename if empty for PDF Preview + * The default value set in the schema will be used instead + */ +// eslint-disable-next-line no-unused-vars -- filter signature requires schema arg. +gform.addFilter('gfpdf_preview_settings', (formData, schema) => { + if (formData.get('name') === '') { + formData.delete('name'); + } + + if (formData.get('filename') === '') { + formData.delete('filename'); + } + + return formData; +}); + +/** + * Trigger the submit events on the form so fields can save their data + * but cancel the event before the browser actually posts the form data + * + * @param {string} formId + * + * @since 7.0 + */ +export function triggerFakeFormSubmit(formId) { + const form = document.getElementById(formId); + const formListener = (event) => event.preventDefault(); + + form.addEventListener('submit', formListener); + + // trigger native submit and jQuery submit (two independent event systems) + $('#' + formId).trigger('submit'); + form.requestSubmit(); + + form.removeEventListener('submit', formListener); +} diff --git a/src/assets/js/react/utilities/PdfSettings/previewButton.js b/src/assets/js/react/utilities/PdfSettings/previewButton.js new file mode 100644 index 000000000..51ee580d7 --- /dev/null +++ b/src/assets/js/react/utilities/PdfSettings/previewButton.js @@ -0,0 +1,67 @@ +import { getPdfPreview, getTemplateSchema } from '../../api/preview'; +import { + getCurrentPdfSettingsForApi, + triggerFakeFormSubmit, +} from './formSettings'; +import { viewFile } from '../download'; +import { spinner } from '../../../admin/helper/spinner'; +import $ from 'jquery'; + +/** + * @package Gravity PDF + * @copyright Copyright (c) 2024, Blue Liquid Designs + * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License + */ + +/** + * Setup PDF Preview functionality in PDF settings + * + * @since 7.0 + */ +export default function () { + // capture bubbled click event for the Preview buttons on the form + document + .getElementById('gfpdf_pdf_form') + .addEventListener('click', async function (e) { + // ignore if wasn't triggered by Preview field + if (e.target.name !== 'gpdf-preview-pdf-settings') { + return; + } + + e.preventDefault(); + e.stopImmediatePropagation(); + + const $spinner = spinner('gfpdf-spinner-template'); + $(e.target).after($spinner); + + // save JS-powered field data + triggerFakeFormSubmit('gfpdf_pdf_form'); + + // get the current template schema + const template = document.getElementById( + 'gfpdf_settings[template]' + ).value; + const templateSchema = await getTemplateSchema(form.id, template); + if (!templateSchema) { + $spinner.remove(); + window.alert(GFPDF.getPreviewResultError); + return; + } + + // get the current PDF settings + const formData = getCurrentPdfSettingsForApi(templateSchema); + formData.append('form', form.id); + + // generate PDF preview + const pdfBlob = await getPdfPreview(formData); + if (!pdfBlob) { + $spinner.remove(); + window.alert(GFPDF.getPreviewResultError); + return; + } + + $spinner.remove(); + + viewFile(pdfBlob); + }); +} diff --git a/src/assets/js/react/utilities/download.js b/src/assets/js/react/utilities/download.js new file mode 100644 index 000000000..5caf14cfc --- /dev/null +++ b/src/assets/js/react/utilities/download.js @@ -0,0 +1,22 @@ +/** + * Open the file in a new window + * @param {File|Blob|MediaSource} file + */ +export function viewFile(file) { + // Create a link and set the URL using `createObjectURL` + const link = document.createElement('a'); + link.style.display = 'none'; + link.href = URL.createObjectURL(file); + link.target = 'gravity-pdf-preview'; + + // It needs to be added to the DOM so it can be clicked + document.body.appendChild(link); + link.click(); + + // To make this work on Firefox we need to wait + // a little while before removing it. + setTimeout(() => { + URL.revokeObjectURL(link.href); + link.parentNode.removeChild(link); + }, 0); +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 1c10a67e4..c04eba486 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -721,11 +721,15 @@ public function rest_api() { $form_setting_controller = new Rest\Rest_Form_Settings( $this->options, $this->gform, $this->misc, $this->templates ); $form_setting_controller->init(); + $pdf_preview_controller = new Rest\Rest_Pdf_Preview( $this->options, $this->gform, $this->misc, $this->templates ); + $pdf_preview_controller->init(); + $download_pdf_controller = new Rest\Rest_Download_Pdf( $this->gform, new Helper\Helper_Url_Signer() ); $download_pdf_controller->init(); /* Add to our singleton controller */ $this->singleton->add_class( $form_setting_controller ); + $this->singleton->add_class( $pdf_preview_controller ); $this->singleton->add_class( $download_pdf_controller ); /* Log any errors for PDF endpoints */ diff --git a/tests/phpunit/integration/Rest/Test_Rest.php b/tests/phpunit/integration/Rest/Test_Rest.php index b99c6233e..d88fc319b 100644 --- a/tests/phpunit/integration/Rest/Test_Rest.php +++ b/tests/phpunit/integration/Rest/Test_Rest.php @@ -41,7 +41,7 @@ public function set_up(): void { parent::set_up(); - /* Start anonymous — some tests assert a 401 before they wp_set_current_user themselves. */ + /* Start anonymous — tests like test_create_item_preview assert a 401 before they wp_set_current_user themselves. */ unset( $GLOBALS['current_user'] ); wp_set_current_user( 0 ); diff --git a/tests/phpunit/integration/Rest/Test_Rest_Pdf_Preview.php b/tests/phpunit/integration/Rest/Test_Rest_Pdf_Preview.php new file mode 100644 index 000000000..320860b23 --- /dev/null +++ b/tests/phpunit/integration/Rest/Test_Rest_Pdf_Preview.php @@ -0,0 +1,157 @@ +api = new Rest_Pdf_Preview( $gfpdf->options, $gfpdf->gform, $gfpdf->misc, $gfpdf->templates ); + + parent::set_up(); + + /* Configure mPDF with available fonts */ + $config = static function ( $config ) { + return array_merge( $config, [ + 'fontDir' => PDF_PLUGIN_DIR . '/tools/phpunit/data/fonts/', + 'fontdata' => [ + 'dejavusans' => [ + 'R' => 'DejaVuSans.ttf', + 'useOTL' => 0xff, + 'useKashida' => 75, + ], + ], + + 'backupSubsFont' => [], + 'backupSIPFont' => '', + 'BMPonly' => [ 'dejavusans' ], + ] ); + }; + + add_filter( 'gfpdf_mpdf_class_config', $config ); + } + + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + + foreach ( $this->api::$endpoints as $route ) { + $this->assertArrayHasKey( '/' . $this->api::NAMESPACE . $route, $routes ); + } + } + + /** + * @group slow + */ + public function test_create_item_preview() { + + $request = new WP_REST_Request( 'POST', '/' . $this->api::get_route_basepath() . '/' . $this->form_id . '/preview' ); + $request->set_body_params( [ + 'name' => 'Document', + 'filename' => 'Filename', + ] ); + + /* Test for authentication error */ + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + + /* Test for forbidden (logged-in editor lacks gravityforms_edit_settings) */ + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status() ); + + /* Test the PDF generator creates the preview */ + wp_set_current_user( self::$admin_id ); + + /* Test the PDF was actually created */ + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 500, $response->get_status() ); + $this->assertSame( 'headers_sent', $response->get_data()['code'] ); + } + + /** + * @group slow + */ + public function test_create_item_preview_with_entry() { + + $entry = $this->gf_factory()->entry->create_and_get( [ 'form_id' => $this->form_id ] ); + + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . $this->api::get_route_basepath() . '/' . $this->form_id . '/preview' ); + $request->set_body_params( [ + 'name' => 'Document', + 'filename' => 'Filename', + 'entry' => $entry['id'], + ] ); + + /* Test the PDF was actually created */ + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 500, $response->get_status() ); + $this->assertSame( 'headers_sent', $response->get_data()['code'] ); + + /* Don't pass the entry */ + $request->set_body_params( [ + 'name' => 'Document', + 'filename' => 'Filename', + ] ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 500, $response->get_status() ); + $this->assertSame( 'headers_sent', $response->get_data()['code'] ); + } + + public function test_create_item_preview_with_invalid_entry() { + wp_set_current_user( self::$admin_id ); + + $request = new WP_REST_Request( 'POST', '/' . $this->api::get_route_basepath() . '/' . $this->form_id . '/preview' ); + $request->set_body_params( [ + 'name' => 'Document', + 'filename' => 'Filename', + 'entry' => 520, + ] ); + + /* Test for error with invalid entry ID */ + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + + /* Test for error with invalid entry ID for requested form */ + $form_id = $this->gf_factory()->form->create(); + $entry_id = $this->gf_factory()->entry->create( [ 'form_id' => $this->form_id ] ); + + $request = new WP_REST_Request( 'POST', '/' . $this->api::get_route_basepath() . '/' . $form_id . '/preview' ); + + $request->set_body_params( [ + 'name' => 'Document', + 'filename' => 'Filename', + 'entry' => $entry_id, + ] ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + } +}