@sonata-innovations/fiber-shared 1.0.0

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,10 @@
1
+ import type { FlowConditionConfig } from "@sonata-innovations/fiber-types";
2
+ /**
3
+ * Determine if a target is hidden based on its condition config and result.
4
+ *
5
+ * - action = "show" → hidden when condition is NOT met
6
+ * - action = "hide" → hidden when condition IS met
7
+ * - No config → always visible
8
+ */
9
+ export declare const isHiddenByCondition: (config: FlowConditionConfig | undefined, conditionMet: boolean | undefined) => boolean;
10
+ //# sourceMappingURL=condition-visibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"condition-visibility.d.ts","sourceRoot":"","sources":["../src/condition-visibility.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAE3E;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAC9B,QAAQ,mBAAmB,GAAG,SAAS,EACvC,cAAc,OAAO,GAAG,SAAS,KAChC,OASF,CAAC"}
@@ -0,0 +1,20 @@
1
+ /* ==========================================================================
2
+ Condition-visibility — shared "is hidden" check
3
+ ========================================================================== */
4
+ /**
5
+ * Determine if a target is hidden based on its condition config and result.
6
+ *
7
+ * - action = "show" → hidden when condition is NOT met
8
+ * - action = "hide" → hidden when condition IS met
9
+ * - No config → always visible
10
+ */
11
+ export const isHiddenByCondition = (config, conditionMet) => {
12
+ if (!config)
13
+ return false;
14
+ const met = conditionMet ?? false;
15
+ if (config.action === "show")
16
+ return !met;
17
+ if (config.action === "hide")
18
+ return met;
19
+ return false;
20
+ };
@@ -0,0 +1,26 @@
1
+ import type { ConditionOperator, ConditionRule, FlowConditionConfig } from "@sonata-innovations/fiber-types";
2
+ /**
3
+ * An operator function receives the live component value and the rule's
4
+ * comparison value. Returns true when the condition is satisfied.
5
+ */
6
+ type OperatorFn = (componentValue: any, ruleValue: ConditionRule["value"]) => boolean;
7
+ export declare const toStr: (v: any) => string;
8
+ export declare const toNum: (v: any) => number;
9
+ /** Normalise a component value to a string array (handles multi-selects). */
10
+ export declare const toArray: (v: any) => string[];
11
+ /** Normalise a rule value to a string array. */
12
+ export declare const ruleToArray: (v: ConditionRule["value"]) => string[];
13
+ export declare const operators: Record<ConditionOperator, OperatorFn>;
14
+ /**
15
+ * Evaluate a single operator against a component value.
16
+ */
17
+ export declare const evaluateOperator: (operator: ConditionOperator, componentValue: any, ruleValue: ConditionRule["value"]) => boolean;
18
+ /**
19
+ * Evaluate a full condition config using a generic value resolver.
20
+ * The resolver takes a component UUID (and optionally the full rule)
21
+ * and returns its current value.
22
+ * Returns true when the condition group is satisfied.
23
+ */
24
+ export declare const evaluateConditionConfig: (config: FlowConditionConfig, resolveValue: (uuid: string, rule?: ConditionRule) => any) => boolean;
25
+ export {};
26
+ //# sourceMappingURL=conditions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conditions.d.ts","sourceRoot":"","sources":["../src/conditions.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,iBAAiB,EACjB,aAAa,EACb,mBAAmB,EACpB,MAAM,iCAAiC,CAAC;AAEzC;;;GAGG;AACH,KAAK,UAAU,GAAG,CAChB,cAAc,EAAE,GAAG,EACnB,SAAS,EAAE,aAAa,CAAC,OAAO,CAAC,KAC9B,OAAO,CAAC;AAIb,eAAO,MAAM,KAAK,GAAI,GAAG,GAAG,KAAG,MAAsC,CAAC;AACtE,eAAO,MAAM,KAAK,GAAI,GAAG,GAAG,KAAG,MAAmB,CAAC;AAEnD,6EAA6E;AAC7E,eAAO,MAAM,OAAO,GAAI,GAAG,GAAG,KAAG,MAAM,EAMtC,CAAC;AAEF,gDAAgD;AAChD,eAAO,MAAM,WAAW,GAAI,GAAG,aAAa,CAAC,OAAO,CAAC,KAAG,MAAM,EAI7D,CAAC;AAIF,eAAO,MAAM,SAAS,EAAE,MAAM,CAAC,iBAAiB,EAAE,UAAU,CA4C3D,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,gBAAgB,GAC3B,UAAU,iBAAiB,EAC3B,gBAAgB,GAAG,EACnB,WAAW,aAAa,CAAC,OAAO,CAAC,KAChC,OAIF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,QAAQ,mBAAmB,EAC3B,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,aAAa,KAAK,GAAG,KACxD,OAiBF,CAAC"}
@@ -0,0 +1,93 @@
1
+ /* ==========================================================================
2
+ Condition engine — shared between FBRE and server
3
+ ========================================================================== */
4
+ /* -- helpers -------------------------------------------------------------- */
5
+ export const toStr = (v) => (v == null ? "" : String(v));
6
+ export const toNum = (v) => Number(v);
7
+ /** Normalise a component value to a string array (handles multi-selects). */
8
+ export const toArray = (v) => {
9
+ if (Array.isArray(v))
10
+ return v.map(String);
11
+ if (typeof v === "string" && v.includes(","))
12
+ return v.split(",").map((s) => s.trim());
13
+ if (v == null || v === "")
14
+ return [];
15
+ return [String(v)];
16
+ };
17
+ /** Normalise a rule value to a string array. */
18
+ export const ruleToArray = (v) => {
19
+ if (Array.isArray(v))
20
+ return v.map(String);
21
+ if (v == null)
22
+ return [];
23
+ return [String(v)];
24
+ };
25
+ /* -- operator map --------------------------------------------------------- */
26
+ export const operators = {
27
+ // Equality
28
+ equals: (cv, rv) => toStr(cv) === toStr(rv),
29
+ notEquals: (cv, rv) => toStr(cv) !== toStr(rv),
30
+ // String
31
+ contains: (cv, rv) => toStr(cv).includes(toStr(rv)),
32
+ notContains: (cv, rv) => !toStr(cv).includes(toStr(rv)),
33
+ startsWith: (cv, rv) => toStr(cv).startsWith(toStr(rv)),
34
+ endsWith: (cv, rv) => toStr(cv).endsWith(toStr(rv)),
35
+ // Presence
36
+ isEmpty: (cv) => cv == null || cv === "" || (Array.isArray(cv) && cv.length === 0),
37
+ isNotEmpty: (cv) => cv != null && cv !== "" && !(Array.isArray(cv) && cv.length === 0),
38
+ // Numeric
39
+ greaterThan: (cv, rv) => toNum(cv) > toNum(rv),
40
+ greaterThanOrEqual: (cv, rv) => toNum(cv) >= toNum(rv),
41
+ lessThan: (cv, rv) => toNum(cv) < toNum(rv),
42
+ lessThanOrEqual: (cv, rv) => toNum(cv) <= toNum(rv),
43
+ // Set membership (single-value source checked against a list)
44
+ isOneOf: (cv, rv) => ruleToArray(rv).includes(toStr(cv)),
45
+ isNotOneOf: (cv, rv) => !ruleToArray(rv).includes(toStr(cv)),
46
+ // Set membership (multi-value source checked against a list)
47
+ includesAny: (cv, rv) => {
48
+ const vals = toArray(cv);
49
+ return ruleToArray(rv).some((r) => vals.includes(r));
50
+ },
51
+ includesAll: (cv, rv) => {
52
+ const vals = toArray(cv);
53
+ return ruleToArray(rv).every((r) => vals.includes(r));
54
+ },
55
+ includesNone: (cv, rv) => {
56
+ const vals = toArray(cv);
57
+ return !ruleToArray(rv).some((r) => vals.includes(r));
58
+ },
59
+ // Boolean
60
+ isTrue: (cv) => cv === true || cv === "true",
61
+ isFalse: (cv) => cv === false || cv === "false" || cv == null || cv === "",
62
+ };
63
+ /**
64
+ * Evaluate a single operator against a component value.
65
+ */
66
+ export const evaluateOperator = (operator, componentValue, ruleValue) => {
67
+ const fn = operators[operator];
68
+ if (!fn)
69
+ return false;
70
+ return fn(componentValue, ruleValue);
71
+ };
72
+ /**
73
+ * Evaluate a full condition config using a generic value resolver.
74
+ * The resolver takes a component UUID (and optionally the full rule)
75
+ * and returns its current value.
76
+ * Returns true when the condition group is satisfied.
77
+ */
78
+ export const evaluateConditionConfig = (config, resolveValue) => {
79
+ const { logic, rules } = config.when;
80
+ if (rules.length === 0)
81
+ return false;
82
+ if (logic === "and") {
83
+ return rules.every((rule) => {
84
+ const cv = resolveValue(rule.source, rule);
85
+ return evaluateOperator(rule.operator, cv, rule.value);
86
+ });
87
+ }
88
+ // "or"
89
+ return rules.some((rule) => {
90
+ const cv = resolveValue(rule.source, rule);
91
+ return evaluateOperator(rule.operator, cv, rule.value);
92
+ });
93
+ };
@@ -0,0 +1,25 @@
1
+ export type FormulaResolver = {
2
+ /** Resolve a component or calculation value by UUID. */
3
+ resolveValue: (uuid: string) => number | null;
4
+ /** Resolve a selected option's metadata value. */
5
+ resolveOptionMetadata: (uuid: string, metadataKey: string) => number | null;
6
+ /** Aggregate over repeater iterations: returns array of values for the template child UUID. */
7
+ resolveRepeaterValues: (uuid: string) => number[] | null;
8
+ };
9
+ /**
10
+ * Evaluate a formula string using the provided resolver.
11
+ * Returns the numeric result, or null if the formula is invalid/empty,
12
+ * has a syntax error, or divides by zero. Unfilled field references
13
+ * default to 0 (spreadsheet-style).
14
+ */
15
+ export declare function evaluateFormula(formula: string, resolver: FormulaResolver): number | null;
16
+ /**
17
+ * Extract all UUIDs referenced in a formula (both direct and inside aggregation functions).
18
+ * Used for building dependency indexes.
19
+ */
20
+ export declare function extractFormulaReferences(formula: string): string[];
21
+ /**
22
+ * Format a calculation result for display.
23
+ */
24
+ export declare function formatCalculationResult(value: number | null, format?: string, decimalPlaces?: number, currencySymbol?: string): string;
25
+ //# sourceMappingURL=formula.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formula.d.ts","sourceRoot":"","sources":["../src/formula.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,eAAe,GAAG;IAC5B,wDAAwD;IACxD,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC9C,kDAAkD;IAClD,qBAAqB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC5E,+FAA+F;IAC/F,qBAAqB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,EAAE,GAAG,IAAI,CAAC;CAC1D,CAAC;AA+WF;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,eAAe,GACxB,MAAM,GAAG,IAAI,CAef;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAYlE;AAED;;GAEG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,GAAG,IAAI,EACpB,MAAM,CAAC,EAAE,MAAM,EACf,aAAa,CAAC,EAAE,MAAM,EACtB,cAAc,CAAC,EAAE,MAAM,GACtB,MAAM,CAaR"}
@@ -0,0 +1,389 @@
1
+ /* ==========================================================================
2
+ Formula engine — parse and evaluate calculation formulas.
3
+
4
+ Syntax:
5
+ {uuid} → component value
6
+ {uuid}.selectedOption.metadata.key → option metadata value
7
+ SUM({uuid}) COUNT({uuid}) AVG({uuid}) → repeater aggregation
8
+ MIN({uuid}) MAX({uuid}) → repeater min/max aggregation
9
+ MIN(expr, expr, ...) MAX(expr, ...) → scalar min/max of N expressions
10
+ IF(cond, then, else) → conditional (cond != 0 → then)
11
+ + - * / ( ) → arithmetic
12
+ > < >= <= == != → comparison (returns 1 or 0)
13
+ numeric literals → constants
14
+ ========================================================================== */
15
+ const FN_NAMES = new Set(["SUM", "COUNT", "AVG", "MIN", "MAX", "IF"]);
16
+ function tokenize(formula) {
17
+ const tokens = [];
18
+ let i = 0;
19
+ const len = formula.length;
20
+ while (i < len) {
21
+ const ch = formula[i];
22
+ // Skip whitespace and zero-width spaces (inserted by contentEditable chip editing)
23
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\u200B") {
24
+ i++;
25
+ continue;
26
+ }
27
+ // Number literal
28
+ if ((ch >= "0" && ch <= "9") || (ch === "." && i + 1 < len && formula[i + 1] >= "0" && formula[i + 1] <= "9")) {
29
+ let num = "";
30
+ while (i < len && ((formula[i] >= "0" && formula[i] <= "9") || formula[i] === ".")) {
31
+ num += formula[i++];
32
+ }
33
+ const parsed = parseFloat(num);
34
+ if (isNaN(parsed))
35
+ return null;
36
+ tokens.push({ type: "number", value: num, numValue: parsed });
37
+ continue;
38
+ }
39
+ // Reference: {uuid} or {uuid}.selectedOption.metadata.key
40
+ if (ch === "{") {
41
+ const close = formula.indexOf("}", i);
42
+ if (close === -1)
43
+ return null;
44
+ const uuid = formula.substring(i + 1, close).trim();
45
+ if (!uuid)
46
+ return null;
47
+ i = close + 1;
48
+ // Check for .selectedOption.metadata.key suffix
49
+ const metaPrefix = ".selectedOption.metadata.";
50
+ if (formula.substring(i, i + metaPrefix.length) === metaPrefix) {
51
+ i += metaPrefix.length;
52
+ let key = "";
53
+ while (i < len && formula[i] !== " " && formula[i] !== ")" && formula[i] !== "+" && formula[i] !== "-" && formula[i] !== "*" && formula[i] !== "/" && formula[i] !== "," && formula[i] !== ">" && formula[i] !== "<" && formula[i] !== "=" && formula[i] !== "!") {
54
+ key += formula[i++];
55
+ }
56
+ if (!key)
57
+ return null;
58
+ tokens.push({ type: "metaRef", value: `{${uuid}}.selectedOption.metadata.${key}`, uuid, metaKey: key });
59
+ }
60
+ else {
61
+ tokens.push({ type: "ref", value: `{${uuid}}`, uuid });
62
+ }
63
+ continue;
64
+ }
65
+ // Function names (SUM, COUNT, AVG)
66
+ if (ch >= "A" && ch <= "Z") {
67
+ let name = "";
68
+ while (i < len && formula[i] >= "A" && formula[i] <= "Z") {
69
+ name += formula[i++];
70
+ }
71
+ if (FN_NAMES.has(name)) {
72
+ tokens.push({ type: "fn", value: name, fn: name });
73
+ }
74
+ else {
75
+ return null; // Unknown identifier
76
+ }
77
+ continue;
78
+ }
79
+ // Comma
80
+ if (ch === ",") {
81
+ tokens.push({ type: "comma", value: "," });
82
+ i++;
83
+ continue;
84
+ }
85
+ // Comparison operators (multi-char first: >=, <=, ==, !=)
86
+ if (ch === ">" || ch === "<" || ch === "=" || ch === "!") {
87
+ if (i + 1 < len && formula[i + 1] === "=") {
88
+ tokens.push({ type: "comp", value: ch + "=" });
89
+ i += 2;
90
+ continue;
91
+ }
92
+ if (ch === ">" || ch === "<") {
93
+ tokens.push({ type: "comp", value: ch });
94
+ i++;
95
+ continue;
96
+ }
97
+ // Lone '=' or '!' without '=' following is invalid
98
+ return null;
99
+ }
100
+ // Arithmetic operators
101
+ if (ch === "+" || ch === "-" || ch === "*" || ch === "/") {
102
+ tokens.push({ type: "op", value: ch });
103
+ i++;
104
+ continue;
105
+ }
106
+ // Parentheses
107
+ if (ch === "(") {
108
+ tokens.push({ type: "lparen", value: "(" });
109
+ i++;
110
+ continue;
111
+ }
112
+ if (ch === ")") {
113
+ tokens.push({ type: "rparen", value: ")" });
114
+ i++;
115
+ continue;
116
+ }
117
+ // Unknown character
118
+ return null;
119
+ }
120
+ return tokens;
121
+ }
122
+ function peek(ctx) {
123
+ return ctx.pos < ctx.tokens.length ? ctx.tokens[ctx.pos] : null;
124
+ }
125
+ function consume(ctx) {
126
+ return ctx.pos < ctx.tokens.length ? ctx.tokens[ctx.pos++] : null;
127
+ }
128
+ // Evaluate aggregation function over repeater values
129
+ function evaluateAggregation(fn, uuid, resolver) {
130
+ const values = resolver.resolveRepeaterValues(uuid) ?? [];
131
+ if (values.length === 0) {
132
+ return fn === "AVG" ? null : 0;
133
+ }
134
+ switch (fn) {
135
+ case "SUM":
136
+ return values.reduce((a, b) => a + b, 0);
137
+ case "COUNT":
138
+ return values.length;
139
+ case "AVG":
140
+ return values.reduce((a, b) => a + b, 0) / values.length;
141
+ case "MIN":
142
+ return Math.min(...values);
143
+ case "MAX":
144
+ return Math.max(...values);
145
+ default:
146
+ return null;
147
+ }
148
+ }
149
+ // Parse comma-separated argument list: expr (',' expr)* ')'
150
+ // Assumes lparen already consumed; consumes the closing rparen.
151
+ function parseArgList(ctx) {
152
+ const args = [];
153
+ // First argument
154
+ const first = parseExpression(ctx);
155
+ if (first === null)
156
+ return null;
157
+ args.push(first);
158
+ while (true) {
159
+ const t = peek(ctx);
160
+ if (!t)
161
+ return null;
162
+ if (t.type === "rparen") {
163
+ consume(ctx);
164
+ return args;
165
+ }
166
+ if (t.type !== "comma")
167
+ return null;
168
+ consume(ctx); // consume comma
169
+ const arg = parseExpression(ctx);
170
+ if (arg === null)
171
+ return null;
172
+ args.push(arg);
173
+ }
174
+ }
175
+ // Expression = Comparison
176
+ function parseExpression(ctx) {
177
+ return parseComparison(ctx);
178
+ }
179
+ // Comparison = Additive (CompOp Additive)?
180
+ function parseComparison(ctx) {
181
+ let left = parseAdditive(ctx);
182
+ if (left === null)
183
+ return null;
184
+ const t = peek(ctx);
185
+ if (t && t.type === "comp") {
186
+ consume(ctx);
187
+ const right = parseAdditive(ctx);
188
+ if (right === null)
189
+ return null;
190
+ switch (t.value) {
191
+ case ">": return left > right ? 1 : 0;
192
+ case "<": return left < right ? 1 : 0;
193
+ case ">=": return left >= right ? 1 : 0;
194
+ case "<=": return left <= right ? 1 : 0;
195
+ case "==": return left === right ? 1 : 0;
196
+ case "!=": return left !== right ? 1 : 0;
197
+ default: return null;
198
+ }
199
+ }
200
+ return left;
201
+ }
202
+ // Additive = Term (('+' | '-') Term)*
203
+ function parseAdditive(ctx) {
204
+ let left = parseTerm(ctx);
205
+ if (left === null)
206
+ return null;
207
+ while (true) {
208
+ const t = peek(ctx);
209
+ if (!t || t.type !== "op" || (t.value !== "+" && t.value !== "-"))
210
+ break;
211
+ consume(ctx);
212
+ const right = parseTerm(ctx);
213
+ if (right === null)
214
+ return null;
215
+ left = t.value === "+" ? left + right : left - right;
216
+ }
217
+ return left;
218
+ }
219
+ // Term = Unary (('*' | '/') Unary)*
220
+ function parseTerm(ctx) {
221
+ let left = parseUnary(ctx);
222
+ if (left === null)
223
+ return null;
224
+ while (true) {
225
+ const t = peek(ctx);
226
+ if (!t || t.type !== "op" || (t.value !== "*" && t.value !== "/"))
227
+ break;
228
+ consume(ctx);
229
+ const right = parseUnary(ctx);
230
+ if (right === null)
231
+ return null;
232
+ if (t.value === "*") {
233
+ left = left * right;
234
+ }
235
+ else {
236
+ if (right === 0)
237
+ return null; // Division by zero → null
238
+ left = left / right;
239
+ }
240
+ }
241
+ return left;
242
+ }
243
+ // Unary = ('-')? Primary
244
+ function parseUnary(ctx) {
245
+ const t = peek(ctx);
246
+ if (t && t.type === "op" && t.value === "-") {
247
+ consume(ctx);
248
+ const val = parsePrimary(ctx);
249
+ return val === null ? null : -val;
250
+ }
251
+ return parsePrimary(ctx);
252
+ }
253
+ // Primary = Number | Ref | MetaRef | FnCall | '(' Expression ')'
254
+ function parsePrimary(ctx) {
255
+ const t = peek(ctx);
256
+ if (!t)
257
+ return null;
258
+ // Number literal
259
+ if (t.type === "number") {
260
+ consume(ctx);
261
+ return t.numValue;
262
+ }
263
+ // Field reference
264
+ if (t.type === "ref") {
265
+ consume(ctx);
266
+ return ctx.resolver.resolveValue(t.uuid) ?? 0;
267
+ }
268
+ // Metadata reference
269
+ if (t.type === "metaRef") {
270
+ consume(ctx);
271
+ return ctx.resolver.resolveOptionMetadata(t.uuid, t.metaKey) ?? 0;
272
+ }
273
+ // Function call
274
+ if (t.type === "fn") {
275
+ consume(ctx);
276
+ const lp = consume(ctx);
277
+ if (!lp || lp.type !== "lparen")
278
+ return null;
279
+ const fn = t.fn;
280
+ // Aggregation-only functions: SUM, COUNT, AVG — always single ref
281
+ if (fn === "SUM" || fn === "COUNT" || fn === "AVG") {
282
+ const arg = consume(ctx);
283
+ if (!arg || arg.type !== "ref")
284
+ return null;
285
+ const rp = consume(ctx);
286
+ if (!rp || rp.type !== "rparen")
287
+ return null;
288
+ return evaluateAggregation(fn, arg.uuid, ctx.resolver);
289
+ }
290
+ // MIN/MAX — detect mode: single ref aggregation vs multi-arg scalar
291
+ if (fn === "MIN" || fn === "MAX") {
292
+ // Peek: if next is a ref followed by rparen → aggregation mode
293
+ const next = peek(ctx);
294
+ if (next && next.type === "ref") {
295
+ const saved = ctx.pos;
296
+ consume(ctx); // consume the ref
297
+ const after = peek(ctx);
298
+ if (after && after.type === "rparen") {
299
+ consume(ctx); // consume rparen
300
+ return evaluateAggregation(fn, next.uuid, ctx.resolver);
301
+ }
302
+ // Not aggregation — backtrack and parse as multi-arg
303
+ ctx.pos = saved;
304
+ }
305
+ // Multi-arg scalar: MIN(expr, expr, ...) / MAX(expr, expr, ...)
306
+ const args = parseArgList(ctx);
307
+ if (args === null || args.length === 0)
308
+ return null;
309
+ return fn === "MIN" ? Math.min(...args) : Math.max(...args);
310
+ }
311
+ // IF(condition, thenExpr, elseExpr)
312
+ if (fn === "IF") {
313
+ const args = parseArgList(ctx);
314
+ if (args === null || args.length !== 3)
315
+ return null;
316
+ return args[0] !== 0 ? args[1] : args[2];
317
+ }
318
+ return null;
319
+ }
320
+ // Parenthesized expression
321
+ if (t.type === "lparen") {
322
+ consume(ctx);
323
+ const val = parseExpression(ctx);
324
+ const rp = consume(ctx);
325
+ if (!rp || rp.type !== "rparen")
326
+ return null;
327
+ return val;
328
+ }
329
+ return null;
330
+ }
331
+ /* ---------- Public API ---------- */
332
+ /**
333
+ * Evaluate a formula string using the provided resolver.
334
+ * Returns the numeric result, or null if the formula is invalid/empty,
335
+ * has a syntax error, or divides by zero. Unfilled field references
336
+ * default to 0 (spreadsheet-style).
337
+ */
338
+ export function evaluateFormula(formula, resolver) {
339
+ if (!formula || !formula.trim())
340
+ return null;
341
+ // Strip zero-width spaces that may leak from contentEditable chip editing
342
+ const clean = formula.replace(/\u200B/g, "");
343
+ if (!clean.trim())
344
+ return null;
345
+ const tokens = tokenize(clean);
346
+ if (!tokens || tokens.length === 0)
347
+ return null;
348
+ const ctx = { tokens, pos: 0, resolver };
349
+ const result = parseExpression(ctx);
350
+ // Ensure all tokens were consumed
351
+ if (ctx.pos !== ctx.tokens.length)
352
+ return null;
353
+ return result;
354
+ }
355
+ /**
356
+ * Extract all UUIDs referenced in a formula (both direct and inside aggregation functions).
357
+ * Used for building dependency indexes.
358
+ */
359
+ export function extractFormulaReferences(formula) {
360
+ if (!formula)
361
+ return [];
362
+ const uuids = [];
363
+ const refPattern = /\{([^}]+)\}/g;
364
+ let match;
365
+ while ((match = refPattern.exec(formula)) !== null) {
366
+ const uuid = match[1].trim();
367
+ if (uuid && !uuids.includes(uuid)) {
368
+ uuids.push(uuid);
369
+ }
370
+ }
371
+ return uuids;
372
+ }
373
+ /**
374
+ * Format a calculation result for display.
375
+ */
376
+ export function formatCalculationResult(value, format, decimalPlaces, currencySymbol) {
377
+ if (value === null)
378
+ return "";
379
+ const dp = decimalPlaces ?? 2;
380
+ const formatted = value.toFixed(dp);
381
+ switch (format) {
382
+ case "currency":
383
+ return `${currencySymbol ?? "$"}${formatted}`;
384
+ case "percentage":
385
+ return `${formatted}%`;
386
+ default:
387
+ return formatted;
388
+ }
389
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=formula.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formula.test.d.ts","sourceRoot":"","sources":["../src/formula.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,312 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { evaluateFormula, extractFormulaReferences, formatCalculationResult } from "./formula";
3
+ /* ---------- Test helpers ---------- */
4
+ function makeResolver(values = {}, repeaterValues = {}, metaValues = {}) {
5
+ return {
6
+ resolveValue: (uuid) => values[uuid] ?? null,
7
+ resolveOptionMetadata: (uuid, key) => metaValues[uuid]?.[key] ?? null,
8
+ resolveRepeaterValues: (uuid) => repeaterValues[uuid] ?? null,
9
+ };
10
+ }
11
+ const empty = makeResolver();
12
+ /* ==========================================================================
13
+ Tokenizer / basic parsing
14
+ ========================================================================== */
15
+ describe("basic expressions", () => {
16
+ it("evaluates number literals", () => {
17
+ expect(evaluateFormula("42", empty)).toBe(42);
18
+ expect(evaluateFormula("3.14", empty)).toBe(3.14);
19
+ });
20
+ it("evaluates addition and subtraction", () => {
21
+ expect(evaluateFormula("1 + 2", empty)).toBe(3);
22
+ expect(evaluateFormula("10 - 3", empty)).toBe(7);
23
+ expect(evaluateFormula("1 + 2 + 3", empty)).toBe(6);
24
+ });
25
+ it("evaluates multiplication and division", () => {
26
+ expect(evaluateFormula("2 * 3", empty)).toBe(6);
27
+ expect(evaluateFormula("10 / 4", empty)).toBe(2.5);
28
+ });
29
+ it("respects operator precedence", () => {
30
+ expect(evaluateFormula("1 + 2 * 3", empty)).toBe(7);
31
+ expect(evaluateFormula("10 - 4 / 2", empty)).toBe(8);
32
+ });
33
+ it("respects parentheses", () => {
34
+ expect(evaluateFormula("(1 + 2) * 3", empty)).toBe(9);
35
+ expect(evaluateFormula("(10 - 4) / 2", empty)).toBe(3);
36
+ });
37
+ it("handles unary negation", () => {
38
+ expect(evaluateFormula("-5", empty)).toBe(-5);
39
+ expect(evaluateFormula("-5 + 3", empty)).toBe(-2);
40
+ expect(evaluateFormula("-(5 + 3)", empty)).toBe(-8);
41
+ });
42
+ it("returns null for division by zero", () => {
43
+ expect(evaluateFormula("1 / 0", empty)).toBe(null);
44
+ });
45
+ it("returns null for empty/whitespace", () => {
46
+ expect(evaluateFormula("", empty)).toBe(null);
47
+ expect(evaluateFormula(" ", empty)).toBe(null);
48
+ });
49
+ it("returns null for invalid syntax", () => {
50
+ expect(evaluateFormula("1 +", empty)).toBe(null);
51
+ expect(evaluateFormula("+ 1", empty)).toBe(null);
52
+ expect(evaluateFormula("1 2", empty)).toBe(null);
53
+ expect(evaluateFormula("(1 + 2", empty)).toBe(null);
54
+ });
55
+ });
56
+ /* ==========================================================================
57
+ Field references
58
+ ========================================================================== */
59
+ describe("field references", () => {
60
+ it("resolves a field value", () => {
61
+ const r = makeResolver({ "abc-123": 10 });
62
+ expect(evaluateFormula("{abc-123}", r)).toBe(10);
63
+ });
64
+ it("defaults null references to 0", () => {
65
+ expect(evaluateFormula("{missing}", empty)).toBe(0);
66
+ });
67
+ it("uses refs in arithmetic", () => {
68
+ const r = makeResolver({ a: 5, b: 3 });
69
+ expect(evaluateFormula("{a} + {b}", r)).toBe(8);
70
+ expect(evaluateFormula("{a} * {b}", r)).toBe(15);
71
+ });
72
+ });
73
+ /* ==========================================================================
74
+ Aggregation functions: SUM, COUNT, AVG
75
+ ========================================================================== */
76
+ describe("SUM / COUNT / AVG", () => {
77
+ const r = makeResolver({}, { items: [10, 20, 30] });
78
+ it("SUM", () => {
79
+ expect(evaluateFormula("SUM({items})", r)).toBe(60);
80
+ });
81
+ it("COUNT", () => {
82
+ expect(evaluateFormula("COUNT({items})", r)).toBe(3);
83
+ });
84
+ it("AVG", () => {
85
+ expect(evaluateFormula("AVG({items})", r)).toBe(20);
86
+ });
87
+ it("SUM of empty repeater returns 0", () => {
88
+ const r2 = makeResolver({}, { items: [] });
89
+ expect(evaluateFormula("SUM({items})", r2)).toBe(0);
90
+ });
91
+ it("AVG of empty repeater returns null", () => {
92
+ const r2 = makeResolver({}, { items: [] });
93
+ expect(evaluateFormula("AVG({items})", r2)).toBe(null);
94
+ });
95
+ it("COUNT of empty repeater returns 0", () => {
96
+ const r2 = makeResolver({}, { items: [] });
97
+ expect(evaluateFormula("COUNT({items})", r2)).toBe(0);
98
+ });
99
+ it("SUM in expression", () => {
100
+ expect(evaluateFormula("SUM({items}) * 2", r)).toBe(120);
101
+ });
102
+ });
103
+ /* ==========================================================================
104
+ MIN / MAX — aggregation mode
105
+ ========================================================================== */
106
+ describe("MIN / MAX aggregation", () => {
107
+ const r = makeResolver({}, { prices: [50, 10, 30, 80] });
108
+ it("MIN({ref}) returns smallest repeater value", () => {
109
+ expect(evaluateFormula("MIN({prices})", r)).toBe(10);
110
+ });
111
+ it("MAX({ref}) returns largest repeater value", () => {
112
+ expect(evaluateFormula("MAX({prices})", r)).toBe(80);
113
+ });
114
+ it("MIN of empty repeater returns 0", () => {
115
+ const r2 = makeResolver({}, { prices: [] });
116
+ expect(evaluateFormula("MIN({prices})", r2)).toBe(0);
117
+ });
118
+ it("MAX of empty repeater returns 0", () => {
119
+ const r2 = makeResolver({}, { prices: [] });
120
+ expect(evaluateFormula("MAX({prices})", r2)).toBe(0);
121
+ });
122
+ it("MIN in expression", () => {
123
+ expect(evaluateFormula("MIN({prices}) + 5", r)).toBe(15);
124
+ });
125
+ });
126
+ /* ==========================================================================
127
+ MIN / MAX — scalar (multi-arg) mode
128
+ ========================================================================== */
129
+ describe("MIN / MAX scalar", () => {
130
+ it("MIN(a, b) returns smaller", () => {
131
+ const r = makeResolver({ a: 10, b: 25 });
132
+ expect(evaluateFormula("MIN({a}, {b})", r)).toBe(10);
133
+ });
134
+ it("MAX(a, b) returns larger", () => {
135
+ const r = makeResolver({ a: 10, b: 25 });
136
+ expect(evaluateFormula("MAX({a}, {b})", r)).toBe(25);
137
+ });
138
+ it("MIN with 3 args", () => {
139
+ const r = makeResolver({ a: 5, b: 3, c: 9 });
140
+ expect(evaluateFormula("MIN({a}, {b}, {c})", r)).toBe(3);
141
+ });
142
+ it("MAX with literals", () => {
143
+ expect(evaluateFormula("MAX(10, 20, 5)", empty)).toBe(20);
144
+ });
145
+ it("MIN with expressions", () => {
146
+ const r = makeResolver({ x: 15 });
147
+ expect(evaluateFormula("MIN({x}, 25)", r)).toBe(15);
148
+ expect(evaluateFormula("MIN({x}, 10)", r)).toBe(10);
149
+ });
150
+ it("MAX for discount capping: MIN({discount}, 25)", () => {
151
+ const r = makeResolver({ discount: 30 });
152
+ expect(evaluateFormula("MIN({discount}, 25)", r)).toBe(25);
153
+ });
154
+ it("MAX for minimum charge: MAX({total}, 500)", () => {
155
+ const r = makeResolver({ total: 300 });
156
+ expect(evaluateFormula("MAX({total}, 500)", r)).toBe(500);
157
+ const r2 = makeResolver({ total: 700 });
158
+ expect(evaluateFormula("MAX({total}, 500)", r2)).toBe(700);
159
+ });
160
+ });
161
+ /* ==========================================================================
162
+ Comparison operators
163
+ ========================================================================== */
164
+ describe("comparison operators", () => {
165
+ it("greater than", () => {
166
+ expect(evaluateFormula("5 > 3", empty)).toBe(1);
167
+ expect(evaluateFormula("3 > 5", empty)).toBe(0);
168
+ expect(evaluateFormula("3 > 3", empty)).toBe(0);
169
+ });
170
+ it("less than", () => {
171
+ expect(evaluateFormula("3 < 5", empty)).toBe(1);
172
+ expect(evaluateFormula("5 < 3", empty)).toBe(0);
173
+ });
174
+ it("greater than or equal", () => {
175
+ expect(evaluateFormula("5 >= 5", empty)).toBe(1);
176
+ expect(evaluateFormula("6 >= 5", empty)).toBe(1);
177
+ expect(evaluateFormula("4 >= 5", empty)).toBe(0);
178
+ });
179
+ it("less than or equal", () => {
180
+ expect(evaluateFormula("5 <= 5", empty)).toBe(1);
181
+ expect(evaluateFormula("4 <= 5", empty)).toBe(1);
182
+ expect(evaluateFormula("6 <= 5", empty)).toBe(0);
183
+ });
184
+ it("equal", () => {
185
+ expect(evaluateFormula("5 == 5", empty)).toBe(1);
186
+ expect(evaluateFormula("5 == 6", empty)).toBe(0);
187
+ });
188
+ it("not equal", () => {
189
+ expect(evaluateFormula("5 != 6", empty)).toBe(1);
190
+ expect(evaluateFormula("5 != 5", empty)).toBe(0);
191
+ });
192
+ it("comparison with field refs", () => {
193
+ const r = makeResolver({ qty: 15 });
194
+ expect(evaluateFormula("{qty} > 10", r)).toBe(1);
195
+ expect(evaluateFormula("{qty} > 20", r)).toBe(0);
196
+ });
197
+ it("comparison with arithmetic", () => {
198
+ expect(evaluateFormula("2 + 3 > 4", empty)).toBe(1);
199
+ expect(evaluateFormula("2 * 3 <= 5", empty)).toBe(0);
200
+ });
201
+ it("rejects lone = or !", () => {
202
+ expect(evaluateFormula("5 = 5", empty)).toBe(null);
203
+ expect(evaluateFormula("!5", empty)).toBe(null);
204
+ });
205
+ });
206
+ /* ==========================================================================
207
+ IF function
208
+ ========================================================================== */
209
+ describe("IF function", () => {
210
+ it("basic IF true branch", () => {
211
+ expect(evaluateFormula("IF(1, 10, 20)", empty)).toBe(10);
212
+ });
213
+ it("basic IF false branch", () => {
214
+ expect(evaluateFormula("IF(0, 10, 20)", empty)).toBe(20);
215
+ });
216
+ it("IF with comparison condition", () => {
217
+ const r = makeResolver({ qty: 15 });
218
+ expect(evaluateFormula("IF({qty} > 10, {qty} * 0.9, {qty})", r)).toBe(13.5);
219
+ });
220
+ it("IF with comparison condition (false)", () => {
221
+ const r = makeResolver({ qty: 5 });
222
+ expect(evaluateFormula("IF({qty} > 10, {qty} * 0.9, {qty})", r)).toBe(5);
223
+ });
224
+ it("nested IF", () => {
225
+ const r = makeResolver({ qty: 25 });
226
+ // IF(qty > 20, price * 0.8, IF(qty > 10, price * 0.9, price))
227
+ expect(evaluateFormula("IF({qty} > 20, 100 * 0.8, IF({qty} > 10, 100 * 0.9, 100))", r)).toBe(80);
228
+ });
229
+ it("nested IF middle tier", () => {
230
+ const r = makeResolver({ qty: 15 });
231
+ expect(evaluateFormula("IF({qty} > 20, 100 * 0.8, IF({qty} > 10, 100 * 0.9, 100))", r)).toBe(90);
232
+ });
233
+ it("nested IF lowest tier", () => {
234
+ const r = makeResolver({ qty: 5 });
235
+ expect(evaluateFormula("IF({qty} > 20, 100 * 0.8, IF({qty} > 10, 100 * 0.9, 100))", r)).toBe(100);
236
+ });
237
+ it("IF with wrong arity returns null", () => {
238
+ expect(evaluateFormula("IF(1, 2)", empty)).toBe(null);
239
+ expect(evaluateFormula("IF(1, 2, 3, 4)", empty)).toBe(null);
240
+ });
241
+ it("IF evaluates both branches (not lazy)", () => {
242
+ // This should not error even though the else branch divides by zero
243
+ // Because IF is not lazy - both evaluate, and div-by-zero returns null
244
+ // which makes the whole IF null
245
+ expect(evaluateFormula("IF(1, 10, 1 / 0)", empty)).toBe(null);
246
+ });
247
+ it("tiered pricing formula", () => {
248
+ const r = makeResolver({ qty: 12, price: 50 });
249
+ // IF(qty > 10, price * 0.9, price)
250
+ expect(evaluateFormula("IF({qty} > 10, {price} * 0.9, {price})", r)).toBe(45);
251
+ });
252
+ });
253
+ /* ==========================================================================
254
+ Combined scenarios
255
+ ========================================================================== */
256
+ describe("combined scenarios", () => {
257
+ it("MIN/MAX with IF", () => {
258
+ const r = makeResolver({ discount: 30 });
259
+ // Clamp discount: MIN(MAX({discount}, 0), 25) → clamp 0..25
260
+ expect(evaluateFormula("MIN(MAX({discount}, 0), 25)", r)).toBe(25);
261
+ });
262
+ it("IF with aggregation", () => {
263
+ const r = makeResolver({}, { items: [10, 20, 30] });
264
+ // IF(COUNT({items}) > 2, SUM({items}), 0)
265
+ // But COUNT and SUM are aggregation-only, so we need to test differently:
266
+ // The IF args would be separate expressions
267
+ // Actually this won't work directly since IF needs comma-separated expressions
268
+ // and SUM({items}) followed by comma would be parsed correctly
269
+ expect(evaluateFormula("SUM({items}) + MAX({items})", r)).toBe(90);
270
+ });
271
+ it("metadata reference with arithmetic", () => {
272
+ const r = makeResolver({}, {}, { sel: { price: 25 } });
273
+ expect(evaluateFormula("{sel}.selectedOption.metadata.price * 2", r)).toBe(50);
274
+ });
275
+ });
276
+ /* ==========================================================================
277
+ extractFormulaReferences
278
+ ========================================================================== */
279
+ describe("extractFormulaReferences", () => {
280
+ it("extracts single ref", () => {
281
+ expect(extractFormulaReferences("{abc}")).toEqual(["abc"]);
282
+ });
283
+ it("extracts multiple refs", () => {
284
+ expect(extractFormulaReferences("{a} + {b}")).toEqual(["a", "b"]);
285
+ });
286
+ it("extracts refs from functions", () => {
287
+ expect(extractFormulaReferences("SUM({items})")).toEqual(["items"]);
288
+ });
289
+ it("deduplicates", () => {
290
+ expect(extractFormulaReferences("{a} + {a}")).toEqual(["a"]);
291
+ });
292
+ it("returns empty for no formula", () => {
293
+ expect(extractFormulaReferences("")).toEqual([]);
294
+ });
295
+ });
296
+ /* ==========================================================================
297
+ formatCalculationResult
298
+ ========================================================================== */
299
+ describe("formatCalculationResult", () => {
300
+ it("formats currency", () => {
301
+ expect(formatCalculationResult(1234.5, "currency", 2, "$")).toBe("$1234.50");
302
+ });
303
+ it("formats percentage", () => {
304
+ expect(formatCalculationResult(75.123, "percentage", 1)).toBe("75.1%");
305
+ });
306
+ it("formats number (default)", () => {
307
+ expect(formatCalculationResult(42, undefined, 0)).toBe("42");
308
+ });
309
+ it("returns empty for null", () => {
310
+ expect(formatCalculationResult(null)).toBe("");
311
+ });
312
+ });
@@ -0,0 +1,5 @@
1
+ export { operators, evaluateOperator, evaluateConditionConfig, toStr, toNum, toArray, ruleToArray, } from "./conditions";
2
+ export { isHiddenByCondition } from "./condition-visibility";
3
+ export { validateValue, getDefaultMessage, formatBytes, isEmpty, } from "./validation";
4
+ export { evaluateFormula, extractFormulaReferences, formatCalculationResult, type FormulaResolver, } from "./formula";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,EACL,KAAK,EACL,OAAO,EACP,WAAW,GACZ,MAAM,cAAc,CAAC;AAEtB,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,WAAW,EACX,OAAO,GACR,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,uBAAuB,EACvB,KAAK,eAAe,GACrB,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @sonata-innovations/fiber-shared — condition engine, validation engine, visibility helpers, formula engine
2
+ export { operators, evaluateOperator, evaluateConditionConfig, toStr, toNum, toArray, ruleToArray, } from "./conditions";
3
+ export { isHiddenByCondition } from "./condition-visibility";
4
+ export { validateValue, getDefaultMessage, formatBytes, isEmpty, } from "./validation";
5
+ export { evaluateFormula, extractFormulaReferences, formatCalculationResult, } from "./formula";
@@ -0,0 +1,14 @@
1
+ import type { ValidationRule, FlowValidationConfig } from "@sonata-innovations/fiber-types";
2
+ export declare function formatBytes(bytes: number): string;
3
+ export declare const getDefaultMessage: (rule: ValidationRule) => string;
4
+ export declare const isEmpty: (value: any) => boolean;
5
+ /**
6
+ * Validate a single value against a validation config.
7
+ * Returns an array of error messages (empty = valid).
8
+ *
9
+ * @param value The component's current value
10
+ * @param config The validation config (rules array)
11
+ * @param resolveFieldValue Optional callback to resolve another field's value (for matchesField)
12
+ */
13
+ export declare const validateValue: (value: any, config: FlowValidationConfig, resolveFieldValue?: (fieldUUID: string) => any) => string[];
14
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,cAAc,EACd,oBAAoB,EACrB,MAAM,iCAAiC,CAAC;AA+BzC,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjD;AAED,eAAO,MAAM,iBAAiB,GAAI,MAAM,cAAc,KAAG,MAkCxD,CAAC;AAIF,eAAO,MAAM,OAAO,GAAI,OAAO,GAAG,KAAG,OAKpC,CAAC;AAiIF;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,GAAG,EACV,QAAQ,oBAAoB,EAC5B,oBAAoB,CAAC,SAAS,EAAE,MAAM,KAAK,GAAG,KAC7C,MAAM,EA+BR,CAAC"}
@@ -0,0 +1,232 @@
1
+ /* ==========================================================================
2
+ Validation engine — shared between FBRE and server
3
+ ========================================================================== */
4
+ /* ---------- Default messages ---------- */
5
+ const DEFAULT_MESSAGES = {
6
+ "validation.required": "This field is required",
7
+ "validation.email": "Please enter a valid email address",
8
+ "validation.phone": "Please enter a valid phone number",
9
+ "validation.url": "Please enter a valid URL",
10
+ "validation.minLength": (min) => `Must be at least ${min} characters`,
11
+ "validation.maxLength": (max) => `Must be no more than ${max} characters`,
12
+ "validation.exactLength": (len) => `Must be exactly ${len} characters`,
13
+ "validation.minValue": (min) => `Must be at least ${min}`,
14
+ "validation.maxValue": (max) => `Must be no more than ${max}`,
15
+ "validation.pattern": "Value does not match the required format",
16
+ "validation.minSelected": (min) => `Select at least ${min} option${min !== 1 ? "s" : ""}`,
17
+ "validation.maxSelected": (max) => `Select no more than ${max} option${max !== 1 ? "s" : ""}`,
18
+ "validation.fileType": "File type is not allowed",
19
+ "validation.fileSize": (max) => `File must be smaller than ${formatBytes(max)}`,
20
+ "validation.contains": (text) => `Must contain "${text}"`,
21
+ "validation.excludes": (text) => `Must not contain "${text}"`,
22
+ "validation.matchesField": "Fields must match",
23
+ };
24
+ export function formatBytes(bytes) {
25
+ if (bytes < 1024)
26
+ return `${bytes} B`;
27
+ if (bytes < 1024 * 1024)
28
+ return `${(bytes / 1024).toFixed(1)} KB`;
29
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
30
+ }
31
+ export const getDefaultMessage = (rule) => {
32
+ const key = `validation.${rule.type}`;
33
+ const template = DEFAULT_MESSAGES[key];
34
+ if (!template)
35
+ return "Invalid value";
36
+ if (typeof template === "function") {
37
+ const p = rule.params ?? {};
38
+ switch (rule.type) {
39
+ case "minLength":
40
+ return template(p.min ?? 0);
41
+ case "maxLength":
42
+ return template(p.max ?? 0);
43
+ case "exactLength":
44
+ return template(p.length ?? 0);
45
+ case "minValue":
46
+ return template(p.min ?? 0);
47
+ case "maxValue":
48
+ return template(p.max ?? 0);
49
+ case "minSelected":
50
+ return template(p.min ?? 0);
51
+ case "maxSelected":
52
+ return template(p.max ?? 0);
53
+ case "fileSize":
54
+ return template(p.max ?? 0);
55
+ case "contains":
56
+ return template(p.text ?? "");
57
+ case "excludes":
58
+ return template(p.text ?? "");
59
+ default:
60
+ return template();
61
+ }
62
+ }
63
+ return template;
64
+ };
65
+ /* ---------- Emptiness check ---------- */
66
+ export const isEmpty = (value) => {
67
+ if (value === null || value === undefined)
68
+ return true;
69
+ if (typeof value === "string" && value.length === 0)
70
+ return true;
71
+ if (Array.isArray(value) && value.length === 0)
72
+ return true;
73
+ return false;
74
+ };
75
+ /* ---------- Email / phone / URL patterns ---------- */
76
+ // RFC 5322-ish: local@domain.tld — covers 99%+ of real addresses
77
+ const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
78
+ // International phone: optional +, digits/spaces/dashes/parens/dots, 7-15 digit core
79
+ const PHONE_RE = /^\+?[\d]{1,4}?[\s.\-()]?(?:[\d][\s.\-()]?){6,14}[\d]$/;
80
+ // URL: http(s) with domain, optional port/path/query/fragment
81
+ const URL_RE = /^https?:\/\/(?:[\w-]+\.)+[a-zA-Z]{2,}(?::\d{1,5})?(?:[/?#]\S*)?$/i;
82
+ const VALIDATORS = {
83
+ required: (value) => !isEmpty(value),
84
+ email: (value) => {
85
+ if (isEmpty(value))
86
+ return true;
87
+ return EMAIL_RE.test(String(value));
88
+ },
89
+ phone: (value) => {
90
+ if (isEmpty(value))
91
+ return true;
92
+ return PHONE_RE.test(String(value));
93
+ },
94
+ url: (value) => {
95
+ if (isEmpty(value))
96
+ return true;
97
+ return URL_RE.test(String(value));
98
+ },
99
+ minLength: (value, params) => {
100
+ if (isEmpty(value))
101
+ return true;
102
+ return String(value).length >= (params.min ?? 0);
103
+ },
104
+ maxLength: (value, params) => {
105
+ if (isEmpty(value))
106
+ return true;
107
+ return String(value).length <= (params.max ?? Infinity);
108
+ },
109
+ exactLength: (value, params) => {
110
+ if (isEmpty(value))
111
+ return true;
112
+ return String(value).length === (params.length ?? 0);
113
+ },
114
+ minValue: (value, params) => {
115
+ if (isEmpty(value))
116
+ return true;
117
+ return Number(value) >= (params.min ?? -Infinity);
118
+ },
119
+ maxValue: (value, params) => {
120
+ if (isEmpty(value))
121
+ return true;
122
+ return Number(value) <= (params.max ?? Infinity);
123
+ },
124
+ pattern: (value, params) => {
125
+ if (isEmpty(value))
126
+ return true;
127
+ try {
128
+ return new RegExp(params.regex ?? "").test(String(value));
129
+ }
130
+ catch {
131
+ // Invalid regex — fail-safe (reject value so misconfiguration is visible)
132
+ return false;
133
+ }
134
+ },
135
+ minSelected: (value, params) => {
136
+ if (isEmpty(value))
137
+ return true;
138
+ if (!Array.isArray(value))
139
+ return true;
140
+ return value.length >= (params.min ?? 0);
141
+ },
142
+ maxSelected: (value, params) => {
143
+ if (isEmpty(value))
144
+ return true;
145
+ if (!Array.isArray(value))
146
+ return true;
147
+ return value.length <= (params.max ?? Infinity);
148
+ },
149
+ fileType: (value, params) => {
150
+ if (isEmpty(value))
151
+ return true;
152
+ const allowed = params.types;
153
+ if (!allowed || allowed.length === 0)
154
+ return true;
155
+ const fileName = value?.name ?? "";
156
+ const fileType = value?.type ?? "";
157
+ return allowed.some((t) => {
158
+ const lower = t.toLowerCase().trim();
159
+ if (lower.startsWith(".")) {
160
+ return fileName.toLowerCase().endsWith(lower);
161
+ }
162
+ return fileType.toLowerCase() === lower;
163
+ });
164
+ },
165
+ fileSize: (value, params) => {
166
+ if (isEmpty(value))
167
+ return true;
168
+ const maxSize = params.max ?? Infinity;
169
+ return (value?.size ?? 0) <= maxSize;
170
+ },
171
+ contains: (value, params) => {
172
+ if (isEmpty(value))
173
+ return true;
174
+ const text = params.text ?? "";
175
+ if (!text)
176
+ return true;
177
+ return String(value).includes(text);
178
+ },
179
+ excludes: (value, params) => {
180
+ if (isEmpty(value))
181
+ return true;
182
+ const text = params.text ?? "";
183
+ if (!text)
184
+ return true;
185
+ return !String(value).includes(text);
186
+ },
187
+ // matchesField is handled externally via resolveFieldValue callback
188
+ matchesField: (value, params) => {
189
+ // This validator requires external resolution; always passes here.
190
+ // Use validateValue() with resolveFieldValue callback for matchesField support.
191
+ return true;
192
+ },
193
+ };
194
+ /* ---------- Public API ---------- */
195
+ /**
196
+ * Validate a single value against a validation config.
197
+ * Returns an array of error messages (empty = valid).
198
+ *
199
+ * @param value The component's current value
200
+ * @param config The validation config (rules array)
201
+ * @param resolveFieldValue Optional callback to resolve another field's value (for matchesField)
202
+ */
203
+ export const validateValue = (value, config, resolveFieldValue) => {
204
+ if (!config || !config.rules || config.rules.length === 0)
205
+ return [];
206
+ const errors = [];
207
+ for (const rule of config.rules) {
208
+ if (rule.type === "matchesField") {
209
+ // Special handling: needs external field resolution
210
+ if (isEmpty(value))
211
+ continue;
212
+ const fieldUUID = rule.params?.field ?? "";
213
+ if (!fieldUUID)
214
+ continue;
215
+ if (resolveFieldValue) {
216
+ const otherValue = resolveFieldValue(fieldUUID);
217
+ if (String(value) !== String(otherValue ?? "")) {
218
+ errors.push(rule.message ?? getDefaultMessage(rule));
219
+ }
220
+ }
221
+ continue;
222
+ }
223
+ const validator = VALIDATORS[rule.type];
224
+ if (!validator)
225
+ continue;
226
+ const passed = validator(value, rule.params ?? {});
227
+ if (!passed) {
228
+ errors.push(rule.message ?? getDefaultMessage(rule));
229
+ }
230
+ }
231
+ return errors;
232
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@sonata-innovations/fiber-shared",
3
+ "version": "1.0.0",
4
+ "description": "Shared condition and validation engines for the Fiber form builder system",
5
+ "keywords": ["fiber", "form-builder", "validation", "conditions", "shared"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sonata-innovations/fiber.git",
10
+ "directory": "shared"
11
+ },
12
+ "bugs": { "url": "https://github.com/sonata-innovations/fiber/issues" },
13
+ "homepage": "https://github.com/sonata-innovations/fiber",
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "sideEffects": false,
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@sonata-innovations/fiber-types": "^1.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.7.0",
40
+ "vitest": "^4.0.18"
41
+ }
42
+ }