From 10068e1c596030c3e8b4f64992fce7d557d51a7f Mon Sep 17 00:00:00 2001 From: Preshin P S Date: Wed, 22 Apr 2026 12:21:39 +0530 Subject: [PATCH 1/2] Fix submit button loader and success/fail feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route onSubmit through a beforeSubmit hook so the submit button stays in its loading state until the caller's async work resolves, and emit submitDone/submitError ourselves for schema-only/nosubmit forms where formio would otherwise never emit them. Supports three onSubmit patterns: promise-returning, legacy manual formio.emit('submitDone'| 'submitError'), and synchronous throws. Defensively reset submit-button loading/disabled state at the end of every path so the spinner can't be left orphaned by internal formio redraws. Bundle Bootstrap 5's .spinner-border rules into the default theme so the loader is actually visible in hosts that don't load Bootstrap (e.g. Ant Design apps) — formio's own CSS only ships the ✓/✗ glyphs. Bump version to 2.0.3. Made-with: Cursor --- example/LoginApiDemo.jsx | 137 ++++++++++++++++++++++++++++++++++++++ example/main.jsx | 3 + package.json | 2 +- src/core/FormRenderer.jsx | 117 ++++++++++++++++++++++++++++++++ src/themes/default.css | 25 +++++++ 5 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 example/LoginApiDemo.jsx diff --git a/example/LoginApiDemo.jsx b/example/LoginApiDemo.jsx new file mode 100644 index 0000000..6ab95b0 --- /dev/null +++ b/example/LoginApiDemo.jsx @@ -0,0 +1,137 @@ +import React, { useRef } from 'react'; +import { Form } from '../src/index'; + +// Schema as provided by the user (agentx-dashboard login form) +const loginForm = { + display: 'form', + components: [ + { + id: 'eiupizq', + key: 'email', + type: 'email', + input: true, + label: 'Email', + inputType: 'email', + validate: { required: true }, + validateOn: 'change', + tableView: true, + persistent: true, + labelPosition: 'top', + }, + { + id: 'emvo7dp', + key: 'password', + type: 'password', + input: true, + label: 'Password', + inputType: 'text', + protected: true, + validate: { required: true }, + validateOn: 'change', + persistent: true, + labelPosition: 'top', + }, + { + id: 'e8j0bd', + key: 'html', + tag: 'p', + type: 'htmlelement', + input: false, + label: 'HTML', + content: "Don't remember your password?", + }, + { + id: 'e91k1hm', + key: 'html1', + tag: 'p', + type: 'htmlelement', + input: false, + label: 'HTML', + content: + 'By continuing, you agree to our Terms and Conditions.', + }, + { + id: 'em0y7pc', + key: 'submit', + type: 'button', + input: true, + label: 'Login Now', + theme: 'primary', + action: 'submit', + size: 'md', + tableView: false, + disableOnInvalid: false, + }, + ], +}; + +const PREFILL = { email: 'test@test.test', password: 'test' }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Mocked login API: +// password === 'test' → resolves after 600ms → button shows ✓ +// anything else → rejects after 800ms → button shows ✗ +// Caller just returns the promise;
handles loader / ✓ / ✗. + +const login = async (submission) => { + const email = submission?.data?.email; + const password = submission?.data?.password; + + if (password === 'test') { + await sleep(600); + return { ok: true, token: 'mock-jwt-token', user: { email } }; + } + + await sleep(800); + const err = new Error('Invalid email or password'); + err.status = 401; + throw err; +}; + + +export default function LoginApiDemo() { + const myRef = useRef(null); + + // Legacy manual-emit pattern: `login` does not return a promise. It kicks + // off an async task and later calls `formio.emit('submitDone')` or + // `formio.emit('submitError')` via the ref. holds the button in + // its loading state until one of those fires. + // password === 'test' → success after 600ms (✓) + // anything else → failure after 800ms (✗) + + // const login = (submission) => { + // const success = submission?.data?.password === 'test'; + // setTimeout(() => { + // if (success) { + // myRef.current?.formio?.emit('submitDone'); + // } else { + // myRef.current?.formio?.emit('submitError'); + // } + // }, success ? 600 : 800); + // }; + + return ( +
+

Login API Demo (mocked)

+

+ Mocked async login. The button shows a loader while the promise is + pending, then ✓ on resolve or ✗ on reject. +
+ Password test → resolves after 600ms (✓). +
+ Any other password → rejects after 800ms with{' '} + Invalid email or password (✗). +

+ +
+ +
+
+ ); +} diff --git a/example/main.jsx b/example/main.jsx index 005bb46..57f3f49 100644 --- a/example/main.jsx +++ b/example/main.jsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { Form, FormBuilder, FormEngineProvider, Formio } from '../src/index'; import 'bootstrap/dist/css/bootstrap.css'; import '../src/themes/default.css'; +import LoginApiDemo from './LoginApiDemo'; // Suppress "Missing projectId" warnings — we use formiojs purely client-side Formio.setProjectUrl(window.location.href); @@ -272,6 +273,8 @@ function App() { provider work correctly.


+ +

