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