form-builder-pro 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4099,7 +4099,8 @@ var FIELD_TYPES = [
4099
4099
  { type: "repeater", label: "Repeater", icon: "Copy" },
4100
4100
  { type: "file", label: "File Upload", icon: "Upload" },
4101
4101
  { type: "image", label: "Image", icon: "Image" },
4102
- { type: "name_generator", label: "Name Generator", icon: "Hash" }
4102
+ { type: "name_generator", label: "Name Generator", icon: "Hash" },
4103
+ { type: "formula", label: "Formula", icon: "Calculator" }
4103
4104
  ];
4104
4105
  var DEFAULT_FIELD_CONFIG = {
4105
4106
  text: { label: "Text Input", placeholder: "Enter text...", width: "100%", enabled: true, visible: true },
@@ -4156,6 +4157,19 @@ var DEFAULT_FIELD_CONFIG = {
4156
4157
  nameGeneratorFormat: "TEXT_ID",
4157
4158
  nameGeneratorText: "",
4158
4159
  nameGeneratorIdPadding: 4
4160
+ },
4161
+ formula: {
4162
+ label: "Formula Field",
4163
+ placeholder: "\u2014",
4164
+ width: "50%",
4165
+ enabled: true,
4166
+ visible: true,
4167
+ formulaConfig: {
4168
+ mode: "single",
4169
+ single: { expression: "" },
4170
+ multiple: { compareField: "", conditions: [], fallbackExpression: "" },
4171
+ decimalPlaces: 2
4172
+ }
4159
4173
  }
4160
4174
  };