diff --git a/package.json b/package.json index b2e0874..4daa64d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-formio-engine", - "version": "2.0.2", + "version": "2.0.3", "description": "Form engine with builder and renderer - formiojs-powered with React functional components", "type": "module", "main": "dist/form-engine.umd.js", diff --git a/src/core/FormRenderer.jsx b/src/core/FormRenderer.jsx index d5959fa..4312724 100644 --- a/src/core/FormRenderer.jsx +++ b/src/core/FormRenderer.jsx @@ -27,6 +27,118 @@ const FormRenderer = forwardRef(function FormRenderer( const opts = { ...options }; + // When onSubmit is a prop, route it through a beforeSubmit hook so the + // submit button stays in its loading state until the caller's async + // work resolves. Supports three patterns: + // 1. onSubmit returns a Promise → await it + // 2. onSubmit emits manually → listen once for submitDone/Error + // 3. onSubmit throws synchronously → treat as rejection + if (typeof onSubmit === 'function') { + const userHooks = opts.hooks || {}; + const userBeforeSubmit = userHooks.beforeSubmit; + + opts.hooks = { + ...userHooks, + beforeSubmit: (submission, next) => { + const runOnSubmit = () => { + const formio = formioRef.current; + + // Schema-only forms / nosubmit never emit submitDone themselves, + // so we must emit it ourselves on success. + const needsManualDoneEmit = () => + formio && (formio.nosubmit || !formio.formio); + + // Defensive: formio's own button listeners can leave the spinner + // `` attached to the DOM when the button gets redrawn between + // click and completion (element.loader becomes orphaned). Force + // loading/disabled off on every submit button via its component + // ref to guarantee the spinner is cleared. + const resetSubmitButtons = () => { + if (!formio || typeof formio.everyComponent !== 'function') return; + formio.everyComponent((c) => { + if ( + c && + c.component && + c.component.type === 'button' && + c.component.action === 'submit' + ) { + try { + c.loading = false; + c.disabled = false; + } catch (_) { + // best-effort — ignore + } + } + }); + }; + + let result; + try { + result = onSubmit(submission); + } catch (err) { + next(err); + if (formio) formio.emit('submitError', err); + resetSubmitButtons(); + return; + } + + if (result && typeof result.then === 'function') { + result.then( + (data) => { + next(); + if (needsManualDoneEmit()) { + formio.emit('submitDone', data ?? submission); + } + resetSubmitButtons(); + }, + (err) => { + const e = err || { message: 'Submission failed' }; + next(e); + if (formio) formio.emit('submitError', e); + resetSubmitButtons(); + }, + ); + return; + } + + // Legacy manual-emit pattern: onSubmit returned nothing, so wait + // for the caller to emit submitDone/submitError on the formio ref. + // If neither is ever emitted the button stays loading — that + // surfaces the caller's missing completion signal rather than + // hiding it. + if (!formio) { + next(); + return; + } + const cleanup = () => { + formio.off('submitDone', onDone); + formio.off('submitError', onErr); + }; + const onDone = () => { + cleanup(); + next(); + resetSubmitButtons(); + }; + const onErr = (err) => { + cleanup(); + next(err || { message: 'Submission failed' }); + resetSubmitButtons(); + }; + formio.on('submitDone', onDone); + formio.on('submitError', onErr); + }; + + if (typeof userBeforeSubmit === 'function') { + userBeforeSubmit(submission, (err) => + err ? next(err) : runOnSubmit(), + ); + } else { + runOnSubmit(); + } + }, + }; + } + const FormClass = formioform || FormioForm; const instance = new FormClass(elementRef.current, formDef, opts); instanceRef.current = instance; @@ -44,6 +156,11 @@ const FormRenderer = forwardRef(function FormRenderer( // Map formio.xxx events to onXxx props instance.onAny(function (event, ...args) { if (event.startsWith('formio.')) { + // When onSubmit is a prop we already invoke it from the beforeSubmit + // hook above; skip the onAny dispatch to avoid calling it twice. + if (event === 'formio.submit' && typeof onSubmit === 'function') { + return; + } const funcName = 'on' + event.charAt(7).toUpperCase() + event.slice(8); const allProps = { onSubmit, onChange, onError, onRender, onCustomEvent, onSubmitDone, diff --git a/src/themes/default.css b/src/themes/default.css index 42b26dd..82bced9 100644 --- a/src/themes/default.css +++ b/src/themes/default.css @@ -15,6 +15,31 @@ display: none; } +/* + * Bootstrap 5 .spinner-border — Formio's submit button injects + * while an onSubmit promise is + * pending. Bundle a self-contained copy so the loader is visible in hosts + * that don't load Bootstrap (e.g. Ant Design apps). + */ +@keyframes spinner-border { + to { transform: rotate(360deg); } +} +.spinner-border { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: -0.125em; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: 0.75s linear infinite spinner-border; +} +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; +} + /* Make builder sidebar component buttons uniform width with spacing */ .formcomponents .btn.formcomponent { display: block; From 4b6504255558ad3ef8b58d2c0071292bb9251b6c Mon Sep 17 00:00:00 2001 From: Preshin P S Date: Wed, 22 Apr 2026 12:22:37 +0530 Subject: [PATCH 2/2] Update default.css --- src/themes/default.css | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/themes/default.css b/src/themes/default.css index 82bced9..42b26dd 100644 --- a/src/themes/default.css +++ b/src/themes/default.css @@ -15,31 +15,6 @@ display: none; } -/* - * Bootstrap 5 .spinner-border — Formio's submit button injects - * while an onSubmit promise is - * pending. Bundle a self-contained copy so the loader is visible in hosts - * that don't load Bootstrap (e.g. Ant Design apps). - */ -@keyframes spinner-border { - to { transform: rotate(360deg); } -} -.spinner-border { - display: inline-block; - width: 2rem; - height: 2rem; - vertical-align: -0.125em; - border: 0.25em solid currentColor; - border-right-color: transparent; - border-radius: 50%; - animation: 0.75s linear infinite spinner-border; -} -.spinner-border-sm { - width: 1rem; - height: 1rem; - border-width: 0.2em; -} - /* Make builder sidebar component buttons uniform width with spacing */ .formcomponents .btn.formcomponent { display: block;