@esheet/builder 0.0.4-0 → 0.0.4-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { useSyncExternalStore } from 'react';
4
+ import { createFormStore, createUIStore, } from '@esheet/core';
5
+ import { createBuilderTools, } from './builder-tools.js';
6
+ import { FormStoreContext, UIContext, useTouchMode } from '@esheet/fields';
7
+ import { convertSurveyJS, isSurveyJSSchema, importFromMcp, isMcpElicitationRequest, importFromFhir, isFhirQuestionnaire, } from '@esheet/adapters';
8
+ import { Canvas } from './components/Canvas.js';
9
+ import { ToolPanel } from './components/ToolPanel.js';
10
+ import { EditPanel } from './components/edit-panel/EditPanel.js';
11
+ import { BuilderHeader } from './components/BuilderHeader.js';
12
+ import { CodeView } from './components/CodeView.js';
13
+ import { PlusIcon } from './icons.js';
14
+ import { ensureDefaultFieldComponentsRegistered } from './register-defaults.js';
15
+ import { MobileBottomDrawer } from './components/MobileBottomDrawer.js';
16
+ import { Switch } from '@mieweb/ui';
17
+ // ---------------------------------------------------------------------------
18
+ // Contexts
19
+ // ---------------------------------------------------------------------------
20
+ export { FormStoreContext, UIContext, useFormStore, useUI, } from '@esheet/fields';
21
+ export const InstanceIdContext = React.createContext('');
22
+ /** Hook to access the per-instance ID for unique DOM element IDs. */
23
+ export function useInstanceId() {
24
+ return React.useContext(InstanceIdContext);
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Component
28
+ // ---------------------------------------------------------------------------
29
+ export const EsheetBuilder = React.forwardRef(function EsheetBuilder({ definition, onChange, onBuilderToolsReady, dragEnabled = true, className = '', children, touchMode: touchModeProp, onTouchModeChange, }, ref) {
30
+ ensureDefaultFieldComponentsRegistered();
31
+ const formRef = React.useRef(null);
32
+ const uiRef = React.useRef(null);
33
+ if (!formRef.current) {
34
+ let resolved;
35
+ if (isFhirQuestionnaire(definition)) {
36
+ resolved = importFromFhir(definition);
37
+ }
38
+ else if (isSurveyJSSchema(definition)) {
39
+ resolved = convertSurveyJS(definition);
40
+ }
41
+ else if (isMcpElicitationRequest(definition)) {
42
+ const mcpReq = definition;
43
+ if (mcpReq.params.mode !== 'url') {
44
+ resolved = importFromMcp(mcpReq.params.requestedSchema, { mcpId: mcpReq.id, mcpMessage: mcpReq.params.message });
45
+ }
46
+ }
47
+ else {
48
+ resolved = definition;
49
+ }
50
+ formRef.current = createFormStore(resolved);
51
+ }
52
+ if (!uiRef.current) {
53
+ uiRef.current = createUIStore();
54
+ }
55
+ const form = formRef.current;
56
+ const ui = uiRef.current;
57
+ // Stable per-instance ID for unique DOM element IDs
58
+ const instanceId = React.useId();
59
+ // Subscribe to mode for conditional rendering
60
+ const mode = useSyncExternalStore((cb) => ui.subscribe(cb), () => ui.getState().mode, () => ui.getState().mode);
61
+ const selectedFieldId = useSyncExternalStore((cb) => ui.subscribe(cb), () => ui.getState().selectedFieldId, () => ui.getState().selectedFieldId);
62
+ const editModalOpen = useSyncExternalStore((cb) => ui.subscribe(cb), () => ui.getState().editModalOpen, () => ui.getState().editModalOpen);
63
+ const [toolsModalOpen, setToolsModalOpen] = React.useState(false);
64
+ // Touch mode state using shared hook
65
+ const { isTouchEnabled, isManualOverride, setTouchMode, resetTouchMode } = useTouchMode({ mode: touchModeProp, onChange: onTouchModeChange });
66
+ // Expose ref API for touch mode
67
+ React.useImperativeHandle(ref, () => ({
68
+ isTouchModeEnabled: () => isTouchEnabled,
69
+ setTouchMode,
70
+ resetTouchMode,
71
+ }), [isTouchEnabled, setTouchMode, resetTouchMode]);
72
+ React.useEffect(() => {
73
+ if (mode !== 'build') {
74
+ setToolsModalOpen(false);
75
+ ui.getState().setEditModalOpen(false);
76
+ }
77
+ }, [mode, ui]);
78
+ React.useEffect(() => {
79
+ if (!selectedFieldId && editModalOpen) {
80
+ ui.getState().setEditModalOpen(false);
81
+ }
82
+ }, [selectedFieldId, editModalOpen, ui]);
83
+ // Subscribe to form changes and forward to onChange.
84
+ React.useEffect(() => {
85
+ if (!onChange)
86
+ return;
87
+ return form.subscribe(() => {
88
+ onChange(form.getState().hydrateDefinition());
89
+ });
90
+ }, [form, onChange]);
91
+ // Expose a narrow BuilderTools facade to MCP / AI tool callers once on mount.
92
+ React.useEffect(() => {
93
+ if (onBuilderToolsReady)
94
+ onBuilderToolsReady(createBuilderTools(form));
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [form]);
97
+ // Apply disabled class when user explicitly disabled touch mode (prevents CSS media query)
98
+ const applyTouchDisabledClass = isManualOverride && !isTouchEnabled;
99
+ // Build root class string
100
+ const rootClasses = [
101
+ 'ms-builder-root',
102
+ isTouchEnabled && 'esheet-touch-active',
103
+ applyTouchDisabledClass && 'touch-mode-disabled',
104
+ '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-[1440px] ms:mx-auto ms:flex-col ms:gap-2',
105
+ 'ms:overflow-x-hidden ms:bg-msbackground ms:text-mstext',
106
+ className,
107
+ ]
108
+ .filter(Boolean)
109
+ .join(' ');
110
+ return (_jsx(FormStoreContext.Provider, { value: form, children: _jsx(UIContext.Provider, { value: ui, children: _jsx(InstanceIdContext.Provider, { value: instanceId, children: _jsxs("div", { className: rootClasses, children: [_jsx("div", { className: "ms:sticky ms:top-0 ms:z-40 ms:bg-msbackground", children: _jsx(BuilderHeader, {}) }), children, mode === 'build' && (_jsxs("div", { className: "builder-layout ms:grid ms:min-w-0 ms:grid-cols-1 ms:lg:grid-cols-[18rem_minmax(0,1fr)_340px] ms:gap-3", children: [_jsx("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", children: _jsx(ToolPanel, {}) }), _jsxs("main", { className: "panel-canvas ms:min-w-0 ms:self-start ms:max-h-[calc(100dvh-12.5rem)] ms:overflow-hidden ms:flex ms:flex-col ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface", children: [_jsx(Canvas, { form: form, ui: ui, dragEnabled: dragEnabled }), _jsx("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", children: _jsxs("button", { type: "button", onClick: () => setToolsModalOpen(true), 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", "aria-label": "Open add field tools", children: [_jsx(PlusIcon, { className: "ms:w-3.5 ms:h-3.5 ms:text-msprimary" }), _jsx("span", { children: "Add field" })] }) })] }), _jsx("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", children: _jsx(EditPanel, {}) }), _jsx(MobileBottomDrawer, { title: "Add Field", open: toolsModalOpen, onClose: () => setToolsModalOpen(false), children: _jsx(ToolPanel, {}) }), _jsx(MobileBottomDrawer, { title: "Edit Field", open: editModalOpen && !!selectedFieldId, onClose: () => ui.getState().setEditModalOpen(false), children: _jsx(EditPanel, {}) })] })), mode === 'code' && (_jsx("div", { className: "code-layout ms:flex ms:h-[calc(100dvh-12.5rem)] ms:min-h-0 ms:min-w-0 ms:overflow-hidden ms:rounded-lg ms:border ms:border-msborder ms:bg-mssurface", children: _jsx(CodeView, { form: form, ui: ui }) })), mode === 'preview' && (_jsxs("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", children: [_jsx("div", { className: "ms:flex ms:items-center ms:justify-end ms:mb-3", children: _jsx(Switch, { size: "sm", checked: isTouchEnabled, onCheckedChange: setTouchMode, label: "Touch Mode" }) }), _jsx(Canvas, { form: form, ui: ui, dragEnabled: false })] }))] }) }) }) }));
111
+ });
@@ -0,0 +1,321 @@
1
+ // ---------------------------------------------------------------------------
2
+ // BuilderTools — narrow facade for MCP / AI tool callers only.
3
+ // Normal developers interact with the builder entirely through its props/UI.
4
+ // ---------------------------------------------------------------------------
5
+ import { getFieldTypeMeta, getRegisteredFieldTypes, hydrateDefinition, } from '@esheet/core';
6
+ export function createBuilderTools(form) {
7
+ function resolveFieldId(fieldId, fieldQuestion) {
8
+ if (fieldId)
9
+ return fieldId;
10
+ if (fieldQuestion) {
11
+ const q = fieldQuestion.toLowerCase();
12
+ const { normalized } = form.getState();
13
+ for (const id of Object.keys(normalized.byId)) {
14
+ const question = normalized.byId[id].definition.question;
15
+ if (question?.toLowerCase().includes(q))
16
+ return id;
17
+ }
18
+ }
19
+ return undefined;
20
+ }
21
+ return {
22
+ resetForm: (definition) => form.getState().loadDefinition(definition),
23
+ generateForm: (questions) => {
24
+ const fields = questions.map((q, i) => ({
25
+ id: `f${i + 1}`,
26
+ fieldType: q.fieldType ?? 'text',
27
+ question: q.question,
28
+ ...(q.required !== undefined && { required: q.required }),
29
+ ...(q.inputType && { inputType: q.inputType }),
30
+ ...(q.options && {
31
+ options: q.options.map((v, j) => ({ id: `o${j + 1}`, value: v })),
32
+ }),
33
+ }));
34
+ form.getState().loadDefinition({
35
+ id: form.getState().formId,
36
+ fields: [],
37
+ });
38
+ form.getState().loadDefinition({
39
+ id: form.getState().formId,
40
+ fields,
41
+ });
42
+ const summary = fields
43
+ .map((f, i) => `${i + 1}. ${f.question} (${f.fieldType})`)
44
+ .join(', ');
45
+ return `Form generated with ${fields.length} field(s): ${summary}`;
46
+ },
47
+ addField: (fieldType, opts) => form.getState().addField(fieldType, opts),
48
+ updateField: (fieldId, patch) => form.getState().updateField(fieldId, patch),
49
+ removeField: (fieldId) => form.getState().removeField(fieldId),
50
+ resolveFieldId,
51
+ fillField: (fieldId, value) => {
52
+ const { normalized } = form.getState();
53
+ const node = normalized.byId[fieldId];
54
+ if (!node)
55
+ return false;
56
+ const def = node.definition;
57
+ function normalizeTextValue(v, inputType) {
58
+ if (!v)
59
+ return v;
60
+ switch (inputType) {
61
+ case 'date':
62
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v))
63
+ return v;
64
+ if (/^\d{4}-\d{2}$/.test(v))
65
+ return `${v}-01`;
66
+ if (/^\d{4}$/.test(v))
67
+ return `${v}-01-01`;
68
+ return null;
69
+ case 'month':
70
+ if (/^\d{4}-\d{2}$/.test(v))
71
+ return v;
72
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v))
73
+ return v.slice(0, 7);
74
+ return null;
75
+ case 'datetime-local': {
76
+ const n = v.replace(' ', 'T');
77
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(n))
78
+ return n;
79
+ if (/^\d{4}-\d{2}-\d{2}$/.test(n))
80
+ return `${n}T00:00`;
81
+ return null;
82
+ }
83
+ case 'time': {
84
+ if (/^\d{2}:\d{2}$/.test(v))
85
+ return v;
86
+ const m = v.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
87
+ if (m) {
88
+ let h = parseInt(m[1], 10);
89
+ if (m[3].toUpperCase() === 'AM' && h === 12)
90
+ h = 0;
91
+ if (m[3].toUpperCase() === 'PM' && h !== 12)
92
+ h += 12;
93
+ return `${String(h).padStart(2, '0')}:${m[2]}`;
94
+ }
95
+ return null;
96
+ }
97
+ default:
98
+ return v;
99
+ }
100
+ }
101
+ let response;
102
+ if (def.fieldType === 'singlematrix' &&
103
+ value !== null &&
104
+ typeof value === 'object') {
105
+ const rows = def.rows ?? [];
106
+ const columns = def.columns ?? [];
107
+ const existing = (form.getState().responses[fieldId]?.selected ??
108
+ {});
109
+ const selected = {
110
+ ...existing,
111
+ };
112
+ const entries = Array.isArray(value)
113
+ ? value.map((v, i) => [rows[i]?.value ?? String(i), v])
114
+ : Object.entries(value);
115
+ for (const [rowKey, colVal] of entries) {
116
+ const row = rows.find((r) => r.value.toLowerCase() === rowKey.toLowerCase() || r.id === rowKey);
117
+ if (!row)
118
+ continue;
119
+ const colStr = String(colVal);
120
+ const col = columns.find((c) => c.value.toLowerCase() === colStr.toLowerCase() ||
121
+ c.id === colStr) ?? columns[parseInt(colStr, 10)];
122
+ if (col)
123
+ selected[row.id] = { id: col.id, value: col.value };
124
+ }
125
+ response = { selected };
126
+ }
127
+ else if (def.fieldType === 'multimatrix' &&
128
+ value !== null &&
129
+ typeof value === 'object') {
130
+ const rows = def.rows ?? [];
131
+ const columns = def.columns ?? [];
132
+ const existing = (form.getState().responses[fieldId]?.selected ??
133
+ {});
134
+ const selected = {
135
+ ...existing,
136
+ };
137
+ const entries = Array.isArray(value)
138
+ ? value.map((v, i) => [rows[i]?.value ?? String(i), v])
139
+ : Object.entries(value);
140
+ for (const [rowKey, colVals] of entries) {
141
+ const row = rows.find((r) => r.value.toLowerCase() === rowKey.toLowerCase() || r.id === rowKey);
142
+ if (!row)
143
+ continue;
144
+ const colValArr = Array.isArray(colVals)
145
+ ? colVals
146
+ : [colVals];
147
+ const matched = colValArr
148
+ .map((v) => {
149
+ const s = String(v);
150
+ return columns.find((c) => c.value.toLowerCase() === s.toLowerCase() || c.id === s);
151
+ })
152
+ .filter((c) => c != null);
153
+ if (matched.length > 0)
154
+ selected[row.id] = matched;
155
+ }
156
+ response = { selected };
157
+ }
158
+ else if (['radio', 'dropdown', 'boolean', 'rating', 'slider'].includes(def.fieldType)) {
159
+ const opts = def.options ?? [];
160
+ const match = opts.find((o) => o.value.toLowerCase() === String(value).toLowerCase() ||
161
+ o.id === value);
162
+ const numVal = Number(value);
163
+ const numMatch = !match && !isNaN(numVal)
164
+ ? opts[numVal - 1] ?? opts[numVal]
165
+ : undefined;
166
+ const resolved = match ?? numMatch;
167
+ response = resolved
168
+ ? { selected: { id: resolved.id, value: resolved.value } }
169
+ : { selected: undefined };
170
+ }
171
+ else if (def.fieldType === 'check' ||
172
+ def.fieldType === 'multiselectdropdown') {
173
+ const opts = def.options ?? [];
174
+ const vals = Array.isArray(value) ? value : [value];
175
+ const matches = opts.filter((o) => vals.some((v) => o.value.toLowerCase() === String(v).toLowerCase() || o.id === v));
176
+ response = {
177
+ selected: matches.map((o) => ({ id: o.id, value: o.value })),
178
+ };
179
+ }
180
+ else if (def.fieldType === 'ranking') {
181
+ const opts = def.options ?? [];
182
+ const vals = Array.isArray(value) ? value : [value];
183
+ const ordered = vals
184
+ .map((v) => opts.find((o) => o.value.toLowerCase() === String(v).toLowerCase() || o.id === v))
185
+ .filter((o) => o != null);
186
+ const mentioned = new Set(ordered.map((o) => o.id));
187
+ const remaining = opts.filter((o) => !mentioned.has(o.id));
188
+ response = { selected: [...ordered, ...remaining] };
189
+ }
190
+ else if (def.fieldType === 'multitext') {
191
+ const opts = def.options ?? [];
192
+ const vals = Array.isArray(value)
193
+ ? value
194
+ : typeof value === 'object' && value !== null
195
+ ? Object.values(value)
196
+ : [value];
197
+ const multitextAnswers = {};
198
+ opts.forEach((opt, i) => {
199
+ if (vals[i] != null)
200
+ multitextAnswers[opt.id] = String(vals[i]);
201
+ });
202
+ response = { multitextAnswers };
203
+ }
204
+ else {
205
+ if (value == null) {
206
+ response = { answer: undefined };
207
+ }
208
+ else {
209
+ const normalized = normalizeTextValue(String(value), def.inputType);
210
+ if (normalized === null) {
211
+ const fmt = {
212
+ date: 'YYYY-MM-DD',
213
+ 'datetime-local': 'YYYY-MM-DDTHH:mm',
214
+ month: 'YYYY-MM',
215
+ time: 'HH:mm',
216
+ };
217
+ const expected = fmt[def.inputType ?? ''];
218
+ return expected
219
+ ? `Error: invalid value for inputType "${def.inputType}" — expected format ${expected}`
220
+ : `Error: invalid value for field`;
221
+ }
222
+ response = { answer: normalized };
223
+ }
224
+ }
225
+ form
226
+ .getState()
227
+ .setResponse(fieldId, { ...response, _ai: true });
228
+ return true;
229
+ },
230
+ clearResponses: () => form.getState().resetResponses(),
231
+ getResponses: () => form.getState().responses,
232
+ option: {
233
+ add: (fieldId, value) => form.getState().addOption(fieldId, value),
234
+ update: (fieldId, optionId, value) => form.getState().updateOption(fieldId, optionId, value),
235
+ remove: (fieldId, optionId) => form.getState().removeOption(fieldId, optionId),
236
+ },
237
+ row: {
238
+ add: (fieldId, value) => form.getState().addRow(fieldId, value),
239
+ update: (fieldId, rowId, value) => form.getState().updateRow(fieldId, rowId, value),
240
+ remove: (fieldId, rowId) => form.getState().removeRow(fieldId, rowId),
241
+ },
242
+ column: {
243
+ add: (fieldId, value) => form.getState().addColumn(fieldId, value),
244
+ update: (fieldId, columnId, value) => form.getState().updateColumn(fieldId, columnId, value),
245
+ remove: (fieldId, columnId) => form.getState().removeColumn(fieldId, columnId),
246
+ },
247
+ moveField: (fieldId, toIndex, toParentId) => form.getState().moveField(fieldId, toIndex, toParentId),
248
+ getField: (fieldId) => form.getState().getField(fieldId),
249
+ getFieldTypes: () => getRegisteredFieldTypes().map((key) => {
250
+ const meta = getFieldTypeMeta(key);
251
+ return {
252
+ key,
253
+ label: meta.label,
254
+ category: meta.category,
255
+ hasOptions: meta.hasOptions,
256
+ hasMatrix: meta.hasMatrix,
257
+ };
258
+ }),
259
+ getFieldSpec: (fieldType) => getFieldTypeMeta(fieldType),
260
+ getDefinition: () => {
261
+ const { normalized, formId } = form.getState();
262
+ return {
263
+ id: formId,
264
+ fields: hydrateDefinition(normalized),
265
+ };
266
+ },
267
+ setFormId: (id) => form.getState().setFormId(id),
268
+ getFormSummary: () => {
269
+ const { normalized, formId } = form.getState();
270
+ const INPUT_TYPE_FORMAT = {
271
+ date: 'YYYY-MM-DD',
272
+ 'datetime-local': 'YYYY-MM-DDTHH:mm',
273
+ month: 'YYYY-MM',
274
+ time: 'HH:mm',
275
+ };
276
+ function summarizeField(id) {
277
+ const node = normalized.byId[id];
278
+ const def = node.definition;
279
+ const isMatrix = def.fieldType === 'singlematrix' || def.fieldType === 'multimatrix';
280
+ const isSection = def.fieldType === 'section';
281
+ const base = {
282
+ id,
283
+ fieldType: def.fieldType,
284
+ question: isSection
285
+ ? def.title
286
+ : def.question,
287
+ required: def.required ?? false,
288
+ options: def.options ?? [],
289
+ rows: def.rows ?? [],
290
+ columns: def.columns ?? [],
291
+ editWith: isMatrix
292
+ ? 'add_row / add_column (NOT add_option)'
293
+ : 'add_option',
294
+ hasRules: (def.rules?.length ?? 0) > 0,
295
+ hasValue: (() => {
296
+ const r = form.getState().responses[id];
297
+ return (r != null &&
298
+ Object.values(r).some((v) => v != null &&
299
+ v !== '' &&
300
+ !(Array.isArray(v) && v.length === 0) &&
301
+ !(typeof v === 'object' &&
302
+ !Array.isArray(v) &&
303
+ Object.keys(v).length === 0)));
304
+ })(),
305
+ ...(def.inputType && INPUT_TYPE_FORMAT[def.inputType]
306
+ ? { valueFormat: INPUT_TYPE_FORMAT[def.inputType] }
307
+ : {}),
308
+ };
309
+ if (isSection && node.childIds.length > 0) {
310
+ return { ...base, children: node.childIds.map(summarizeField) };
311
+ }
312
+ return base;
313
+ }
314
+ return {
315
+ formId,
316
+ fieldCount: Object.keys(normalized.byId).length,
317
+ fields: normalized.rootIds.map(summarizeField),
318
+ };
319
+ },
320
+ };
321
+ }