4161
4175
  var VALIDATION_TYPE_PRESETS = {
@@ -4505,6 +4519,8 @@ function normalizeFieldType(type) {
4505
4519
  return "datetime";
4506
4520
  if (str === "NAME_GENERATOR" || normalized === "namegenerator")
4507
4521
  return "name_generator";
4522
+ if (str === "FORMULA" || normalized === "formula")
4523
+ return "formula";
4508
4524
  return str.toLowerCase();
4509
4525
  }
4510
4526
  function transformField(field) {
@@ -4733,6 +4749,31 @@ function transformField(field) {
4733
4749
  transformed.repeatIncrementEnabled = field.repeatIncrementEnabled;
4734
4750
  if (field.dateConstraints !== void 0)
4735
4751
  transformed.dateConstraints = field.dateConstraints;
4752
+ if (field.formulaConfig !== void 0) {
4753
+ transformed.formulaConfig = field.formulaConfig;
4754
+ } else if (field.type === "formula" && field.formulaType) {
4755
+ const dp = field.floatingPrecision ?? 2;
4756
+ if (field.formulaType === "SIMPLE") {
4757
+ transformed.formulaConfig = {
4758
+ mode: "single",
4759
+ single: { expression: field.formulaExpression ?? "" },
4760
+ multiple: { compareField: "", conditions: [], fallbackExpression: "" },
4761
+ decimalPlaces: dp
4762
+ };
4763
+ } else if (field.formulaType === "SWITCH") {
4764
+ const cases = Array.isArray(field.switchCases) ? field.switchCases.map((c) => ({ value: c.matchValue ?? c.value ?? "", expression: c.formulaExpression ?? c.expression ?? "" })) : [];
4765
+ transformed.formulaConfig = {
4766
+ mode: "multiple",
4767
+ single: { expression: "" },
4768
+ multiple: {
4769
+ compareField: field.switchField ?? "",
4770
+ conditions: cases,
4771
+ fallbackExpression: field.switchDefaultFormula ?? ""
4772
+ },
4773
+ decimalPlaces: dp
4774
+ };
4775
+ }
4776
+ }
4736
4777
  if (field.nameGeneratorFormat !== void 0)
4737
4778
  transformed.nameGeneratorFormat = field.nameGeneratorFormat;
4738
4779
  if (field.nameGeneratorText !== void 0)
@@ -5047,6 +5088,36 @@ function fieldToPayload(field, opts) {
5047
5088
  payload.showWhenValueOffFields = field.showWhenValueOffFields;
5048
5089
  }
5049
5090
  }
5091
+ if (field.type === "formula") {
5092
+ payload.fieldType = "FORMULA";
5093
+ payload.type = "formula";
5094
+ payload.readOnly = true;
5095
+ payload.floatingPrecision = field.formulaConfig?.decimalPlaces ?? 2;
5096
+ if (field.formulaConfig?.mode === "single") {
5097
+ payload.formulaType = "SIMPLE";
5098
+ payload.formulaExpression = field.formulaConfig.single?.expression ?? "";
5099
+ payload.switchField = null;
5100
+ payload.switchCases = null;
5101
+ payload.switchDefaultFormula = null;
5102
+ } else if (field.formulaConfig?.mode === "multiple") {
5103
+ const multi = field.formulaConfig.multiple;
5104
+ payload.formulaType = "SWITCH";
5105
+ payload.formulaExpression = "";
5106
+ payload.switchField = multi?.compareField ?? null;
5107
+ payload.switchCases = (multi?.conditions ?? []).map((c) => ({
5108
+ matchValue: c.value,
5109
+ formulaExpression: c.expression ?? ""
5110
+ }));
5111
+ payload.switchDefaultFormula = multi?.fallbackExpression ?? "";
5112
+ payload.switchCases = payload.switchCases;
5113
+ } else {
5114
+ payload.formulaType = "SIMPLE";
5115
+ payload.formulaExpression = "";
5116
+ payload.switchField = null;
5117
+ payload.switchCases = null;
5118
+ payload.switchDefaultFormula = null;
5119
+ }
5120
+ }
5050
5121
  if (field.type === "repeater") {
5051
5122
  payload.fieldType = "REPEATER";
5052
5123
  payload.type = "number";
@@ -5994,7 +6065,8 @@ var ICONS = {
5994
6065
  "MapPin": '<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />',
5995
6066
  "Briefcase": '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.67.38-1.053.33-2.177.58-3.345.726-.26.032-.52.05-.778.066m-13.882-6.626h.008v.008h-.008v-.008zm1.096-3.837a48.116 48.116 0 00-3.413.387c-1.069.16-1.837 1.094-1.837 2.175v4.784c0 .493.196.958.536 1.344m0 0a17.8 17.8 0 013.344.726c.25.085.476.215.67.38m13.784-5.32c-.34-.386-.536-.851-.536-1.344v-4.784c0-1.081-.768-2.015-1.837-2.175a48.041 48.041 0 01-3.413-.387m-4.5 8.006c.194.165.42.295.67.38 1.053.33 2.177.58 3.345.726.26.032.52.05.778.066m0-7.384V5.626a2.25 2.25 0 00-2.25-2.25h-4.5a2.25 2.25 0 00-2.25 2.25v2.247M16.5 6h-9" />',
5996
6067
  "Eye": '<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />',
5997
- "Cog": '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />'
6068
+ "Cog": '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />',
6069
+ "Calculator": '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 15.75V18m-7.5-6.75h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V13.5zm0 2.25h.008v.008H8.25v-.008zm0 2.25h.008v.008H8.25V18zm2.498-6.75h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V13.5zm0 2.25h.007v.008h-.007v-.008zm0 2.25h.007v.008h-.007V18zm2.504-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zm0 2.25h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V18zm2.498-6.75h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V13.5zM8.25 6h7.5v2.25h-7.5V6zM12 2.25c-1.892 0-3.758.11-5.593.322C5.307 2.7 4.5 3.65 4.5 4.757V19.5a2.25 2.25 0 002.25 2.25h10.5a2.25 2.25 0 002.25-2.25V4.757c0-1.108-.806-2.057-1.907-2.185A48.507 48.507 0 0012 2.25z" />'
5998
6070
  };
5999
6071
  function getIcon(name, size = 20) {
6000
6072
  const svgString = ICONS[name] || `<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />`;
@@ -6228,6 +6300,311 @@ function getNumericFieldsForFormula(schema, excludeFieldId) {
6228
6300
  }
6229
6301
  return result;
6230
6302
  }
6303
+ var _tokenCache = /* @__PURE__ */ new Map();
6304
+ function _tokenize(expr) {
6305
+ if (_tokenCache.has(expr))
6306
+ return _tokenCache.get(expr);
6307
+ const tokens = [];
6308
+ let i = 0;
6309
+ while (i < expr.length) {
6310
+ const c = expr[i];
6311
+ if (/\s/.test(c)) {
6312
+ i++;
6313
+ continue;
6314
+ }
6315
+ if (/[+\-*/(),]/.test(c)) {
6316
+ tokens.push(c);
6317
+ i++;
6318
+ continue;
6319
+ }
6320
+ if (/[0-9.]/.test(c)) {
6321
+ let num = "";
6322
+ while (i < expr.length && /[0-9.]/.test(expr[i])) {
6323
+ num += expr[i++];
6324
+ }
6325
+ tokens.push(num);
6326
+ continue;
6327
+ }
6328
+ if (/[a-zA-Z_]/.test(c)) {
6329
+ let ident = "";
6330
+ while (i < expr.length && /[a-zA-Z0-9_]/.test(expr[i])) {
6331
+ ident += expr[i++];
6332
+ }
6333
+ tokens.push(ident);
6334
+ continue;
6335
+ }
6336
+ i++;
6337
+ }
6338
+ _tokenCache.set(expr, tokens);
6339
+ return tokens;
6340
+ }
6341
+ function _evalResolved(expr) {
6342
+ const tokens = _tokenize(expr);
6343
+ let pos = 0;
6344
+ const parseExpr = () => {
6345
+ let left = parseTerm();
6346
+ while (pos < tokens.length) {
6347
+ if (tokens[pos] === "+") {
6348
+ pos++;
6349
+ left += parseTerm();
6350
+ } else if (tokens[pos] === "-") {
6351
+ pos++;
6352
+ left -= parseTerm();
6353
+ } else
6354
+ break;
6355
+ }
6356
+ return left;
6357
+ };
6358
+ const parseTerm = () => {
6359
+ let left = parseFactor();
6360
+ while (pos < tokens.length) {
6361
+ if (tokens[pos] === "*") {
6362
+ pos++;
6363
+ left *= parseFactor();
6364
+ } else if (tokens[pos] === "/") {
6365
+ pos++;
6366
+ const r = parseFactor();
6367
+ if (r === 0)
6368
+ return NaN;
6369
+ left /= r;
6370
+ } else
6371
+ break;
6372
+ }
6373
+ return left;
6374
+ };
6375
+ const parseArgs = () => {
6376
+ const args = [];
6377
+ if (pos < tokens.length && tokens[pos] !== ")") {
6378
+ args.push(parseExpr());
6379
+ while (pos < tokens.length && tokens[pos] === ",") {
6380
+ pos++;
6381
+ args.push(parseExpr());
6382
+ }
6383
+ }
6384
+ return args;
6385
+ };
6386
+ const parseFactor = () => {
6387
+ if (pos >= tokens.length)
6388
+ return NaN;
6389
+ const t = tokens[pos];
6390
+ if (t === "(") {
6391
+ pos++;
6392
+ const v = parseExpr();
6393
+ if (tokens[pos] === ")")
6394
+ pos++;
6395
+ return v;
6396
+ }
6397
+ if (t === "-") {
6398
+ pos++;
6399
+ return -parseFactor();
6400
+ }
6401
+ if (t === "+") {
6402
+ pos++;
6403
+ return parseFactor();
6404
+ }
6405
+ const n = parseFloat(t);
6406
+ if (!isNaN(n)) {
6407
+ pos++;
6408
+ return n;
6409
+ }
6410
+ if (/^[a-zA-Z_]/.test(t) && pos + 1 < tokens.length && tokens[pos + 1] === "(") {
6411
+ const fn = t.toUpperCase();
6412
+ pos += 2;
6413
+ const args = parseArgs();
6414
+ if (pos < tokens.length && tokens[pos] === ")")
6415
+ pos++;
6416
+ switch (fn) {
6417
+ case "ROUND":
6418
+ return args.length >= 2 ? Math.round(args[0] * Math.pow(10, args[1])) / Math.pow(10, args[1]) : Math.round(args[0] ?? 0);
6419
+ case "ABS":
6420
+ return Math.abs(args[0] ?? 0);
6421
+ case "MIN":
6422
+ return args.length ? Math.min(...args) : NaN;
6423
+ case "MAX":
6424
+ return args.length ? Math.max(...args) : NaN;
6425
+ case "FLOOR":
6426
+ return Math.floor(args[0] ?? 0);
6427
+ case "CEIL":
6428
+ return Math.ceil(args[0] ?? 0);
6429
+ case "SQRT":
6430
+ return Math.sqrt(args[0] ?? 0);
6431
+ case "POW":
6432
+ return Math.pow(args[0] ?? 0, args[1] ?? 2);
6433
+ default:
6434
+ return NaN;
6435
+ }
6436
+ }
6437
+ pos++;
6438
+ return 0;
6439
+ };
6440
+ try {
6441
+ const result = parseExpr();
6442
+ return isNaN(result) ? NaN : result;
6443
+ } catch {
6444
+ return NaN;
6445
+ }
6446
+ }
6447
+ function extractBracketFields(expression) {
6448
+ if (!expression)
6449
+ return [];
6450
+ const seen = /* @__PURE__ */ new Set();
6451
+ const out = [];
6452
+ for (const m of expression.matchAll(/\{([^}]+)\}/g)) {
6453
+ const name = m[1].trim();
6454
+ if (name && !seen.has(name)) {
6455
+ seen.add(name);
6456
+ out.push(name);
6457
+ }
6458
+ }
6459
+ return out;
6460
+ }
6461
+ function evaluateFormulaExpression(expression, values) {
6462
+ if (!expression?.trim())
6463
+ return NaN;
6464
+ const resolved = expression.replace(/\{([^}]+)\}/g, (_, name) => {
6465
+ const v = values[name.trim()];
6466
+ if (v === void 0 || v === null || v === "")
6467
+ return "0";
6468
+ const n = typeof v === "number" ? v : parseFloat(String(v));
6469
+ return isNaN(n) ? "0" : String(n);
6470
+ });
6471
+ _tokenCache.delete(resolved);
6472
+ return _evalResolved(resolved);
6473
+ }
6474
+ function evaluateFormulaConfig(config, values, compareValue) {
6475
+ if (!config)
6476
+ return { result: NaN, error: "No formula config" };
6477
+ const dp = config.decimalPlaces ?? 2;
6478
+ try {
6479
+ let expression;
6480
+ if (config.mode === "single") {
6481
+ expression = config.single?.expression ?? "";
6482
+ if (!expression.trim())
6483
+ return { result: NaN, error: "No expression defined" };
6484
+ } else {
6485
+ const cmpVal = compareValue ?? "";
6486
+ const matched = config.multiple?.conditions?.find((c) => c.value === cmpVal);
6487
+ expression = matched?.expression ?? config.multiple?.fallbackExpression ?? "";
6488
+ if (!expression.trim())
6489
+ return { result: NaN, error: "No matching condition and no fallback" };
6490
+ }
6491
+ const raw = evaluateFormulaExpression(expression, values);
6492
+ if (isNaN(raw))
6493
+ return { result: NaN, error: "Expression evaluation failed (check syntax or divide-by-zero)" };
6494
+ const result = parseFloat(raw.toFixed(dp));
6495
+ return { result };
6496
+ } catch (e) {
6497
+ return { result: NaN, error: String(e) };
6498
+ }
6499
+ }
6500
+ function validateFormulaExpression(expression, availableFieldNames) {
6501
+ if (!expression?.trim())
6502
+ return { valid: false, error: "Expression cannot be empty" };
6503
+ const refs = extractBracketFields(expression);
6504
+ const known = new Set(availableFieldNames);
6505
+ for (const ref of refs) {
6506
+ if (!known.has(ref)) {
6507
+ return { valid: false, error: `Unknown field reference: "{${ref}}"` };
6508
+ }
6509
+ }
6510
+ let open = 0;
6511
+ for (const c of expression) {
6512
+ if (c === "(")
6513
+ open++;
6514
+ else if (c === ")") {
6515
+ open--;
6516
+ if (open < 0)
6517
+ return { valid: false, error: "Unbalanced parentheses" };
6518
+ }
6519
+ }
6520
+ if (open !== 0)
6521
+ return { valid: false, error: "Unbalanced parentheses" };
6522
+ return { valid: true };
6523
+ }
6524
+ function getFieldsForFormula(schema, excludeFieldId) {
6525
+ const result = [];
6526
+ const NUMERIC_TYPES = /* @__PURE__ */ new Set(["number", "formula"]);
6527
+ for (const section of schema.sections) {
6528
+ for (const field of section.fields) {
6529
+ if (!NUMERIC_TYPES.has(field.type))
6530
+ continue;
6531
+ if (excludeFieldId && field.id === excludeFieldId)
6532
+ continue;
6533
+ const fieldName = field.fieldName ?? field.id;
6534
+ result.push({ id: field.id, fieldName, label: field.label || fieldName });
6535
+ }
6536
+ }
6537
+ return result;
6538
+ }
6539
+ function detectFormulaFieldCircularDependency(schema, formulaFieldId, config) {
6540
+ const resolveField = (ref) => {
6541
+ for (const s of schema.sections) {
6542
+ for (const f of s.fields) {
6543
+ if (f.fieldName === ref || f.id === ref)
6544
+ return f;
6545
+ }
6546
+ }
6547
+ return void 0;
6548
+ };
6549
+ const getDepsFromConfig = (f) => {
6550
+ if (f.type !== "formula" || !f.formulaConfig)
6551
+ return [];
6552
+ const cfg = f.formulaConfig;
6553
+ const exprs = [];
6554
+ if (cfg.mode === "single") {
6555
+ if (cfg.single?.expression)
6556
+ exprs.push(cfg.single.expression);
6557
+ } else {
6558
+ cfg.multiple?.conditions?.forEach((c) => {
6559
+ if (c.expression)
6560
+ exprs.push(c.expression);
6561
+ });
6562
+ if (cfg.multiple?.fallbackExpression)
6563
+ exprs.push(cfg.multiple.fallbackExpression);
6564
+ }
6565
+ return exprs.flatMap((e) => extractBracketFields(e));
6566
+ };
6567
+ const visited = /* @__PURE__ */ new Set();
6568
+ const hasCycle = (fieldId) => {
6569
+ if (visited.has(fieldId))
6570
+ return true;
6571
+ visited.add(fieldId);
6572
+ const field = resolveField(fieldId);
6573
+ if (!field || field.type !== "formula" || !field.formulaConfig) {
6574
+ visited.delete(fieldId);
6575
+ return false;
6576
+ }
6577
+ for (const dep of getDepsFromConfig(field)) {
6578
+ const depField = resolveField(dep);
6579
+ if (!depField)
6580
+ continue;
6581
+ if (depField.id === formulaFieldId) {
6582
+ visited.delete(fieldId);
6583
+ return true;
6584
+ }
6585
+ if (hasCycle(depField.id)) {
6586
+ visited.delete(fieldId);
6587
+ return true;
6588
+ }
6589
+ }
6590
+ visited.delete(fieldId);
6591
+ return false;
6592
+ };
6593
+ const thisField = resolveField(formulaFieldId);
6594
+ if (!thisField || !config)
6595
+ return false;
6596
+ const thisDeps = getDepsFromConfig({ ...thisField, formulaConfig: config });
6597
+ for (const dep of thisDeps) {
6598
+ const depField = resolveField(dep);
6599
+ if (!depField)
6600
+ continue;
6601
+ if (depField.id === formulaFieldId)
6602
+ return true;
6603
+ if (hasCycle(depField.id))
6604
+ return true;
6605
+ }
6606
+ return false;
6607
+ }
6231
6608
 
6232
6609
  // src/core/countryData.ts
6233
6610
  var COUNTRY_DATA = [
@@ -6877,6 +7254,19 @@ var FieldRenderer = class {
6877
7254
  });
6878
7255
  break;
6879
7256
  }
