Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions example/LoginApiDemo.jsx
Original file line number Diff line number Diff line change
@@ -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: "<a class='forgot-password'>Don't remember your password?</a>",
},
{
id: 'e91k1hm',
key: 'html1',
tag: 'p',
type: 'htmlelement',
input: false,
label: 'HTML',
content:
'By continuing, you agree to our <a href="https://www.workato.com/legal/services-privacy-policy">Terms and Conditions</a>.',
},
{
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; <Form /> 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. <Form /> 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 (
<div style={{ marginBottom: 40 }}>
<h2>Login API Demo (mocked)</h2>
<p style={{ color: '#666' }}>
Mocked async login. The button shows a loader while the promise is
pending, then ✓ on resolve or ✗ on reject.
<br />
Password <code>test</code> → resolves after 600ms (✓).
<br />
Any other password → rejects after 800ms with{' '}
<code>Invalid email or password</code> (✗).
</p>

<div style={{ border: '1px solid #ddd', padding: 20, borderRadius: 8, maxWidth: 480 }}>
<Form
src={loginForm}
submission={{ data: PREFILL }}
options={{ noAlerts: true }}
onSubmit={login}
ref={myRef}
/>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions example/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -272,6 +273,8 @@ function App() {
provider work correctly.
</p>
<hr />
<LoginApiDemo />
<hr />
<BugFixDemo />
<hr />
<FormRendererDemo />
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
117 changes: 117 additions & 0 deletions src/core/FormRenderer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// `<i>` 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;
Expand All @@ -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,
Expand Down
Loading