// =========================
// CustomBaseValidator Class
// =========================
class CustomBaseValidator {
static allCustomValidators = {}; // All custom validators
constructor(targetErrorDisplayLocation, messageText, evaluationFunction, includeLabel = true) {
if (!targetErrorDisplayLocation)
throw new Error("targetErrorDisplayLocation is required");
// Flag for including field label in error
this.includeLabel = includeLabel;
// Resolve target element
if (targetErrorDisplayLocation instanceof HTMLElement) {
this.targetErrorDisplayEl = targetErrorDisplayLocation;
} else if (typeof targetErrorDisplayLocation === "string") {
this.targetErrorDisplayEl = document.getElementById(targetErrorDisplayLocation);
} else {
throw new Error("targetErrorDisplayLocation must be an HTMLElement or a string ID");
}
if (!this.targetErrorDisplayEl)
throw new Error("Element not found in DOM");
this.messageText = messageText || "";
// Use provided evaluation function.
if (evaluationFunction && typeof evaluationFunction === "function") {
this.evaluationFunction = evaluationFunction;
}
// Create error display element.
this.element = document.createElement("span");
this.element.id = `${this.constructor.name}_${this.targetErrorDisplayEl.id}_validator`;
this.element.style.display = "none";
this.element.errormessage = `${this.messageText}`;
this.element.evaluationfunction = this.evaluationFunction.bind(this);
// Store this instance.
CustomBaseValidator.allCustomValidators[this.element.id] = this;
// Register with the underlying system.
if (typeof Page_Validators !== "undefined") {
Page_Validators.push(this.element);
Page_Validators.sort((a, b) => {
if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
return -1;
if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING)
return 1;
return 0;
});
}
}
evaluationFunction() {
throw new Error("evaluationFunction must be overridden");
}
updateMessage(newMessage) {
if (newMessage !== undefined) this.messageText = newMessage;
const content = this.includeLabel && this.targetErrorDisplayEl.innerText
? `${this.targetErrorDisplayEl.innerText} ${this.messageText}`
: this.messageText;
this.element.errormessage = `${content}`;
}
}
// ==========================
// CustomFieldValidator Class
// ==========================
class CustomFieldValidator extends CustomBaseValidator {
static childParentMapping = {};
constructor({
controlToValidate,
messageText = "",
id = "",
evaluationFunction,
targetErrorDisplayLocation,
includeLabel = true
} = {}) {
if (!controlToValidate) throw new Error("controlToValidate is required.");
// Resolve the control element.
const control = controlToValidate instanceof HTMLElement ? controlToValidate : document.getElementById(controlToValidate);
if (!control) throw new Error("Control not found in DOM.");
const cell = control.closest('.cell');
const info = cell.querySelector('.info, .table-info');
// Get the control's label element.
const fieldLabelEL = info.querySelector('label');
if (!fieldLabelEL) throw new Error("Label for field not found.");
// Ensure consistent validation-field-label class
fieldLabelEL.classList.add('validation-field-label');
// Get the validators container from the control.
let validatorsContainer = info.querySelector('.validators');
if (!validatorsContainer) {
validatorsContainer = document.createElement("div");
validatorsContainer.className = "validators";
fieldLabelEL.insertAdjacentElement('beforebegin', validatorsContainer);
}
// Determine display target.
const displayTarget = targetErrorDisplayLocation || fieldLabelEL;
// Initialize parent with includeLabel flag.
super(displayTarget, "", evaluationFunction, includeLabel);
if (Object.keys(CustomFieldValidator.childParentMapping).includes(controlToValidate.id)) {
let parentValidation = CustomFieldValidator.childParentMapping[controlToValidate.id];
parentValidation.subValidations.push(this);
}
this.fieldLabel = fieldLabelEL;
this.validatorsContainer = validatorsContainer;
this.validatorsContainer.appendChild(this.element);
this.subValidations = [];
this.element.id = `${this.constructor.name}_${control.id}_validator`;
this.control = control;
if (messageText) this.customError = true;
this.updateMessage(messageText);
}
updateMessage(newMessage) {
if (newMessage !== undefined) this.messageText = newMessage;
const labelPart = this.includeLabel ? `${this.getControlLabelText()} `: '';
const content = `${labelPart}${this.messageText}`;
this.element.errormessage = `${content}`;
}
getChildrenValidationElements() {}
static markControlForSubValidation(childControl, parentValidation) {
CustomFieldValidator.childParentMapping[childControl.id] = parentValidation;
}
getControlLabelText() {
const fallbackLabel = {
'en': 'This',
'fr': 'Ceci'
}
if (this.control?.label) {
const firstChild = this.control.label.childNodes[0];
// Check if the first child is a text node (nodeType 3) and has a value
if (firstChild && firstChild.nodeType === 3) {
return firstChild.nodeValue.trim();
}
}
return fallbackLabel[LANG];
}
}
// ===================================
// DynamicFieldRequiredValidator Class
// ===================================
class DynamicFieldRequiredValidator extends CustomFieldValidator {
constructor({ controlToValidate, messageText = "", id = "", evaluationFunction, includeLabel = true } = {}) {
const defaultMessage =
{
'en': "is a required field",
'fr': "est obligatoire."
}
if (messageText === "") messageText = defaultMessage[LANG];
super({ controlToValidate, messageText, id, evaluationFunction });
// Optionally add a 'required' class.
if (this.control?.label != null) {
this.control.label.required = true;
}
}
evaluationFunction() {
return this.hasContent();
}
hasContent() {
if (this.control.hasOwnProperty('boolvalue')) {
return this.control.boolvalue != null;
} else if ('value' in this.control) {
return this.control.value !== "";
} else if (this.control.classList.contains('subgrid')){
return true;
} else {
throw new Error("hasContent not detected");
return false;
}
}
}
class EmailMatchValidator extends CustomFieldValidator {
constructor({ controlToValidate, controlToCompare, virtualFieldLocation, messageText, includeLabel= true }) {
if (!controlToValidate) throw new Error("controlToValidate is required.");
if ((controlToCompare && virtualFieldLocation) || (!controlToCompare && !virtualFieldLocation)) {
throw new Error("Either controlToCompare or virtualFieldLocation must be set, but not both.");
}
const control = controlToValidate instanceof HTMLElement ? controlToValidate : document.getElementById(controlToValidate);
if (!control) throw new Error("Control not found in DOM.");
controlToCompare = controlToCompare instanceof HTMLElement ? controlToCompare : document.getElementById(controlToCompare);
if (!messageText) {
const defaultMessage =
{
'en': "The email addresses entered do not match.",
'fr': "Les adresses de courrier électronique saisies ne correspondent pas."
}
messageText = defaultMessage[LANG];
}
if (controlToCompare) {
super({controlToValidate: controlToCompare, messageText: messageText, id: "verifyEmailValidator", includeLabel: includeLabel});
this.control = controlToValidate;
this.compareControl = controlToCompare;
} else {
let {input: controlToCompare, validationLocation: cc_info} = EmailMatchValidator.generateVirtualizedField(control, virtualFieldLocation);
super({ controlToValidate: controlToCompare, messageText: messageText, targetErrorDisplayLocation: cc_info, id: "verifyEmailValidator", includeLabel: includeLabel});
this.control = controlToValidate;
this.compareControl = controlToCompare;
}
}
evaluationFunction() {
return this.control.value === this.compareControl.value;
}
static generateVirtualizedField(control, virtualFieldLocation) {
let parentCell = control.closest(".cell");
let vf_isCell = virtualFieldLocation?.classList.contains('cell');
let newCell = (vf_isCell) ? virtualFieldLocation : document.createElement("td");
newCell.className = "clearfix cell text form-control-cell";
newCell.setAttribute("data-inner-field-id", "emailaddress_virtual");
newCell.setAttribute("maxchar", "100");
newCell.setAttribute("maxwords", "19");
newCell.innerHTML = `
Re-enter your email address.
0 / 100
`;
if (!vf_isCell) {
parentCell.insertAdjacentElement("afterend", newCell);
}
let input = newCell.querySelector("input");
initField(input)
let validationLocation = newCell.querySelector("#verify_email_label");
return {input, validationLocation};
}
}
// ===================================
// LinkedBooleanRequiredValidator Class (Parent)
// ===================================
class LinkedBooleanRequiredValidator extends DynamicFieldRequiredValidator {
static isLinkedParent = true;
constructor({ controlToValidate, linkedBooleanControl, validCondition = true, messageText = "", requiredOnlyIfParentRequired = true,} = {}) {
super({ controlToValidate, messageText });
this.bool_control = linkedBooleanControl;
this.valid_condition = validCondition;
this.requiredOnlyIfParentRequired = requiredOnlyIfParentRequired;
CustomFieldValidator.markControlForSubValidation(controlToValidate, this);
// Attach listener to linkedBooleanControl
this.bool_control.addEventListener('change', () => {
this.syncRequiredLabel();
});
// Initial sync
this.syncRequiredLabel();
}
evaluationFunction() {
const boolRequired = this.bool_control.info?.classList?.contains("required");
const boolNull = this.bool_control.boolvalue === null;
const parentControlValid = (this.bool_control.boolvalue == this.valid_condition);
if (boolNull || !parentControlValid) {
this.subValidations.forEach((val) => { val.element.enabled = false });
} else {
this.subValidations.forEach((val) => { val.element.enabled = true });
}
if (boolNull || !boolRequired || !parentControlValid) return true;
return this.hasContent();
}
syncRequiredLabel() {
const label = this.control?.label;
if (!label) return;
const parentBoolRequired = this.bool_control.label.required;
const boolNull = this.bool_control.boolvalue === null;
const parentControlValid = (this.bool_control.boolvalue == this.valid_condition);
const shouldBeRequired = (!this.requiredOnlyIfParentRequired || parentBoolRequired) && parentControlValid && !boolNull;
label.required = shouldBeRequired;
}
}
// ===================================
// LinkedSelectRequiredValidator Class (Parent)
// ===================================
class LinkedSelectRequiredValidator extends DynamicFieldRequiredValidator {
static isLinkedParent = true;
constructor({
controlToValidate,
linkedSelectControl,
validValues = [], // array of values that trigger requirement
messageText = "",
requiredOnlyIfParentRequired = true,
invertLogic = false
} = {}) {
super({ controlToValidate, messageText });
this.select_control = linkedSelectControl;
this.valid_values = validValues;
this.requiredOnlyIfParentRequired = requiredOnlyIfParentRequired;
this.invertLogic = invertLogic;
CustomFieldValidator.markControlForSubValidation(controlToValidate, this);
// Attach listener to linkedSelectControl
this.select_control.addEventListener("change", () => {
this.syncRequiredLabel();
});
// Initial sync
this.syncRequiredLabel();
}
evaluationFunction() {
const selectRequired = this.select_control.info?.classList?.contains("required");
const selectValue = this.select_control.value;
let parentControlValid = (this.invertLogic) ? !this.valid_values.includes(selectValue) : this.valid_values.includes(selectValue);
if (!parentControlValid) {
this.subValidations.forEach((val) => {
val.element.enabled = false;
});
} else {
this.subValidations.forEach((val) => {
val.element.enabled = true;
});
}
if (!selectRequired || !parentControlValid) return true;
return this.hasContent();
}
syncRequiredLabel() {
const label = this.control?.label;
if (!label) return;
const parentSelectRequired = this.select_control.label.required;
const selectValue = this.select_control.value;
let parentControlValid = (this.invertLogic) ? !this.valid_values.includes(selectValue) : this.valid_values.includes(selectValue);
const shouldBeRequired =
(!this.requiredOnlyIfParentRequired || parentSelectRequired) &&
parentControlValid;
label.required = shouldBeRequired;
}
}
// ===================================
// AtLeastOneValidator Class (Child)
// ===================================
class AtLeastOneValidator extends CustomFieldValidator {
constructor({
controlToValidate,
controlToCompare,
messageText = ""
} = {}) {
super({
controlToValidate,
messageText,
includeLabel: false
});
this.controlToCompare = controlToCompare instanceof HTMLElement
? controlToCompare
: document.getElementById(controlToCompare);
if (!this.controlToCompare) {
// Throw the error *here* if the control is missing
throw new Error(`Control to compare not found in DOM for ID: ${controlToCompare}`);
}
if (messageText === "") {
// const label1 = this.getControlLabelText(this.control.id);
// const label2 = this.getControlLabelText(controlToValidate);
const label1 = this.getControlLabelText(this.controlToCompare.id);
const label2 = this.getControlLabelText(this.control.id);
const enMsg = `You must provide a value for either ${label1} or ${label2}.`;
const frMsg = `Vous devez fournir une valeur soit pour ${label1} ou ${label2}.`;
messageText = LANG === 'fr' ? frMsg : enMsg;
this.updateMessage (messageText);
}
this.element.evaluationfunction = this.evaluationFunction.bind(this);
}
// Override the base class's evaluation function
evaluationFunction() {
// Get values of Field 1 and Field 2, trimming whitespace
const value1 = this.controlToCompare.value.trim();
const value2 = this.control.value.trim();
// It fails if BOTH are empty.
return value1 !== "" || value2 !== "";
}
}
// =========================
// SubgridValidator Class (Child)
// =========================
class SubgridValidator extends CustomFieldValidator {
constructor({
subgridElement,
rowEvaluationFunction,
minValidRows = 1,
maxValidRows = Infinity,
aggregator,
messageText = "",
markSectionInvalid = true //Marks a section invalid on table reload if evaluation function fails.
} = {}) {
if (!subgridElement) throw new Error("subgridElement is required.");
const subgridEl = subgridElement instanceof HTMLElement ? subgridElement : document.getElementById(subgridElement);
if (!subgridEl) throw new Error("Subgrid element not found in DOM.");
// Generate markup.
const { infoEl, labelEl, validatorsContainer } = SubgridValidator.generateMissingMarkup(subgridEl);
// Default aggregator: count valid rows.
if (typeof aggregator !== "function") {
aggregator = (results, min, max) => {
const count = results.filter(Boolean).length;
return count >= min && count <= max;
};
}
// Default row evaluation.
if (typeof rowEvaluationFunction !== "function") {
rowEvaluationFunction = row => true;
}
super({
controlToValidate: subgridElement,
messageText: messageText
});
validatorsContainer.appendChild(this.element);
this.element.id = `SubgridValidator_${subgridEl.id}_validator`;
this.isFirstRun = true; // Instance-level flag for each subgrid
this.rowEvaluationFunction = rowEvaluationFunction;
this.minValidRows = minValidRows;
this.maxValidRows = maxValidRows;
this.aggregator = aggregator;
if (minValidRows > 0) {
infoEl.classList.add("required");
}
this.element.evaluationfunction = this.evaluationFunction.bind(this);
this.markSectionInvalid = markSectionInvalid;
this.element.evaluationfunction = this.evaluationFunction.bind(this);
// Setup the subgrid loading handler (runs every refresh of a subgrid);
$(subgridEl.firstChild).on("loaded", ()=>{ SubgridValidator.subgridReloadHandler(this);});
}
static async subgridReloadHandler(validatorInstance) {
// Check if it's the first run (don't excute on page load, only after a change is made in the subgrid)
if (validatorInstance.isFirstRun) {
validatorInstance.isFirstRun = false; // Set the flag to false after the first run
return; // Skip the validation for the first run
}
if (validatorInstance.markSectionInvalid && FormSteps.thisStepValid) {
if (!validatorInstance.element.evaluationfunction()) {
FormSteps.markStepIncompleteAsync();
}
}
}
static generateMissingMarkup(subgridEl) {
const CELL = subgridEl.closest(".subgrid-cell");
let infoEl = CELL.querySelector(".info, .table-info");
if (!infoEl) {
infoEl = document.createElement("h3");
infoEl.className = "info form-subgrid-heading";
CELL.prepend(infoEl);
}
let labelEl = infoEl.querySelector("label");
if (!labelEl) {
labelEl = document.createElement("label");
labelEl.className = "field-label";
labelEl.style.display = "block";
if (CELL.id) {
labelEl.setAttribute("for", CELL.id);
labelEl.id = `${CELL.id}_label`;
}
labelEl.innerText = CELL.getAttribute("data-label") || "Subgrid";
infoEl.appendChild(labelEl);
}
let validatorsContainer = infoEl.querySelector(".validators");
if (!validatorsContainer) {
validatorsContainer = document.createElement("div");
validatorsContainer.className = "validators";
infoEl.appendChild(validatorsContainer);
}
return { infoEl, labelEl, validatorsContainer };
}
evaluationFunction() {
const rows = Array.from(
this.control.querySelectorAll("table tbody > tr[data-entity]")
);
const results = rows.map((row) => {
const isValidRow = this.rowEvaluationFunction(row);
row.dataset.valid = isValidRow;
return isValidRow;
});
const validCount = results.filter(Boolean).length;
let failCondition = "";
if (this.minValidRows > 0 && rows.length === 0) {
failCondition = "noRows";
} else if (this.minValidRows == 'all' && validCount != rows.length) {
failCondition = "min"; //Note requires a rowEvaluationFunction
} else if (validCount < this.minValidRows) {
failCondition = "min";
} else if (validCount > this.maxValidRows) {
failCondition = "max";
}
if (!this.customError) {
const errorMessage = this.generateMessage(failCondition, validCount, rows.length);
this.updateMessage(errorMessage);
}
return failCondition === "";
}
generateMessage(failCondition, validCount, totalRows) {
let message = "";
if (LANG === 'fr') {
switch (failCondition) {
case "noRows":
message = `Au moins ${this.minValidRows} enregistrement(s) requis, mais aucun trouvé.`;
break;
case "min":
message = `${validCount} enregistrement(s) valides trouvés; le minimum requis est ${this.minValidRows}.`;
break;
case "max":
message = `Trop d'enregistrements valides: ${validCount} trouvés; le maximum autorisé est ${this.maxValidRows}.`;
break;
}
} else {
switch (failCondition) {
case "noRows":
message = `At least ${this.minValidRows} record(s) required, but none were found.`;
break;
case "min":
message = `${validCount} valid record(s) found; minimum required is ${this.minValidRows}.`;
break;
case "max":
message = `Too many valid records: ${validCount} found; maximum allowed is ${this.maxValidRows}.`;
break;
}
}
return message;
}
getValidRows() {
const rows = Array.from(
this.subgridEl.querySelectorAll("table tbody > tr[data-entity]")
);
return rows.filter(this.rowEvaluationFunction);
}
getInvalidRows() {
const rows = Array.from(
this.subgridEl.querySelectorAll("table tbody > tr[data-entity]")
);
return rows.filter(row => !this.rowEvaluationFunction(row));
}
}
// ===================================
// PostalZipValidator Class
// ===================================
class PostalZipValidator extends CustomFieldValidator {
constructor({ controlToValidate, countryControl, id = "" } = {}) {
const defaultMessage =
{
'en': "Please enter a valid Postal or ZIP code format.",
'fr': "Veuillez saisir un code postal ou ZIP valide."
}
super({ controlToValidate, messageText: defaultMessage[LANG], id, includeLabel: false });
this.countryControl = countryControl;
// Optionally add a 'required' class.
this.control.label.required = true;
}
evaluationFunction() {
function validateUSZip(zip) {
const zipRegex = /^\d{5}(-\d{4})?$/;
return zipRegex.test(zip);
}
function validateCanadianPostal(postal) {
const postalRegex = /^[A-Za-z]\d[A-Za-z][ ]?\d[A-Za-z]\d$/;
return postalRegex.test(postal);
}
if (this.countryControl.value == 'd800ecae-fd66-ef11-a670-000d3af45d64'){
return validateCanadianPostal(this.control.value);
}
if (this.countryControl.value == 'c200ecae-fd66-ef11-a670-000d3af45d64'){
return validateUSZip(this.control.value);
}
return true;//All other countries
}
}
class DateAfterTodayValidator extends CustomFieldValidator {
constructor({ controlToValidate, messageText = "" } = {}) {
super({
controlToValidate,
messageText: messageText || (
LANG === "fr"
? "ne peut pas être antérieure à aujourd’hui."
: "cannot be before today."
)
});
// wire up our check
this.element.evaluationfunction = this.evaluationFunction.bind(this);
}
evaluationFunction() {
const raw = this.control.value;
if (!raw) return true;
const picked = new Date(raw);
const today = new Date(new Date().toDateString());
return !isNaN(picked) && picked >= today;
}
}
class DateRangeFieldValidator extends CustomFieldValidator {
constructor({
startDateControl,
endDateControl,
maxRange = Infinity, // Maximum duration after start
minRange = new Duration({ days: 0 }), // Minimum duration after start
minStart = null, // Earliest allowed start date (String of ISO date, or Date object or "today")
maxEnd = null, // Latest allowed end date (String of ISO date, or Date object)
maxFiscalYears = Infinity, // Max number of fiscal years the range can span
messageText = "",
id = "",
lang = LANG,
targetErrorDisplayLocation = null
} = {}) {
const displayTarget = targetErrorDisplayLocation || endDateControl;
super({
controlToValidate: endDateControl,
messageText: "",
id,
evaluationFunction: undefined,
targetErrorDisplayLocation: displayTarget
});
this.startDateControl = startDateControl;
this.endDateControl = endDateControl;
this.maxRange = maxRange;
this.minRange = minRange;
this.minStartRaw = minStart; // store raw input (e.g. "today")
this.maxEndRaw = maxEnd; // store raw input in case we want same treatment
this.minStartIsToday = (minStart === "today"); // keep this flag for messaging
this.maxFiscalYears = maxFiscalYears;
this.lang = lang;
this.element.isvalid = true;
this.updateMessage(this.generateMessage());
}
normalizeDateInput(input) {
if (input === "today") {
return new Date(new Date().toDateString()); // today at midnight
}
if (input instanceof Date) return input;
if (typeof input === "string" || typeof input === "number") return new Date(input);
return null;
}
generateMessage(failCondition) {
const locale = this.lang === "fr" ? "fr-CA" : "en-CA";
const shortLang = this.lang || "en";
const options = { year: "numeric", month: "short", day: "numeric" };
const messages = {
recordCount: {
en: {
noRows: min => `At least ${min} record(s) required, but none were found.`,
min: (valid, min) => `${valid} valid record(s) found; minimum required is ${min}.`,
max: (valid, max) => `Too many valid records: ${valid} found; maximum allowed is ${max}.`,
},
fr: {
noRows: min => `Au moins ${min} enregistrement(s) requis, mais aucun trouvé.`,
min: (valid, min) => `${valid} enregistrement(s) valides trouvés; le minimum requis est ${min}.`,
max: (valid, max) => `Trop d'enregistrements valides: ${valid} trouvés; le maximum autorisé est ${max}.`,
},
},
dateRange: {
en: {
endDate: "must be after the start date.",
minRange: min => `Minimum range is ${min}.`,
maxRange: max => `Maximum range is ${max === Infinity ? "∞" : max}.`,
bothRange: (min, max) => `Date must be between ${min} and ${max}.`,
minStartToday: "Start date cannot be before today.",
minStart: date => `Start date cannot be before ${date}.`,
maxEnd: date => `End date cannot be after ${date}.`,
fiscal: (max, html) => ` spans more than ${max} fiscal years: ${html}`,
},
fr: {
endDate: "doit être postérieure à la date de début.",
minRange: min => `La plage minimale est de ${min}.`,
maxRange: max => `La plage maximale est de ${max === Infinity ? "∞" : max}.`,
bothRange: (min, max) => `La date doit être comprise entre ${min} et ${max}.`,
minStartToday: "La date de début ne peut pas être antérieure à aujourd’hui.",
minStart: date => `La date de début ne peut pas être antérieure au ${date}.`,
maxEnd: date => `La date de fin ne peut pas être postérieure au ${date}.`,
fiscal: (max, html) => ` s'étend sur plus de ${max} années fiscales : ${html}`,
},
},
};
const t = {
count: messages.recordCount[shortLang],
date: messages.dateRange[shortLang]
};
switch (failCondition) {
case "noRows":
return t.count.noRows(this.minValidRows);
case "min":
return t.count.min(this.validCount, this.minValidRows);
case "max":
return t.count.max(this.validCount, this.maxValidRows);
case "endDate":
return t.date.endDate;
case "minRange":
return t.date.minRange(this.minRange.toString(locale));
case "maxRange":
return t.date.maxRange(this.maxRange.toString(locale));
case "bothRange":
return t.date.bothRange(this.minRange.toString(locale), this.maxRange.toString(locale));
case "minStartToday":
return t.date.minStartToday;
case "minStart":
return t.date.minStart(this.normalizeDateInput(this.minStartRaw).toLocaleDateString(locale));
case "maxEnd":
return t.date.maxEnd(this.maxEnd.toLocaleDateString(locale));
case "fiscal": {
const start = new Date(this.startDateControl.value);
const end = new Date(this.endDateControl.value);
const fiscalYearFor = d => d.getMonth() < 3 ? d.getFullYear() - 1 : d.getFullYear();
const startFY = fiscalYearFor(start);
const endFY = fiscalYearFor(end);
const fiscalYearsSpanned = [];
for (let year = startFY; year <= endFY; year++) {
const from = new Date(year, 3, 1);
const to = new Date(year + 1, 2, 31);
const label = `${from.toLocaleDateString(locale, options)} – ${to.toLocaleDateString(locale, options)}`;
fiscalYearsSpanned.push(`${label}; `);
}
const listHTML = `${fiscalYearsSpanned.join("")}
`;
return t.date.fiscal(this.maxFiscalYears, listHTML);
}
default:
return "";
}
}
countFiscalYearsSpanned(start, end) {
const fiscalYearFor = (date) =>
date.getMonth() < 3 ? date.getFullYear() - 1 : date.getFullYear();
const startFY = fiscalYearFor(start);
const endFY = fiscalYearFor(end);
return endFY - startFY + 1;
}
evaluationFunction() {
const startDate = new Date(this.startDateControl.value);
const endDate = new Date(this.endDateControl.value);
const durationMs = endDate - startDate;
const minStart = this.normalizeDateInput(this.minStartRaw);
const maxEnd = this.normalizeDateInput(this.maxEndRaw);
let failCondition = "";
if (isNaN(startDate) || isNaN(endDate)) {
this.updateMessage("");
return true;
}
if (startDate >= endDate) {
failCondition = "endDate";
} else {
const allowedMin = this.minRange instanceof Duration
? this.minRange.addTo(new Date(0)).getTime() - new Date(0).getTime()
: 0;
const allowedMax = this.maxRange === Infinity
? Infinity
: this.maxRange instanceof Duration
? this.maxRange.addTo(new Date(0)).getTime() - new Date(0).getTime()
: Infinity;
if (allowedMin > 0 && allowedMax < Infinity) {
if (durationMs < allowedMin || durationMs > allowedMax) {
failCondition = "bothRange";
}
} else if (allowedMin > 0 && allowedMax === Infinity) {
if (durationMs < allowedMin) {
failCondition = "minRange";
}
} else if (allowedMax < Infinity && allowedMin === 0) {
if (durationMs > allowedMax) {
failCondition = "maxRange";
}
}
if (!failCondition && minStart && startDate < minStart) {
failCondition = this.minStartIsToday ? "minStartToday" : "minStart";
}
if (!failCondition && maxEnd && endDate > maxEnd) {
failCondition = "maxEnd";
}
if (!failCondition && this.maxFiscalYears !== Infinity) {
const fiscalYears = this.countFiscalYearsSpanned(startDate, endDate);
if (fiscalYears > this.maxFiscalYears) {
failCondition = "fiscal";
}
}
}
const errorMessage = this.generateMessage(failCondition);
this.updateMessage(errorMessage);
return errorMessage === "";
}
}
class AllStepsCompleteValidator extends CustomFieldValidator {
constructor({
portalStepsControl,
menuSelector = 'section.menu>ul>li:not(.hidden)',
messageText = "",
id = ""
} = {}) {
const portalEl = portalStepsControl instanceof HTMLElement
? portalStepsControl
: document.getElementById("gac_stepscompleted_portal");
const activeAnchor = document.querySelector(`${menuSelector} a.active`);
const targetEl = activeAnchor ? activeAnchor.parentElement : portalEl;
super({ controlToValidate: portalEl, messageText, id, evaluationFunction: undefined, targetErrorDisplayLocation: targetEl });
this.menuSelector = menuSelector;
this.portalStepsControl = portalEl;
window.AllStepsValidator = this;
}
evaluationFunction() {
let stepsJSON;
try {
stepsJSON = JSON.parse(this.portalStepsControl.value);
} catch (e) {
stepsJSON = null;
}
let valid = true;
let missingSteps = "";
const stepArray = [];
const stepMenuItem = {};
const stepUrl = {};
const stepInnerText = {};
document.querySelectorAll(this.menuSelector).forEach(li => {
stepArray.push(li.id);
const anchor = li.querySelector('a');
if (anchor) {
stepMenuItem[li.id] = li;
stepUrl[li.id] = anchor.href;
stepInnerText[li.id] = anchor.innerText.trim();
}
});
stepArray.forEach(step => {
if (!stepsJSON || stepsJSON[step] !== 'true') {
missingSteps += '' + stepInnerText[step] + '';
stepMenuItem[step].classList.add('incomplete');
valid = false;
}
});
const overViewWarning = (LANG == 'en') ? 'These sections are not yet complete: ' : 'Ces sections ne sont pas encore terminées :';
const errorMessage = valid
? ""
: `${overViewWarning}`;
this.updateMessage(errorMessage);
return valid;
}
// Override to prevent parent's label wrapping.
updateMessage(newMessage) {
this.element.errormessage = newMessage;
}
}
// =========================
// Duration Class
// =========================
/**
* Duration
* Represents a time duration.
* @param {Object} params - Parameters object.
* @param {number} [params.years=0] - Years component.
* @param {number} [params.months=0] - Months component.
* @param {number} [params.days=0] - Days component.
*/
class Duration {
constructor({ years = 0, months = 0, days = 0 } = {}) {
this.years = years;
this.months = months;
this.days = days;
}
/**
* addTo
* Adds this duration to a given date.
* @param {Date} date - The date to add the duration to.
* @returns {Date} New date with duration added.
*/
addTo(date) {
const newDate = new Date(date);
newDate.setFullYear(newDate.getFullYear() + this.years);
newDate.setMonth(newDate.getMonth() + this.months);
newDate.setDate(newDate.getDate() + this.days);
return newDate;
}
/**
* toString
* Returns a string representation of the duration.
* @param {string} [lang="en"] - Language code ("en" or "fr").
* @returns {string} Duration as a string.
*/
toString(lang = "en") {
const parts = [];
if (this.years)
parts.push(lang === "fr" ? `${this.years} ${this.years > 1 ? "ans" : "an"}` : `${this.years} ${this.years > 1 ? "years" : "year"}`);
if (this.months)
parts.push(lang === "fr" ? `${this.months} mois` : `${this.months} ${this.months > 1 ? "months" : "month"}`);
if (this.days)
parts.push(lang === "fr" ? `${this.days} ${this.days > 1 ? "jours" : "jour"}` : `${this.days} ${this.days > 1 ? "days" : "day"}`);
return parts.join(" ") || (lang === "fr" ? "0 jour" : "0 day");
}
}
document.addEventListener('DOMContentLoaded', () => {
// 2) Inline‐error injector
const summary = document.querySelector('div.validation-summary');
if (summary) {
const updateCustomErrors = () => {
try {
// a) Remove any existing custom errors
document.querySelectorAll('strong.customError').forEach(el => el.remove());
// b) Only proceed if the summary is visible
if (summary.style.display !== '') return;
const prefix = LANG === 'fr' ? 'Erreur : ' : 'Error: ';
// Skip if AllStepsCompleteValidator is present
/* if (
Object.values(CustomBaseValidator.allCustomValidators)
.some(v => v instanceof AllStepsCompleteValidator)
) return; */
summary.querySelectorAll('li').forEach(li => {
const link = li.querySelector('a[href], [referencecontrolid]');
if (!link || !link.textContent.trim()) return;
// Get the field’s ID (strip leading “#” if present)
const rawId =
(link.getAttribute('href') || '').replace(/^#/, '') ||
link.getAttribute('referencecontrolid');
const fieldEl = document.getElementById(rawId);
if (!fieldEl) return;
// Use only the anchor’s innerHTML (so href isn’t copied)
const errorHtml = link.innerHTML;
fieldEl.insertAdjacentHTML(
'afterend',
`
${prefix}
${errorHtml}
`
);
});
} catch (err) {
console.error('Custom-error injection failed:', err);
}
};
// Single observer for style changes & new list items
const observer = new MutationObserver(muts => {
for (const m of muts) {
if (
(m.type === 'attributes' && m.attributeName === 'style') ||
m.type === 'childList'
) {
updateCustomErrors();
break;
}
}
});
observer.observe(summary, {
attributes: true,
attributeFilter: ['style'],
childList: true
});
// Clean up on unload
window.addEventListener('beforeunload', () => observer.disconnect());
}
// 3) Remove inline errors as soon as the user edits
const formContainer = document.querySelector('.crmEntityFormView');
if (formContainer) {
const remover = e =>
e.target.closest('td')?.querySelector('strong.customError')?.remove();
formContainer
.querySelectorAll(
'input[type="text"], input[type="url"], input[type="email"], textarea, select'
)
.forEach(i => {
i.addEventListener('input', remover);
i.addEventListener('change', remover);
});
formContainer
.querySelectorAll('button.launchentitylookup')
.forEach(btn => btn.addEventListener('click', remover));
}
});
// AllStepsValidator Integration With the Submission Handler Workflow.
// USAGE: Requires AllStepsValidator and the process workflow button on the same page
// Notes: Makes use of a proxy button since the built in workflow event handlers are capturing meaning we can't inject code "before" them without signifcant effort.
window.addEventListener('load', () => {
function checkValidBeforeProceed() {
return new Promise((resolve) => {
FormSteps.validateWithServerCheck().then(resolve);
});
}
if (typeof AllStepsValidator !== 'undefined') {
let SubmitHandlerWorkflowButton = document.querySelector('.workflow-link[data-workflowid="30b42c5d-2fe3-ef11-be21-6045bdf9bd66"]');
let BuiltInSubmit = document.querySelector('.submit-btn');
if (SubmitHandlerWorkflowButton) {
if(BuiltInSubmit) { BuiltInSubmit.remove()}
WebHelpFunc.replaceButtonVirtually(SubmitHandlerWorkflowButton, 'VirtualSubmitHandlerWorkflowButton', checkValidBeforeProceed );
}
}
});