7257
+ case "formula": {
7258
+ const formulaDisplay = value !== void 0 && value !== null ? String(value) : "\u2014";
7259
+ input = createElement("input", {
7260
+ type: "text",
7261
+ className: "flex min-h-touch w-full rounded-md border border-input bg-gray-50 dark:bg-gray-800 px-3 py-2 text-sm sm:text-base font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
7262
+ placeholder: "\u2014",
7263
+ value: formulaDisplay,
7264
+ readonly: true,
7265
+ disabled: true,
7266
+ title: "Computed field \u2014 value is calculated from a formula"
7267
+ });
7268
+ break;
7269
+ }
6880
7270
  default:
6881
7271
  const rules = getValidationRules(field);
6882
7272
  const useNumericTextInput = field.type === "text" && isNumericTextField(field);
@@ -7301,6 +7691,21 @@ function buildFormulaValuesMap(schema, data) {
7301
7691
  values[field.fieldName] = newVal;
7302
7692
  }
7303
7693
  }
7694
+ const formulaTypeFields = allFields.filter((f) => f.type === "formula" && f.formulaConfig);
7695
+ for (let pass = 0; pass < Math.max(1, formulaTypeFields.length); pass++) {
7696
+ for (const field of formulaTypeFields) {
7697
+ const modelKey = getModelKey(field);
7698
+ const compareFieldName = field.formulaConfig.multiple?.compareField;
7699
+ const compareValue = compareFieldName ? String(values[compareFieldName] ?? "") : "";
7700
+ const evalResult = evaluateFormulaConfig(field.formulaConfig, values, compareValue);
7701
+ const dp = field.formulaConfig.decimalPlaces ?? 2;
7702
+ const newVal = !evalResult.error && !isNaN(evalResult.result) ? parseFloat(evalResult.result.toFixed(dp)) : void 0;
7703
+ values[modelKey] = newVal;
7704
+ values[field.id] = newVal;
7705
+ if (field.fieldName)
7706
+ values[field.fieldName] = newVal;
7707
+ }
7708
+ }
7304
7709
  return values;
7305
7710
  }
7306
7711
  function computeFormulaValue(field, schema, data) {
@@ -7324,6 +7729,31 @@ function isFormulaDependency(schema, modelKey, fieldId) {
7324
7729
  if (fieldId && field.dependencies.includes(fieldId))
7325
7730
  return true;
7326
7731
  }
7732
+ if (field.type === "formula" && field.formulaConfig) {
7733
+ const cfg = field.formulaConfig;
7734
+ const exprs = [];
7735
+ if (cfg.mode === "single") {
7736
+ if (cfg.single?.expression)
7737
+ exprs.push(cfg.single.expression);
7738
+ } else {
7739
+ cfg.multiple?.conditions?.forEach((c) => {
7740
+ if (c.expression)
7741
+ exprs.push(c.expression);
7742
+ });
7743
+ if (cfg.multiple?.fallbackExpression)
7744
+ exprs.push(cfg.multiple.fallbackExpression);
7745
+ if (cfg.multiple?.compareField) {
7746
+ if (cfg.multiple.compareField === modelKey || cfg.multiple.compareField === fieldId)
7747
+ return true;
7748
+ }
7749
+ }
7750
+ for (const expr of exprs) {
7751
+ for (const ref of extractBracketFields(expr)) {
7752
+ if (ref === modelKey || fieldId && ref === fieldId)
7753
+ return true;
7754
+ }
7755
+ }
7756
+ }
7327
7757
  }
7328
7758
  }
7329
7759
  return false;
