4a49ec4f05
- G-01: ProjectWizard renders blueprint fieldDefs with DynamicFieldInput component - G-02: Blueprint rolePresets validated via RolePresetsSchema in wizard; API keeps loose schema - G-03: ProjectWizard step 2/3 validation (role, hoursPerDay, headcount required) - G-04: EstimateWizard validates baseCurrency and demand line cost rates - G-05: Project lifecycle transition guards with ALLOWED_TRANSITIONS map - G-06: Blueprint validator extended for minLength/maxLength/pattern and DATE range checks - G-07: assertBlueprintDynamicFields merges global blueprint fieldDefs into validation - G-08: (tracked — chapter managed dropdown; deferred to backend ticket) - G-09: JSDoc added to lcrCents/ucrCents clarifying LCR/UCR terminology - G-10: Dispo route redirect already in place — closed as done - G-11: packages/ui empty by design — closed as documented - G-12: @deprecated JSDoc added to CreateAllocationSchema and UpdateAllocationSchema - G-13: ProjectWizard review step enhanced with blueprint name, field values, skills, assignments - G-14: ProjectWizard handleSubmit collects per-item warnings instead of silent swallowing - G-15: Vacation cancel reverses usedDays entitlement for APPROVED ANNUAL/OTHER vacations Tests: all 1575 passing (1 pre-existing failure in insights-summary unrelated to these changes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.6 KiB
TypeScript
127 lines
4.6 KiB
TypeScript
import { FieldType, type BlueprintFieldDefinition } from "@capakraken/shared";
|
|
|
|
export interface CustomFieldValidationError {
|
|
key: string;
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Validates a `dynamicFields` record against an array of BlueprintFieldDefinitions.
|
|
* Returns an array of errors (empty = valid).
|
|
*/
|
|
export function validateCustomFields(
|
|
fieldDefs: BlueprintFieldDefinition[],
|
|
dynamicFields: Record<string, unknown>,
|
|
): CustomFieldValidationError[] {
|
|
const errors: CustomFieldValidationError[] = [];
|
|
|
|
for (const def of fieldDefs) {
|
|
const value = dynamicFields[def.key];
|
|
const isEmpty = value === undefined || value === null || value === "";
|
|
|
|
if (def.required && isEmpty) {
|
|
errors.push({ key: def.key, message: `${def.label} is required` });
|
|
continue;
|
|
}
|
|
|
|
if (isEmpty) continue;
|
|
|
|
switch (def.type) {
|
|
case FieldType.NUMBER: {
|
|
if (typeof value !== "number" && isNaN(Number(value))) {
|
|
errors.push({ key: def.key, message: `${def.label} must be a number` });
|
|
}
|
|
const validation = def.validation;
|
|
if (validation) {
|
|
const num = Number(value);
|
|
if (validation.min !== undefined && num < validation.min) {
|
|
errors.push({ key: def.key, message: `${def.label} must be at least ${validation.min}` });
|
|
}
|
|
if (validation.max !== undefined && num > validation.max) {
|
|
errors.push({ key: def.key, message: `${def.label} must be at most ${validation.max}` });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case FieldType.BOOLEAN:
|
|
if (value !== true && value !== false && value !== "true" && value !== "false") {
|
|
errors.push({ key: def.key, message: `${def.label} must be a boolean` });
|
|
}
|
|
break;
|
|
|
|
case FieldType.SELECT:
|
|
if (def.options && def.options.length > 0) {
|
|
const valid = def.options.some((o) => o.value === value);
|
|
if (!valid) {
|
|
const allowed = def.options.map((o) => o.label || o.value).join(", ");
|
|
errors.push({ key: def.key, message: `${def.label} must be one of: ${allowed}` });
|
|
}
|
|
}
|
|
break;
|
|
|
|
case FieldType.MULTI_SELECT:
|
|
if (Array.isArray(value) && def.options && def.options.length > 0) {
|
|
const validSet = new Set(def.options.map((o) => o.value));
|
|
const invalid = (value as string[]).filter((v) => !validSet.has(v));
|
|
if (invalid.length > 0) {
|
|
errors.push({ key: def.key, message: `${def.label} contains invalid values: ${invalid.join(", ")}` });
|
|
}
|
|
}
|
|
break;
|
|
|
|
case FieldType.URL:
|
|
try {
|
|
new URL(String(value));
|
|
} catch {
|
|
errors.push({ key: def.key, message: `${def.label} must be a valid URL` });
|
|
}
|
|
break;
|
|
|
|
case FieldType.EMAIL:
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
|
|
errors.push({ key: def.key, message: `${def.label} must be a valid email address` });
|
|
}
|
|
break;
|
|
|
|
case FieldType.TEXT:
|
|
case FieldType.TEXTAREA: {
|
|
const strVal = String(value);
|
|
const v = def.validation;
|
|
if (v) {
|
|
if (v.minLength !== undefined && strVal.length < v.minLength) {
|
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must be at least ${v.minLength} characters` });
|
|
}
|
|
if (v.maxLength !== undefined && strVal.length > v.maxLength) {
|
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must be at most ${v.maxLength} characters` });
|
|
}
|
|
if (v.pattern !== undefined && !new RegExp(v.pattern).test(strVal)) {
|
|
errors.push({ key: def.key, message: v.message ?? `${def.label} has an invalid format` });
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case FieldType.DATE: {
|
|
const dateVal = new Date(String(value));
|
|
if (isNaN(dateVal.getTime())) {
|
|
errors.push({ key: def.key, message: `${def.label} must be a valid date` });
|
|
} else {
|
|
const v = def.validation;
|
|
if (v) {
|
|
if (v.min !== undefined && dateVal.getTime() < new Date(v.min).getTime()) {
|
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must not be before ${new Date(v.min).toLocaleDateString()}` });
|
|
}
|
|
if (v.max !== undefined && dateVal.getTime() > new Date(v.max).getTime()) {
|
|
errors.push({ key: def.key, message: v.message ?? `${def.label} must not be after ${new Date(v.max).toLocaleDateString()}` });
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|