mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
added validation for contact and address question
This commit is contained in:
@@ -11,7 +11,12 @@ import {
|
||||
TSurveyOpenTextElementInputType,
|
||||
TValidationLogic,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
import {
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { TAllowedFileExtension, ALLOWED_FILE_EXTENSIONS } from "@formbricks/types/storage";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -70,6 +75,35 @@ export const ValidationRulesEditor = ({
|
||||
const validationRules = validation?.rules ?? [];
|
||||
const validationLogic = validation?.logic ?? "and";
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Field options for address and contact info elements
|
||||
const isAddress = elementType === TSurveyElementTypeEnum.Address;
|
||||
const isContactInfo = elementType === TSurveyElementTypeEnum.ContactInfo;
|
||||
const needsFieldSelector = isAddress || isContactInfo;
|
||||
|
||||
const addressFields: { value: TAddressField; label: string }[] = [
|
||||
{ value: "addressLine1", label: t("environments.surveys.edit.address_line_1") },
|
||||
{ value: "addressLine2", label: t("environments.surveys.edit.address_line_2") },
|
||||
{ value: "city", label: t("environments.surveys.edit.city") },
|
||||
{ value: "state", label: t("environments.surveys.edit.state") },
|
||||
{ value: "zip", label: t("environments.surveys.edit.zip") },
|
||||
{ value: "country", label: t("environments.surveys.edit.country") },
|
||||
];
|
||||
|
||||
const contactInfoFields: { value: TContactInfoField; label: string }[] = [
|
||||
{ value: "firstName", label: t("environments.surveys.edit.first_name") },
|
||||
{ value: "lastName", label: t("environments.surveys.edit.last_name") },
|
||||
{ value: "email", label: t("common.email") },
|
||||
{ value: "phone", label: t("common.phone") },
|
||||
{ value: "company", label: t("environments.surveys.edit.company") },
|
||||
];
|
||||
|
||||
let fieldOptions: { value: TAddressField | TContactInfoField; label: string }[] = [];
|
||||
if (isAddress) {
|
||||
fieldOptions = addressFields;
|
||||
} else if (isContactInfo) {
|
||||
fieldOptions = contactInfoFields;
|
||||
}
|
||||
const {
|
||||
billingInfo,
|
||||
error: billingInfoError,
|
||||
@@ -134,10 +168,13 @@ export const ValidationRulesEditor = ({
|
||||
elementType !== TSurveyElementTypeEnum.Matrix || (element && !element.required);
|
||||
|
||||
const handleEnable = () => {
|
||||
// For address/contact info, get rules for first field
|
||||
const defaultField = needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined;
|
||||
const availableRules = getAvailableRuleTypes(
|
||||
elementType,
|
||||
[],
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
defaultField
|
||||
);
|
||||
if (availableRules.length > 0) {
|
||||
const defaultRuleType = availableRules[0];
|
||||
@@ -166,6 +203,8 @@ export const ValidationRulesEditor = ({
|
||||
id: uuidv7(),
|
||||
type: defaultRuleType,
|
||||
params: createRuleParams(defaultRuleType, defaultValue),
|
||||
// For address/contact info, set field to first available field if not set
|
||||
field: needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined,
|
||||
} as TValidationRule;
|
||||
onUpdateValidation({ rules: [newRule], logic: validationLogic });
|
||||
}
|
||||
@@ -176,10 +215,14 @@ export const ValidationRulesEditor = ({
|
||||
};
|
||||
|
||||
const handleAddRule = (insertAfterIndex: number) => {
|
||||
// For address/contact info, get rules for the field of the rule we're inserting after (or first field)
|
||||
const insertAfterRule = validationRules[insertAfterIndex];
|
||||
const fieldForNewRule = insertAfterRule?.field ?? (needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined);
|
||||
const availableRules = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules,
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
fieldForNewRule
|
||||
);
|
||||
if (availableRules.length === 0) return;
|
||||
|
||||
@@ -209,6 +252,8 @@ export const ValidationRulesEditor = ({
|
||||
id: uuidv7(),
|
||||
type: newRuleType,
|
||||
params: createRuleParams(newRuleType, defaultValue),
|
||||
// For address/contact info, set field to first available field if not set
|
||||
field: needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined,
|
||||
} as TValidationRule;
|
||||
const newRules = [...validationRules];
|
||||
newRules.splice(insertAfterIndex + 1, 0, newRule);
|
||||
@@ -221,6 +266,24 @@ export const ValidationRulesEditor = ({
|
||||
};
|
||||
|
||||
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
|
||||
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||
if (!ruleToUpdate) return;
|
||||
|
||||
// For address/contact info, verify the new rule type is valid for the selected field
|
||||
if (needsFieldSelector && ruleToUpdate.field) {
|
||||
const availableRulesForField = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== ruleId),
|
||||
undefined,
|
||||
ruleToUpdate.field
|
||||
);
|
||||
|
||||
// If the new rule type is not available for this field, don't change it
|
||||
if (!availableRulesForField.includes(newType)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return {
|
||||
@@ -232,6 +295,39 @@ export const ValidationRulesEditor = ({
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleFieldChange = (ruleId: string, field: TAddressField | TContactInfoField | undefined) => {
|
||||
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||
if (!ruleToUpdate) return;
|
||||
|
||||
// If changing field, check if current rule type is still valid for the new field
|
||||
// If not, change to first available rule type for that field
|
||||
let updatedRule = { ...ruleToUpdate, field } as TValidationRule;
|
||||
|
||||
if (field) {
|
||||
const availableRulesForField = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== ruleId),
|
||||
undefined,
|
||||
field
|
||||
);
|
||||
|
||||
// If current rule type is not available for the new field, change it
|
||||
if (!availableRulesForField.includes(ruleToUpdate.type) && availableRulesForField.length > 0) {
|
||||
updatedRule = {
|
||||
...updatedRule,
|
||||
type: availableRulesForField[0],
|
||||
params: createRuleParams(availableRulesForField[0]),
|
||||
} as TValidationRule;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return updatedRule;
|
||||
});
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleRuleValueChange = (ruleId: string, value: string) => {
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
@@ -348,7 +444,13 @@ export const ValidationRulesEditor = ({
|
||||
const availableRulesForAdd = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules,
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
// For address/contact info, use first field if no rules exist, or use the field from last rule
|
||||
needsFieldSelector && validationRules.length > 0
|
||||
? validationRules[validationRules.length - 1]?.field
|
||||
: needsFieldSelector && fieldOptions.length > 0
|
||||
? fieldOptions[0].value
|
||||
: undefined
|
||||
);
|
||||
const canAddMore = availableRulesForAdd.length > 0;
|
||||
|
||||
@@ -390,10 +492,13 @@ export const ValidationRulesEditor = ({
|
||||
|
||||
|
||||
// Get available types for this rule (current type + unused types, no duplicates)
|
||||
// For address/contact info, filter by selected field
|
||||
const ruleField = rule.field;
|
||||
const otherAvailableTypes = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== rule.id),
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
ruleField
|
||||
).filter((t) => t !== ruleType);
|
||||
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
|
||||
|
||||
@@ -415,6 +520,25 @@ export const ValidationRulesEditor = ({
|
||||
|
||||
return (
|
||||
<div key={rule.id} className="flex w-full items-center gap-2">
|
||||
{/* Field Selector (for Address and Contact Info elements) */}
|
||||
{needsFieldSelector && (
|
||||
<Select
|
||||
value={rule.field ?? ""}
|
||||
onValueChange={(value) =>
|
||||
handleFieldChange(rule.id, value ? (value as TAddressField | TContactInfoField) : undefined)
|
||||
}>
|
||||
<SelectTrigger className="h-9 min-w-[140px] bg-white">
|
||||
<SelectValue placeholder={t("environments.surveys.edit.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((field) => (
|
||||
<SelectItem key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* Input Type Selector (only for OpenText, first rule) */}
|
||||
{showInputTypeSelector && inputType !== undefined && onUpdateInputType && (
|
||||
<Select
|
||||
|
||||
@@ -4,10 +4,36 @@ import {
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
APPLICABLE_RULES,
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
|
||||
const stringRules: TValidationRuleType[] = ["minLength", "maxLength", "pattern", "equals", "doesNotEqual", "contains", "doesNotContain"];
|
||||
|
||||
// Rules applicable per field for Address elements
|
||||
// General text fields don't support format-specific validators (email, url, phone)
|
||||
export const RULES_BY_ADDRESS_FIELD: Record<TAddressField, TValidationRuleType[]> = {
|
||||
addressLine1: stringRules,
|
||||
addressLine2: stringRules,
|
||||
city: stringRules,
|
||||
state: stringRules,
|
||||
zip: stringRules,
|
||||
country: stringRules,
|
||||
};
|
||||
|
||||
// Rules applicable per field for Contact Info elements
|
||||
// Note: "email" and "phone" validation are automatically enforced for their respective fields
|
||||
// and should not appear as selectable options in the UI
|
||||
export const RULES_BY_CONTACT_INFO_FIELD: Record<TContactInfoField, TValidationRuleType[]> = {
|
||||
firstName: stringRules,
|
||||
lastName: stringRules,
|
||||
email: stringRules,
|
||||
phone: ["equals", "doesNotEqual", "contains", "doesNotContain"],
|
||||
company: stringRules,
|
||||
};
|
||||
|
||||
// Rules applicable per input type for OpenText
|
||||
export const RULES_BY_INPUT_TYPE: Record<TSurveyOpenTextElementInputType, TValidationRuleType[]> = {
|
||||
text: [
|
||||
@@ -65,11 +91,13 @@ export const RULES_BY_INPUT_TYPE: Record<TSurveyOpenTextElementInputType, TValid
|
||||
/**
|
||||
* Get available rule types for an element type, excluding already added rules
|
||||
* For OpenText elements, filters rules based on inputType
|
||||
* For Address/ContactInfo elements, filters rules based on field
|
||||
*/
|
||||
export const getAvailableRuleTypes = (
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
existingRules: TValidationRule[],
|
||||
inputType?: TSurveyOpenTextElementInputType
|
||||
inputType?: TSurveyOpenTextElementInputType,
|
||||
field?: TAddressField | TContactInfoField
|
||||
): TValidationRuleType[] => {
|
||||
const elementTypeKey = elementType.toString();
|
||||
|
||||
@@ -80,6 +108,20 @@ export const getAvailableRuleTypes = (
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For Address elements, use field-based filtering
|
||||
if (elementType === TSurveyElementTypeEnum.Address && field) {
|
||||
const applicable = RULES_BY_ADDRESS_FIELD[field as TAddressField] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For Contact Info elements, use field-based filtering
|
||||
if (elementType === TSurveyElementTypeEnum.ContactInfo && field) {
|
||||
const applicable = RULES_BY_CONTACT_INFO_FIELD[field as TContactInfoField] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For other element types, use standard filtering
|
||||
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
|
||||
@@ -108,41 +108,43 @@ function FormField({
|
||||
/>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative space-y-3">
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{visibleFields.map((field) => {
|
||||
const fieldRequired = isFieldRequired(field);
|
||||
const fieldValue = currentValues[field.id] ?? "";
|
||||
const fieldInputId = `${elementId}-${field.id}`;
|
||||
<div className="space-y-3">
|
||||
{visibleFields.map((field) => {
|
||||
const fieldRequired = isFieldRequired(field);
|
||||
const fieldValue = currentValues[field.id] ?? "";
|
||||
const fieldInputId = `${elementId}-${field.id}`;
|
||||
|
||||
// Determine input type
|
||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||
if (field.id === "email" && !field.type) {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone" && !field.type) {
|
||||
inputType = "tel";
|
||||
}
|
||||
// Determine input type
|
||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||
if (field.id === "email" && !field.type) {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone" && !field.type) {
|
||||
inputType = "tel";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={fieldInputId} variant="default">
|
||||
{fieldRequired ? `${field.label}*` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldInputId}
|
||||
type={inputType}
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
handleFieldChange(field.id, e.target.value);
|
||||
}}
|
||||
required={fieldRequired}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={fieldInputId} variant="default">
|
||||
{fieldRequired ? `${field.label}*` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldInputId}
|
||||
type={inputType}
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
handleFieldChange(field.id, e.target.value);
|
||||
}}
|
||||
required={fieldRequired}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface AddressElementProps {
|
||||
currentElementId: string;
|
||||
autoFocusEnabled: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function AddressElement({
|
||||
@@ -26,6 +27,7 @@ export function AddressElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
errorMessage,
|
||||
}: Readonly<AddressElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = element.id === currentElementId;
|
||||
@@ -122,6 +124,7 @@ export function AddressElement({
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ContactInfoElementProps {
|
||||
currentElementId: string;
|
||||
autoFocusEnabled: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function ContactInfoElement({
|
||||
@@ -27,6 +28,7 @@ export function ContactInfoElement({
|
||||
setTtc,
|
||||
currentElementId,
|
||||
dir = "auto",
|
||||
errorMessage,
|
||||
}: Readonly<ContactInfoElementProps>) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = element.id === currentElementId;
|
||||
@@ -118,6 +120,7 @@ export function ContactInfoElement({
|
||||
dir={dir}
|
||||
imageUrl={element.imageUrl}
|
||||
videoUrl={element.videoUrl}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -310,6 +310,7 @@ export function ElementConditional({
|
||||
currentElementId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
case TSurveyElementTypeEnum.Ranking:
|
||||
@@ -338,6 +339,7 @@ export function ElementConditional({
|
||||
currentElementId={currentElementId}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
dir={dir}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
|
||||
import type {
|
||||
TSurveyElement
|
||||
TSurveyElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import type {
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationError,
|
||||
TValidationErrorMap,
|
||||
TValidationResult,
|
||||
TValidationRule,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { validators } from "./validators";
|
||||
|
||||
/**
|
||||
@@ -36,6 +39,33 @@ const createRequiredError = (t: TFunction): TValidationError => {
|
||||
} as TValidationError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get field label for address/contact info elements
|
||||
*/
|
||||
const getFieldLabel = (
|
||||
element: TSurveyElement,
|
||||
field: TAddressField | TContactInfoField | undefined,
|
||||
languageCode: string
|
||||
): string | undefined => {
|
||||
if (!field) return undefined;
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.Address && "addressLine1" in element) {
|
||||
const fieldConfig = element[field as TAddressField];
|
||||
if (fieldConfig && "placeholder" in fieldConfig) {
|
||||
return getLocalizedValue(fieldConfig.placeholder, languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.ContactInfo && "firstName" in element) {
|
||||
const fieldConfig = element[field as TContactInfoField];
|
||||
if (fieldConfig && "placeholder" in fieldConfig) {
|
||||
return getLocalizedValue(fieldConfig.placeholder, languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default error message from rule or validator
|
||||
*/
|
||||
@@ -49,11 +79,21 @@ const getDefaultErrorMessage = (
|
||||
if (!validator) {
|
||||
return t("errors.invalid_format");
|
||||
}
|
||||
return (
|
||||
|
||||
const baseMessage =
|
||||
rule.customErrorMessage?.[languageCode] ??
|
||||
rule.customErrorMessage?.default ??
|
||||
validator.getDefaultMessage(rule.params, element, t)
|
||||
);
|
||||
validator.getDefaultMessage(rule.params, element, t);
|
||||
|
||||
// For field-specific validation, prepend the field name
|
||||
if (rule.field) {
|
||||
const fieldLabel = getFieldLabel(element, rule.field, languageCode);
|
||||
if (fieldLabel) {
|
||||
return `${fieldLabel}: ${baseMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
return baseMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -142,6 +182,31 @@ export const validateElementResponse = (
|
||||
}
|
||||
}
|
||||
|
||||
// For ContactInfo elements, automatically add email/phone validation for their respective fields
|
||||
if (element.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
const contactInfoElement = element;
|
||||
|
||||
// Automatically validate email field if it's shown
|
||||
if (contactInfoElement.email?.show && !rules.some((r) => r.type === "email" && r.field === "email")) {
|
||||
rules.push({
|
||||
id: "__implicit_email_field__",
|
||||
type: "email",
|
||||
field: "email",
|
||||
params: {},
|
||||
} as TValidationRule);
|
||||
}
|
||||
|
||||
// Automatically validate phone field if it's shown
|
||||
if (contactInfoElement.phone?.show && !rules.some((r) => r.type === "phone" && r.field === "phone")) {
|
||||
rules.push({
|
||||
id: "__implicit_phone_field__",
|
||||
type: "phone",
|
||||
field: "phone",
|
||||
params: {},
|
||||
} as TValidationRule);
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
@@ -149,6 +214,32 @@ export const validateElementResponse = (
|
||||
// Get validation logic (default to "and" if not specified)
|
||||
const validationLogic = validation?.logic ?? "and";
|
||||
|
||||
// Helper function to get field value for address/contact info elements
|
||||
const getFieldValue = (rule: TValidationRule, elementValue: TResponseDataValue): TResponseDataValue => {
|
||||
// If rule doesn't have a field, validate the whole value
|
||||
if (!rule.field) {
|
||||
return elementValue;
|
||||
}
|
||||
|
||||
// For address and contact info, value is an array
|
||||
if (element.type === TSurveyElementTypeEnum.Address && Array.isArray(elementValue)) {
|
||||
const addressFieldOrder: TAddressField[] = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||
const fieldIndex = addressFieldOrder.indexOf(rule.field as TAddressField);
|
||||
if (fieldIndex >= 0 && fieldIndex < elementValue.length) {
|
||||
return elementValue[fieldIndex] ?? "";
|
||||
}
|
||||
} else if (element.type === TSurveyElementTypeEnum.ContactInfo && Array.isArray(elementValue)) {
|
||||
const contactFieldOrder: TContactInfoField[] = ["firstName", "lastName", "email", "phone", "company"];
|
||||
const fieldIndex = contactFieldOrder.indexOf(rule.field as TContactInfoField);
|
||||
if (fieldIndex >= 0 && fieldIndex < elementValue.length) {
|
||||
return elementValue[fieldIndex] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return empty string if field not found
|
||||
return "";
|
||||
};
|
||||
|
||||
if (validationLogic === "or") {
|
||||
// OR logic: at least one rule must pass
|
||||
const ruleResults: { valid: boolean; error?: TValidationError }[] = [];
|
||||
@@ -162,7 +253,9 @@ export const validateElementResponse = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const checkResult = validator.check(value, rule.params, element);
|
||||
// Get the value to validate (field-specific for address/contact info)
|
||||
const valueToValidate = getFieldValue(rule, value);
|
||||
const checkResult = validator.check(valueToValidate, rule.params, element);
|
||||
|
||||
if (checkResult.valid) {
|
||||
// At least one rule passed, validation succeeds
|
||||
@@ -198,7 +291,9 @@ export const validateElementResponse = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const checkResult = validator.check(value, rule.params, element);
|
||||
// Get the value to validate (field-specific for address/contact info)
|
||||
const valueToValidate = getFieldValue(rule, value);
|
||||
const checkResult = validator.check(valueToValidate, rule.params, element);
|
||||
|
||||
if (!checkResult.valid) {
|
||||
const message = getDefaultErrorMessage(rule, element, languageCode, t);
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { ZI18nString } from "../i18n";
|
||||
|
||||
// Field types for field-specific validation (address and contact info elements)
|
||||
export const ZAddressField = z.enum(["addressLine1", "addressLine2", "city", "state", "zip", "country"]);
|
||||
export type TAddressField = z.infer<typeof ZAddressField>;
|
||||
|
||||
export const ZContactInfoField = z.enum(["firstName", "lastName", "email", "phone", "company"]);
|
||||
export type TContactInfoField = z.infer<typeof ZContactInfoField>;
|
||||
|
||||
// Union type for all possible field types
|
||||
export const ZValidationRuleField = z.union([ZAddressField, ZContactInfoField]);
|
||||
export type TValidationRuleField = z.infer<typeof ZValidationRuleField>;
|
||||
|
||||
// Validation rule type enum - extensible for future rule types
|
||||
export const ZValidationRuleType = z.enum([
|
||||
// Text/OpenText rules
|
||||
@@ -212,162 +223,189 @@ export type TValidationRuleParamsFileExtensionIs = z.infer<typeof ZValidationRul
|
||||
export type TValidationRuleParamsFileExtensionIsNot = z.infer<typeof ZValidationRuleParamsFileExtensionIsNot>;
|
||||
|
||||
// Validation rule stored on element - discriminated union with type at top level
|
||||
// Field property is optional and used for address/contact info elements to target specific sub-fields
|
||||
export const ZValidationRule = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("minLength"),
|
||||
params: ZValidationRuleParamsMinLength,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("maxLength"),
|
||||
params: ZValidationRuleParamsMaxLength,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("pattern"),
|
||||
params: ZValidationRuleParamsPattern,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("email"),
|
||||
params: ZValidationRuleParamsEmail,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("url"),
|
||||
params: ZValidationRuleParamsUrl,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("phone"),
|
||||
params: ZValidationRuleParamsPhone,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("minValue"),
|
||||
params: ZValidationRuleParamsMinValue,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("maxValue"),
|
||||
params: ZValidationRuleParamsMaxValue,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("minSelections"),
|
||||
params: ZValidationRuleParamsMinSelections,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("maxSelections"),
|
||||
params: ZValidationRuleParamsMaxSelections,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("equals"),
|
||||
params: ZValidationRuleParamsEquals,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("doesNotEqual"),
|
||||
params: ZValidationRuleParamsDoesNotEqual,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("contains"),
|
||||
params: ZValidationRuleParamsContains,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("doesNotContain"),
|
||||
params: ZValidationRuleParamsDoesNotContain,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isGreaterThan"),
|
||||
params: ZValidationRuleParamsIsGreaterThan,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isLessThan"),
|
||||
params: ZValidationRuleParamsIsLessThan,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isLaterThan"),
|
||||
params: ZValidationRuleParamsIsLaterThan,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isEarlierThan"),
|
||||
params: ZValidationRuleParamsIsEarlierThan,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isBetween"),
|
||||
params: ZValidationRuleParamsIsBetween,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("isNotBetween"),
|
||||
params: ZValidationRuleParamsIsNotBetween,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("minRanked"),
|
||||
params: ZValidationRuleParamsMinRanked,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("minRowsAnswered"),
|
||||
params: ZValidationRuleParamsMinRowsAnswered,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("fileSizeAtLeast"),
|
||||
params: ZValidationRuleParamsFileSizeAtLeast,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("fileSizeAtMost"),
|
||||
params: ZValidationRuleParamsFileSizeAtMost,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("fileExtensionIs"),
|
||||
params: ZValidationRuleParamsFileExtensionIs,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.literal("fileExtensionIsNot"),
|
||||
params: ZValidationRuleParamsFileExtensionIsNot,
|
||||
customErrorMessage: ZI18nString.optional(),
|
||||
field: ZValidationRuleField.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -413,8 +451,31 @@ const MATRIX_RULES = ["minRowsAnswered"] as const;
|
||||
const RANKING_RULES = ["minRanked"] as const;
|
||||
const FILE_UPLOAD_RULES = ["fileSizeAtLeast", "fileSizeAtMost", "fileExtensionIs", "fileExtensionIsNot"] as const;
|
||||
const PICTURE_SELECTION_RULES = ["minSelections", "maxSelections"] as const;
|
||||
const ADDRESS_RULES = [] as const;
|
||||
const CONTACT_INFO_RULES = [] as const;
|
||||
// Address and Contact Info can use text-based validation rules on specific fields
|
||||
const ADDRESS_RULES = [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
] as const;
|
||||
const CONTACT_INFO_RULES = [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
] as const;
|
||||
const CAL_RULES = [] as const;
|
||||
const CTA_RULES = [] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user