@@ -7392,6 +7822,15 @@ var FormRenderer = class {
7392
7822
  const computed = computeFormulaValue(field, this.schema, this.data);
7393
7823
  fieldValue = computed;
7394
7824
  this.data[modelKey] = computed;
7825
+ } else if (field.type === "formula" && field.formulaConfig) {
7826
+ const allValues = buildFormulaValuesMap(this.schema, this.data);
7827
+ const compareFieldName = field.formulaConfig.multiple?.compareField;
7828
+ const compareValue = compareFieldName ? String(allValues[compareFieldName] ?? "") : "";
7829
+ const evalResult = evaluateFormulaConfig(field.formulaConfig, allValues, compareValue);
7830
+ const dp = field.formulaConfig.decimalPlaces ?? 2;
7831
+ const computed = !evalResult.error && !isNaN(evalResult.result) ? parseFloat(evalResult.result.toFixed(dp)) : void 0;
7832
+ fieldValue = computed;
7833
+ this.data[modelKey] = computed;
7395
7834
  } else if (field.type === "name_generator") {
7396
7835
  fieldValue = this.data[modelKey];
7397
7836
  if (!fieldValue) {
@@ -7403,7 +7842,7 @@ var FormRenderer = class {
7403
7842
  } else {
7404
7843
  fieldValue = this.data[modelKey];
7405
7844
  }
7406
- const isFormulaField = field.type === "number" && field.valueSource === "formula";
7845
+ const isFormulaField = field.type === "number" && field.valueSource === "formula" || field.type === "formula";
7407
7846
  const fieldEl = FieldRenderer.render(
7408
7847
  field,
7409
7848
  fieldValue,
@@ -10115,8 +10554,647 @@ var SectionList = class {
10115
10554
  }
10116
10555
  };
10117
10556
 
10557
+ // src/utils/formulaTokenParser.ts
10558
+ var FUNCTION_NAMES = /* @__PURE__ */ new Set(["ROUND", "ABS", "MIN", "MAX", "FLOOR", "CEIL", "SQRT", "POW"]);
10559
+ var OPERATOR_CHARS = /* @__PURE__ */ new Set(["+", "-", "*", "/"]);
10560
+ function parseExpressionToTokens(expression, fieldMap, mode) {
10561
+ if (!expression)
10562
+ return [];
10563
+ const tokens = [];
10564
+ let i = 0;
10565
+ const len = expression.length;
10566
+ while (i < len) {
10567
+ const ch = expression[i];
10568
+ if (/\s/.test(ch)) {
10569
+ let ws = "";
10570
+ while (i < len && /\s/.test(expression[i]))
10571
+ ws += expression[i++];
10572
+ tokens.push({ type: "space", value: ws, rawValue: ws });
10573
+ continue;
10574
+ }
10575
+ if (mode === "bracket" && ch === "{") {
10576
+ i++;
10577
+ let ref = "";
10578
+ while (i < len && expression[i] !== "}")
10579
+ ref += expression[i++];
10580
+ if (i < len && expression[i] === "}")
10581
+ i++;
10582
+ const trimmedRef = ref.trim();
10583
+ const field = fieldMap.get(trimmedRef);
10584
+ tokens.push({
10585
+ type: "field",
10586
+ value: field ? field.label || field.fieldName || trimmedRef : trimmedRef,
10587
+ rawValue: `{${trimmedRef}}`,
10588
+ fieldRef: trimmedRef
10589
+ });
10590
+ continue;
10591
+ }
10592
+ if (OPERATOR_CHARS.has(ch)) {
10593
+ tokens.push({ type: "operator", value: ch, rawValue: ch });
10594
+ i++;
10595
+ continue;
10596
+ }
10597
+ if (ch === "%") {
10598
+ tokens.push({ type: "percent", value: "%", rawValue: "%" });
10599
+ i++;
10600
+ continue;
10601
+ }
10602
+ if (ch === "(" || ch === ")") {
10603
+ tokens.push({ type: "paren", value: ch, rawValue: ch });
10604
+ i++;
10605
+ continue;
10606
+ }
10607
+ if (ch === ",") {
10608
+ tokens.push({ type: "comma", value: ",", rawValue: "," });
10609
+ i++;
10610
+ continue;
10611
+ }
10612
+ if (/[0-9]/.test(ch) || ch === "." && i + 1 < len && /[0-9]/.test(expression[i + 1])) {
10613
+ let num = "";
10614
+ while (i < len && /[0-9.]/.test(expression[i]))
10615
+ num += expression[i++];
10616
+ tokens.push({ type: "number", value: num, rawValue: num });
10617
+ continue;
10618
+ }
10619
+ if (/[a-zA-Z_]/.test(ch)) {
10620
+ let ident = "";
10621
+ while (i < len && /[a-zA-Z0-9_]/.test(expression[i]))
10622
+ ident += expression[i++];
10623
+ if (FUNCTION_NAMES.has(ident.toUpperCase())) {
10624
+ tokens.push({ type: "function", value: ident, rawValue: ident });
10625
+ continue;
10626
+ }
10627
+ if (mode === "plain") {
10628
+ const field = fieldMap.get(ident);
10629
+ tokens.push({
10630
+ type: "field",
10631
+ value: field ? field.label || field.fieldName || ident : ident,
10632
+ rawValue: ident,
10633
+ fieldRef: ident
10634
+ });
10635
+ continue;
10636
+ }
10637
+ tokens.push({ type: "unknown", value: ident, rawValue: ident });
10638
+ continue;
10639
+ }
10640
+ tokens.push({ type: "unknown", value: ch, rawValue: ch });
10641
+ i++;
10642
+ }
10643
+ return tokens;
10644
+ }
10645
+ function buildFieldMap(fields) {
10646
+ const map = /* @__PURE__ */ new Map();
10647
+ for (const f of fields) {
10648
+ if (f.fieldName)
10649
+ map.set(f.fieldName, f);
10650
+ if (f.id && f.id !== f.fieldName)
10651
+ map.set(f.id, f);
10652
+ }
10653
+ return map;
10654
+ }
10655
+ function getFieldRef(field) {
10656
+ return field.fieldName && field.fieldName !== field.id ? field.fieldName : field.id;
10657
+ }
10658
+
10659
+ // src/builder/FormulaEditorWidget.ts
10660
+ var _stylesInjected = false;
10661
+ var STYLE_ID = "formula-editor-widget-styles-v2";
10662
+ function injectStyles() {
10663
+ if (_stylesInjected && document.getElementById(STYLE_ID))
10664
+ return;
10665
+ document.getElementById(STYLE_ID)?.remove();
10666
+ document.getElementById("formula-editor-widget-styles")?.remove();
10667
+ _stylesInjected = true;
10668
+ const style = document.createElement("style");
10669
+ style.id = STYLE_ID;
10670
+ style.textContent = `
10671
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
10672
+ FormulaEditorWidget v2
10673
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
10674
+
10675
+ /* \u2500\u2500 Container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10676
+ .few-container {
10677
+ position: relative;
10678
+ width: 100%;
10679
+ }
10680
+
10681
+ /* \u2500\u2500 Editor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10682
+ /*
10683
+ * NOTE: color uses !important so it is never muted by
10684
+ * Angular ViewEncapsulation, host-element rules, or global resets.
10685
+ * This ensures operators/numbers always render at full text contrast.
10686
+ */
10687
+ .few-editor {
10688
+ display: block;
10689
+ width: 100%;
10690
+ min-height: 38px;
10691
+ padding: 6px 32px 6px 12px; /* right padding reserves space for clear btn */
10692
+ border: 1px solid #e2e8f0;
10693
+ border-radius: 6px;
10694
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
10695
+ font-size: 13px;
10696
+ font-weight: 700 !important; /* \u2190 operators/numbers always bold */
10697
+ line-height: 1.7;
10698
+ color: rgb(213, 12, 65) !important; /* operator/text color \u2014 chips override with their own !important */
10699
+ caret-color: #635bff;
10700
+ background: transparent;
10701
+ outline: none;
10702
+ word-break: break-word;
10703
+ white-space: pre-wrap;
10704
+ cursor: text;
10705
+ transition: border-color 0.15s, box-shadow 0.15s;
10706
+ box-sizing: border-box;
10707
+ }
10708
+
10709
+ .few-editor:focus {
10710
+ border-color: #635bff;
10711
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.15);
10712
+ }
10713
+
10714
+ .few-editor.few-has-error {
10715
+ border-color: #ef4444;
10716
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
10717
+ }
10718
+
10719
+ /* \u2500\u2500 Placeholder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10720
+ .few-placeholder {
10721
+ position: absolute;
10722
+ top: 0;
10723
+ left: 0;
10724
+ right: 32px; /* don't overlap clear button */
10725
+ bottom: 0;
10726
+ padding: 6px 0 6px 12px;
10727
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, monospace;
10728
+ font-size: 13px;
10729
+ line-height: 1.7;
10730
+ color: #94a3b8;
10731
+ pointer-events: none;
10732
+ user-select: none;
10733
+ white-space: nowrap;
10734
+ overflow: hidden;
10735
+ text-overflow: ellipsis;
10736
+ }
10737
+
10738
+ /* \u2500\u2500 Field chip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10739
+ .few-chip {
10740
+ display: inline-flex;
10741
+ align-items: center;
10742
+ padding: 0px 7px 1px 6px;
10743
+ margin: 0 2px;
10744
+ font-size: 11.5px;
10745
+ font-weight: 600 !important; /* explicit !important so the editor's 700 !important doesn't cascade in */
10746
+ font-family: ui-sans-serif, system-ui, sans-serif;
10747
+ letter-spacing: 0.01em;
10748
+ border-radius: 4px;
10749
+ background: rgba(99, 91, 255, 0.10);
10750
+ color: #635bff !important; /* chip label always purple \u2014 not inherited */
10751
+ border: 1px solid rgba(99, 91, 255, 0.28);
10752
+ cursor: default;
10753
+ user-select: none;
10754
+ vertical-align: middle;
10755
+ line-height: 1.6;
10756
+ white-space: nowrap;
10757
+ transition: background 0.1s;
10758
+ }
10759
+
10760
+ .few-chip:hover {
10761
+ background: rgba(99, 91, 255, 0.17);
10762
+ }
10763
+
10764
+ .few-chip-unknown {
10765
+ background: rgba(239, 68, 68, 0.08);
10766
+ color: #dc2626 !important;
10767
+ border-color: rgba(239, 68, 68, 0.25);
10768
+ }
10769
+
10770
+ /* \u2500\u2500 Clear (\xD7) button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10771
+ .few-clear-btn {
10772
+ position: absolute;
10773
+ top: 50%;
10774
+ right: 7px;
10775
+ transform: translateY(-50%);
10776
+ display: flex;
10777
+ align-items: center;
10778
+ justify-content: center;
10779
+ width: 18px;
10780
+ height: 18px;
10781
+ padding: 0;
10782
+ border: none;
10783
+ border-radius: 50%;
10784
+ background: #94a3b8;
10785
+ color: #ffffff;
10786
+ font-size: 12px;
10787
+ line-height: 1;
10788
+ font-weight: 700;
10789
+ cursor: pointer;
10790
+ opacity: 0;
10791
+ pointer-events: none;
10792
+ transition: opacity 0.15s, background 0.15s;
10793
+ z-index: 2;
10794
+ }
10795
+
10796
+ .few-clear-btn:hover {
10797
+ background: #475569;
10798
+ }
10799
+
10800
+ /* Shown only when the editor has content */
10801
+ .few-container.few-has-content .few-clear-btn {
10802
+ opacity: 1;
10803
+ pointer-events: auto;
10804
+ }
10805
+
10806
+ /* \u2500\u2500 Dark mode (media query) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10807
+ @media (prefers-color-scheme: dark) {
10808
+ .few-editor {
10809
+ border-color: #334155;
10810
+ /* operator color is set via inline !important \u2014 not overridden here */
10811
+ }
10812
+ .few-editor:focus {
10813
+ border-color: #635bff;
10814
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.25);
10815
+ }
10816
+ .few-placeholder { color: #64748b; }
10817
+ .few-chip {
10818
+ background: rgba(99, 91, 255, 0.18);
10819
+ color: #a5b4fc !important;
10820
+ border-color: rgba(99, 91, 255, 0.38);
10821
+ }
10822
+ .few-chip-unknown {
10823
+ background: rgba(239, 68, 68, 0.15);
10824
+ color: #f87171 !important;
10825
+ border-color: rgba(239, 68, 68, 0.35);
10826
+ }
10827
+ .few-clear-btn {
10828
+ background: #64748b;
10829
+ color: #f1f5f9;
10830
+ }
10831
+ .few-clear-btn:hover { background: #475569; }
10832
+ }
10833
+
10834
+ /* \u2500\u2500 Dark mode (Tailwind class strategy) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
10835
+ .dark .few-editor {
10836
+ border-color: #334155;
10837
+ /* operator color is set via inline !important \u2014 not overridden here */
10838
+ }
10839
+ .dark .few-editor:focus {
10840
+ border-color: #635bff;
10841
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.25);
10842
+ }
10843
+ .dark .few-placeholder { color: #64748b; }
10844
+ .dark .few-chip {
10845
+ background: rgba(99, 91, 255, 0.18);
10846
+ color: #a5b4fc !important;
10847
+ border-color: rgba(99, 91, 255, 0.38);
10848
+ }
10849
+ .dark .few-chip-unknown {
10850
+ background: rgba(239, 68, 68, 0.15);
10851
+ color: #f87171 !important;
10852
+ border-color: rgba(239, 68, 68, 0.35);
10853
+ }
10854
+ .dark .few-clear-btn {
10855
+ background: #64748b;
10856
+ color: #f1f5f9;
10857
+ }
10858
+ .dark .few-clear-btn:hover { background: #475569; }
10859
+ `;
10860
+ document.head.appendChild(style);
10861
+ }
10862
+ var FormulaEditorWidget = class {
10863
+ constructor(options) {
10864
+ __publicField(this, "_container");
10865
+ __publicField(this, "_editor");
10866
+ __publicField(this, "_placeholderEl");
10867
+ __publicField(this, "_clearBtn");
10868
+ __publicField(this, "_options");
10869
+ __publicField(this, "_fieldMap");
10870
+ /**
10871
+ * The canonical internal expression — SINGLE SOURCE OF TRUTH.
10872
+ * The DOM is always derived from this; never the reverse.
10873
+ * Updated by _onEditorInput() (user edits) and setValue() (programmatic).
10874
+ */
10875
+ __publicField(this, "_internalValue", "");
10876
+ injectStyles();
10877
+ this._options = { ...options };
10878
+ this._fieldMap = buildFieldMap(options.availableFields);
10879
+ this._container = document.createElement("div");
10880
+ this._container.className = "few-container";
10881
+ this._placeholderEl = document.createElement("div");
10882
+ this._placeholderEl.className = "few-placeholder";
10883
+ this._placeholderEl.textContent = options.placeholder ?? "Enter formula\u2026";
10884
+ this._editor = document.createElement("div");
10885
+ this._editor.className = "few-editor";
10886
+ this._editor.contentEditable = "true";
10887
+ this._editor.spellcheck = false;
10888
+ this._editor.setAttribute("autocomplete", "off");
10889
+ this._editor.setAttribute("autocorrect", "off");
10890
+ this._editor.setAttribute("autocapitalize", "off");
10891
+ this._editor.setAttribute("data-formula-editor", "true");
10892
+ this._editor.style.setProperty("color", "rgb(213, 12, 65)", "important");
10893
+ this._editor.style.setProperty("font-weight", "700", "important");
10894
+ this._editor.style.setProperty("caret-color", "#635bff", "important");
10895
+ this._clearBtn = document.createElement("button");
10896
+ this._clearBtn.type = "button";
10897
+ this._clearBtn.className = "few-clear-btn";
10898
+ this._clearBtn.title = "Clear formula";
10899
+ this._clearBtn.textContent = "\xD7";
10900
+ this._clearBtn.addEventListener("mousedown", (e) => {
10901
+ e.preventDefault();
10902
+ this.clear();
10903
+ });
10904
+ this._container.appendChild(this._placeholderEl);
10905
+ this._container.appendChild(this._editor);
10906
+ this._container.appendChild(this._clearBtn);
10907
+ this._attachEvents();
10908
+ if (options.initialValue) {
10909
+ this.setValue(options.initialValue);
10910
+ } else {
10911
+ this._syncUI();
10912
+ }
10913
+ }
10914
+ // ─── Internal event wiring ───────────────────────────────────────────────
10915
+ _attachEvents() {
10916
+ this._editor.addEventListener("input", () => this._onEditorInput());
10917
+ this._editor.addEventListener("keydown", (e) => {
10918
+ if (e.key === "Enter") {
10919
+ e.preventDefault();
10920
+ return;
10921
+ }
10922
+ if (e.key === "Backspace")
10923
+ this._handleBackspaceOnChip(e);
10924
+ });
10925
+ this._editor.addEventListener("paste", (e) => {
10926
+ e.preventDefault();
10927
+ const text = e.clipboardData?.getData("text/plain") ?? "";
10928
+ if (text) {
10929
+ this._insertTextAtCaret(text);
10930
+ this._onEditorInput();
10931
+ }
10932
+ });
10933
+ this._editor.addEventListener("dragover", (e) => e.preventDefault());
10934
+ this._editor.addEventListener("drop", (e) => e.preventDefault());
10935
+ }
10936
+ // ─── Core edit cycle ─────────────────────────────────────────────────────
10937
+ _onEditorInput() {
10938
+ this._normaliseDom();
10939
+ const newValue = this._readInternalExpression();
10940
+ const changed = newValue !== this._internalValue;
10941
+ this._internalValue = newValue;
10942
+ this._syncUI();
10943
+ if (changed) {
10944
+ this._options.onChange?.(newValue);
10945
+ }
10946
+ }
10947
+ /**
10948
+ * Remove browser-inserted artefacts (<br>, stray <div>/<span>) that some
10949
+ * browsers inject into a contenteditable. Chips (data-field-ref) are kept.
10950
+ */
10951
+ _normaliseDom() {
10952
+ for (const node of Array.from(this._editor.childNodes)) {
10953
+ if (node.nodeType !== Node.ELEMENT_NODE)
10954
+ continue;
10955
+ const el = node;
10956
+ if (el.hasAttribute("data-field-ref"))
10957
+ continue;
10958
+ if (el.tagName === "BR") {
10959
+ el.remove();
10960
+ continue;
10961
+ }
10962
+ const textNode = document.createTextNode(el.textContent ?? "");
10963
+ this._editor.replaceChild(textNode, el);
10964
+ }
10965
+ }
10966
+ /**
10967
+ * Backspace immediately before a chip should delete the whole chip, not
10968
+ * step into its non-editable content.
10969
+ */
10970
+ _handleBackspaceOnChip(e) {
10971
+ const sel = window.getSelection();
10972
+ if (!sel || sel.rangeCount === 0)
10973
+ return;
10974
+ const range = sel.getRangeAt(0);
10975
+ if (!range.collapsed)
10976
+ return;
10977
+ let prev = null;
10978
+ if (range.startContainer === this._editor) {
10979
+ if (range.startOffset > 0)
10980
+ prev = this._editor.childNodes[range.startOffset - 1];
10981
+ } else if (range.startContainer.nodeType === Node.TEXT_NODE) {
10982
+ if (range.startOffset === 0)
10983
+ prev = range.startContainer.previousSibling;
10984
+ }
10985
+ if (prev && prev.hasAttribute?.("data-field-ref")) {
10986
+ e.preventDefault();
10987
+ prev.remove();
10988
+ this._onEditorInput();
10989
+ }
10990
+ }
10991
+ // ─── Serialisation: DOM → internal expression ────────────────────────────
10992
+ _readInternalExpression() {
10993
+ let result = "";
10994
+ for (const node of Array.from(this._editor.childNodes)) {
10995
+ if (node.nodeType === Node.TEXT_NODE) {
10996
+ result += node.textContent ?? "";
10997
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
10998
+ const el = node;
10999
+ const ref = el.getAttribute("data-field-ref");
11000
+ if (ref !== null) {
11001
+ result += this._options.mode === "bracket" ? `{${ref}}` : ref;
11002
+ } else {
11003
+ result += el.textContent ?? "";
11004
+ }
11005
+ }
11006
+ }
11007
+ return result;
11008
+ }
11009
+ // ─── Deserialization: internal expression → DOM ──────────────────────────
11010
+ /**
11011
+ * Load an internal expression string.
11012
+ *
11013
+ * This is the ONLY path that populates the editor with chips.
11014
+ * It does NOT fire onChange (programmatic set ≠ user edit).
11015
+ *
11016
+ * Design invariant: the DOM is ALWAYS fully rebuilt from _internalValue,
11017
+ * never from user-visible text. This guarantees correct state on re-render
11018
+ * after field switches, undo/redo, or hot-reload.
11019
+ */
11020
+ setValue(internalExpression) {
11021
+ this._internalValue = internalExpression ?? "";
11022
+ this._editor.innerHTML = "";
11023
+ if (this._internalValue.trim()) {
11024
+ const tokens = parseExpressionToTokens(
11025
+ this._internalValue,
11026
+ this._fieldMap,
11027
+ this._options.mode
11028
+ );
11029
+ for (const token of tokens) {
11030
+ if (token.type === "field" && token.fieldRef !== void 0) {
11031
+ const field = this._fieldMap.get(token.fieldRef) ?? {
11032
+ id: token.fieldRef,
11033
+ fieldName: token.fieldRef,
11034
+ label: token.fieldRef
11035
+ // shows raw ref — still readable
11036
+ };
11037
+ const isUnknown = !this._fieldMap.has(token.fieldRef);
11038
+ this._editor.appendChild(this._createChipEl(field, isUnknown));
11039
+ } else {
11040
+ this._editor.appendChild(document.createTextNode(token.value));
11041
+ }
11042
+ }
11043
+ }
11044
+ this._syncUI();
11045
+ }
11046
+ /** Return the current stored expression. Always reflects live DOM state. */
11047
+ getValue() {
11048
+ return this._readInternalExpression();
11049
+ }
11050
+ // ─── Public mutation ─────────────────────────────────────────────────────
11051
+ /**
11052
+ * Insert a field chip at the caret (or append if editor lacks focus).
11053
+ * Handles spacing automatically so chips never run into adjacent text.
11054
+ */
11055
+ insertField(field) {
11056
+ this._editor.focus();
11057
+ const chip = this._createChipEl(field, false);
11058
+ const sel = window.getSelection();
11059
+ const inEditor = sel && sel.rangeCount > 0 && this._editor.contains(sel.getRangeAt(0).commonAncestorContainer);
11060
+ if (inEditor) {
11061
+ const range = sel.getRangeAt(0);
11062
+ range.deleteContents();
11063
+ if (this._needsSpaceBefore(range)) {
11064
+ range.insertNode(document.createTextNode(" "));
11065
+ range.collapse(false);
11066
+ }
11067
+ range.insertNode(chip);
11068
+ const spaceAfter = document.createTextNode(" ");
11069
+ range.setStartAfter(chip);
11070
+ range.setEndAfter(chip);
11071
+ range.insertNode(spaceAfter);
11072
+ range.setStartAfter(spaceAfter);
11073
+ range.setEndAfter(spaceAfter);
11074
+ sel.removeAllRanges();
11075
+ sel.addRange(range);
11076
+ } else {
11077
+ this._appendAtEnd(chip);
11078
+ }
11079
+ this._onEditorInput();
11080
+ }
11081
+ /**
11082
+ * Insert plain text at the caret (operators, function names, parens).
11083
+ * Operators rendered by this path are text nodes and always inherit
11084
+ * .few-editor's high-contrast color.
11085
+ */
11086
+ insertText(text) {
11087
+ this._editor.focus();
11088
+ this._insertTextAtCaret(text);
11089
+ this._onEditorInput();
11090
+ }
11091
+ /**
11092
+ * Clear the entire formula expression.
11093
+ * Fires onChange('') so the store is updated immediately.
11094
+ */
11095
+ clear() {
11096
+ this._editor.innerHTML = "";
11097
+ this._internalValue = "";
11098
+ this._syncUI();
11099
+ this._options.onChange?.("");
11100
+ }
11101
+ // ─── Visual state ────────────────────────────────────────────────────────
11102
+ /** Toggle error (red border) state. */
11103
+ setError(hasError) {
11104
+ this._editor.classList.toggle("few-has-error", hasError);
11105
+ }
11106
+ /**
11107
+ * Refresh the field map with a new field list (e.g. schema changed).
11108
+ * Re-renders from _internalValue so stale labels / unknowns are resolved.
11109
+ */
11110
+ updateFields(fields) {
11111
+ this._options.availableFields = fields;
11112
+ this._fieldMap = buildFieldMap(fields);
11113
+ this.setValue(this._internalValue);
11114
+ }
11115
+ /** Return the root element to mount. */
11116
+ getElement() {
11117
+ return this._container;
11118
+ }
11119
+ /** Focus the editable area. */
11120
+ focus() {
11121
+ this._editor.focus();
11122
+ }
11123
+ /** Remove from DOM. */
11124
+ destroy() {
11125
+ this._container.remove();
11126
+ }
11127
+ // ─── Private helpers ─────────────────────────────────────────────────────
11128
+ _insertTextAtCaret(text) {
11129
+ const sel = window.getSelection();
11130
+ const inEditor = sel && sel.rangeCount > 0 && this._editor.contains(sel.getRangeAt(0).commonAncestorContainer);
11131
+ if (inEditor) {
11132
+ const range = sel.getRangeAt(0);
11133
+ range.deleteContents();
11134
+ const tn = document.createTextNode(text);
11135
+ range.insertNode(tn);
11136
+ range.setStartAfter(tn);
11137
+ range.setEndAfter(tn);
11138
+ sel.removeAllRanges();
11139
+ sel.addRange(range);
11140
+ } else {
11141
+ this._editor.appendChild(document.createTextNode(text));
11142
+ }
11143
+ }
11144
+ _appendAtEnd(chip) {
11145
+ const last = this._editor.lastChild;
11146
+ if (last) {
11147
+ if (last.nodeType === Node.TEXT_NODE) {
11148
+ const txt = last.textContent ?? "";
11149
+ if (txt.length > 0 && !/\s$/.test(txt)) {
11150
+ this._editor.appendChild(document.createTextNode(" "));
11151
+ }
11152
+ } else if (last.hasAttribute?.("data-field-ref")) {
11153
+ this._editor.appendChild(document.createTextNode(" "));
11154
+ }
11155
+ }
11156
+ this._editor.appendChild(chip);
11157
+ this._editor.appendChild(document.createTextNode(" "));
11158
+ }
11159
+ _needsSpaceBefore(range) {
11160
+ const { startContainer, startOffset } = range;
11161
+ if (startContainer.nodeType === Node.TEXT_NODE) {
11162
+ const text = startContainer.textContent ?? "";
11163
+ const ch = text[startOffset - 1];
11164
+ return startOffset > 0 && ch !== void 0 && !/[\s(]/.test(ch);
11165
+ }
11166
+ if (startContainer === this._editor && startOffset > 0) {
11167
+ const prev = this._editor.childNodes[startOffset - 1];
11168
+ return prev?.hasAttribute?.("data-field-ref") ?? false;
11169
+ }
11170
+ return false;
11171
+ }
11172
+ _createChipEl(field, isUnknown) {
11173
+ const chip = document.createElement("span");
11174
+ chip.contentEditable = "false";
11175
+ chip.setAttribute("data-field-ref", getFieldRef(field));
11176
+ chip.textContent = field.label || field.fieldName || field.id;
11177
+ chip.className = isUnknown ? "few-chip few-chip-unknown" : "few-chip";
11178
+ chip.title = this._options.mode === "bracket" ? `{${getFieldRef(field)}}` : getFieldRef(field);
11179
+ return chip;
11180
+ }
11181
+ /**
11182
+ * Synchronise all purely-visual state:
11183
+ * - placeholder visibility
11184
+ * - clear-button visibility (few-has-content on container)
11185
+ */
11186
+ _syncUI() {
11187
+ const hasContent = this._internalValue.trim().length > 0 || !!this._editor.querySelector("[data-field-ref]");
11188
+ this._placeholderEl.style.display = hasContent ? "none" : "block";
11189
+ this._container.classList.toggle("few-has-content", hasContent);
11190
+ this._editor.style.setProperty("color", "rgb(213, 12, 65)", "important");
11191
+ this._editor.style.setProperty("font-weight", "700", "important");
11192
+ }
11193
+ };
11194
+
10118
11195
  // src/builder/FormBuilder.ts
10119
11196
  var advancedCssPanelState = /* @__PURE__ */ new Map();
11197
+ var lastFocusedFormulaWidget = null;
10120
11198
  var LABEL_DEBOUNCE_MS = 300;
10121
11199
  var labelUpdateTimeouts = /* @__PURE__ */ new Map();
10122
11200
  var FormBuilder = class {
@@ -10591,6 +11669,43 @@ var FormBuilder = class {
10591
11669
  }
10592
11670
  }
10593
11671
  }
11672
+ for (const field of schema.sections.flatMap((s) => s.fields)) {
11673
+ if (field.type !== "formula" || !field.formulaConfig)
11674
+ continue;
11675
+ const fcfg = field.formulaConfig;
11676
+ const allOtherFields = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== field.id);
11677
+ const validRefSet = /* @__PURE__ */ new Set();
11678
+ allOtherFields.forEach((f) => {
11679
+ if (f.fieldName)
11680
+ validRefSet.add(f.fieldName);
11681
+ if (f.id)
11682
+ validRefSet.add(f.id);
11683
+ });
11684
+ const validRefs = Array.from(validRefSet);
11685
+ const exprs = [];
11686
+ if (fcfg.mode === "single") {
11687
+ if (fcfg.single?.expression)
11688
+ exprs.push(fcfg.single.expression);
11689
+ } else {
11690
+ fcfg.multiple?.conditions?.forEach((c) => {
11691
+ if (c.expression)
11692
+ exprs.push(c.expression);
11693
+ });
11694
+ if (fcfg.multiple?.fallbackExpression)
11695
+ exprs.push(fcfg.multiple.fallbackExpression);
11696
+ }
11697
+ for (const expr of exprs) {
11698
+ const result = validateFormulaExpression(expr, validRefs);
11699
+ if (!result.valid) {
11700
+ alert(`Formula error in "${field.label}": ${result.error}`);
11701
+ return;
11702
+ }
11703
+ }
11704
+ if (detectFormulaFieldCircularDependency(schema, field.id, fcfg)) {
11705
+ alert(`Circular dependency detected in formula for "${field.label}"`);
11706
+ return;
11707
+ }
11708
+ }
10594
11709
  schema.sections.forEach((section) => {
10595
11710
  section.fields?.forEach((field) => {
10596
11711
  if (field.type === "number" && field.validations) {
@@ -11338,38 +12453,64 @@ var FormBuilder = class {
11338
12453
  const numericFields = getNumericFieldsForFormula(schema, selectedField.id);
11339
12454
  const availableIds = numericFields.map((f) => f.id);
11340
12455
  const availableNames = numericFields.map((f) => f.fieldName);
12456
+ const allFieldInfosForWidget = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== selectedField.id).map((f) => ({
12457
+ id: f.id,
12458
+ fieldName: f.fieldName ?? f.id,
12459
+ label: f.label || f.fieldName || f.id
12460
+ }));
12461
+ const numFieldInfos = numericFields.map((f) => ({
12462
+ id: f.id,
12463
+ fieldName: f.fieldName,
12464
+ label: f.label || f.fieldName || f.id
12465
+ }));
11341
12466
  const formulaGroup = createElement("div", { className: "mb-3" });
11342
- formulaGroup.appendChild(createElement("label", { className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1", text: "Formula" }));
11343
- const formulaInput = createElement("input", {
11344
- type: "text",
11345
- className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent font-mono text-sm",
11346
- value: selectedField.formula || "",
12467
+ formulaGroup.appendChild(createElement("label", {
12468
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12469
+ text: "Formula"
12470
+ }));
12471
+ const numFormulaWidget = new FormulaEditorWidget({
12472
+ mode: "plain",
12473
+ availableFields: allFieldInfosForWidget,
12474
+ // Always read from the store snapshot — guarantees correct reload
12475
+ // after field switch and re-render.
12476
+ initialValue: selectedField.formula || "",
11347
12477
  placeholder: "e.g. quantity * price",
11348
- "data-focus-id": `field-formula-${selectedField.id}`,
11349
- oninput: (e) => {
11350
- const formula = e.target.value.trim();
12478
+ onChange: (formula) => {
11351
12479
  const deps = parseFormulaDependencies(formula);
11352
- const validation = validateFormula(formula, availableIds, availableNames, selectedField.id);
11353
- const hasCircular = deps.length > 0 && detectCircularDependency(schema, selectedField.id, formula, deps);
11354
- const errEl = formulaGroup.querySelector(".formula-error");
11355
- if (errEl) {
11356
- if (validation.valid && !hasCircular) {
11357
- errEl.textContent = "";
11358
- errEl.classList.add("hidden");
11359
- } else {
11360
- errEl.textContent = !validation.valid ? validation.error : "Circular dependency detected";
11361
- errEl.classList.remove("hidden");
11362
- }
12480
+ const isValid2 = (() => {
12481
+ if (!formula.trim())
12482
+ return true;
12483
+ const v = validateFormula(formula, availableIds, availableNames, selectedField.id);
12484
+ if (!v.valid)
12485
+ return false;
12486
+ return !(deps.length > 0 && detectCircularDependency(schema, selectedField.id, formula, deps));
12487
+ })();
12488
+ numFormulaWidget.setError(!isValid2 && formula.trim().length > 0);
12489
+ if (!isValid2 && formula.trim()) {
12490
+ const v = validateFormula(formula, availableIds, availableNames, selectedField.id);
12491
+ formulaError.textContent = !v.valid ? v.error : "Circular dependency detected";
12492
+ formulaError.classList.remove("hidden");
12493
+ } else {
12494
+ formulaError.textContent = "";
12495
+ formulaError.classList.add("hidden");
11363
12496
  }
11364
12497
  formStore.getState().updateField(selectedField.id, { formula, dependencies: deps });
11365
12498
  }
11366
12499
  });
11367
- formulaGroup.appendChild(formulaInput);
11368
- const formulaError = createElement("div", { className: "text-xs text-red-600 dark:text-red-400 mt-1 formula-error hidden" });
12500
+ numFormulaWidget.getElement().querySelector("[data-formula-editor]")?.addEventListener("focus", () => {
12501
+ lastFocusedFormulaWidget = numFormulaWidget;
12502
+ });
12503
+ formulaGroup.appendChild(numFormulaWidget.getElement());
12504
+ const formulaError = createElement("div", {
12505
+ className: "text-xs text-red-600 dark:text-red-400 mt-1 formula-error hidden"
12506
+ });
11369
12507
  formulaGroup.appendChild(formulaError);
11370
12508
  body.appendChild(formulaGroup);
11371
12509
  const insertGroup = createElement("div", { className: "mb-3" });
11372
- insertGroup.appendChild(createElement("label", { className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1", text: "Insert Field" }));
12510
+ insertGroup.appendChild(createElement("label", {
12511
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12512
+ text: "Insert Field"
12513
+ }));
11373
12514
  const insertSelect = createElement("select", {
11374
12515
  className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
11375
12516
  onchange: (e) => {
@@ -11377,33 +12518,364 @@ var FormBuilder = class {
11377
12518
  const ref = sel.value;
11378
12519
  if (!ref)
11379
12520
  return;
11380
- const current = selectedField.formula || "";
11381
- const insert = current ? ` ${ref} ` : ref;
11382
- const newFormula = current + insert;
11383
- formStore.getState().updateField(selectedField.id, {
11384
- formula: newFormula,
11385
- dependencies: parseFormulaDependencies(newFormula)
11386
- });
11387
- formulaInput.value = newFormula;
12521
+ const field = numFieldInfos.find((f) => f.fieldName === ref || f.id === ref);
12522
+ if (field) {
12523
+ numFormulaWidget.insertField(field);
12524
+ lastFocusedFormulaWidget = numFormulaWidget;
12525
+ }
11388
12526
  sel.value = "";
11389
- this.render();
11390
12527
  }
11391
12528
  });
11392
- insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert...", selected: true }));
11393
- numericFields.forEach((f) => {
12529
+ insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert\u2026", selected: true }));
12530
+ numFieldInfos.forEach((f) => {
11394
12531
  const ref = f.fieldName !== f.id ? f.fieldName : f.id;
11395
- insertSelect.appendChild(createElement("option", { value: ref, text: `${f.label} (${ref})` }));
12532
+ insertSelect.appendChild(createElement("option", { value: ref, text: f.label }));
11396
12533
  });
11397
12534
  insertGroup.appendChild(insertSelect);
11398
12535
  body.appendChild(insertGroup);
11399
- const hintEl = createElement("p", {
12536
+ body.appendChild(createElement("label", {
12537
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12538
+ text: "Operators"
12539
+ }));
12540
+ const numMathWrap = createElement("div", { className: "flex flex-wrap gap-1 mb-3" });
12541
+ [
12542
+ { text: "+", insert: " + " },
12543
+ { text: "-", insert: " - " },
12544
+ { text: "*", insert: " * " },
12545
+ { text: "/", insert: " / " },
12546
+ { text: "%", insert: " % " },
12547
+ { text: "(", insert: "(" },
12548
+ { text: ")", insert: ")" }
12549
+ ].forEach((op) => {
12550
+ numMathWrap.appendChild(createElement("button", {
12551
+ type: "button",
12552
+ className: "px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded font-mono hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors",
12553
+ text: op.text,
12554
+ onclick: () => {
12555
+ (lastFocusedFormulaWidget ?? numFormulaWidget).insertText(op.insert);
12556
+ }
12557
+ }));
12558
+ });
12559
+ body.appendChild(numMathWrap);
12560
+ body.appendChild(createElement("p", {
11400
12561
  className: "text-xs text-gray-500 dark:text-gray-400 mb-2",
11401
- text: "Use +, -, *, / and parentheses. Reference fields by their name or ID."
12562
+ text: "Fields shown as labels \u2014 stored as field references internally."
12563
+ }));
12564
+ }
12565
+ }
12566
+ if (selectedField.type === "formula") {
12567
+ const schema = formStore.getState().schema;
12568
+ const allSchemaFieldInfos = schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== selectedField.id).map((f) => ({
12569
+ id: f.id,
12570
+ fieldName: f.fieldName ?? f.id,
12571
+ label: f.label || f.fieldName || f.id
12572
+ }));
12573
+ const availableFields = getFieldsForFormula(schema, selectedField.id);
12574
+ const cfg = selectedField.formulaConfig ?? {
12575
+ mode: "single",
12576
+ single: { expression: "" },
12577
+ multiple: { compareField: "", conditions: [], fallbackExpression: "" },
12578
+ decimalPlaces: 2
12579
+ };
12580
+ const patchFormulaConfig = (patch) => {
12581
+ const fresh = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id);
12582
+ const current = fresh?.formulaConfig ?? cfg;
12583
+ formStore.getState().updateField(selectedField.id, { formulaConfig: { ...current, ...patch } });
12584
+ };
12585
+ const patchMultiple = (patch) => {
12586
+ const fresh = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id);
12587
+ const current = fresh?.formulaConfig ?? cfg;
12588
+ patchFormulaConfig({ multiple: { ...current.multiple, ...patch } });
12589
+ };
12590
+ const formulaHeader = createElement("h3", {
12591
+ className: "text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 mt-4",
12592
+ text: "Formula Configuration"
12593
+ });
12594
+ body.appendChild(formulaHeader);
12595
+ const modeGroup = createElement("div", { className: "mb-4" });
12596
+ modeGroup.appendChild(createElement("label", {
12597
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-2",
12598
+ text: "Mode"
12599
+ }));
12600
+ const modeRow = createElement("div", { className: "flex gap-2" });
12601
+ const activeModeClass = "flex-1 px-3 py-1.5 text-sm rounded-md border bg-[#635bff] text-white border-[#635bff] font-medium";
12602
+ const inactiveModeClass = "flex-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-[#635bff] transition-colors";
12603
+ const singleBtn = createElement("button", {
12604
+ type: "button",
12605
+ className: cfg.mode === "single" ? activeModeClass : inactiveModeClass,
12606
+ text: "Single Expression",
12607
+ onclick: () => {
12608
+ patchFormulaConfig({ mode: "single" });
12609
+ this.render();
12610
+ }
12611
+ });
12612
+ const multipleBtn = createElement("button", {
12613
+ type: "button",
12614
+ className: cfg.mode === "multiple" ? activeModeClass : inactiveModeClass,
12615
+ text: "Multiple Conditions",
12616
+ onclick: () => {
12617
+ patchFormulaConfig({ mode: "multiple" });
12618
+ this.render();
12619
+ }
12620
+ });
12621
+ modeRow.appendChild(singleBtn);
12622
+ modeRow.appendChild(multipleBtn);
12623
+ modeGroup.appendChild(modeRow);
12624
+ body.appendChild(modeGroup);
12625
+ const fwFieldInfos = allSchemaFieldInfos;
12626
+ lastFocusedFormulaWidget = null;
12627
+ const registerFormulaWidget = (widget) => {
12628
+ widget.getElement().querySelector("[data-formula-editor]")?.addEventListener("focus", () => {
12629
+ lastFocusedFormulaWidget = widget;
12630
+ });
12631
+ };
12632
+ if (cfg.mode === "single") {
12633
+ const exprGroup = createElement("div", { className: "mb-3" });
12634
+ exprGroup.appendChild(createElement("label", {
12635
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12636
+ text: "Expression"
12637
+ }));
12638
+ const exprError = createElement("div", { className: "text-xs text-red-500 mt-1 hidden" });
12639
+ const singleWidget = new FormulaEditorWidget({
12640
+ mode: "bracket",
12641
+ // Use ALL schema fields for the fieldMap so any ref resolves to its label
12642
+ availableFields: fwFieldInfos,
12643
+ // Source of truth: always read from persisted cfg, never from a
12644
+ // closure variable. This guarantees correct reload after field switch.
12645
+ initialValue: cfg.single?.expression ?? "",
12646
+ placeholder: "e.g. {fieldA} + {fieldB} * 1.18",
12647
+ onChange: (expr) => {
12648
+ const result = validateFormulaExpression(expr, fwFieldInfos.map((f) => f.fieldName));
12649
+ const isValid2 = result.valid || !expr.trim();
12650
+ singleWidget.setError(!isValid2);
12651
+ exprError.textContent = isValid2 ? "" : result.error;
12652
+ exprError.classList.toggle("hidden", isValid2);
12653
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12654
+ formStore.getState().updateField(selectedField.id, {
12655
+ formulaConfig: { ...freshCfg, single: { expression: expr } }
12656
+ });
12657
+ }
12658
+ });
12659
+ registerFormulaWidget(singleWidget);
12660
+ lastFocusedFormulaWidget = singleWidget;
12661
+ exprGroup.appendChild(singleWidget.getElement());
12662
+ exprGroup.appendChild(exprError);
12663
+ body.appendChild(exprGroup);
12664
+ } else {
12665
+ const compareGroup = createElement("div", { className: "mb-3" });
12666
+ compareGroup.appendChild(createElement("label", {
12667
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12668
+ text: "Compare Field"
12669
+ }));
12670
+ const compareSelect = createElement("select", {
12671
+ className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
12672
+ onchange: (e) => {
12673
+ const val = e.target.value;
12674
+ patchMultiple({ compareField: val });
12675
+ }
12676
+ });
12677
+ compareSelect.appendChild(createElement("option", { value: "", text: "Select field to compare\u2026", selected: !cfg.multiple?.compareField }));
12678
+ schema.sections.flatMap((s) => s.fields).filter((f) => f.id !== selectedField.id).forEach((f) => {
12679
+ const fn = f.fieldName ?? f.id;
12680
+ compareSelect.appendChild(createElement("option", {
12681
+ value: fn,
12682
+ text: `${f.label} (${fn})`,
12683
+ selected: cfg.multiple?.compareField === fn
12684
+ }));
12685
+ });
12686
+ compareGroup.appendChild(compareSelect);
12687
+ body.appendChild(compareGroup);
12688
+ body.appendChild(createElement("label", {
12689
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-2",
12690
+ text: "Conditions"
12691
+ }));
12692
+ const conditions = cfg.multiple?.conditions ?? [];
12693
+ conditions.forEach((cond, idx) => {
12694
+ const row = createElement("div", { className: "mb-2 p-2 rounded-md border border-gray-100 dark:border-gray-800 space-y-1" });
12695
+ row.appendChild(createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "When equals" }));
12696
+ row.appendChild(createElement("input", {
12697
+ type: "text",
12698
+ className: "w-full px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
12699
+ value: cond.value,
12700
+ placeholder: "e.g. BHS 146",
12701
+ oninput: (e) => {
12702
+ const v = e.target.value;
12703
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12704
+ const newConds = [...freshCfg.multiple?.conditions ?? []];
12705
+ newConds[idx] = { ...newConds[idx], value: v };
12706
+ patchMultiple({ conditions: newConds });
12707
+ }
12708
+ }));
12709
+ row.appendChild(createElement("div", { className: "text-xs text-gray-500 dark:text-gray-400", text: "\u2192 Expression" }));
12710
+ const condWidget = new FormulaEditorWidget({
12711
+ mode: "bracket",
12712
+ availableFields: fwFieldInfos,
12713
+ // all-schema fieldMap
12714
+ initialValue: cond.expression ?? "",
12715
+ placeholder: "e.g. {fieldA} + {fieldB}",
12716
+ onChange: (expr) => {
12717
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12718
+ const newConds = [...freshCfg.multiple?.conditions ?? []];
12719
+ newConds[idx] = { ...newConds[idx], expression: expr };
12720
+ patchMultiple({ conditions: newConds });
12721
+ }
12722
+ });
12723
+ registerFormulaWidget(condWidget);
12724
+ if (!lastFocusedFormulaWidget)
12725
+ lastFocusedFormulaWidget = condWidget;
12726
+ row.appendChild(condWidget.getElement());
12727
+ row.appendChild(createElement("button", {
12728
+ type: "button",
12729
+ className: "text-xs text-red-500 hover:text-red-700 dark:text-red-400",
12730
+ text: "\u2212 Remove",
12731
+ onclick: () => {
12732
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12733
+ const newConds = [...freshCfg.multiple?.conditions ?? []];
12734
+ newConds.splice(idx, 1);
12735
+ patchMultiple({ conditions: newConds });
12736
+ this.render();
12737
+ }
12738
+ }));
12739
+ body.appendChild(row);
11402
12740
  });
11403
- body.appendChild(hintEl);
12741
+ body.appendChild(createElement("button", {
12742
+ type: "button",
12743
+ className: "w-full mb-3 py-1.5 text-sm border border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-500 dark:text-gray-400 hover:border-[#635bff] hover:text-[#635bff] transition-colors",
12744
+ text: "+ Add Condition",
12745
+ onclick: () => {
12746
+ const freshCfg = formStore.getState().schema.sections.flatMap((s) => s.fields).find((f) => f.id === selectedField.id)?.formulaConfig ?? cfg;
12747
+ const newConds = [...freshCfg.multiple?.conditions ?? [], { value: "", expression: "" }];
12748
+ patchMultiple({ conditions: newConds });
12749
+ this.render();
12750
+ }
12751
+ }));
12752
+ const fallbackGroup = createElement("div", { className: "mb-3" });
12753
+ fallbackGroup.appendChild(createElement("label", {
12754
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12755
+ text: "Fallback Expression"
12756
+ }));
12757
+ const fallbackWidget = new FormulaEditorWidget({
12758
+ mode: "bracket",
12759
+ availableFields: fwFieldInfos,
12760
+ initialValue: cfg.multiple?.fallbackExpression ?? "",
12761
+ placeholder: "e.g. 0",
12762
+ onChange: (expr) => {
12763
+ patchMultiple({ fallbackExpression: expr });
12764
+ }
12765
+ });
12766
+ registerFormulaWidget(fallbackWidget);
12767
+ if (!lastFocusedFormulaWidget)
12768
+ lastFocusedFormulaWidget = fallbackWidget;
12769
+ fallbackGroup.appendChild(fallbackWidget.getElement());
12770
+ body.appendChild(fallbackGroup);
11404
12771
  }
12772
+ if (fwFieldInfos.length > 0) {
12773
+ const insertGroup = createElement("div", { className: "mb-3" });
12774
+ insertGroup.appendChild(createElement("label", {
12775
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12776
+ text: "Insert Field Reference"
12777
+ }));
12778
+ const insertSelect = createElement("select", {
12779
+ className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
12780
+ onchange: (e) => {
12781
+ const sel = e.target;
12782
+ const ref = sel.value;
12783
+ if (!ref)
12784
+ return;
12785
+ const field = fwFieldInfos.find((f) => f.fieldName === ref || f.id === ref);
12786
+ const target = lastFocusedFormulaWidget;
12787
+ if (field && target) {
12788
+ target.insertField(field);
12789
+ }
12790
+ sel.value = "";
12791
+ }
12792
+ });
12793
+ insertSelect.appendChild(createElement("option", { value: "", text: "Select field to insert\u2026", selected: true }));
12794
+ fwFieldInfos.forEach((f) => {
12795
+ insertSelect.appendChild(createElement("option", {
12796
+ value: f.fieldName !== f.id ? f.fieldName : f.id,
12797
+ text: f.label
12798
+ // show only human-readable label
12799
+ }));
12800
+ });
12801
+ insertGroup.appendChild(insertSelect);
12802
+ body.appendChild(insertGroup);
12803
+ }
12804
+ body.appendChild(createElement("label", {
12805
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12806
+ text: "Math Helpers"
12807
+ }));
12808
+ const mathWrap = createElement("div", { className: "flex flex-wrap gap-1 mb-3" });
12809
+ const mathOps = [
12810
+ { text: "+", insert: " + " },
12811
+ { text: "-", insert: " - " },
12812
+ { text: "*", insert: " * " },
12813
+ { text: "/", insert: " / " },
12814
+ { text: "(", insert: "(" },
12815
+ { text: ")", insert: ")" },
12816
+ { text: "ROUND", insert: "ROUND(" },
12817
+ { text: "ABS", insert: "ABS(" },
12818
+ { text: "MIN", insert: "MIN(" },
12819
+ { text: "MAX", insert: "MAX(" },
12820
+ { text: "FLOOR", insert: "FLOOR(" },
12821
+ { text: "CEIL", insert: "CEIL(" }
12822
+ ];
12823
+ mathOps.forEach((op) => {
12824
+ mathWrap.appendChild(createElement("button", {
12825
+ type: "button",
12826
+ className: "px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-700 rounded font-mono hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors",
12827
+ text: op.text,
12828
+ onclick: () => {
12829
+ lastFocusedFormulaWidget?.insertText(op.insert);
12830
+ }
12831
+ }));
12832
+ });
12833
+ body.appendChild(mathWrap);
12834
+ const dpGroup = createElement("div", { className: "mb-3" });
12835
+ dpGroup.appendChild(createElement("label", {
12836
+ className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1",
12837
+ text: "Decimal Places"
12838
+ }));
12839
+ dpGroup.appendChild(createElement("input", {
12840
+ type: "number",
12841
+ className: "w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md bg-transparent",
12842
+ value: String(cfg.decimalPlaces ?? 2),
12843
+ min: "0",
12844
+ max: "10",
12845
+ placeholder: "2",
12846
+ oninput: (e) => {
12847
+ const v = e.target.value;
12848
+ patchFormulaConfig({ decimalPlaces: v !== "" ? parseInt(v, 10) : 2 });
12849
+ }
12850
+ }));
12851
+ body.appendChild(dpGroup);
12852
+ const zeroValues = {};
12853
+ availableFields.forEach((f) => {
12854
+ zeroValues[f.fieldName] = 0;
12855
+ zeroValues[f.id] = 0;
12856
+ });
12857
+ let previewText = "\u2014";
12858
+ try {
12859
+ const previewResult = evaluateFormulaConfig(cfg, zeroValues);
12860
+ if (!previewResult.error && !isNaN(previewResult.result)) {
12861
+ previewText = previewResult.result.toFixed(cfg.decimalPlaces ?? 2);
12862
+ } else if (previewResult.error) {
12863
+ previewText = `Error: ${previewResult.error}`;
12864
+ }
12865
+ } catch {
12866
+ }
12867
+ const previewSection = createElement("div", { className: "mb-4" });
12868
+ previewSection.appendChild(createElement("label", {
12869
+ className: "block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1",
12870
+ text: "Preview (fields = 0)"
12871
+ }));
12872
+ previewSection.appendChild(createElement("div", {
12873
+ className: "px-3 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md text-sm font-mono text-gray-700 dark:text-gray-300",
12874
+ text: previewText
12875
+ }));
12876
+ body.appendChild(previewSection);
11405
12877
  }
11406
- if (selectedField.type !== "image") {
12878
+ if (selectedField.type !== "image" && selectedField.type !== "formula") {
11407
12879
  const placeholderGroup = createElement("div");
11408
12880
  placeholderGroup.appendChild(createElement("label", { className: "block text-sm font-normal text-gray-700 dark:text-gray-300 mb-1", text: "Placeholder" }));
11409
12881
  placeholderGroup.appendChild(createElement("input", {
@@ -12885,10 +14357,15 @@ exports.builderToPlatform = builderToPlatform;
12885
14357
  exports.cleanFormSchema = cleanFormSchema;
12886
14358
  exports.convertValidationObjectToArray = convertValidationObjectToArray;
12887
14359
  exports.detectCircularDependency = detectCircularDependency;
14360
+ exports.detectFormulaFieldCircularDependency = detectFormulaFieldCircularDependency;
12888
14361
  exports.evaluateFormula = evaluateFormula;
14362
+ exports.evaluateFormulaConfig = evaluateFormulaConfig;
14363
+ exports.evaluateFormulaExpression = evaluateFormulaExpression;
14364
+ exports.extractBracketFields = extractBracketFields;
12889
14365
  exports.formStore = formStore;
12890
14366
  exports.generateName = generateName;
12891
14367
  exports.getColSpanFromWidth = getColSpanFromWidth;
14368
+ exports.getFieldsForFormula = getFieldsForFormula;
12892
14369
  exports.getNumericFieldsForFormula = getNumericFieldsForFormula;
12893
14370
  exports.getValidationConfigForAngular = getValidationConfigForAngular;
12894
14371
  exports.initFormBuilder = initFormBuilder;
@@ -12897,5 +14374,6 @@ exports.parseWidth = parseWidth;
12897
14374
  exports.platformToBuilder = platformToBuilder;
12898
14375
  exports.resetNameGeneratorCounter = resetNameGeneratorCounter;
12899
14376
  exports.validateFormula = validateFormula;
14377
+ exports.validateFormulaExpression = validateFormulaExpression;
12900
14378
  //# sourceMappingURL=out.js.map
12901
14379
  //# sourceMappingURL=index.js.map