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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
build:
name: Build check
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Build library
run: npm run build

- name: Verify dist output
run: |
if [ ! -f dist/form-engine.es.js ] || [ ! -f dist/form-engine.umd.js ]; then
echo "❌ Expected dist files not found"
exit 1
fi
echo "✅ dist/form-engine.es.js and dist/form-engine.umd.js present"
74 changes: 74 additions & 0 deletions example/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,43 @@ const contactForm = {
],
};

// Form specifically for testing the two bug fixes:
// 1. Number field — typing 0 as first digit should not be stripped
// 2. Required validation — submit with empty fields must show errors
const bugFixForm = {
display: 'form',
components: [
{
type: 'number',
key: 'quantity',
label: 'Quantity (try typing 0, then another digit — should keep the 0)',
input: true,
validate: { required: true },
placeholder: 'e.g. 07, 0.5, 042',
},
{
type: 'number',
key: 'price',
label: 'Price (required — leave empty and hit Submit to test validation)',
input: true,
validate: { required: true },
},
{
type: 'textfield',
key: 'name',
label: 'Name (required)',
input: true,
validate: { required: true },
},
{
type: 'button',
action: 'submit',
label: 'Submit',
theme: 'primary',
},
],
};

// ---------- Demo Components ----------

function FormRendererDemo() {
Expand Down Expand Up @@ -188,6 +225,41 @@ function FormBuilderDemo() {
);
}

function BugFixDemo() {
const [submitted, setSubmitted] = useState(null);

return (
<div style={{ marginBottom: 40 }}>
<h2>Bug Fix Verification</h2>
<ul style={{ color: '#555', lineHeight: 1.8 }}>
<li>
<strong>Number — leading zero:</strong> Type <code>07</code> or <code>0.5</code> in
the Quantity field. The <code>0</code> should NOT be stripped.
</li>
<li>
<strong>Required validation:</strong> Leave fields empty and click Submit.
Inline error messages should appear (not silently blocked).
</li>
</ul>
<div style={{ border: '1px solid #ddd', padding: 20, borderRadius: 8 }}>
<Form
src={bugFixForm}
options={{ noAlerts: true }}
onSubmit={(sub) => setSubmitted(sub.data)}
/>
</div>
{submitted && (
<div style={{ marginTop: 16 }}>
<h4>✅ Submitted Data:</h4>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{JSON.stringify(submitted, null, 2)}
</pre>
</div>
)}
</div>
);
}

// ---------- App ----------

function App() {
Expand All @@ -200,6 +272,8 @@ function App() {
provider work correctly.
</p>
<hr />
<BugFixDemo />
<hr />
<FormRendererDemo />
<FormRendererWithDataDemo />
<FormBuilderDemo />
Expand Down
47 changes: 27 additions & 20 deletions src/core/createFormComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,33 +58,47 @@ export function createFormComponent({
this._root = null;
}

updateValue = (value) => {
updateValue = (value, flags = {}) => {
const newValue = value === undefined || value === null ? this.getValue() : value;
const changed = newValue !== undefined ? this.hasChanged(newValue, this.dataValue) : false;
this.dataValue = Array.isArray(newValue) ? [...newValue] : newValue;
this.updateOnChange({}, changed);
return true;
if (changed) {
this.dataValue = Array.isArray(newValue) ? [...newValue] : newValue;
this.updateOnChange(flags, changed);
}
return changed;
};

render() {
return super.render(`<div ref="react-${this.id}"></div>`);
}

// Wraps updateValue with { modified: true } so that user-driven React
// onChange calls correctly mark the component as no longer pristine,
// enabling proper real-time validation error display.
_onReactChange = (value) => {
this.updateValue(value, { modified: true });
};

_renderReact(value) {
if (!this._root) return;
this._root.render(
render({
component: this.component,
value,
onChange: this._onReactChange,
data: this.data,
})
);
}

attach(element) {
super.attach(element);
this.loadRefs(element, { [`react-${this.id}`]: 'single' });

const reactEl = this.refs[`react-${this.id}`];
if (reactEl) {
this._root = createRoot(reactEl);
this._root.render(
render({
component: this.component,
value: this.dataValue,
onChange: this.updateValue,
data: this.data,
})
);
this._renderReact(this.dataValue);

if (this.shouldSetValue) {
this.setValue(this.dataForSetting);
Expand All @@ -105,14 +119,7 @@ export function createFormComponent({

setValue(value) {
if (this._root) {
this._root.render(
render({
component: this.component,
value: value,
onChange: this.updateValue,
data: this.data,
})
);
this._renderReact(value);
this.shouldSetValue = false;
} else {
this.shouldSetValue = true;
Expand Down
22 changes: 22 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export { registerComponent } from './core/registry';

// Re-export from @formio/js — accessed via Components to avoid restricted deep imports
import { Components, Formio as _Formio } from '@formio/js';
import { createNumberMask } from '@formio/text-mask-addons';

// ---------------------------------------------------------------------------
// @formio/js v5 runtime patches
Expand Down Expand Up @@ -53,6 +54,27 @@ if (EditGrid?.prototype?.saveRow) {
};
}

// Patch 3: Number component — allow leading zeros as first digit
// createNumberMask defaults allowLeadingZeroes to false, which strips the leading
// zero when a second digit is typed (e.g. typing "07" becomes "7"). This breaks
// use cases where users need to enter values starting with 0 (e.g. "0.5", "007").
const NumberComponent = Components.components?.number;
if (NumberComponent?.prototype?.createNumberMask) {
NumberComponent.prototype.createNumberMask = function () {
return createNumberMask({
prefix: '',
suffix: '',
requireDecimal: this.component?.requireDecimal ?? false,
thousandsSeparatorSymbol: this.delimiter || '',
decimalSymbol: this.component?.decimalSymbol ?? this.decimalSeparator,
decimalLimit: this.component?.decimalLimit ?? this.decimalLimit,
allowNegative: this.component?.allowNegative ?? true,
allowDecimal: this.isDecimalAllowed(),
allowLeadingZeroes: true,
});
};
}

// ---------------------------------------------------------------------------

// ReactComponent (Field class) for backward compat with custom components
Expand Down
5 changes: 5 additions & 0 deletions vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export default defineConfig(({ command }) => {
if (command === 'serve') {
return {
plugins: [react()],
// Force a single React instance regardless of where it's imported from
// (prevents "Invalid hook call" when @formio/js pulls in its own React)
resolve: {
dedupe: ['react', 'react-dom'],
},
// Handle JSX in .js and .jsx files
esbuild: {
loader: 'jsx',
Expand Down
Loading