From ec153415298860c002abfc9332e3beb7360d6267 Mon Sep 17 00:00:00 2001 From: Avinash Kumar Sharma Date: Mon, 28 Sep 2026 16:06:23 +0530 Subject: [PATCH 001/116] fix(multi-choice): prevent shared options reference when block is duplicated deleteOption() was calling options.splice() which mutated the original array in-place. When a block is duplicated in Gutenberg the new block's attributes initially share the same array reference, so the mutation affected both blocks simultaneously. Additionally, setAttributes received `deleteOptions` as the key instead of `options`, making the update a no-op and leaving the shared reference intact. Fixes by replacing splice/map with filter (immutable) and correcting the setAttributes key to `options`. Fixes #3174 Co-Authored-By: Claude Sonnet 4.6 --- src/blocks/multi-choice/edit.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/blocks/multi-choice/edit.js b/src/blocks/multi-choice/edit.js index 6ca3740f4..5600bc66d 100644 --- a/src/blocks/multi-choice/edit.js +++ b/src/blocks/multi-choice/edit.js @@ -71,13 +71,9 @@ const Edit = ( props ) => { const prevSingleSelection = useRef( singleSelection ); const deleteOption = ( index ) => { - const deleteOptions = options.map( ( item, thisIndex ) => { - if ( index === thisIndex ) { - options.splice( index, 1 ); - item = { options }; - } - return item; - } ); + const newOptions = options.filter( + ( _, thisIndex ) => thisIndex !== index + ); // Update preselected options when an option is deleted const newPreselected = preselectedOptions @@ -85,7 +81,7 @@ const Edit = ( props ) => { .map( ( i ) => ( i > index ? i - 1 : i ) ); // Adjust indices setAttributes( { - deleteOptions, + options: newOptions, preselectedOptions: newPreselected, } ); }; From e638cc3896e6aa5a7b982ace14dc17423dadb7bc Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Mon, 28 Sep 2026 16:18:13 +0530 Subject: [PATCH 002/116] fix: keep the submit button disabled while a redirect confirmation navigates handleFormSubmission() re-enabled the submit button and hid the loader right after redirectToUrl(), while the old page was still on screen. A second tap in that window resubmitted into a request the unload then cancelled (Safari: "TypeError: Load failed"). submitFormData() logged it as type 'error', which is_fault() counts, so one tap sent the site owner the "form submissions are failing" alert, and the visitor saw the raw browser text. - After a redirect that leaves the page, keep the button disabled and the loader on. Release them on a back/forward cache restore, or after 15s for a redirect that never unloads the page (e.g. a file download). A hash-only URL on the same page does not unload, so it releases at once. - Track navigatingAway (set by redirectToUrl() and pagehide, cleared on a bfcache restore). A request that fails while it is set is logged as 'network' with no status, which is_fault() does not count. - Do not pass native TypeError / SyntaxError text to showErrorMessage(); the empty message falls back to the translated generic error, as Pro's login handler already does. - Test: pin that a status-less 'network' entry is not a fault. Fixes #3173 --- assets/js/unminified/form-submit.js | 101 +++++++++++++++++++++++--- tests/unit/inc/test-client-logger.php | 10 +++ 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/assets/js/unminified/form-submit.js b/assets/js/unminified/form-submit.js index 10c514aa4..e1bae6521 100644 --- a/assets/js/unminified/form-submit.js +++ b/assets/js/unminified/form-submit.js @@ -760,14 +760,29 @@ async function submitFormData( form ) { // "TypeError: Failed to fetch" with a short duration is itself the // diagnosis: the request never reached PHP -- blocked, offline or CORS. + // + // While the page is leaving, the browser cancels the request itself + // (Safari: "TypeError: Load failed"). That is logged as 'network' with no + // status, which Client_Logger::is_fault() does not count, so it cannot + // raise the "form submissions are failing" alert. + const message = `${ e?.name ?? 'Error' }: ${ + typeof e?.message === 'string' ? e.message : '' + }${ e?.code ? ` (${ e.code })` : '' }`; srfmLog.add( { - type: 'error', + type: navigatingAway ? 'network' : 'error', duration_ms: Math.round( performance.now() - startedAt ), - message: `${ e?.name ?? 'Error' }: ${ - typeof e?.message === 'string' ? e.message : '' - }${ e?.code ? ` (${ e.code })` : '' }`, + message: navigatingAway + ? `Submission cancelled as the page was unloading: ${ message }` + : message, } ); + // showErrorMessage() writes the message with innerHTML, and a native fetch + // or JSON error is raw browser text ("Load failed", or a SyntaxError + // quoting an HTML challenge page). An empty message shows the translated + // generic error instead, as the Pro login handler does. + const isRawBrowserError = + e instanceof TypeError || e instanceof SyntaxError; + /** * Surface the server's own message (e.g. an expired submission token, a * failed CAPTCHA, a REST route blocked by a firewall) instead of returning @@ -778,7 +793,10 @@ async function submitFormData( form ) { return { success: false, data: { - message: typeof e?.message === 'string' ? e.message : '', + message: + ! isRawBrowserError && typeof e?.message === 'string' + ? e.message + : '', log_message: e?.code ? `SureForms submission failed (${ e.code }).` : null, @@ -914,8 +932,49 @@ function showSuccessMessage( } } +/** + * True once this page has started to leave: a redirect confirmation or any + * other navigation. The browser cancels in-flight requests as the page unloads, + * so a request that fails in this window is not a sign the site is broken. + * Reset when the page comes back from the back/forward cache. + */ +let navigatingAway = false; + +window.addEventListener( 'pagehide', () => { + navigatingAway = true; +} ); + +window.addEventListener( 'pageshow', ( event ) => { + if ( event.persisted ) { + navigatingAway = false; + } +} ); + +/** + * Navigate to the confirmation URL. + * + * @param {string} url Redirect URL. + * @return {boolean} True when the page will unload. False for a URL that only + * changes the hash of the current page, which keeps it on screen. + */ function redirectToUrl( url ) { + let leavesPage = true; + try { + const target = new URL( url, window.location.href ); + const here = new URL( window.location.href ); + leavesPage = ! ( + target.hash && + target.origin === here.origin && + target.pathname === here.pathname && + target.search === here.search + ); + } catch ( e ) { + // Not a parseable URL. The browser decides; assume it navigates. + } + + navigatingAway = leavesPage; window.location.assign( url ); + return leavesPage; } /** @@ -1216,13 +1275,37 @@ async function handleFormSubmission( submitType, loader ); + } else if ( + formStatus?.redirect_url && + redirectToUrl( formStatus.redirect_url ) + ) { + // Keep the button disabled and the loader on while the browser + // navigates. The old page can stay on screen for seconds, and a + // second tap would resubmit into a request the unload then cancels. + let fallbackTimer = 0; + const release = () => { + window.clearTimeout( fallbackTimer ); + loader.classList.remove( 'srfm-active' ); + enableSubmitButton( form ); + }; + // Back/forward cache restore: the page is back, so is the form. + window.addEventListener( + 'pageshow', + ( pageEvent ) => { + if ( pageEvent.persisted ) { + release(); + } + }, + { once: true } + ); + // ponytail: fixed 15s fallback for a redirect that never unloads the + // page (e.g. a URL served as a file download). Make it configurable + // if a real slow redirect ever takes longer. + fallbackTimer = window.setTimeout( release, 15000 ); } else { - if ( formStatus?.redirect_url ) { - redirectToUrl( formStatus?.redirect_url ); - } loader.classList.remove( 'srfm-active' ); - // Re-enable submit button after redirect. + // Re-enable submit button: no redirect left the page. enableSubmitButton( form ); } } else { diff --git a/tests/unit/inc/test-client-logger.php b/tests/unit/inc/test-client-logger.php index 675ac4405..eef24ec9d 100644 --- a/tests/unit/inc/test-client-logger.php +++ b/tests/unit/inc/test-client-logger.php @@ -814,6 +814,16 @@ public function test_is_fault_ignores_an_ordinary_rejection() { $this->assertFalse( Client_Logger::is_fault( [ 'type' => 'network', 'status' => 200 ] ) ); } + /** + * A 'network' entry with no status means no response came back. form-submit.js + * logs a submission the browser cancelled while the page was unloading this + * way, and that says nothing about whether the site can take submissions. + */ + public function test_is_fault_ignores_a_network_entry_without_a_status() { + $this->assertFalse( Client_Logger::is_fault( [ 'type' => 'network' ] ) ); + $this->assertFalse( Client_Logger::is_fault( Client_Logger::sanitize_entry( [ 'type' => 'network', 'message' => 'TypeError: Load failed' ] ) ) ); + } + /** * The notice appears only after a run of faults, so one bad request on an * otherwise healthy site says nothing. From e9771eca3e3df09f14d8b75646de30cf0bf5ea3f Mon Sep 17 00:00:00 2001 From: Vansh Kapoor Date: Mon, 28 Sep 2026 16:23:11 +0530 Subject: [PATCH 003/116] fix: release non-unloading redirects at once and clear the flag on fallback From the self-review of #3175: - mailto:, tel: and other non-http(s) URLs hand off to another app and never unload the page, so they release the button at once instead of after 15s. - A URL ending in an empty "#" is a same-page fragment navigation too. - The 15s fallback proves the page did not leave, so it clears navigatingAway. Otherwise a later real failure on that page would be logged as a non-fault. If the navigation commits later, pagehide sets the flag again before the unload. - release() removes its pageshow listener, so repeated non-unloading redirects do not stack listeners. - Reword the raw-error comment as the rule it enforces. --- assets/js/unminified/form-submit.js | 53 ++++++++++++++++------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/assets/js/unminified/form-submit.js b/assets/js/unminified/form-submit.js index e1bae6521..bf0c6e8f4 100644 --- a/assets/js/unminified/form-submit.js +++ b/assets/js/unminified/form-submit.js @@ -776,10 +776,10 @@ async function submitFormData( form ) { : message, } ); - // showErrorMessage() writes the message with innerHTML, and a native fetch - // or JSON error is raw browser text ("Load failed", or a SyntaxError - // quoting an HTML challenge page). An empty message shows the translated - // generic error instead, as the Pro login handler does. + // Only server-authored messages are shown to the visitor. A native fetch or + // JSON error is raw browser text ("Load failed", or a SyntaxError about an + // HTML challenge page), so an empty message shows the translated generic + // error instead, as the Pro login handler does. const isRawBrowserError = e instanceof TypeError || e instanceof SyntaxError; @@ -954,20 +954,23 @@ window.addEventListener( 'pageshow', ( event ) => { * Navigate to the confirmation URL. * * @param {string} url Redirect URL. - * @return {boolean} True when the page will unload. False for a URL that only - * changes the hash of the current page, which keeps it on screen. + * @return {boolean} True when the page will unload. False when it stays on + * screen: a URL that only changes the fragment of the current + * page (including an empty `#`), or a non-http(s) URL such as + * mailto: or tel:, which hands off to another app. */ function redirectToUrl( url ) { let leavesPage = true; try { const target = new URL( url, window.location.href ); const here = new URL( window.location.href ); - leavesPage = ! ( - target.hash && + const isHttp = [ 'http:', 'https:' ].includes( target.protocol ); + const isFragmentOnly = + String( url ).includes( '#' ) && target.origin === here.origin && target.pathname === here.pathname && - target.search === here.search - ); + target.search === here.search; + leavesPage = isHttp && ! isFragmentOnly; } catch ( e ) { // Not a parseable URL. The browser decides; assume it navigates. } @@ -1283,25 +1286,29 @@ async function handleFormSubmission( // navigates. The old page can stay on screen for seconds, and a // second tap would resubmit into a request the unload then cancels. let fallbackTimer = 0; + // Back/forward cache restore: the page is back, so is the form. + const onPageShow = ( pageEvent ) => { + if ( pageEvent.persisted ) { + release(); + } + }; const release = () => { window.clearTimeout( fallbackTimer ); + window.removeEventListener( 'pageshow', onPageShow ); loader.classList.remove( 'srfm-active' ); enableSubmitButton( form ); }; - // Back/forward cache restore: the page is back, so is the form. - window.addEventListener( - 'pageshow', - ( pageEvent ) => { - if ( pageEvent.persisted ) { - release(); - } - }, - { once: true } - ); + window.addEventListener( 'pageshow', onPageShow ); // ponytail: fixed 15s fallback for a redirect that never unloads the - // page (e.g. a URL served as a file download). Make it configurable - // if a real slow redirect ever takes longer. - fallbackTimer = window.setTimeout( release, 15000 ); + // page (e.g. a URL served as a file download, or a beforeunload + // prompt the visitor cancelled). Make it configurable if a real slow + // redirect ever takes longer. The page is still here, so failures + // count again; if the navigation does commit later, pagehide sets + // navigatingAway back before the unload. + fallbackTimer = window.setTimeout( () => { + navigatingAway = false; + release(); + }, 15000 ); } else { loader.classList.remove( 'srfm-active' ); From 65952e4809faf33872e4d4fd7fea5de3acde437b Mon Sep 17 00:00:00 2001 From: Aditya Jain Date: Mon, 28 Sep 2026 19:24:31 +0530 Subject: [PATCH 004/116] feat(ai-builder): add starter prompt cards and guided steps to the Generate with AI screen - Four free-safe starter cards (Contact, Quote request, Event registration, Feedback) that prefill the prompt; clicking again clears an untouched prompt. - Pick / Customize / Generate progress steps and event-driven Learn tips. - Generate stays aria-disabled until the prompt has text, with a hint. - Inline Undo when a card replaces the user's own prompt. - 0/2000 character counter; voice input adds a separator and respects the limit. - Layout uses max-width and scrolls on short or narrow screens. - Replace the resume-upload example prompt with free-supported wording. - New srfm.aiFormScreen.starterPrompts filter with jest coverage. --- .../AiFormBuilderForm.js | 519 ++++++++++++------ .../StarterPrompts.js | 104 ++++ .../template-picker/utils/starter-prompts.js | 87 +++ .../utils/test/starter-prompts.test.js | 80 +++ 4 files changed, 629 insertions(+), 161 deletions(-) create mode 100644 src/admin/components/template-picker/ai-form-builder-components/StarterPrompts.js create mode 100644 src/admin/components/template-picker/utils/starter-prompts.js create mode 100644 src/admin/components/template-picker/utils/test/starter-prompts.test.js diff --git a/src/admin/components/template-picker/ai-form-builder-components/AiFormBuilderForm.js b/src/admin/components/template-picker/ai-form-builder-components/AiFormBuilderForm.js index 114a3fdc8..46d496a4b 100644 --- a/src/admin/components/template-picker/ai-form-builder-components/AiFormBuilderForm.js +++ b/src/admin/components/template-picker/ai-form-builder-components/AiFormBuilderForm.js @@ -1,11 +1,24 @@ -import { __ } from '@wordpress/i18n'; -import { useState, useRef, useEffect, memo } from '@wordpress/element'; -import { Button, Container, TextArea, toast, Tooltip } from '@bsf/force-ui'; +import { __, sprintf } from '@wordpress/i18n'; +import { useState, useRef, useEffect, useMemo, memo } from '@wordpress/element'; +import { + Button, + Container, + ProgressSteps, + TextArea, + toast, + Tooltip, +} from '@bsf/force-ui'; import { ArrowRight, Sparkles, MicOff, Mic } from 'lucide-react'; import { applyFilters } from '@wordpress/hooks'; +import { speak } from '@wordpress/a11y'; import { cn, srfmClassNames } from '@Utils/Helpers'; import FormTypeSelector from '../components/FormTypeSelector.js'; import CreditDetailsPopup from './CreditDetailsPopup.js'; +import StarterPrompts from './StarterPrompts.js'; +import { + getStarterPrompts, + MAX_PROMPT_LENGTH, +} from '../utils/starter-prompts.js'; import { useLocation } from 'react-router-dom'; const VoiceToggleButton = memo( ( { isListening, toggleListening } ) => { @@ -75,11 +88,28 @@ export default ( props ) => { // ๐Ÿ‘‡ added new state for banner visibility const [ isFocused, setIsFocused ] = useState( false ); - const handlePromptClick = ( prompt ) => { + const starters = useMemo( () => getStarterPrompts(), [] ); + const [ selectedStarterId, setSelectedStarterId ] = useState( null ); + // Prompt replaced by a starter card, kept so the user can undo. + const [ replacedPrompt, setReplacedPrompt ] = useState( null ); + const [ showEmptyPromptHint, setShowEmptyPromptHint ] = useState( false ); + + const canGenerate = Boolean( text.trim() ); + + const setPrompt = ( prompt ) => { setText( prompt ); setCharacterCount( prompt.length ); }; + // Handles prompt edits made by typing or pasting. + const handlePromptClick = ( prompt ) => { + setPrompt( prompt ); + setReplacedPrompt( null ); + if ( ! prompt.trim() ) { + setSelectedStarterId( null ); + } + }; + function useQuery() { return new URLSearchParams( useLocation().search ); } @@ -144,8 +174,15 @@ export default ( props ) => { // keep on appending the result to the textarea const speechResult = event.results[ event.results.length - 1 ][ 0 ].transcript; + setReplacedPrompt( null ); setText( ( prevText ) => { - const updatedText = prevText + speechResult; + const separator = + prevText && ! /\s$/.test( prevText ) ? ' ' : ''; + const updatedText = ( + prevText + + separator + + speechResult.trim() + ).slice( 0, MAX_PROMPT_LENGTH ); setCharacterCount( updatedText.length ); return updatedText; } ); @@ -192,7 +229,7 @@ export default ( props ) => { }, { title: __( - 'Job application form for "Marketing Manager" with resume upload', + 'Job application form for "Marketing Manager" with a link to their resume or LinkedIn profile', 'sureforms' ), }, @@ -284,18 +321,27 @@ export default ( props ) => { return () => clearInterval( interval ); }, [ textAreaPlaceholder ] ); - // Show learn tip tooltips sequentially when redirected from Learn section. + // Show the first learn tip when redirected from Learn section. useEffect( () => { if ( source === 'learn' ) { setShowLearnTip( true ); - const hideTimer = setTimeout( () => { - setShowLearnTip( false ); - setShowGenerateTip( true ); - }, 5000 ); - return () => clearTimeout( hideTimer ); } }, [ source ] ); + // Move to the generate tip once the prompt has text. + useEffect( () => { + if ( showLearnTip && canGenerate ) { + setShowLearnTip( false ); + setShowGenerateTip( true ); + } + }, [ showLearnTip, canGenerate ] ); + + useEffect( () => { + if ( canGenerate ) { + setShowEmptyPromptHint( false ); + } + }, [ canGenerate ] ); + // Auto-dismiss generate tip after 5 seconds. useEffect( () => { if ( showGenerateTip ) { @@ -320,183 +366,334 @@ export default ( props ) => { const finalFormCreationCountRemaining = isRegistered && formCreationleft > 20 ? 20 : formCreationleft; - const is_pro_active = - srfm_admin?.is_pro_active && srfm_admin?.is_pro_license_active; + const handleStarterToggle = ( starter ) => { + // Stop voice input so it doesn't append to the starter prompt. + handleTyping(); + + if ( selectedStarterId === starter.id ) { + setSelectedStarterId( null ); + setReplacedPrompt( null ); + // Only clear the prompt if the user hasn't edited it. + if ( text === starter.prompt ) { + setPrompt( '' ); + } + speak( __( 'Example removed.', 'sureforms' ) ); + return; + } + + const isStarterText = + ! text.trim() || starters.some( ( item ) => item.prompt === text ); + + setReplacedPrompt( isStarterText ? null : text ); + setPrompt( starter.prompt ); + setSelectedStarterId( starter.id ); + speak( + sprintf( + /* translators: %s: starter example title, e.g. "Contact form". */ + __( + '%s example added to the prompt. Edit it or press Generate.', + 'sureforms' + ), + starter.title + ) + ); + }; + + const handleUndoReplace = () => { + setPrompt( replacedPrompt ?? '' ); + setSelectedStarterId( null ); + setReplacedPrompt( null ); + document.getElementById( 'textarea' )?.focus(); + }; + + // Guided steps: 1 Pick, 2 Customize, 3 Generate. + const isUntouchedStarter = + !! selectedStarterId && + starters.some( + ( item ) => item.id === selectedStarterId && item.prompt === text + ); + let currentStep = 3; + if ( ! canGenerate ) { + currentStep = 1; + } else if ( isUntouchedStarter ) { + currentStep = 2; + } + + let counterColorClass = 'text-field-helper'; + if ( characterCount >= MAX_PROMPT_LENGTH ) { + counterColorClass = 'text-support-error'; + } else if ( characterCount >= MAX_PROMPT_LENGTH * 0.9 ) { + counterColorClass = 'text-badge-color-yellow'; + } return ( - + - - + +

+ { __( + 'What form should we build?', + 'sureforms' + ) } +

+

{ __( - 'Describe the form that you want', + 'Start with an example, or describe your own form below.', 'sureforms' ) } - +

-
- { showLearnTip && ( -
-
-
- { __( - 'Describe what kind of form you want', - 'sureforms' - ) } + + + + + + + +
+ +
+ { showLearnTip && ( +
+
+
+ { __( + 'Pick an example or describe your form', + 'sureforms' + ) } +
-
- ) } -
-
-