diff --git a/assets/src/js/godam-recorder/index.js b/assets/src/js/godam-recorder/index.js index 1080ae6e2..ea573753b 100644 --- a/assets/src/js/godam-recorder/index.js +++ b/assets/src/js/godam-recorder/index.js @@ -19,6 +19,27 @@ import { __, sprintf } from '@wordpress/i18n'; */ import { forgetGravityFormsSavedFile, hasGravityFormsSavedFile } from './gravity-forms-saved-file'; +/** + * Finds the file input and trigger button that belong to a recorder container. + * + * The server builds the button id with wp_unique_id(), so a container rendered again carries + * a different id. Read it from the container every time instead of keeping an earlier value. + * + * @param {HTMLElement} container - The recorder container. + * @return {Object} The input id, button id, file input and button; an element is null when it is missing. + */ +const resolveRecorderMarkup = ( container ) => { + const inputId = container.getAttribute( 'data-input-id' ); + const uploadButtonId = container.getAttribute( 'data-video-upload-button-id' ); + + return { + inputId, + uploadButtonId, + fileInput: document.getElementById( inputId ), + uploadButton: container.querySelector( `#${ uploadButtonId }` ), + }; +}; + /** * Class to handle Uppy video uploads within Gravity Forms. * Supports webcam, screen capture, and local file uploads with preview, @@ -31,12 +52,7 @@ class UppyVideoUploader { * @param {HTMLElement} container - The container element with data-* attributes for config. */ constructor( container ) { - this.container = container; this.containerId = container.id; - this.inputId = container.getAttribute( 'data-input-id' ); - this.uploadButtonId = container.getAttribute( - 'data-video-upload-button-id', - ); this.maxFileSize = Number( container.getAttribute( 'data-max-size' ) ); this.maxDurationSeconds = Number( container.getAttribute( 'data-max-duration' ) ) || 0; this.enabledSelectors = @@ -44,8 +60,7 @@ class UppyVideoUploader { 'webcam,screen_capture'; this.selectorArray = this.enabledSelectors.split( ',' ); - this.fileInput = document.getElementById( this.inputId ); - this.uploadButton = container.querySelector( `#${ this.uploadButtonId }` ); + this.useMarkup( container, resolveRecorderMarkup( container ) ); // Uppy target for forms used inside godam video. this.uppyModalTarget = document.getElementById( 'uppy-godam-video-modal-container' ); @@ -67,6 +82,20 @@ class UppyVideoUploader { this.setupGravityFormsAjaxHandler(); } + /** + * Points the uploader at a container's elements. + * + * @param {HTMLElement} container - The recorder container the uploader works in. + * @param {Object} markup - The ids and elements from resolveRecorderMarkup(). + */ + useMarkup( container, markup ) { + this.container = container; + this.inputId = markup.inputId; + this.uploadButtonId = markup.uploadButtonId; + this.fileInput = markup.fileInput; + this.uploadButton = markup.uploadButton; + } + /** * Gets the duration of a media file in seconds. * @param {File} file - The media file. @@ -499,52 +528,62 @@ class UppyVideoUploader { } /** - * Sets up the Gravity Forms `gform_post_render` hook to reattach triggers and restore files after AJAX submission. + * Opens the Uppy dashboard from the uploader's current trigger button. + * + * Uppy binds the dashboard trigger once, when the plugin is installed. A button that + * Gravity Forms renders later is a different element, so it needs a listener of its own. */ - setupGravityFormsAjaxHandler() { - jQuery( document ).on( 'gform_post_render', ( _, formId ) => { - const submission = window?.gform?.submission; - if ( ! submission ) { - return; - } - - const form = document.getElementById( `gform_${ formId }` ); - if ( ! form ) { - return; - } - - // Skip if submission is POSTBACK (non-AJAX). - if ( submission.getSubmissionMethod( form ) === submission.SUBMISSION_METHOD_POSTBACK ) { - return; - } + bindUploadButton() { + // Prevent duplicate listeners by checking marker attribute. + if ( this.uploadButton.getAttribute( 'uppy-click-attached' ) ) { + return; + } - // Skip on initial load, run only after AJAX validation errors. - const gformWrapper = form.closest( 'div[id^=gform_wrapper_]' ); - if ( ! gformWrapper.classList.contains( 'gform_validation_error' ) ) { - return; - } + this.uploadButton.addEventListener( 'click', () => { + this.uppy.getPlugin( 'Dashboard' ).openModal(); + } ); + this.uploadButton.setAttribute( 'uppy-click-attached', 'true' ); + } + /** + * Sets up the Gravity Forms `gform_post_render` hook to move the uploader onto markup that + * Gravity Forms rendered again, after a page change or a failed submit over AJAX. + */ + setupGravityFormsAjaxHandler() { + jQuery( document ).on( 'gform_post_render', () => { this.handleUppyUIOnAjaxRequest(); } ); } /** - * Reattaches Uppy modal open handlers and restores file previews after Gravity Forms AJAX submission. + * Moves the uploader onto its field's new markup and shows the recording it holds again. + * + * Gravity Forms replaces a page's markup on every AJAX page change, so the container, file + * input and button the uploader was bound to leave the page. Their ids are the same in the + * new markup, except the button's, which is read from the new container. */ handleUppyUIOnAjaxRequest() { - const dashboardTrigger = document.getElementById( this.uploadButtonId ); + // The markup is still on the page: first render, a postback, or a page the response did not replace. + if ( this.container.isConnected ) { + return; + } - // Prevent duplicate listeners by checking marker attribute. - if ( ! dashboardTrigger.getAttribute( 'uppy-click-attached' ) ) { - dashboardTrigger.addEventListener( 'click', () => { - this.uppy.getPlugin( 'Dashboard' ).openModal(); - } ); - dashboardTrigger.setAttribute( 'uppy-click-attached', 'true' ); + const container = document.getElementById( this.containerId ); + if ( ! container ) { + return; } + const markup = resolveRecorderMarkup( container ); + if ( ! markup.fileInput || ! markup.uploadButton ) { + return; + } + + this.useMarkup( container, markup ); + this.bindUploadButton(); + const restoredFile = this.uppy.getFiles()?.[ 0 ]; if ( restoredFile ) { - // Re-render preview on AJAX validation errors. + // Draw the recording's preview again on the new markup. this.processVideoUpload( restoredFile, 'restored' ); } } diff --git a/assets/src/js/godam-recorder/index.test.js b/assets/src/js/godam-recorder/index.test.js new file mode 100644 index 000000000..aef504569 --- /dev/null +++ b/assets/src/js/godam-recorder/index.test.js @@ -0,0 +1,297 @@ +/** + * The recorder after Gravity Forms renders a field's markup again. + * + * Over AJAX, Gravity Forms swaps a page's markup on every page change or failed submit and then + * fires `gform_post_render`. Uppy binds its dashboard trigger once, when it is installed, so the + * recorder has to move onto the new markup itself. These tests replace the markup the same way + * and fire the same event. + */ + +/** + * Stand-ins for Uppy. The dashboard fake looks its trigger up once when it is installed and binds + * that element only, as @uppy/dashboard does, so a button rendered later opens nothing by itself. + */ +jest.mock( '@uppy/core', () => class MockUppy { + constructor() { + this.plugins = {}; + this.files = []; + this.handlers = {}; + global.mockUppyInstances.push( this ); + } + + use( Plugin, options ) { + const plugin = new Plugin( this, options ); + this.plugins[ plugin.id ] = plugin; + if ( plugin.install ) { + plugin.install(); + } + return this; + } + + getPlugin( id ) { + return this.plugins[ id ]; + } + + getFiles() { + return this.files; + } + + on( event, handler ) { + this.handlers[ event ] = [ ...( this.handlers[ event ] || [] ), handler ]; + return this; + } + + removeFile( id ) { + this.files = this.files.filter( ( file ) => file.id !== id ); + ( this.handlers[ 'file-removed' ] || [] ).forEach( ( handler ) => handler() ); + } +} ); +jest.mock( '@uppy/dashboard', () => class MockDashboard { + constructor( uppy, options ) { + this.id = 'Dashboard'; + this.options = options; + this.openModal = jest.fn(); + this.closeModal = jest.fn(); + } + + install() { + global.document.querySelectorAll( this.options.trigger ).forEach( ( trigger ) => { + trigger.addEventListener( 'click', this.openModal ); + } ); + } +} ); +jest.mock( '@uppy/webcam', () => class MockWebcam {} ); +jest.mock( '@uppy/screen-capture', () => class MockScreenCapture {} ); +jest.mock( '@uppy/audio', () => class MockAudio {} ); +jest.mock( '@uppy/golden-retriever', () => class MockGoldenRetriever {} ); + +const SAVED_FILES = JSON.stringify( { input_3: [ { uploaded_filename: 'clip.webm', temp_filename: 'abc_input_3_clip.webm' } ] } ); + +/** + * Markup of one Record field as the Gravity Forms field class prints it. + * + * @param {string} buttonId The button id; the server makes a new one on every render. + * @return {string} The field markup. + */ +const fieldMarkup = ( buttonId ) => ` + +
+ +
+
+
`; + +const pageMarkup = ( buttonId ) => `
${ fieldMarkup( buttonId ) }
`; + +const heldRecording = () => ( { + id: 'uppy-clip', + name: 'clip.webm', + type: 'video/webm', + source: 'Webcam', + data: new Blob( [ 'recording' ], { type: 'video/webm' } ), +} ); + +let handlers = {}; + +/** + * Loads the form, lets the recorder start as it does on page load, and gives it a recording. + * + * @param {Object} options Options. + * @param {string} options.buttonId The first render's button id. + * @param {boolean} options.saved Whether Gravity Forms holds a saved copy of the recording, as 2.9.18+ does after a page change. + * @return {Object} The recorder's Uppy instance. + */ +const loadForm = ( { buttonId = 'uppy-video-upload-1', saved = true } = {} ) => { + document.body.innerHTML = ` +
+ + ${ pageMarkup( buttonId ) } +
`; + + document.dispatchEvent( new Event( 'DOMContentLoaded' ) ); + jest.advanceTimersByTime( 100 ); + + document.querySelector( '[name=gform_uploaded_files]' ).value = saved ? SAVED_FILES : ''; + + const uppy = global.mockUppyInstances[ 0 ]; + uppy.files = [ heldRecording() ]; + return uppy; +}; + +/** + * Replaces the page's markup the way Gravity Forms' AJAX page change does, then fires its event. + * + * @param {string} buttonId The new markup's button id. + */ +const rerenderPage = ( buttonId ) => { + document.getElementById( 'gform_page_1_2' ).outerHTML = pageMarkup( buttonId ); +}; + +const firePostRender = () => { + ( handlers.gform_post_render || [] ).forEach( ( handler ) => handler( {}, 1, 2 ) ); +}; + +const click = ( id ) => document.getElementById( id ).dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ); + +beforeAll( () => { + global.mockUppyInstances = []; + global.URL.createObjectURL = jest.fn( () => 'blob:recording' ); + global.URL.revokeObjectURL = jest.fn(); + + // Same hook surface as jQuery's `.on()`, kept in a table the tests can fire. + global.jQuery = () => ( { + on: ( name, handler ) => { + handlers[ name ] = [ ...( handlers[ name ] || [] ), handler ]; + }, + ready: ( callback ) => callback(), + } ); + + require( './index' ); +} ); + +beforeEach( () => { + jest.useFakeTimers(); + handlers = {}; + global.mockUppyInstances.length = 0; +} ); + +afterEach( () => { + jest.useRealTimers(); + document.body.innerHTML = ''; +} ); + +describe( 'recorder after Gravity Forms renders the field again', () => { + it( 'opens the dashboard from the first render\'s button', () => { + const uppy = loadForm(); + + click( 'uppy-video-upload-1' ); + + expect( uppy.getPlugin( 'Dashboard' ).openModal ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'opens the dashboard from the new button after a page change', () => { + const uppy = loadForm(); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + click( 'uppy-video-upload-1' ); + + expect( uppy.getPlugin( 'Dashboard' ).openModal ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'follows a button id that changed between renders', () => { + const uppy = loadForm( { buttonId: 'uppy-video-upload-1' } ); + + rerenderPage( 'uppy-video-upload-4' ); + firePostRender(); + click( 'uppy-video-upload-4' ); + + expect( uppy.getPlugin( 'Dashboard' ).openModal ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'binds the new button once however many times Gravity Forms fires the event', () => { + const uppy = loadForm(); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + firePostRender(); + firePostRender(); + click( 'uppy-video-upload-1' ); + + expect( uppy.getPlugin( 'Dashboard' ).openModal ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'keeps working through several page changes', () => { + const uppy = loadForm(); + const { openModal } = uppy.getPlugin( 'Dashboard' ); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + click( 'uppy-video-upload-1' ); + + rerenderPage( 'uppy-video-upload-2' ); + firePostRender(); + click( 'uppy-video-upload-2' ); + + expect( openModal ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'leaves the markup alone while it is still on the page', () => { + loadForm(); + + firePostRender(); + + expect( document.getElementById( 'uppy-video-upload-1' ).hasAttribute( 'uppy-click-attached' ) ).toBe( false ); + expect( document.querySelector( '#uppy_preview_1_3 video' ) ).toBeNull(); + } ); + + it( 'shows the held recording on the new markup', () => { + loadForm(); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + + expect( document.querySelector( '#uppy_preview_1_3 video' ) ).not.toBeNull(); + expect( document.getElementById( 'uppy_filename_1_3' ).textContent ).toBe( 'clip.webm' ); + } ); + + it( 'does not send a recording again when Gravity Forms already holds a copy', () => { + loadForm(); + const sent = jest.fn(); + document.addEventListener( 'godamFormInputchange', sent ); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + document.removeEventListener( 'godamFormInputchange', sent ); + + expect( sent ).not.toHaveBeenCalled(); + expect( document.getElementById( 'input_1_3' ).files ).toHaveLength( 0 ); + } ); + + it( 'puts the recording on the new file input when Gravity Forms kept no copy', () => { + global.DataTransfer = class { + constructor() { + this.files = []; + this.items = { add: ( file ) => this.files.push( file ) }; + } + }; + loadForm( { saved: false } ); + rerenderPage( 'uppy-video-upload-1' ); + // jsdom's file input has no settable `files`; browsers do. + const input = document.getElementById( 'input_1_3' ); + Object.defineProperty( input, 'files', { configurable: true, writable: true, value: null } ); + + firePostRender(); + + expect( input.files ).toHaveLength( 1 ); + expect( input.files[ 0 ].name ).toBe( 'clip.webm' ); + delete global.DataTransfer; + } ); + + it( 'drops Gravity Forms\' saved copy when the recording is removed after the page change', () => { + const uppy = loadForm(); + const savedFilesInput = document.querySelector( '[name=gform_uploaded_files]' ); + + rerenderPage( 'uppy-video-upload-1' ); + firePostRender(); + document.querySelector( '.uppy-remove-recording-button' ).click(); + + expect( uppy.getFiles() ).toHaveLength( 0 ); + expect( JSON.parse( savedFilesInput.value ) ).toEqual( {} ); + expect( document.querySelector( '#uppy_preview_1_3 video' ) ).toBeNull(); + } ); + + it( 'waits while the field is not on the page and binds it when it comes back', () => { + const uppy = loadForm(); + + document.getElementById( 'gform_page_1_2' ).remove(); + expect( firePostRender ).not.toThrow(); + + document.getElementById( 'gform_1' ).insertAdjacentHTML( 'beforeend', pageMarkup( 'uppy-video-upload-1' ) ); + firePostRender(); + click( 'uppy-video-upload-1' ); + + expect( uppy.getPlugin( 'Dashboard' ).openModal ).toHaveBeenCalledTimes( 1 ); + } ); +} );