Skip to content

Commit e010cef

Browse files
committed
feat: add rich fields, expression engine updates, and mobile builder UX
- Introduce new rich field types (Display, Signature, Diagram, DrawingPad) under fields/rich - Register and export rich fields via @msheet/fields and builder defaults - Add test-rich-content-schema.json for validation and development - Enhance expression engine: - Support identifier tokens as field references in parsePrimary - Export evaluateExpression from core - Update condition tests to reflect new behavior - Refactor core types and registry: - Simplify and consolidate field definition types - Clean up registry registration logic - Minor form-store adjustments - Update related tests - Improve builder mobile UX: - Add MobileBottomDrawer for field editing - Responsive layout and sticky "Add field" control - Fix header layering (z-index) - Improve ToolPanel status and actions - Update FieldWrapper mobile edit behavior - Minor LogicEditor fix - Wire rich field types into builder registration Overall: introduces rich content field support, expands expression capabilities, and significantly improves mobile builder experience
1 parent 7638bd5 commit e010cef

22 files changed

Lines changed: 2256 additions & 106 deletions
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
{
2+
"schemaType": "mieforms-v1.0",
3+
"id": "rich-content-test-schema",
4+
"title": "Rich Content Fields — Comprehensive Test",
5+
"fields": [
6+
7+
{
8+
"id": "intro-html",
9+
"fieldType": "html",
10+
"htmlContent": "<h2>Rich Content Fields Test</h2><p>This form exercises all five rich content field types:</p><ul><li><strong>HTML</strong> — arbitrary HTML rendered in a sandboxed iframe</li><li><strong>Image</strong> — upload / display an image with caption</li><li><strong>Diagram</strong> — freehand drawing pad with a background hint</li><li><strong>Signature</strong> — signature capture pad</li><li><strong>Display</strong> — computed read-only text with live field interpolation and markdown formatting</li></ul><p>Fill in the inputs below to observe the Display field update in real-time.</p>",
11+
"iframeHeight": 230
12+
},
13+
14+
{
15+
"id": "section-inputs",
16+
"fieldType": "section",
17+
"title": "Inputs (drive the Display field)",
18+
"fields": [
19+
20+
{
21+
"id": "first-name",
22+
"fieldType": "text",
23+
"question": "First name",
24+
"inputType": "string",
25+
"required": true
26+
},
27+
28+
{
29+
"id": "last-name",
30+
"fieldType": "text",
31+
"question": "Last name",
32+
"inputType": "string",
33+
"required": true
34+
},
35+
36+
{
37+
"id": "weight-kg",
38+
"fieldType": "text",
39+
"question": "Weight (kg)",
40+
"inputType": "number",
41+
"required": true,
42+
"unit": "kg"
43+
},
44+
45+
{
46+
"id": "height-cm",
47+
"fieldType": "text",
48+
"question": "Height (cm)",
49+
"inputType": "number",
50+
"required": true,
51+
"unit": "cm"
52+
},
53+
54+
{
55+
"id": "score-a",
56+
"fieldType": "slider",
57+
"question": "Score A (1–10)",
58+
"required": true,
59+
"options": [
60+
{ "id": "sa-1", "value": "1", "text": "1" },
61+
{ "id": "sa-2", "value": "2", "text": "2" },
62+
{ "id": "sa-3", "value": "3", "text": "3" },
63+
{ "id": "sa-4", "value": "4", "text": "4" },
64+
{ "id": "sa-5", "value": "5", "text": "5" },
65+
{ "id": "sa-6", "value": "6", "text": "6" },
66+
{ "id": "sa-7", "value": "7", "text": "7" },
67+
{ "id": "sa-8", "value": "8", "text": "8" },
68+
{ "id": "sa-9", "value": "9", "text": "9" },
69+
{ "id": "sa-10", "value": "10", "text": "10" }
70+
]
71+
},
72+
73+
{
74+
"id": "score-b",
75+
"fieldType": "slider",
76+
"question": "Score B (1–10)",
77+
"required": true,
78+
"options": [
79+
{ "id": "sb-1", "value": "1", "text": "1" },
80+
{ "id": "sb-2", "value": "2", "text": "2" },
81+
{ "id": "sb-3", "value": "3", "text": "3" },
82+
{ "id": "sb-4", "value": "4", "text": "4" },
83+
{ "id": "sb-5", "value": "5", "text": "5" },
84+
{ "id": "sb-6", "value": "6", "text": "6" },
85+
{ "id": "sb-7", "value": "7", "text": "7" },
86+
{ "id": "sb-8", "value": "8", "text": "8" },
87+
{ "id": "sb-9", "value": "9", "text": "9" },
88+
{ "id": "sb-10", "value": "10", "text": "10" }
89+
]
90+
}
91+
92+
]
93+
},
94+
95+
{
96+
"id": "section-display",
97+
"fieldType": "section",
98+
"title": "Display Field Tests",
99+
"fields": [
100+
101+
{
102+
"id": "display-simple-ref",
103+
"fieldType": "display",
104+
"question": "Simple field reference — {field-id}",
105+
"content": "Hello, {first-name} {last-name}!\n\nYour first name is *{first-name}* and your last name is *{last-name}*."
106+
},
107+
108+
{
109+
"id": "display-arithmetic",
110+
"fieldType": "display",
111+
"question": "Arithmetic expressions — <expression>",
112+
"content": "# Score Summary\n\n- Score A: {score-a}\n- Score B: {score-b}\n- Combined: <{score-a} + {score-b}>\n- Average: <({score-a} + {score-b}) / 2>\n- Product: <{score-a} * {score-b}>"
113+
},
114+
115+
{
116+
"id": "display-bmi",
117+
"fieldType": "display",
118+
"question": "Multi-step calculation — BMI",
119+
"content": "## BMI Calculator\n\n- Weight: {weight-kg} kg\n- Height: {height-cm} cm\n- BMI: <{weight-kg} / (({height-cm} / 100) * ({height-cm} / 100))>"
120+
},
121+
122+
{
123+
"id": "display-markdown",
124+
"fieldType": "display",
125+
"question": "Markdown formatting — bold, italic, underline, strike, bullets, headings",
126+
"content": "# Heading 1\n## Heading 2\n### Heading 3\n\n*bold text* and -italic text- and _underlined text_ and ~strikethrough text~\n\n- Bullet one\n- Bullet two\n- Bullet three: score is {score-a}"
127+
},
128+
129+
{
130+
"id": "display-conditional-shown",
131+
"fieldType": "display",
132+
"question": "Conditionally visible — appears when Score A > 7",
133+
"content": "## High Score Alert\n\n*{first-name}* scored *{score-a}* on Score A — that's above the threshold of 7!\n\nCombined total: <{score-a} + {score-b}>",
134+
"rules": [
135+
{
136+
"effect": "visible",
137+
"logic": "AND",
138+
"conditions": [
139+
{ "conditionType": "expression", "expression": "{score-a} > 7" }
140+
]
141+
}
142+
]
143+
},
144+
145+
{
146+
"id": "display-conditional-hidden",
147+
"fieldType": "display",
148+
"question": "Conditionally visible — appears when Score A <= 7",
149+
"content": "Score A is {score-a} — under the threshold. Try increasing it above 7 to swap these two display fields.",
150+
"rules": [
151+
{
152+
"effect": "visible",
153+
"logic": "AND",
154+
"conditions": [
155+
{ "conditionType": "expression", "expression": "{score-a} <= 7" }
156+
]
157+
}
158+
]
159+
}
160+
161+
]
162+
},
163+
164+
{
165+
"id": "section-html",
166+
"fieldType": "section",
167+
"title": "HTML Field Tests",
168+
"fields": [
169+
170+
{
171+
"id": "html-basic",
172+
"fieldType": "html",
173+
"htmlContent": "<h3>Basic HTML rendering</h3><p>This field renders arbitrary HTML in a <code>sandbox=\"\"</code> iframe.</p><p>Supported content: headings, paragraphs, lists, tables, images, links, inline styles.</p>",
174+
"iframeHeight": 140
175+
},
176+
177+
{
178+
"id": "html-table",
179+
"fieldType": "html",
180+
"htmlContent": "<h3>Table example</h3><table style=\"border-collapse:collapse;width:100%\"><thead><tr style=\"background:#f3f4f6\"><th style=\"padding:8px 12px;border:1px solid #e5e7eb;text-align:left\">Item</th><th style=\"padding:8px 12px;border:1px solid #e5e7eb;text-align:left\">Value</th></tr></thead><tbody><tr><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">Alpha</td><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">100</td></tr><tr><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">Beta</td><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">200</td></tr><tr><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">Gamma</td><td style=\"padding:8px 12px;border:1px solid #e5e7eb\">300</td></tr></tbody></table>",
181+
"iframeHeight": 180
182+
},
183+
184+
{
185+
"id": "html-xss-probe",
186+
"fieldType": "html",
187+
"htmlContent": "<!-- XSS / injection test vectors — all must be inert inside sandbox=\"\" -->\n<script>document.title='XSS';<\/script>\n<img src=\"x\" onerror=\"document.title='XSS'\" />\n<a href=\"javascript:alert('XSS')\">Click me (href must be inert)</a>\n<iframe src=\"https://example.com\"></iframe>\n<p><strong>Expected:</strong> All vectors above are inert. Page title unchanged, no alert fires, no navigation.</p>",
188+
"iframeHeight": 120
189+
}
190+
191+
]
192+
},
193+
194+
{
195+
"id": "section-drawing",
196+
"fieldType": "section",
197+
"title": "Drawing Fields (Image / Diagram / Signature)",
198+
"fields": [
199+
200+
{
201+
"id": "test-image",
202+
"fieldType": "image",
203+
"question": "Image field — upload a file or paste a URL",
204+
"altText": "Test image upload",
205+
"caption": "Supports PNG, JPG, GIF, WebP. Stored as a data URL in the response."
206+
},
207+
208+
{
209+
"id": "test-diagram",
210+
"fieldType": "diagram",
211+
"question": "Diagram field — freehand drawing pad",
212+
"padPlaceholder": "Draw anything here — the pad records normalised strokes"
213+
},
214+
215+
{
216+
"id": "test-signature",
217+
"fieldType": "signature",
218+
"question": "Signature field — sign your name",
219+
"padPlaceholder": "Sign here",
220+
"required": true
221+
}
222+
223+
]
224+
}
225+
226+
]
227+
}

