@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,392 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { CONDITION_OPERATORS, CONDITIONAL_EFFECTS, getFieldTypeMeta, hasOptions, isExpressionValid, } from '@esheet/core';
4
+ import { TrashIcon, PlusIcon } from '../../icons.js';
5
+ import { useInstanceId } from '../../EsheetBuilder.js';
6
+ import { useFormApi } from '../../hooks/useFormApi.js';
7
+ /**
8
+ * LogicEditor — manage conditional rules (visible / enable / required)
9
+ * for the selected field.
10
+ *
11
+ * Rules are grouped by effect. Multiple rules with the same effect
12
+ * use OR semantics (any rule can trigger the effect). Within a rule,
13
+ * conditions combine with the rule's logic mode (AND / OR).
14
+ */
15
+ export function LogicEditor({ fieldId, rules }) {
16
+ const instanceId = useInstanceId();
17
+ const { normalized, field_ } = useFormApi(fieldId);
18
+ const otherFields = React.useMemo(() => buildOtherFields(normalized, fieldId), [normalized, fieldId]);
19
+ const updateRules = (next) => {
20
+ field_.update({ rules: next });
21
+ };
22
+ // ── Handlers ────────────────────────────────────────────────
23
+ const handleAddRule = (effect) => {
24
+ // Pick a default target — first available field
25
+ const defaultTarget = otherFields.length > 0 ? otherFields[0].id : '';
26
+ const newRule = {
27
+ effect,
28
+ logic: 'AND',
29
+ conditions: [
30
+ {
31
+ conditionType: 'field',
32
+ targetId: defaultTarget,
33
+ operator: 'equals',
34
+ expected: '',
35
+ },
36
+ ],
37
+ };
38
+ updateRules([...rules, newRule]);
39
+ };
40
+ const handleRemoveRule = (ruleIdx) => {
41
+ updateRules(rules.filter((_, i) => i !== ruleIdx));
42
+ };
43
+ const handleUpdateRule = (ruleIdx, patch) => {
44
+ updateRules(rules.map((r, i) => (i === ruleIdx ? { ...r, ...patch } : r)));
45
+ };
46
+ const handleUpdateCondition = (ruleIdx, condIdx, patch) => {
47
+ updateRules(rules.map((r, i) => {
48
+ if (i !== ruleIdx)
49
+ return r;
50
+ const conditions = r.conditions.map((c, j) => j === condIdx ? { ...c, ...patch } : c);
51
+ return { ...r, conditions };
52
+ }));
53
+ };
54
+ const handleAddCondition = (ruleIdx) => {
55
+ const defaultTarget = otherFields.length > 0 ? otherFields[0].id : '';
56
+ updateRules(rules.map((r, i) => {
57
+ if (i !== ruleIdx)
58
+ return r;
59
+ return {
60
+ ...r,
61
+ conditions: [
62
+ ...r.conditions,
63
+ {
64
+ conditionType: 'field',
65
+ targetId: defaultTarget,
66
+ operator: 'equals',
67
+ expected: '',
68
+ },
69
+ ],
70
+ };
71
+ }));
72
+ };
73
+ const handleRemoveCondition = (ruleIdx, condIdx) => {
74
+ const next = rules.flatMap((r, i) => {
75
+ if (i !== ruleIdx)
76
+ return [r];
77
+ const conditions = r.conditions.filter((_, j) => j !== condIdx);
78
+ return conditions.length === 0 ? [] : [{ ...r, conditions }];
79
+ });
80
+ updateRules(next);
81
+ };
82
+ // ── Group rules by effect ──────────────────────────────────
83
+ const grouped = groupByEffect(rules);
84
+ if (otherFields.length === 0) {
85
+ return (_jsx("div", { className: "logic-editor-empty ms:text-sm ms:text-mstextmuted ms:text-center ms:py-6", children: "Add more fields to the form to create logic rules." }));
86
+ }
87
+ return (_jsx("div", { className: "logic-editor ms:space-y-5", children: CONDITIONAL_EFFECTS.map((effect) => (_jsx(EffectSection, { instanceId: instanceId, fieldId: fieldId, effect: effect, ruleEntries: grouped[effect], otherFields: otherFields, onAddRule: () => handleAddRule(effect), onRemoveRule: handleRemoveRule, onUpdateRule: handleUpdateRule, onAddCondition: handleAddCondition, onRemoveCondition: handleRemoveCondition, onUpdateCondition: handleUpdateCondition }, effect))) }));
88
+ }
89
+ const EFFECT_LABELS = {
90
+ visible: 'Visible',
91
+ enable: 'Enable',
92
+ required: 'Required',
93
+ };
94
+ const EFFECT_DESCRIPTIONS = {
95
+ visible: 'Show this field when…',
96
+ enable: 'Enable this field when…',
97
+ required: 'Require this field when…',
98
+ };
99
+ function EffectSection({ instanceId, fieldId, effect, ruleEntries, otherFields, onAddRule, onRemoveRule, onUpdateRule, onAddCondition, onRemoveCondition, onUpdateCondition, }) {
100
+ const hasRules = ruleEntries.length > 0;
101
+ return (_jsxs("div", { className: "effect-section ms:space-y-2", children: [_jsxs("div", { className: "effect-header ms:flex ms:items-center ms:justify-between", children: [_jsxs("div", { children: [_jsx("span", { className: "ms:text-sm ms:font-medium ms:text-mstext", children: EFFECT_LABELS[effect] }), _jsx("span", { className: "ms:text-xs ms:text-mstextmuted ms:ml-2", children: hasRules
102
+ ? `${ruleEntries.length} rule${ruleEntries.length > 1 ? 's' : ''}`
103
+ : 'Always' })] }), _jsxs("button", { type: "button", onClick: onAddRule, "aria-label": `Add ${effect} rule`, className: "add-rule-btn ms:flex ms:items-center ms:gap-1 ms:px-2 ms:py-1 ms:text-xs ms:font-medium ms:bg-transparent ms:text-msprimary ms:border ms:border-msprimary/40 ms:rounded ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: [_jsx(PlusIcon, { className: "ms:w-3 ms:h-3" }), _jsx("span", { children: "Rule" })] })] }), ruleEntries.map(({ rule, globalIdx }, localIdx) => (_jsxs(React.Fragment, { children: [localIdx > 0 && (_jsx("div", { className: "or-divider ms:text-xs ms:text-mstextmuted ms:text-center ms:py-0.5", children: "\u2014 OR \u2014" })), _jsx(RuleCard, { instanceId: instanceId, fieldId: fieldId, rule: rule, globalIdx: globalIdx, otherFields: otherFields, onRemove: () => onRemoveRule(globalIdx), onUpdate: (patch) => onUpdateRule(globalIdx, patch), onAddCondition: () => onAddCondition(globalIdx), onRemoveCondition: (condIdx) => onRemoveCondition(globalIdx, condIdx), onUpdateCondition: (condIdx, patch) => onUpdateCondition(globalIdx, condIdx, patch) })] }, globalIdx))), !hasRules && (_jsx("div", { className: "ms:text-xs ms:text-mstextmuted ms:italic", children: EFFECT_DESCRIPTIONS[effect] }))] }));
104
+ }
105
+ function RuleCard({ instanceId, fieldId, rule, globalIdx, otherFields, onRemove, onUpdate, onAddCondition, onRemoveCondition, onUpdateCondition, }) {
106
+ return (_jsxs("div", { className: "rule-card ms:border ms:border-msborder ms:rounded-lg ms:bg-mssurface ms:shadow-sm", children: [_jsxs("div", { className: "rule-header ms:flex ms:items-center ms:justify-between ms:px-3 ms:py-2 ms:border-b ms:border-msborder ms:bg-msbackground ms:rounded-t-lg", children: [_jsxs("div", { className: "ms:flex ms:items-center ms:gap-2", children: [_jsx("span", { className: "ms:text-xs ms:text-mstextmuted", children: "Match" }), _jsx(LogicToggle, { instanceId: instanceId, fieldId: fieldId, ruleIdx: globalIdx, value: rule.logic, onChange: (logic) => onUpdate({ logic }) }), _jsx("span", { className: "ms:text-xs ms:text-mstextmuted", children: rule.logic === 'AND' ? 'all conditions' : 'any condition' })] }), _jsx("button", { type: "button", onClick: onRemove, "aria-label": "Remove rule", className: "remove-rule-btn ms:p-1 ms:rounded ms:bg-transparent ms:text-mstextmuted ms:hover:text-msdanger ms:border-0 ms:outline-none ms:focus:outline-none ms:transition-colors ms:cursor-pointer", children: _jsx(TrashIcon, { className: "ms:w-3.5 ms:h-3.5" }) })] }), _jsxs("div", { className: "rule-conditions ms:p-3 ms:space-y-3", children: [rule.conditions.map((cond, condIdx) => (_jsx(ConditionRow, { instanceId: instanceId, fieldId: fieldId, ruleIdx: globalIdx, condIdx: condIdx, condition: cond, otherFields: otherFields, onUpdate: (patch) => onUpdateCondition(condIdx, patch), onRemove: () => onRemoveCondition(condIdx), canRemove: rule.conditions.length > 1 }, condIdx))), _jsx("button", { type: "button", onClick: onAddCondition, className: "add-condition-btn ms:w-full ms:px-2 ms:py-1.5 ms:text-xs ms:font-medium ms:bg-transparent ms:text-msprimary ms:border ms:border-dashed ms:border-msprimary/40 ms:rounded ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: "+ Add Condition" })] })] }));
107
+ }
108
+ function LogicToggle({ instanceId, fieldId, ruleIdx, value, onChange, }) {
109
+ const id = `${instanceId}-logic-toggle-${fieldId}-${ruleIdx}`;
110
+ return (_jsxs("div", { className: "logic-toggle ms:flex ms:rounded ms:border ms:border-msborder ms:overflow-hidden", children: [_jsx("button", { type: "button", id: `${id}-and`, "aria-label": "Match all conditions (AND)", onClick: () => onChange('AND'), className: `ms:px-2 ms:py-0.5 ms:text-xs ms:font-medium ms:border-0 ms:outline-none ms:focus:outline-none ms:cursor-pointer ms:transition-colors ${value === 'AND'
111
+ ? 'ms:bg-msprimary ms:text-mstextsecondary'
112
+ : 'ms:bg-transparent ms:text-mstextmuted ms:hover:bg-msbackgroundhover'}`, children: "AND" }), _jsx("button", { type: "button", id: `${id}-or`, "aria-label": "Match any condition (OR)", onClick: () => onChange('OR'), className: `ms:px-2 ms:py-0.5 ms:text-xs ms:font-medium ms:border-0 ms:outline-none ms:focus:outline-none ms:cursor-pointer ms:transition-colors ${value === 'OR'
113
+ ? 'ms:bg-msprimary ms:text-mstextsecondary'
114
+ : 'ms:bg-transparent ms:text-mstextmuted ms:hover:bg-msbackgroundhover'}`, children: "OR" })] }));
115
+ }
116
+ const EXPRESSION_OPERATOR_CHIPS = [
117
+ '>',
118
+ '>=',
119
+ '<',
120
+ '<=',
121
+ '==',
122
+ '!=',
123
+ '===',
124
+ '!==',
125
+ '&&',
126
+ '||',
127
+ '+',
128
+ '-',
129
+ '*',
130
+ '/',
131
+ ];
132
+ /** Operators that don't need an expected value. */
133
+ const UNARY_OPERATORS = new Set(['empty', 'notEmpty']);
134
+ /** Operators that only make sense for numeric / countable values. */
135
+ const NUMERIC_OPERATORS = new Set([
136
+ 'greaterThan',
137
+ 'greaterThanOrEqual',
138
+ 'lessThan',
139
+ 'lessThanOrEqual',
140
+ ]);
141
+ const OPERATOR_LABELS = {
142
+ equals: 'equals',
143
+ notEquals: 'not equals',
144
+ contains: 'contains',
145
+ includes: 'includes',
146
+ empty: 'is empty',
147
+ notEmpty: 'is not empty',
148
+ greaterThan: '>',
149
+ greaterThanOrEqual: '>=',
150
+ lessThan: '<',
151
+ lessThanOrEqual: '<=',
152
+ };
153
+ function ConditionRow({ instanceId, fieldId, ruleIdx, condIdx, condition, otherFields, onUpdate, onRemove, canRemove, }) {
154
+ const idPrefix = `${instanceId}-logic-cond-${fieldId}-${ruleIdx}-${condIdx}`;
155
+ const conditionType = condition.conditionType ?? 'field';
156
+ const target = otherFields.find((f) => f.id === (condition.targetId ?? ''));
157
+ const operator = condition.operator ?? 'equals';
158
+ const isUnary = UNARY_OPERATORS.has(operator);
159
+ // Assisted expression state
160
+ const inputRef = React.useRef(null);
161
+ const [fieldPickerOpen, setFieldPickerOpen] = React.useState(false);
162
+ const knownFieldIds = React.useMemo(() => new Set(otherFields.map((f) => f.id)), [otherFields]);
163
+ const expressionErrors = React.useMemo(() => validateExpressionLocally(condition.expression ?? '', knownFieldIds, otherFields), [condition.expression, knownFieldIds, otherFields]);
164
+ React.useEffect(() => {
165
+ if (!fieldPickerOpen)
166
+ return;
167
+ const handleClick = () => setFieldPickerOpen(false);
168
+ document.addEventListener('click', handleClick);
169
+ return () => document.removeEventListener('click', handleClick);
170
+ }, [fieldPickerOpen]);
171
+ const insertAtCursor = React.useCallback((text) => {
172
+ const el = inputRef.current;
173
+ if (!el) {
174
+ onUpdate({ expression: (condition.expression ?? '') + text });
175
+ return;
176
+ }
177
+ const start = el.selectionStart ?? el.value.length;
178
+ const end = el.selectionEnd ?? el.value.length;
179
+ const next = el.value.slice(0, start) + text + el.value.slice(end);
180
+ const newCursor = start + text.length;
181
+ onUpdate({ expression: next });
182
+ requestAnimationFrame(() => {
183
+ el.setSelectionRange(newCursor, newCursor);
184
+ el.focus();
185
+ });
186
+ }, [condition.expression, onUpdate]);
187
+ // Determine available operators based on target type
188
+ const availableOperators = getAvailableOperators(target, condition.propertyAccessor);
189
+ // If target changed and operator is now invalid, reset it
190
+ const resolvedOperator = conditionType === 'field' && availableOperators.includes(operator)
191
+ ? operator
192
+ : availableOperators[0];
193
+ if (conditionType === 'field' && resolvedOperator !== condition.operator) {
194
+ // Schedule reset for next tick to avoid render-during-render
195
+ Promise.resolve().then(() => onUpdate({ operator: resolvedOperator, expected: '' }));
196
+ }
197
+ return (_jsxs("div", { className: "condition-row ms:flex ms:flex-col ms:gap-1.5 ms:p-2.5 ms:border ms:border-msborder ms:rounded-md ms:bg-msbackground", children: [_jsxs("div", { className: "ms:flex ms:items-center ms:justify-between ms:gap-1.5", children: [_jsxs("div", { className: "ms:inline-flex ms:rounded ms:border ms:border-msborder ms:overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => onUpdate({
198
+ conditionType: 'field',
199
+ expression: '',
200
+ targetId: condition.targetId || otherFields[0]?.id || '',
201
+ operator: 'equals',
202
+ expected: '',
203
+ }), className: `ms:px-2 ms:py-0.5 ms:text-xs ms:font-medium ms:border-0 ms:outline-none ms:focus:outline-none ms:cursor-pointer ms:transition-colors ${conditionType === 'field'
204
+ ? 'ms:bg-msprimary ms:text-mstextsecondary'
205
+ : 'ms:bg-transparent ms:text-mstextmuted ms:hover:bg-msbackgroundhover'}`, children: "Field" }), _jsx("button", { type: "button", onClick: () => onUpdate({
206
+ conditionType: 'expression',
207
+ expression: condition.expression ?? '',
208
+ targetId: undefined,
209
+ propertyAccessor: undefined,
210
+ operator: undefined,
211
+ expected: undefined,
212
+ }), className: `ms:px-2 ms:py-0.5 ms:text-xs ms:font-medium ms:border-0 ms:outline-none ms:focus:outline-none ms:cursor-pointer ms:transition-colors ${conditionType === 'expression'
213
+ ? 'ms:bg-msprimary ms:text-mstextsecondary'
214
+ : 'ms:bg-transparent ms:text-mstextmuted ms:hover:bg-msbackgroundhover'}`, children: "Expression" })] }), canRemove && (_jsx("button", { type: "button", onClick: onRemove, "aria-label": `Remove condition ${condIdx + 1}`, className: "remove-condition-btn ms:shrink-0 ms:p-1.5 ms:rounded ms:bg-transparent ms:text-mstextmuted ms:hover:text-msdanger ms:border-0 ms:outline-none ms:focus:outline-none ms:transition-colors ms:cursor-pointer", children: _jsx(TrashIcon, { className: "ms:w-3.5 ms:h-3.5" }) }))] }), conditionType === 'field' ? (_jsxs("select", { id: `${idPrefix}-target`, "aria-label": "Target field", value: condition.targetId ?? '', onChange: (e) => onUpdate({ targetId: e.currentTarget.value, expected: '' }), className: "condition-target ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary ms:cursor-pointer", children: [!target && condition.targetId && (_jsxs("option", { value: condition.targetId, children: ["\u26A0 ", condition.targetId, " (missing)"] })), otherFields.map((f) => (_jsxs("option", { value: f.id, children: [f.label, " (", f.fieldType, ")"] }, f.id)))] })) : (_jsxs("div", { className: "ms:flex ms:flex-col ms:gap-1.5", children: [_jsx("input", { ref: inputRef, id: `${idPrefix}-expression`, "aria-label": "Expression", type: "text", value: condition.expression ?? '', onChange: (e) => onUpdate({ expression: e.currentTarget.value }), placeholder: "{fieldId} > 0", className: "condition-expression-input ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:font-mono ms:placeholder:text-mstextmuted ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary" }), _jsxs("div", { className: "condition-expression-toolbar ms:flex ms:flex-wrap ms:items-center ms:gap-1", children: [_jsxs("div", { className: "ms:relative", children: [_jsxs("button", { type: "button", onClick: (e) => {
215
+ e.stopPropagation();
216
+ setFieldPickerOpen((v) => !v);
217
+ }, className: "condition-field-picker-btn ms:px-1.5 ms:py-0.5 ms:text-xs ms:font-medium ms:bg-transparent ms:text-msprimary ms:border ms:border-msprimary/40 ms:rounded ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: ['{ }', " Field"] }), fieldPickerOpen && (_jsx("div", { className: "condition-field-picker-dropdown ms:absolute ms:top-full ms:left-0 ms:mt-0.5 ms:z-50 ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:shadow-lg ms:min-w-max ms:max-h-48 ms:overflow-y-auto", onClick: (e) => e.stopPropagation(), children: otherFields.map((f) => (_jsxs("button", { type: "button", onClick: () => {
218
+ insertAtCursor(`{${f.id}}`);
219
+ setFieldPickerOpen(false);
220
+ }, className: "ms:flex ms:flex-col ms:w-full ms:px-2 ms:py-1.5 ms:text-left ms:bg-transparent ms:border-0 ms:text-mstext ms:hover:bg-msbackgroundhover ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: [_jsx("span", { className: "ms:text-xs ms:font-medium", children: f.label }), _jsx("span", { className: "ms:text-xs ms:text-mstextmuted ms:font-mono", children: '{' + f.id + '}' })] }, f.id))) }))] }), EXPRESSION_OPERATOR_CHIPS.map((op) => (_jsx("button", { type: "button", onClick: () => insertAtCursor(` ${op} `), className: "operator-chip ms:px-1.5 ms:py-0.5 ms:text-xs ms:font-mono ms:bg-transparent ms:text-mstextmuted ms:border ms:border-msborder ms:rounded ms:hover:bg-msbackgroundhover ms:hover:text-mstext ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: op }, op)))] }), expressionErrors.length > 0 && (_jsx("ul", { className: "condition-expression-errors ms:list-none ms:p-0 ms:m-0 ms:space-y-0.5", children: expressionErrors.map((err, i) => (_jsx("li", { className: "ms:text-xs ms:text-msdanger", children: err }, i))) }))] })), conditionType === 'field' && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "ms:flex ms:gap-1.5", children: [_jsxs("select", { id: `${idPrefix}-accessor`, "aria-label": "Property accessor", value: condition.propertyAccessor ?? '', onChange: (e) => onUpdate({
221
+ propertyAccessor: e.currentTarget.value || undefined,
222
+ expected: '',
223
+ }), className: "condition-accessor ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary ms:cursor-pointer", children: [_jsx("option", { value: "", children: "value" }), _jsx("option", { value: "length", children: "length" }), _jsx("option", { value: "count", children: "count" })] }), _jsx("select", { id: `${idPrefix}-operator`, "aria-label": "Operator", value: resolvedOperator, onChange: (e) => onUpdate({
224
+ operator: e.currentTarget.value,
225
+ expected: '',
226
+ }), className: "condition-operator ms:flex-1 ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary ms:cursor-pointer", children: availableOperators.map((op) => (_jsx("option", { value: op, children: OPERATOR_LABELS[op] }, op))) })] }), !isUnary && (_jsx(ExpectedValueInput, { idPrefix: idPrefix, target: target, condition: condition, onUpdate: onUpdate }))] }))] }));
227
+ }
228
+ function ExpectedValueInput({ idPrefix, target, condition, onUpdate, }) {
229
+ const operator = condition.operator ?? 'equals';
230
+ const expected = condition.expected ?? '';
231
+ // Boolean fields always compare as true/false (the stored option id for the
232
+ // Yes option is "o1" / true-ish, but the condition engine compares the
233
+ // option id directly). Show a hardcoded Yes/No picker using the actual
234
+ // option ids from the field so the condition value is always correct.
235
+ if (target?.fieldType === 'boolean') {
236
+ if (operator === 'equals' || operator === 'notEquals') {
237
+ const yesOpt = target.options?.find((o) => o.value.toLowerCase() === 'yes');
238
+ const noOpt = target.options?.find((o) => o.value.toLowerCase() === 'no');
239
+ return (_jsxs("select", { id: `${idPrefix}-expected`, "aria-label": "Expected value", value: expected, onChange: (e) => onUpdate({ expected: e.currentTarget.value }), className: "condition-expected ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary ms:cursor-pointer", children: [_jsx("option", { value: "", children: "Select a value\u2026" }), yesOpt && _jsx("option", { value: yesOpt.id, children: "Yes" }), noOpt && _jsx("option", { value: noOpt.id, children: "No" })] }));
240
+ }
241
+ }
242
+ // If target has options and we're using equals/notEquals/includes,
243
+ // show a dropdown of option values
244
+ if (target?.hasOptions && target.options && target.options.length > 0) {
245
+ if (operator === 'equals' ||
246
+ operator === 'notEquals' ||
247
+ operator === 'includes') {
248
+ return (_jsxs("select", { id: `${idPrefix}-expected`, "aria-label": "Expected value", value: expected, onChange: (e) => onUpdate({ expected: e.currentTarget.value }), className: "condition-expected ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary ms:cursor-pointer", children: [_jsx("option", { value: "", children: "Select a value\u2026" }), target.options.map((opt) => (_jsx("option", { value: opt.id, children: opt.value }, opt.id)))] }));
249
+ }
250
+ }
251
+ // Numeric operators → number input
252
+ if (NUMERIC_OPERATORS.has(operator) || condition.propertyAccessor) {
253
+ return (_jsx("input", { id: `${idPrefix}-expected`, "aria-label": "Expected value", type: "number", value: expected, onChange: (e) => onUpdate({ expected: e.currentTarget.value }), placeholder: "Enter a number", className: "condition-expected ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:placeholder:text-mstextmuted ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary" }));
254
+ }
255
+ // Default: text input
256
+ return (_jsx("input", { id: `${idPrefix}-expected`, "aria-label": "Expected value", type: "text", value: expected, onChange: (e) => onUpdate({ expected: e.currentTarget.value }), placeholder: "Enter expected value", className: "condition-expected ms:w-full ms:min-w-0 ms:px-2 ms:py-1.5 ms:text-xs ms:bg-mssurface ms:border ms:border-msborder ms:rounded ms:text-mstext ms:placeholder:text-mstextmuted ms:focus:outline-none ms:focus:ring-1 ms:focus:ring-msprimary" }));
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // Helpers
260
+ // ---------------------------------------------------------------------------
261
+ /** Resolve available operators based on target field type. */
262
+ function getAvailableOperators(target, propertyAccessor) {
263
+ if (!target)
264
+ return [...CONDITION_OPERATORS];
265
+ if (propertyAccessor) {
266
+ return [
267
+ 'equals',
268
+ 'notEquals',
269
+ 'greaterThan',
270
+ 'greaterThanOrEqual',
271
+ 'lessThan',
272
+ 'lessThanOrEqual',
273
+ ];
274
+ }
275
+ return getOperatorsForTarget(target);
276
+ }
277
+ function getOperatorsForTarget(target) {
278
+ const answerType = target.answerType;
279
+ // Multi-value answers should use inclusion semantics, not scalar equality.
280
+ if (answerType === 'multiselection' || answerType === 'multitext') {
281
+ return ['includes', 'empty', 'notEmpty'];
282
+ }
283
+ // Matrix answers are object maps; only empty checks are meaningful without accessor.
284
+ if (answerType === 'matrix') {
285
+ return ['empty', 'notEmpty'];
286
+ }
287
+ // Single-value selection fields (radio/dropdown/boolean etc.).
288
+ if (answerType === 'selection') {
289
+ const ops = [
290
+ 'equals',
291
+ 'notEquals',
292
+ 'empty',
293
+ 'notEmpty',
294
+ ];
295
+ if (target.supportsNumericCompare) {
296
+ ops.push('greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual');
297
+ }
298
+ return ops;
299
+ }
300
+ // Text-like scalar values.
301
+ if (answerType === 'text') {
302
+ const ops = [
303
+ 'equals',
304
+ 'notEquals',
305
+ 'contains',
306
+ 'empty',
307
+ 'notEmpty',
308
+ ];
309
+ if (target.supportsNumericCompare) {
310
+ ops.push('greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual');
311
+ }
312
+ return ops;
313
+ }
314
+ // Fallback: conservative scalar checks.
315
+ return ['equals', 'notEquals', 'empty', 'notEmpty'];
316
+ }
317
+ /** Collect other fields from the normalized map (exclude self). */
318
+ function buildOtherFields(normalized, selfId) {
319
+ const result = [];
320
+ for (const [id, node] of Object.entries(normalized.byId)) {
321
+ if (id === selfId)
322
+ continue;
323
+ // Skip sections — they don't have answerable values
324
+ if (node.definition.fieldType === 'section')
325
+ continue;
326
+ const meta = getFieldTypeMeta(node.definition.fieldType);
327
+ const supportsNumericCompare = node.definition.fieldType === 'rating' ||
328
+ node.definition.fieldType === 'slider' ||
329
+ (node.definition.fieldType === 'text' &&
330
+ node.definition.inputType === 'number');
331
+ result.push({
332
+ id,
333
+ label: node.definition.question || node.definition.id,
334
+ fieldType: node.definition.fieldType,
335
+ hasOptions: meta?.hasOptions ?? false,
336
+ options: hasOptions(node.definition)
337
+ ? node.definition.options
338
+ : undefined,
339
+ answerType: meta?.answerType ?? 'none',
340
+ supportsNumericCompare,
341
+ });
342
+ }
343
+ return result;
344
+ }
345
+ /** Group rules by effect, preserving original indices for update callbacks. */
346
+ function groupByEffect(rules) {
347
+ const result = {
348
+ visible: [],
349
+ enable: [],
350
+ required: [],
351
+ };
352
+ rules.forEach((rule, idx) => {
353
+ if (result[rule.effect]) {
354
+ result[rule.effect].push({ rule, globalIdx: idx });
355
+ }
356
+ });
357
+ return result;
358
+ }
359
+ function validateExpressionLocally(expr, knownFieldIds, otherFields) {
360
+ if (!expr.trim())
361
+ return [];
362
+ const errors = [];
363
+ const fieldMap = new Map(otherFields.map((f) => [f.id, f]));
364
+ // Unknown field references
365
+ for (const match of expr.matchAll(/\{([^}]+)\}/g)) {
366
+ const id = match[1].trim();
367
+ if (id && !knownFieldIds.has(id)) {
368
+ errors.push(`Unknown field: {${id}}`);
369
+ }
370
+ }
371
+ // Syntax check
372
+ if (!isExpressionValid(expr)) {
373
+ errors.push('Expression syntax is invalid.');
374
+ }
375
+ // Type-aware: warn when a non-numeric field is used with a relational operator.
376
+ // Strip == and != first so their < > characters don't trigger false positives.
377
+ const stripped = expr.replace(/===|!==|==|!=/g, ' ');
378
+ for (const match of expr.matchAll(/\{([^}]+)\}/g)) {
379
+ const id = match[1].trim();
380
+ const field = fieldMap.get(id);
381
+ if (!field || field.supportsNumericCompare)
382
+ continue;
383
+ const start = match.index;
384
+ const end = start + match[0].length;
385
+ const before = stripped.slice(Math.max(0, start - 4), start);
386
+ const after = stripped.slice(end, end + 4);
387
+ if (/[<>]/.test(before) || /[<>]/.test(after)) {
388
+ errors.push(`{${id}} is a ${field.fieldType} field — numeric comparison (> < >= <=) may not produce meaningful results. Use == or !=.`);
389
+ }
390
+ }
391
+ return errors;
392
+ }
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { TrashIcon } from '@esheet/fields';
4
+ import { useInstanceId } from '../../EsheetBuilder.js';
5
+ import { useFormApi } from '../../hooks/useFormApi.js';
6
+ const MAX_ROWS = 10;
7
+ const MAX_COLUMNS = 10;
8
+ /**
9
+ * MatrixEditor — add / edit / remove rows and columns for matrix fields.
10
+ * Max 10 rows, max 10 columns.
11
+ */
12
+ export function MatrixEditor({ fieldId, rows, columns }) {
13
+ const instanceId = useInstanceId();
14
+ const { row: rowApi, column: columnApi } = useFormApi(fieldId);
15
+ const rowsRef = React.useRef(null);
16
+ const colsRef = React.useRef(null);
17
+ const handleAddRow = () => {
18
+ rowApi.add();
19
+ requestAnimationFrame(() => {
20
+ if (rowsRef.current)
21
+ rowsRef.current.scrollTop = rowsRef.current.scrollHeight;
22
+ });
23
+ };
24
+ const handleAddColumn = () => {
25
+ columnApi.add();
26
+ requestAnimationFrame(() => {
27
+ if (colsRef.current)
28
+ colsRef.current.scrollTop = colsRef.current.scrollHeight;
29
+ });
30
+ };
31
+ return (_jsxs("div", { className: "matrix-editor ms:space-y-4", children: [_jsxs("div", { className: "matrix-rows ms:space-y-2", children: [_jsx("span", { className: "edit-label ms:block ms:text-sm ms:font-medium ms:text-mstext", children: "Rows" }), rows.length >= MAX_ROWS && (_jsxs("div", { className: "ms:text-xs ms:text-mstextmuted ms:italic", children: ["Maximum ", MAX_ROWS, " rows"] })), _jsx("div", { ref: rowsRef, className: "row-list ms:space-y-2", children: rows.map((row, idx) => (_jsxs("div", { className: "row-item ms:flex ms:items-center ms:gap-2 ms:px-3 ms:py-2 ms:border ms:border-msborder ms:rounded-lg ms:shadow-sm ms:hover:border-msprimary/50 ms:transition-colors", children: [_jsxs("span", { className: "ms:text-xs ms:text-mstextmuted ms:w-5 ms:text-right ms:shrink-0", children: [idx + 1, "."] }), _jsx("input", { id: `${instanceId}-editor-row-${fieldId}-${row.id}`, "aria-label": `Row ${idx + 1}`, type: "text", value: row.value, onChange: (e) => rowApi.update(row.id, e.currentTarget.value), placeholder: `Row ${idx + 1}`, className: "ms:flex-1 ms:min-w-0 ms:outline-none ms:bg-transparent ms:text-mstext ms:placeholder:text-mstextmuted ms:border-0 ms:text-sm" }), _jsx("button", { type: "button", onClick: () => rowApi.remove(row.id), "aria-label": `Remove row ${idx + 1}`, className: "remove-row-btn ms:shrink-0 ms:p-0.5 ms:rounded ms:bg-transparent ms:text-mstextmuted ms:hover:text-msdanger ms:border-0 ms:outline-none ms:focus:outline-none ms:transition-colors ms:cursor-pointer", children: _jsx(TrashIcon, { className: "ms:w-4 ms:h-4" }) })] }, row.id))) }), rows.length < MAX_ROWS && (_jsx("button", { type: "button", onClick: handleAddRow, className: "add-row-btn ms:w-full ms:px-3 ms:py-2 ms:text-sm ms:font-medium ms:bg-mssurface ms:text-msprimary ms:border ms:border-msprimary/50 ms:rounded-lg ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: "+ Add Row" }))] }), _jsxs("div", { className: "matrix-columns ms:space-y-2", children: [_jsx("span", { className: "edit-label ms:block ms:text-sm ms:font-medium ms:text-mstext", children: "Columns" }), columns.length >= MAX_COLUMNS && (_jsxs("div", { className: "ms:text-xs ms:text-mstextmuted ms:italic", children: ["Maximum ", MAX_COLUMNS, " columns"] })), _jsx("div", { ref: colsRef, className: "column-list ms:space-y-2", children: columns.map((col, idx) => (_jsxs("div", { className: "column-item ms:flex ms:items-center ms:gap-2 ms:px-3 ms:py-2 ms:border ms:border-msborder ms:rounded-lg ms:shadow-sm ms:hover:border-msprimary/50 ms:transition-colors", children: [_jsxs("span", { className: "ms:text-xs ms:text-mstextmuted ms:w-5 ms:text-right ms:shrink-0", children: [idx + 1, "."] }), _jsx("input", { id: `${instanceId}-editor-col-${fieldId}-${col.id}`, "aria-label": `Column ${idx + 1}`, type: "text", value: col.value, onChange: (e) => columnApi.update(col.id, e.currentTarget.value), placeholder: `Column ${idx + 1}`, className: "ms:flex-1 ms:min-w-0 ms:outline-none ms:bg-transparent ms:text-mstext ms:placeholder:text-mstextmuted ms:border-0 ms:text-sm" }), _jsx("button", { type: "button", onClick: () => columnApi.remove(col.id), "aria-label": `Remove column ${idx + 1}`, className: "remove-col-btn ms:shrink-0 ms:p-0.5 ms:rounded ms:bg-transparent ms:text-mstextmuted ms:hover:text-msdanger ms:border-0 ms:outline-none ms:focus:outline-none ms:transition-colors ms:cursor-pointer", children: _jsx(TrashIcon, { className: "ms:w-4 ms:h-4" }) })] }, col.id))) }), columns.length < MAX_COLUMNS && (_jsx("button", { type: "button", onClick: handleAddColumn, className: "add-col-btn ms:w-full ms:px-3 ms:py-2 ms:text-sm ms:font-medium ms:bg-mssurface ms:text-msprimary ms:border ms:border-msprimary/50 ms:rounded-lg ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: "+ Add Column" }))] })] }));
32
+ }
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { TrashIcon } from '@esheet/fields';
4
+ import { useInstanceId } from '../../EsheetBuilder.js';
5
+ import { useFormApi } from '../../hooks/useFormApi.js';
6
+ /**
7
+ * OptionListEditor — add / edit / remove options for choice fields.
8
+ *
9
+ * Disables delete for boolean (fixed Yes/No).
10
+ * Uses form.addOption / updateOption / removeOption directly.
11
+ */
12
+ export function OptionListEditor({ fieldId, fieldType, options, }) {
13
+ const instanceId = useInstanceId();
14
+ const { option } = useFormApi(fieldId);
15
+ const listRef = React.useRef(null);
16
+ const isBoolean = fieldType === 'boolean';
17
+ const label = fieldType === 'multitext' ? 'Text Inputs' : 'Options';
18
+ const handleAdd = () => {
19
+ option.add();
20
+ requestAnimationFrame(() => {
21
+ if (listRef.current) {
22
+ listRef.current.scrollTop = listRef.current.scrollHeight;
23
+ }
24
+ });
25
+ };
26
+ return (_jsxs("div", { className: "option-list-editor ms:space-y-2", children: [_jsx("span", { className: "edit-label ms:block ms:text-sm ms:font-medium ms:text-mstext", children: label }), _jsx("div", { ref: listRef, className: "option-list ms:space-y-2", children: options.map((opt, idx) => (_jsxs("div", { className: "option-row ms:flex ms:items-center ms:gap-2 ms:px-3 ms:py-2 ms:border ms:border-msborder ms:rounded-lg ms:shadow-sm ms:hover:border-msprimary/50 ms:transition-colors", children: [_jsx("input", { id: `${instanceId}-editor-option-${fieldId}-${opt.id}`, "aria-label": `Option ${idx + 1}`, type: "text", value: opt.value, onChange: (e) => option.update(opt.id, e.currentTarget.value), placeholder: `Option ${idx + 1}`, className: "ms:flex-1 ms:min-w-0 ms:outline-none ms:bg-transparent ms:text-mstext ms:placeholder:text-mstextmuted ms:border-0 ms:text-sm" }), !isBoolean && (_jsx("button", { type: "button", onClick: () => option.remove(opt.id), "aria-label": `Remove option ${idx + 1}`, className: "remove-option-btn ms:shrink-0 ms:p-0.5 ms:rounded ms:bg-transparent ms:text-mstextmuted ms:hover:text-msdanger ms:border-0 ms:outline-none ms:focus:outline-none ms:transition-colors ms:cursor-pointer", children: _jsx(TrashIcon, { className: "ms:w-4 ms:h-4" }) }))] }, opt.id))) }), !isBoolean && (_jsxs("button", { type: "button", onClick: handleAdd, className: "add-option-btn ms:w-full ms:px-3 ms:py-2 ms:text-sm ms:font-medium ms:bg-mssurface ms:text-msprimary ms:border ms:border-msprimary/50 ms:rounded-lg ms:hover:bg-msprimary/10 ms:transition-colors ms:outline-none ms:focus:outline-none ms:cursor-pointer", children: ["+ Add ", fieldType === 'multitext' ? 'Input' : 'Option'] }))] }));
27
+ }
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { useStore } from 'zustand';
3
+ import { useFormStore } from '@esheet/fields';
4
+ /**
5
+ * useFormApi — reactive field state + form store actions.
6
+ *
7
+ * Touches only the FormStore. For UI state (mode, selection, tabs) use
8
+ * useUiApi(). For the computed visible root IDs use useVisibleRootIds().
9
+ *
10
+ * @param fieldId - The field to bind to. Pass undefined for form-level ops only.
11
+ */
12
+ export function useFormApi(fieldId) {
13
+ const form = useFormStore();
14
+ // --- Reactive field state ---
15
+ const field = useStore(form, (s) => fieldId ? s.getField(fieldId) : undefined);
16
+ const response = useStore(form, (s) => fieldId ? s.getResponse(fieldId) : undefined);
17
+ const isVisible = useStore(form, (s) => fieldId ? s.isVisible(fieldId) : true);
18
+ const isEnabled = useStore(form, (s) => fieldId ? s.isEnabled(fieldId) : true);
19
+ const isRequired = useStore(form, (s) => fieldId ? s.isRequired(fieldId) : false);
20
+ // --- Reactive form-level state ---
21
+ const normalized = useStore(form, (s) => s.normalized);
22
+ const responses = useStore(form, (s) => s.responses);
23
+ const instanceId = useStore(form, (s) => s.instanceId);
24
+ return React.useMemo(() => ({
25
+ field,
26
+ response,
27
+ isVisible,
28
+ isEnabled,
29
+ isRequired,
30
+ normalized,
31
+ responses,
32
+ instanceId,
33
+ form: {
34
+ addField: (type, opts) => form.getState().addField(type, opts),
35
+ loadDefinition: form.getState().loadDefinition,
36
+ setFormId: form.getState().setFormId,
37
+ hydrateDefinition: () => form.getState().hydrateDefinition(),
38
+ hydrateResponse: () => form.getState().hydrateResponse(),
39
+ resetResponses: () => form.getState().resetResponses(),
40
+ },
41
+ field_: {
42
+ update: (patch) => fieldId ? form.getState().updateField(fieldId, patch) : false,
43
+ remove: () => (fieldId ? form.getState().removeField(fieldId) : false),
44
+ move: (toIndex, toParentId) => fieldId
45
+ ? form.getState().moveField(fieldId, toIndex, toParentId)
46
+ : false,
47
+ setResponse: (resp) => fieldId ? form.getState().setResponse(fieldId, resp) : undefined,
48
+ clearResponse: () => fieldId ? form.getState().clearResponse(fieldId) : undefined,
49
+ },
50
+ option: {
51
+ add: (value) => fieldId ? form.getState().addOption(fieldId, value) : null,
52
+ update: (optId, value) => fieldId ? form.getState().updateOption(fieldId, optId, value) : false,
53
+ remove: (optId) => fieldId ? form.getState().removeOption(fieldId, optId) : false,
54
+ },
55
+ row: {
56
+ add: (value) => fieldId ? form.getState().addRow(fieldId, value) : null,
57
+ update: (rowId, value) => fieldId ? form.getState().updateRow(fieldId, rowId, value) : false,
58
+ remove: (rowId) => fieldId ? form.getState().removeRow(fieldId, rowId) : false,
59
+ },
60
+ column: {
61
+ add: (value) => fieldId ? form.getState().addColumn(fieldId, value) : null,
62
+ update: (colId, value) => fieldId ? form.getState().updateColumn(fieldId, colId, value) : false,
63
+ remove: (colId) => fieldId ? form.getState().removeColumn(fieldId, colId) : false,
64
+ },
65
+ _form: form,
66
+ }),
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ [
69
+ fieldId,
70
+ field,
71
+ response,
72
+ isVisible,
73
+ isEnabled,
74
+ isRequired,
75
+ normalized,
76
+ responses,
77
+ instanceId,
78
+ form,
79
+ ]);
80
+ }
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { useStore } from 'zustand';
3
+ import { useUI } from '@esheet/fields';
4
+ /**
5
+ * useUiApi — lightweight hook for components that only need UI state/actions.
6
+ *
7
+ * Useful when you don't need field-scoped state. Requires UIContext provider.
8
+ */
9
+ export function useUiApi() {
10
+ const ui = useUI();
11
+ const mode = useStore(ui, (s) => s.mode);
12
+ const selectedFieldId = useStore(ui, (s) => s.selectedFieldId);
13
+ const selectedFieldChildId = useStore(ui, (s) => s.selectedFieldChildId);
14
+ const editTab = useStore(ui, (s) => s.editTab);
15
+ const codeEditorHasError = useStore(ui, (s) => s.codeEditorHasError);
16
+ return React.useMemo(() => ({
17
+ mode,
18
+ selectedFieldId,
19
+ selectedFieldChildId,
20
+ editTab,
21
+ codeEditorHasError,
22
+ selectField: (id) => ui.getState().selectField(id),
23
+ selectFieldChild: (parentId, childId) => ui.getState().selectFieldChild(parentId, childId),
24
+ setMode: (m) => ui.getState().setMode(m),
25
+ setEditTab: (tab) => ui.getState().setEditTab(tab),
26
+ setEditModalOpen: (open) => ui.getState().setEditModalOpen(open),
27
+ clearDragState: () => ui.getState().clearDragState(),
28
+ _ui: ui,
29
+ }), [
30
+ mode,
31
+ selectedFieldId,
32
+ selectedFieldChildId,
33
+ editTab,
34
+ codeEditorHasError,
35
+ ui,
36
+ ]);
37
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { useStore } from 'zustand';
3
+ import { useFormStore, useUI } from '@esheet/fields';
4
+ /**
5
+ * useVisibleRootIds — returns the root field IDs to render.
6
+ *
7
+ * In build/code mode: all root IDs.
8
+ * In preview mode: only IDs whose visibility rules evaluate to true.
9
+ *
10
+ * Requires both FormStoreContext and UIContext providers.
11
+ * Uses a stable-ref cache so the array reference only changes when content does.
12
+ */
13
+ export function useVisibleRootIds() {
14
+ const form = useFormStore();
15
+ const ui = useUI();
16
+ const normalized = useStore(form, (s) => s.normalized);
17
+ const responses = useStore(form, (s) => s.responses);
18
+ const mode = useStore(ui, (s) => s.mode);
19
+ const cacheRef = React.useRef([]);
20
+ return React.useMemo(() => {
21
+ const rootIds = mode !== 'preview'
22
+ ? normalized.rootIds
23
+ : normalized.rootIds.filter((id) => form.getState().isVisible(id));
24
+ const prev = cacheRef.current;
25
+ if (prev.length === rootIds.length &&
26
+ prev.every((v, i) => v === rootIds[i])) {
27
+ return prev;
28
+ }
29
+ cacheRef.current = rootIds;
30
+ return rootIds;
31
+ }, [normalized, responses, mode, form]);
32
+ }