Fix custom formula wizard bugs and add field name validation
- Fix Add Field blanking inputs: cfFields was IIFE-scoped so inline oninput handlers couldn't reach it; expose cfUpdateField on window - Fix ManualUnitPrice dropped in buildItemFromData: condition excluded isCustomFormulaItem, causing FixedRate items to reprice from scratch - Fix formula card missing on job pages: load CustomFormulaTemplates in PopulateJobItemDropDownsAsync so Details, EditItems, and Edit all get it; add customFormulaTemplates + formulaEvalUrl to Details and EditItems pageMeta - Add NCalc field name validation: client-side inline feedback (is-invalid + message on oninput) and pre-save sweep; server-side ValidateTemplateFields on Create and Update; rules: letter-start, letters/digits/underscores only, no duplicates, "rate" reserved Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3001,6 +3001,9 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||
entity.CompanyId = companyId;
|
||||
@@ -3019,6 +3022,9 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data." });
|
||||
|
||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
|
||||
if (entity == null || entity.CompanyId != companyId)
|
||||
@@ -3136,6 +3142,44 @@ public class CompanySettingsController : Controller
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
|
||||
/// must start with a letter, contain only letters/digits/underscores, and not use the
|
||||
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
|
||||
/// Returns an error message string on failure, or null if all names are valid.
|
||||
/// </summary>
|
||||
private static string? ValidateTemplateFields(string? fieldsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
|
||||
|
||||
List<System.Text.Json.JsonElement>? fields;
|
||||
try
|
||||
{
|
||||
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
|
||||
}
|
||||
catch { return "Invalid fields JSON."; }
|
||||
|
||||
if (fields == null) return null;
|
||||
|
||||
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return "All fields must have a variable name.";
|
||||
if (name == "rate")
|
||||
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
|
||||
if (!nameRegex.IsMatch(name))
|
||||
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
|
||||
if (!seen.Add(name))
|
||||
return $"Duplicate field name \"{name}\".";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||
|
||||
@@ -3340,6 +3340,13 @@ public class JobsController : Controller
|
||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||
ViewBag.UseMetric = useMetric;
|
||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||
|
||||
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
|
||||
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
|
||||
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
|
||||
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
|
||||
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2489,7 +2489,9 @@
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
"itemsFieldPrefix": "JobItems",
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
|
||||
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
|
||||
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@
|
||||
"useMetric": @Json.Serialize((bool)(ViewBag.UseMetric ?? false)),
|
||||
"pricingUrl": "@Url.Action("CalculatePricing", "Jobs")",
|
||||
"itemsFieldPrefix": "JobItems",
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")"
|
||||
"aiRecalcUrl": "@Url.Action("AiRecalcPrice", "Quotes")",
|
||||
"customFormulaTemplates": @Json.Serialize(ViewBag.CustomFormulaTemplates ?? new List<object>()),
|
||||
"formulaEvalUrl": "@Url.Action("EvaluateFormula", "CompanySettings")"
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -137,6 +137,37 @@
|
||||
cfRenderFields();
|
||||
};
|
||||
|
||||
window.cfUpdateField = function (i, key, val, isNumber) {
|
||||
cfFields[i][key] = isNumber ? (parseFloat(val) || 0) : val;
|
||||
if (key === 'name') cfValidateFieldNameInput(i, val);
|
||||
};
|
||||
|
||||
function cfValidateFieldName(name) {
|
||||
if (!name) return 'Field variable name is required.';
|
||||
if (name === 'rate') return '"rate" is reserved — it is pre-populated from the template\'s Default Rate.';
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) return 'Must start with a letter and contain only letters, digits, or underscores (no spaces).';
|
||||
return null;
|
||||
}
|
||||
|
||||
function cfValidateFieldNameInput(i, val) {
|
||||
const inputs = document.querySelectorAll('#cfFieldsList .field-name-input');
|
||||
const input = inputs[i];
|
||||
if (!input) return;
|
||||
const err = cfValidateFieldName(val);
|
||||
input.classList.toggle('is-invalid', !!err);
|
||||
let fb = input.nextElementSibling;
|
||||
if (err) {
|
||||
if (!fb || !fb.classList.contains('invalid-feedback')) {
|
||||
fb = document.createElement('div');
|
||||
fb.className = 'invalid-feedback';
|
||||
input.after(fb);
|
||||
}
|
||||
fb.textContent = err;
|
||||
} else if (fb && fb.classList.contains('invalid-feedback')) {
|
||||
fb.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function cfRenderFields() {
|
||||
const el = document.getElementById('cfFieldsList');
|
||||
if (!cfFields.length) {
|
||||
@@ -147,24 +178,24 @@
|
||||
<div class="border rounded p-2 mb-2 bg-light">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-3">
|
||||
<input type="text" class="form-control form-control-sm font-monospace"
|
||||
<input type="text" class="form-control form-control-sm font-monospace field-name-input${cfValidateFieldName(f.name) ? ' is-invalid' : ''}"
|
||||
placeholder="var_name" value="${escHtml(f.name)}"
|
||||
oninput="cfFields[${i}].name=this.value" />
|
||||
oninput="cfUpdateField(${i},'name',this.value)" />${cfValidateFieldName(f.name) ? `<div class="invalid-feedback">${escHtml(cfValidateFieldName(f.name))}</div>` : ''}
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Label" value="${escHtml(f.label)}"
|
||||
oninput="cfFields[${i}].label=this.value" />
|
||||
oninput="cfUpdateField(${i},'label',this.value)" />
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Unit" value="${escHtml(f.unit ?? '')}"
|
||||
oninput="cfFields[${i}].unit=this.value" />
|
||||
oninput="cfUpdateField(${i},'unit',this.value)" />
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
placeholder="Default" value="${f.defaultValue ?? ''}"
|
||||
oninput="cfFields[${i}].defaultValue=parseFloat(this.value)||0" />
|
||||
oninput="cfUpdateField(${i},'defaultValue',this.value,true)" />
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
<button type="button" class="btn btn-link text-danger p-0" onclick="cfRemoveField(${i})">
|
||||
@@ -292,6 +323,17 @@
|
||||
const formula = document.getElementById('cfFormula').value.trim();
|
||||
if (!formula) { showCfError('Formula is required.'); return; }
|
||||
|
||||
const fieldErrors = cfFields.map((f, i) => ({ i, err: cfValidateFieldName(f.name) })).filter(x => x.err);
|
||||
if (fieldErrors.length) {
|
||||
fieldErrors.forEach(x => cfValidateFieldNameInput(x.i, cfFields[x.i].name));
|
||||
showCfError(`Fix field name errors before saving: ${fieldErrors[0].err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const names = cfFields.map(f => f.name);
|
||||
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (dupes.length) { showCfError(`Duplicate field name: "${dupes[0]}"`); return; }
|
||||
|
||||
const dto = {
|
||||
id,
|
||||
name,
|
||||
|
||||
@@ -2865,7 +2865,7 @@ function buildItemFromWizard() {
|
||||
surfaceAreaSqFt: d.surfaceAreaSqFt || 0,
|
||||
estimatedMinutes: d.estimatedMinutes || 0,
|
||||
catalogItemId: d.catalogItemId || null,
|
||||
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null),
|
||||
manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem || d.isCustomFormulaItem ? (d.manualUnitPrice ?? null) : null),
|
||||
powderCostOverride: d.powderCostOverride ?? null,
|
||||
isGenericItem: !!d.isGenericItem,
|
||||
isLaborItem: !!d.isLaborItem,
|
||||
|
||||
Reference in New Issue
Block a user