packages/builder/src/lib/MsheetBuilder.tsx

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ToolPanel } from './components/ToolPanel.js';
1212
import { EditPanel } from './components/edit-panel/EditPanel.js';
1313
import { BuilderHeader } from './components/BuilderHeader.js';
1414
import { CodeView } from './components/CodeView.js';
15+
import { PlusIcon } from './icons.js';
1516

1617
// ---------------------------------------------------------------------------
1718
// Contexts
@@ -57,6 +58,47 @@ export interface MsheetBuilderProps {
5758
children?: React.ReactNode;
5859
}
5960

61+
interface MobileBottomDrawerProps {
62+
title: string;
63+
open: boolean;
64+
onClose: () => void;
65+
children: React.ReactNode;
66+
}
67+
68+
function MobileBottomDrawer({
69+
title,
70+
open,
71+
onClose,
72+
children,
73+
}: MobileBottomDrawerProps) {
74+
if (!open) return null;
75+
76+
return (
77+
<>
78+
<button
79+
type="button"
80+
className="ms:lg:hidden ms:fixed ms:inset-0 ms:z-40 ms:bg-msoverlay ms:border-0"
81+
onClick={onClose}
82+
aria-label={`Close ${title} drawer`}
83+
/>
84+
<div className="ms:lg:hidden ms:fixed ms:left-0 ms:right-0 ms:bottom-0 ms:z-50 ms:h-[50dvh] ms:bg-mssurface ms:border-t ms:border-msborder ms:rounded-t-2xl ms:shadow-2xl ms:overflow-hidden">
85+
<div className="ms:flex ms:items-center ms:justify-between ms:px-4 ms:py-2 ms:border-b ms:border-msborder">
86+
<span className="ms:text-sm ms:font-medium ms:text-mstext">{title}</span>
87+
<button
88+
type="button"
89+
onClick={onClose}
90+
className="ms:px-2 ms:py-1 ms:bg-transparent ms:text-mstextmuted ms:border-0 ms:outline-none ms:focus:outline-none"
91+
aria-label={`Close ${title} drawer`}
92+
>
93+
Close
94+
</button>
95+
</div>
96+
<div className="ms:h-[calc(50dvh-45px)] ms:overflow-y-auto">{children}</div>
97+
</div>
98+
</>
99+
);
100+
}
101+
60102
// ---------------------------------------------------------------------------
61103
// Component
62104
// ---------------------------------------------------------------------------
@@ -90,6 +132,30 @@ export function MsheetBuilder({
90132
() => ui.getState().mode,
91133
() => ui.getState().mode
92134
);
135+
const selectedFieldId = useSyncExternalStore(
136+
(cb) => ui.subscribe(cb),
137+
() => ui.getState().selectedFieldId,
138+
() => ui.getState().selectedFieldId
139+
);
140+
const editModalOpen = useSyncExternalStore(
141+
(cb) => ui.subscribe(cb),
142+
() => ui.getState().editModalOpen,
143+
() => ui.getState().editModalOpen
144+
);
145+
const [toolsModalOpen, setToolsModalOpen] = React.useState(false);
146+
147+
React.useEffect(() => {
148+
if (mode !== 'build') {
149+
setToolsModalOpen(false);
150+
ui.getState().setEditModalOpen(false);
151+
}
152+
}, [mode, ui]);
153+
154+
React.useEffect(() => {
155+
if (!selectedFieldId && editModalOpen) {
156+
ui.getState().setEditModalOpen(false);
157+
}
158+
}, [selectedFieldId, editModalOpen, ui]);
93159

94160
// Subscribe to form changes and forward to onChange.
95161
React.useEffect(() => {
@@ -107,21 +173,48 @@ export function MsheetBuilder({
107173
className={`ms-builder-root ms:flex ms:h-full ms:flex-1 ms:min-h-0 ms:max-h-full ms:w-full ms:min-w-0 ms:max-w-full ms:flex-col ms:gap-2
108174
ms:overflow-x-hidden ms:bg-msbackground ms:text-mstext ${className}`.trim()}
109175
>
110-
<div className="ms:sticky ms:top-0 ms:z-999 ms:bg-msbackground">
176+
<div className="ms:sticky ms:top-0 ms:z-50 ms:bg-msbackground">
111177
<BuilderHeader form={form} ui={ui} />
112178
</div>
113179
{children}
114180
{mode === 'build' && (
115-
<div className="builder-layout ms:grid ms:flex-1 ms:min-h-0 ms:min-w-0 ms:grid-cols-[18rem_minmax(0,1fr)_340px] ms:gap-3 ms:overflow-hidden">
116-
<aside className="panel-tools-wrap panel-tools ms:flex ms:self-start ms:min-h-0 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto ms:flex-col ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface">
181+
<div className="builder-layout ms:grid ms:flex-1 ms:min-h-0 ms:min-w-0 ms:grid-cols-1 ms:lg:grid-cols-[18rem_minmax(0,1fr)_340px] ms:gap-3 ms:overflow-hidden">
182+
<aside className="panel-tools-wrap panel-tools ms:hidden ms:lg:flex ms:self-start ms:min-h-0 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto ms:flex-col ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface">
117183
<ToolPanel form={form} ui={ui} />
118184
</aside>
119185
<main className="panel-canvas ms:self-start ms:min-w-0 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface ms:p-4">
120186
<Canvas form={form} ui={ui} dragEnabled={dragEnabled} />
187+
<div className="ms:lg:hidden ms:sticky ms:bottom-0 ms:z-20 ms:pt-2 ms:pb-3 ms:flex ms:justify-center ms:pointer-events-none">
188+
<button
189+
type="button"
190+
onClick={() => setToolsModalOpen(true)}
191+
className="ms:pointer-events-auto ms:inline-flex ms:items-center ms:gap-1.5 ms:px-3.5 ms:py-2 ms:rounded-full ms:bg-mssurface/95 ms:backdrop-blur-sm ms:text-mstext ms:text-sm ms:font-semibold ms:border ms:border-msprimary/35 ms:shadow-lg ms:shadow-msprimary/10 ms:outline-none ms:focus:outline-none ms:hover:bg-mssurface ms:hover:border-msprimary/50 ms:hover:shadow-xl ms:hover:shadow-msprimary/15 ms:transition-all"
192+
aria-label="Open add field tools"
193+
>
194+
<PlusIcon className="ms:w-3.5 ms:h-3.5 ms:text-msprimary" />
195+
<span>Add field</span>
196+
</button>
197+
</div>
121198
</main>
122-
<aside className="panel-editor-wrap panel-editor ms:flex ms:self-start ms:min-h-0 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto ms:flex-col ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface">
199+
<aside className="panel-editor-wrap panel-editor ms:hidden ms:lg:flex ms:self-start ms:min-h-0 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto ms:flex-col ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface">
123200
<EditPanel form={form} ui={ui} />
124201
</aside>
202+
203+
<MobileBottomDrawer
204+
title="Add Field"
205+
open={toolsModalOpen}
206+
onClose={() => setToolsModalOpen(false)}
207+
>
208+
<ToolPanel form={form} ui={ui} />
209+
</MobileBottomDrawer>
210+
211+
<MobileBottomDrawer
212+
title="Edit Field"
213+
open={editModalOpen && !!selectedFieldId}
214+
onClose={() => ui.getState().setEditModalOpen(false)}
215+
>
216+
<EditPanel form={form} ui={ui} />
217+
</MobileBottomDrawer>
125218
</div>
126219
)}
127220
{mode === 'code' && (
@@ -130,7 +223,7 @@ export function MsheetBuilder({
130223
</div>
131224
)}
132225
{mode === 'preview' && (
133-
<div className="preview-layout ms:flex-1 ms:min-h-0 ms:min-w-0 ms:w-full ms:max-w-2xl ms:mx-auto ms:p-4">
226+
<div className="preview-layout ms:flex-1 ms:min-h-0 ms:min-w-0 ms:w-full ms:max-w-2xl ms:mx-auto ms:p-4 ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-y-auto">
134227
<Canvas form={form} ui={ui} dragEnabled={false} />
135228
</div>
136229
)}

packages/builder/src/lib/components/FieldWrapper.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,15 @@ export function FieldWrapper({
294294
{/* Edit button (mobile only) */}
295295
<button
296296
type="button"
297-
onClick={handleSelect}
297+
onClick={(e) => {
298+
e.stopPropagation();
299+
if (onSelectOverride) {
300+
onSelectOverride(e);
301+
} else {
302+
ui.getState().selectField(fieldId);
303+
}
304+
ui.getState().setEditModalOpen(true);
305+
}}
298306
className="field-edit-btn ms:block ms:lg:hidden ms:p-1.5 ms:bg-transparent ms:text-mstextmuted ms:hover:bg-msbackgroundhover ms:rounded ms:transition-colors ms:border-0 ms:outline-none ms:focus:outline-none"
299307
title="Edit"
300308
aria-label="Edit field"

0 commit comments

Comments
 (0)