Skip to content

Commit 5bc8cd6

Browse files
authored
fix: embed styling/validation + field settings cleanup (1.0.0-rc.2) (#68)
* fix(embed): left-align, novalidate; field settings cleanup Embed (server-rendered): - reset text-align:left so the form doesn't inherit the host page's centering - set form.noValidate so the plugin's own validation + server errors render (custom/required messages) instead of the browser's native bubble Field settings panel: - Required lives only on the Validation tab now (removed the duplicate General toggle); its custom error message is stored as a required validation rule - Placeholder shown only where it applies (text-like + textarea); relabeled to 'Empty option text' for select; hidden for checkbox/radio/checkbox-group/date/time - Validation rules limited per type (email/url/number auto-validate by type, so those rules aren't re-offered); the Rules block hides entirely for select/radio/checkbox/date where only Required applies * chore: release 1.0.0-rc.2
1 parent 828f448 commit 5bc8cd6

5 files changed

Lines changed: 107 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
---
99

10+
## [1.0.0-rc.2] - 2026-07-02
11+
12+
### Fixed
13+
- Embed: reset `text-align` so the form no longer inherits the host page's centering.
14+
- Embed: set the form to `novalidate` so required/validation errors render the plugin's own messages (custom or default) instead of the browser's native bubble.
15+
16+
### Changed
17+
- Field settings: "Required" now lives only on the Validation tab (removed the duplicate on General) and supports a custom error message.
18+
- Field settings: settings are now conditional per field type — Placeholder only where it applies (relabeled "Empty option text" for select; hidden for checkbox/radio/checkbox-group/date/time); validation rules limited to those valid for the type (email/url/number auto-validate by type, so those rules aren't re-offered; the Rules block hides for choice/date fields where only Required applies).
19+
20+
---
21+
1022
## [1.0.0-rc.1] - 2026-07-02
1123

1224
### Changed

admin/src/components/FieldSettingsPanel.tsx

Lines changed: 88 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ interface Props {
88
onChange: (updated: FormField) => void;
99
}
1010

11+
// Which settings apply to which field type (drives conditional visibility).
12+
const PLACEHOLDER_TYPES = ['text', 'email', 'number', 'phone', 'url', 'password', 'textarea', 'select'];
13+
14+
const RULE_LABELS: Record<string, string> = {
15+
minLength: 'Min. length', maxLength: 'Max. length',
16+
min: 'Min. value', max: 'Max. value',
17+
email: 'Email', url: 'URL', pattern: 'Pattern (regex)',
18+
};
19+
20+
// Extra validation rules (beyond Required) that make sense for a given type.
21+
// email/url/number already auto-validate their format by type, so those rules
22+
// are only offered where you'd validate free text.
23+
function ruleTypesFor(type: string): string[] {
24+
if (type === 'number') return ['min', 'max'];
25+
if (type === 'text' || type === 'textarea') return ['minLength', 'maxLength', 'pattern', 'email', 'url'];
26+
if (['email', 'url', 'phone', 'password'].includes(type)) return ['minLength', 'maxLength', 'pattern'];
27+
return []; // select, radio, checkbox, checkbox-group, date, time → only Required
28+
}
29+
1130
// One-time style block for input focus + placeholder (inline styles can't do :focus).
1231
const STYLE_ID = 'sfb-settings-style';
1332
function ensureStyle() {
@@ -89,19 +108,28 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
89108
update({ options: (field.options || []).map((o, j) => (j === i ? { ...o, ...patch } : o)) });
90109
const removeOption = (i: number) => update({ options: (field.options || []).filter((_, j) => j !== i) });
91110

111+
const ruleTypes = ruleTypesFor(field.type);
92112
const addValidation = () => {
93113
const used = field.validation.map((r) => r.type);
94-
const def =
95-
field.type === 'number' ? (used.includes('min') ? (used.includes('max') ? 'pattern' : 'max') : 'min') :
96-
field.type === 'email' ? (used.includes('email') ? 'minLength' : 'email') :
97-
field.type === 'url' ? (used.includes('url') ? 'minLength' : 'url') :
98-
used.includes('minLength') ? (used.includes('maxLength') ? 'pattern' : 'maxLength') : 'minLength';
99-
update({ validation: [...field.validation, { type: def }] });
114+
const next = ruleTypes.find((t) => !used.includes(t));
115+
if (!next) return;
116+
update({ validation: [...field.validation, { type: next }] });
100117
};
101118
const updateValidation = (i: number, patch: Partial<ValidationRule>) =>
102119
update({ validation: field.validation.map((v, j) => (j === i ? { ...v, ...patch } : v)) });
103120
const removeValidation = (i: number) => update({ validation: field.validation.filter((_, j) => j !== i) });
104121

122+
// "required" is a boolean flag; its custom message lives in a validation rule of type 'required'
123+
const reqMsg = field.validation.find((v) => v.type === 'required')?.message || '';
124+
const setRequired = (on: boolean) => {
125+
const rest = field.validation.filter((v) => v.type !== 'required');
126+
update({ required: on, validation: on ? field.validation : rest });
127+
};
128+
const setReqMsg = (msg: string) => {
129+
const rest = field.validation.filter((v) => v.type !== 'required');
130+
update({ validation: msg ? [...rest, { type: 'required', message: msg }] : rest });
131+
};
132+
105133
const kicker = `${typeName(field.type)} field`.toUpperCase();
106134

107135
const tabBtn = (id: 'general' | 'validation', label: string) => (
@@ -133,7 +161,13 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
133161
{!deco && (
134162
<>
135163
<TextField label="Name (technical)" value={field.name} onChange={(v) => update({ name: v })} mono hint="Used as the key in submissions & the API payload." />
136-
<TextField label="Placeholder" value={field.placeholder || ''} onChange={(v) => update({ placeholder: v })} />
164+
{PLACEHOLDER_TYPES.includes(field.type) && (
165+
<TextField
166+
label={field.type === 'select' ? 'Empty option text' : 'Placeholder'}
167+
value={field.placeholder || ''}
168+
onChange={(v) => update({ placeholder: v })}
169+
/>
170+
)}
137171
<TextField label="Help text" value={field.helpText || ''} onChange={(v) => update({ helpText: v })} />
138172
</>
139173
)}
@@ -154,16 +188,6 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
154188
</Labeled>
155189
)}
156190

157-
{!deco && (
158-
<>
159-
<div style={{ height: 1, background: C.n150, margin: '2px 0' }} />
160-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
161-
<span style={{ font: `600 13px ${FF}`, color: C.n700 }}>Required</span>
162-
<Toggle on={field.required} onClick={() => update({ required: !field.required })} />
163-
</div>
164-
</>
165-
)}
166-
167191
{field.type !== 'divider' && (
168192
<Labeled label="Width">
169193
<Seg value={field.width} onChange={(v) => update({ width: v })} />
@@ -176,49 +200,57 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
176200

177201
{tab === 'validation' && !deco && (
178202
<div style={{ padding: 18, display: 'flex', flexDirection: 'column', gap: 12 }}>
179-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
180-
<span style={{ font: `700 11px ${FF}`, letterSpacing: '.5px', textTransform: 'uppercase', color: C.n500 }}>Rules</span>
181-
<button type="button" onClick={addValidation} style={{ height: 30, padding: '0 10px', borderRadius: 4, border: `1px solid ${C.p200}`, background: C.p100, color: C.p700, font: `600 12px ${FF}`, cursor: 'pointer' }}>+ Add rule</button>
203+
{/* Required — flag + custom message */}
204+
<div style={{ border: `1px solid ${C.n200}`, borderRadius: 5, padding: 11, display: 'flex', flexDirection: 'column', gap: 8, background: C.n0 }}>
205+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
206+
<span style={{ font: `600 13px ${FF}`, color: C.n800 }}>Required</span>
207+
<Toggle on={field.required} onClick={() => setRequired(!field.required)} />
208+
</div>
209+
{field.required && (
210+
<input className="sfb-inp" value={reqMsg} placeholder={`${field.label || 'This field'} is required`} onChange={(e) => setReqMsg(e.target.value)} style={inpStyle} />
211+
)}
182212
</div>
183213

184-
{field.validation.length === 0 && (
185-
<span style={{ font: `400 12px ${FF}`, color: C.n500, lineHeight: 1.5 }}>No rules yet. Required is set on the General tab; add length, value or pattern rules here.</span>
186-
)}
187-
188-
{field.validation.map((rule, i) => {
189-
const used = field.validation.filter((_, j) => j !== i).map((r) => r.type);
190-
const isNumber = field.type === 'number', isEmail = field.type === 'email', isUrl = field.type === 'url';
191-
const available = [
192-
!isNumber && { value: 'minLength', label: 'Min. length' },
193-
!isNumber && { value: 'maxLength', label: 'Max. length' },
194-
isNumber && { value: 'min', label: 'Min. value' },
195-
isNumber && { value: 'max', label: 'Max. value' },
196-
!isEmail && { value: 'email', label: 'Email' },
197-
!isUrl && { value: 'url', label: 'URL' },
198-
{ value: 'pattern', label: 'Pattern (regex)' },
199-
].filter((o): o is { value: string; label: string } => !!o && (o.value === rule.type || !used.includes(o.value)));
200-
const hasValue = ['minLength', 'maxLength', 'min', 'max', 'pattern'].includes(rule.type);
201-
return (
202-
<div key={i} style={{ border: `1px solid ${C.n200}`, borderRadius: 5, padding: 11, display: 'flex', flexDirection: 'column', gap: 8, background: C.n0 }}>
203-
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
204-
<select className="sfb-inp" value={rule.type} onChange={(e) => updateValidation(i, { type: e.target.value, value: undefined, message: '' })} style={{ ...inpStyle, flex: 1, cursor: 'pointer' }}>
205-
{available.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
206-
</select>
207-
<button type="button" onClick={() => removeValidation(i)} style={{ width: 28, height: 28, borderRadius: 4, border: `1px solid ${C.n200}`, color: C.n400, background: C.n0, cursor: 'pointer', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
208-
<Trash width="14px" height="14px" fill="currentColor" />
209-
</button>
210-
</div>
211-
{hasValue && (
212-
<input className="sfb-inp" placeholder={rule.type === 'pattern' ? '^[a-z0-9]+$' : 'Value'} value={String(rule.value ?? '')} onChange={(e) => updateValidation(i, { value: e.target.value })} style={{ ...inpStyle, ...(rule.type === 'pattern' ? { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 } : {}) }} />
213-
)}
214-
<input className="sfb-inp" placeholder="Custom error message (optional)" value={rule.message || ''} onChange={(e) => updateValidation(i, { message: e.target.value })} style={inpStyle} />
214+
{ruleTypes.length > 0 && (
215+
<>
216+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
217+
<span style={{ font: `700 11px ${FF}`, letterSpacing: '.5px', textTransform: 'uppercase', color: C.n500 }}>Rules</span>
218+
<button type="button" onClick={addValidation} style={{ height: 30, padding: '0 10px', borderRadius: 4, border: `1px solid ${C.p200}`, background: C.p100, color: C.p700, font: `600 12px ${FF}`, cursor: 'pointer' }}>+ Add rule</button>
215219
</div>
216-
);
217-
})}
218220

219-
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, font: `400 11px ${FF}`, color: C.n500, lineHeight: 1.5 }}>
220-
<span style={{ color: C.wrn600 }}></span>Rules run on the public form and in the preview.
221-
</div>
221+
{field.validation.filter((v) => v.type !== 'required').length === 0 && (
222+
<span style={{ font: `400 12px ${FF}`, color: C.n500, lineHeight: 1.5 }}>No extra rules yet.</span>
223+
)}
224+
225+
{field.validation.map((rule, i) => {
226+
if (rule.type === 'required') return null;
227+
const used = field.validation.filter((_, j) => j !== i).map((r) => r.type);
228+
// options = rules valid for this type, minus ones already used (keep current)
229+
const available = ruleTypes.filter((t) => t === rule.type || !used.includes(t));
230+
const hasValue = ['minLength', 'maxLength', 'min', 'max', 'pattern'].includes(rule.type);
231+
return (
232+
<div key={i} style={{ border: `1px solid ${C.n200}`, borderRadius: 5, padding: 11, display: 'flex', flexDirection: 'column', gap: 8, background: C.n0 }}>
233+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
234+
<select className="sfb-inp" value={rule.type} onChange={(e) => updateValidation(i, { type: e.target.value, value: undefined, message: '' })} style={{ ...inpStyle, flex: 1, cursor: 'pointer' }}>
235+
{available.map((t) => <option key={t} value={t}>{RULE_LABELS[t]}</option>)}
236+
</select>
237+
<button type="button" onClick={() => removeValidation(i)} style={{ width: 28, height: 28, borderRadius: 4, border: `1px solid ${C.n200}`, color: C.n400, background: C.n0, cursor: 'pointer', flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
238+
<Trash width="14px" height="14px" fill="currentColor" />
239+
</button>
240+
</div>
241+
{hasValue && (
242+
<input className="sfb-inp" placeholder={rule.type === 'pattern' ? '^[a-z0-9]+$' : 'Value'} value={String(rule.value ?? '')} onChange={(e) => updateValidation(i, { value: e.target.value })} style={{ ...inpStyle, ...(rule.type === 'pattern' ? { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12 } : {}) }} />
243+
)}
244+
<input className="sfb-inp" placeholder="Custom error message (optional)" value={rule.message || ''} onChange={(e) => updateValidation(i, { message: e.target.value })} style={inpStyle} />
245+
</div>
246+
);
247+
})}
248+
249+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 7, font: `400 11px ${FF}`, color: C.n500, lineHeight: 1.5 }}>
250+
<span style={{ color: C.wrn600 }}></span>Rules run on the public form and in the preview.
251+
</div>
252+
</>
253+
)}
222254
</div>
223255
)}
224256
</div>

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "strapi-plugin-form-builder-cms",
3-
"version": "1.0.0-rc.1",
3+
"version": "1.0.0-rc.2",
44
"description": "Visual drag-and-drop form builder plugin for Strapi 5. Create, publish and embed forms on any website.",
55
"keywords": [
66
"strapi",

server/src/controllers/form.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ function embedScript(): string {
8888
function mount(el, form, base) {
8989
var formEl = document.createElement('form');
9090
formEl.className = 'sfb-form';
91+
// let the plugin's own validation + server errors drive messages instead of
92+
// the browser's native required bubble
93+
formEl.noValidate = true;
9194
9295
// honeypot: hidden field bots tend to fill; humans never see it
9396
if (form.settings && form.settings.enableHoneypot) {
@@ -332,7 +335,7 @@ function embedScript(): string {
332335
var s = document.createElement('style');
333336
s.id = 'sfb-css';
334337
s.textContent = [
335-
'.sfb-form { font-family: inherit; }',
338+
'.sfb-form { font-family: inherit; text-align: left; color: #32324d; line-height: 1.5; }',
336339
'.sfb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }',
337340
'.sfb-field { display: flex; flex-direction: column; gap: 4px; }',
338341
'.sfb-label { font-size: 13px; font-weight: 600; color: #32324d; }',

0 commit comments

Comments
 (0)