@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.
- package/dist/condition-visibility.d.ts +10 -0
- package/dist/condition-visibility.d.ts.map +1 -0
- package/dist/condition-visibility.js +20 -0
- package/dist/conditions.d.ts +26 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +93 -0
- package/dist/formula.d.ts +25 -0
- package/dist/formula.d.ts.map +1 -0
- package/dist/formula.js +389 -0
- package/dist/formula.test.d.ts +2 -0
- package/dist/formula.test.d.ts.map +1 -0
- package/dist/formula.test.js +312 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/validation.d.ts +14 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +232 -0
- package/package.json +42 -0
|
@@ -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"}
|
package/dist/formula.js
ADDED
|
@@ -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 @@
|
|
|
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|